import { describe, expect, it } from 'vitest' import type { ExamTemplateDetail } from '../../types/exam.types' import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './model' const template: ExamTemplateDetail = { id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1, institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [], layout: [], } describe('exam setup canvas serialization', () => { it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => { 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: 220, label: 'Q1(a)', maxMarks: 3 }, { id: 'resp-1', kind: 'response', x: 130, y: 250, w: 300, h: 90, responseForm: 'lines' }, ]) const main = payload.questions.find((q) => q.is_container) const part = payload.questions.find((q) => !q.is_container) expect(main?.label).toBe('Q1') expect(part?.parent_id).toBe(main?.id) expect(part?.bounds).toEqual({ x: 100, y: 180, w: 400, h: 220 }) expect(payload.response_areas[0]).toMatchObject({ question_id: part?.id, kind: 'response', response_form: 'lines' }) expect(payload.boundaries).toHaveLength(2) expect(payload.boundaries.every((b) => b.question_id === main?.id)).toBe(true) expect(payload.questions.every((q) => isUuid(q.id))).toBe(true) expect(payload.response_areas.every((r) => isUuid(r.id))).toBe(true) expect(payload.boundaries.every((b) => isUuid(b.id))).toBe(true) }) it('maps shapes to the visible PDF page geometry rather than a fixed page height', () => { const pages = [ { pageNumber: 1, x: 260, y: 0, w: 780, h: 1000 }, { pageNumber: 2, x: 260, y: 1000, w: 780, h: 1200 }, ] expect(pageForY(1050, pages)).toBe(2) const payload = serializeCanvasShapes(template, [ { id: 'b-top', kind: 'boundary', x: 260, y: 1020, w: 700, h: 8, label: 'Q1 start' }, { id: 'b-bottom', kind: 'boundary', x: 260, y: 1700, w: 700, h: 8, label: 'Q1 end' }, { id: 'part-1', kind: 'part', x: 300, y: 1120, w: 300, h: 160, label: 'Q1(a)' }, { id: 'resp-1', kind: 'response', x: 320, y: 1160, w: 240, h: 80 }, ], pages) expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2) expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true) expect(payload.boundaries.every((b) => b.bounds?.x === 260 && b.bounds?.w === 780)).toBe(true) expect(payload.response_areas[0].page).toBe(2) }) it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => { const shapes = shapesFromTemplate({ ...template, questions: [ { id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'manual', confirmed: true, confidence: null, derivation: null }, { id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1, source: 'manual', confirmed: true, confidence: null, derivation: null }, ], response_areas: [ { id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null, derivation: null }, { id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null, derivation: null }, ], boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true, confidence: null, derivation: null }], }) expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response']) expect(shapes.find((s) => s.kind === 'boundary')).toMatchObject({ id: 'b1', x: 0, y: 100, w: 780, h: 8 }) expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 }) }) it('carries AI provenance into canvas models, flags cheap review issues, and preserves it on save', () => { const detail: ExamTemplateDetail = { ...template, questions: [ { id: '11111111-1111-4111-8111-111111111111', template_id: 'tpl-1', parent_id: null, label: 'Q?', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' }, { id: '22222222-2222-4222-8222-222222222222', template_id: 'tpl-1', parent_id: '11111111-1111-4111-8111-111111111111', label: 'Q?', order: 0, max_marks: 0, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 100, y: 120, w: 200, h: 100 }, page: 1, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' }, { id: '66666666-6666-4666-8666-666666666666', template_id: 'tpl-1', parent_id: '11111111-1111-4111-8111-111111111111', label: 'Q1(b)', order: 1, max_marks: 1, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 150, y: 150, w: 200, h: 100 }, page: 1, source: 'ai', confirmed: false, confidence: 0.8, derivation: 'g6' }, ], response_areas: [ { id: '33333333-3333-4333-8333-333333333333', question_id: '22222222-2222-4222-8222-222222222222', template_id: 'tpl-1', page: 1, bounds: { x: 120, y: 140, w: 120, h: 40 }, kind: 'response', response_form: 'lines', source: 'ai', confirmed: false, confidence: 0.8, derivation: 'regions' }, ], boundaries: [ { id: '44444444-4444-4444-8444-444444444444', template_id: 'tpl-1', question_id: '11111111-1111-4111-8111-111111111111', label: 'Q? start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' }, { id: '55555555-5555-4555-8555-555555555555', template_id: 'tpl-1', question_id: '11111111-1111-4111-8111-111111111111', label: 'Q? end', page_index: 0, y: 500, bounds: { x: 0, y: 500, w: 700, h: 8 }, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' }, ], } const shapes = shapesFromTemplate(detail) const part = shapes.find((shape) => shape.kind === 'part') expect(part).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' }) expect(part?.reviewFlags).toEqual(expect.arrayContaining(['unconfirmed AI', 'low confidence', 'uncertain question label', 'missing marks', 'overlapping shapes'])) const payload = serializeCanvasShapes(template, shapes) expect(payload.questions.find((q) => q.id === '22222222-2222-4222-8222-222222222222')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' }) expect(payload.response_areas.find((r) => r.id === '33333333-3333-4333-8333-333333333333')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.8, derivation: 'regions' }) 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 }) })