"""Test the extraction-service contract → app ghost-row mapping (P2). Mocks the PDF geometry so the mapper is tested in isolation: page-fraction → 780-canvas conversion, per-template uuid remap, FK-safety (orphan regions / dangling parents), and rich meta passthrough. """ import routers.exam.templates as T TID = "11111111-1111-1111-1111-111111111111" # two pages, each rendered 780 wide × 1000 tall; page 2 stacked below page 1 _GEOM = [ {"rendered_w": 780.0, "rendered_h": 1000.0, "page_top": 0.0, "page_pt_w": 595.0, "page_pt_h": 842.0, "crop_x0": 0.0, "crop_y0": 0.0}, {"rendered_w": 780.0, "rendered_h": 1000.0, "page_top": 1000.0, "page_pt_w": 595.0, "page_pt_h": 842.0, "crop_x0": 0.0, "crop_y0": 0.0}, ] _CONTRACT = { "coordinate_space": "page_fraction", "suggestions": { "questions": [ {"uid": "Q1", "label": "1", "order": 0, "max_marks": 5, "is_container": True, "page": 1}, {"uid": "Q1a", "parent_uid": "Q1", "label": "1(a)", "order": 1, "max_marks": 3, "answer_type": "short", "is_container": False, "page": 1}, {"uid": "Q1b", "parent_uid": "Q1", "label": "1(b)", "order": 2, "max_marks": 2, "answer_type": "mcq", "is_container": False, "page": 2}, ], "response_areas": [ {"uid": "RA1", "question_uid": "Q1a", "page": 1, "kind": "response", "response_form": "answer-box", "confidence": 0.9, "bounds": {"x": 0.1, "y": 0.2, "w": 0.5, "h": 0.05}, "meta": {"unit": "m/s", "quantity": "v", "n_lines": 1}}, {"uid": "RA2", "question_uid": "Q1b", "page": 2, "kind": "response", "response_form": "tick-boxes", "bounds": {"x": 0.2, "y": 0.3, "w": 0.4, "h": 0.2}, "meta": {"select_n": 2, "n_options": 5, "boxes": [{"x0": 0.2, "y0": 0.3, "fill": 0.8}]}}, {"uid": "CTX1", "question_uid": "Q1b", "page": 2, "kind": "context", "response_form": None, "bounds": {"x": 0.1, "y": 0.1, "w": 0.6, "h": 0.15}}, {"uid": "ORPH", "question_uid": "GHOST", "page": 1, "kind": "response", "response_form": "lines", "bounds": {"x": 0.1, "y": 0.5, "w": 0.5, "h": 0.1}}, # orphan owner → must be dropped ], "boundaries": [ {"question_uid": "Q1", "label": "1", "page_index": 0, "y": 0.18, "confidence": 0.85}, ], }, "meta": {"n_pages": 2}, } def test_contract_maps_to_canvas_rows(monkeypatch): monkeypatch.setattr(T, "_pdf_page_geometry", lambda _b: _GEOM) rows = T._map_service_contract_to_rows(TID, _CONTRACT, b"%PDF-1.4") q = rows["questions"] assert len(q) == 3 container = next(x for x in q if x["label"] == "1") a = next(x for x in q if x["label"] == "1(a)") assert container["is_container"] is True and container["parent_id"] is None assert a["parent_id"] == container["id"] # parent remapped to the container's per-template id assert a["answer_type"] == "short" and a["max_marks"] == 3 assert all(x["source"] == "ai" and x["confirmed"] is False for x in q) ra = rows["response_areas"] assert len(ra) == 3 # orphan (owner GHOST) dropped ids = {r["id"] for r in ra} assert len(ids) == 3 # ids unique box = next(r for r in ra if r["response_form"] == "answer-box") # page-fraction → 780-canvas: x=0.1*780=78, y=0.2*1000=200, w=0.5*780=390, h=0.05*1000=50 assert box["bounds"] == {"x": 78.0, "y": 200.0, "w": 390.0, "h": 50.0} assert box["meta"]["unit"] == "m/s" # rich meta preserved tick = next(r for r in ra if r["response_form"] == "tick-boxes") # page 2 y stacked: 0.3*1000 + page_top(1000) = 1300 assert tick["bounds"]["y"] == 1300.0 and tick["meta"]["select_n"] == 2 ctx = next(r for r in ra if r["kind"] == "context") assert ctx["response_form"] is None # context carries no answer form assert all(r["question_id"] in {x["id"] for x in q} for r in ra) # FK-safe b = rows["boundaries"] assert len(b) == 1 and b[0]["y"] == 180.0 and b[0]["question_id"] == container["id"]