From 7f09452c1006118147a1f77c4a9ce1e886f7b89f Mon Sep 17 00:00:00 2001 From: carlacostea Date: Fri, 3 Jul 2026 10:52:10 +0300 Subject: [PATCH] fix(graphing): compare derived a/b coefficients when scoring exponentials + add exponential/absolute test coverage SCSTU-377 PIE-745 --- .../controller/src/__tests__/index.test.js | 136 ++++++++++++++++++ .../controller/src/__tests__/utils.test.js | 66 +++++++++ packages/graphing/controller/src/utils.js | 4 +- 3 files changed, 204 insertions(+), 2 deletions(-) diff --git a/packages/graphing/controller/src/__tests__/index.test.js b/packages/graphing/controller/src/__tests__/index.test.js index ddc57e03a9..62388ff2f8 100644 --- a/packages/graphing/controller/src/__tests__/index.test.js +++ b/packages/graphing/controller/src/__tests__/index.test.js @@ -577,6 +577,142 @@ describe('outcome', () => { expect(result.score).toEqual(1); }); + // Regression for SCSTU-377: students were awarded full marks for exponential + // marks whose coordinates did not match the correct answer. The controller's + // equalExponential destructured non-existent keys, so every comparison returned + // true. These tests pin the full model + outcome scoring path. + it('Exponentials are correctly scored (SCSTU-377)', async () => { + const m = { + element: 'pie-element-graphing', + id: 'scstu-377', + graph: { width: 500, height: 500 }, + domain: { min: -20, max: 20, step: 1, labelStep: 5, axisLabel: 'x' }, + range: { min: -20, max: 60, step: 5, labelStep: 10, axisLabel: 'f(x)' }, + rationale: 'Rationale', + prompt: 'Prompt', + // actual assessment: correct exponential passes through (1,1) and (9,9) + answers: { + correctAnswer: { + marks: [{ type: 'exponential', root: { x: 1, y: 1 }, edge: { x: 9, y: 9 } }], + }, + }, + toolbarTools: ['exponential'], + }; + const env = { mode: 'evaluate' }; + + // student drew a different exponential (the ticket's (10,30),(18,50)) => incorrect + const wrongSession = { + answer: [{ type: 'exponential', root: { x: 10, y: 30 }, edge: { x: 18, y: 50 } }], + }; + expect((await outcome(await model(m, wrongSession, env), wrongSession, env)).score).toEqual(0); + + // any two positive points used to score full marks before the fix => still incorrect + const wrongSession2 = { + answer: [{ type: 'exponential', root: { x: 2, y: 3 }, edge: { x: 5, y: 4 } }], + }; + expect((await outcome(await model(m, wrongSession2, env), wrongSession2, env)).score).toEqual(0); + + // exact match => full marks + const correctSession = { + answer: [{ type: 'exponential', root: { x: 1, y: 1 }, edge: { x: 9, y: 9 } }], + }; + expect((await outcome(await model(m, correctSession, env), correctSession, env)).score).toEqual(1); + + // same curve sampled at a different point still matches => full marks + // y = 2 * 3^x passes through (0,2),(1,6) and (1,6),(2,18) + const curveModel = { + ...m, + answers: { + correctAnswer: { + marks: [{ type: 'exponential', root: { x: 0, y: 2 }, edge: { x: 1, y: 6 } }], + }, + }, + }; + const equivalentSession = { + answer: [{ type: 'exponential', root: { x: 1, y: 6 }, edge: { x: 2, y: 18 } }], + }; + expect((await outcome(await model(curveModel, equivalentSession, env), equivalentSession, env)).score).toEqual(1); + }); + + // Faithful reproduction of the SCSTU-377 ticket steps, using the exact + // coordinates from the SchoolCity assessment items. + describe('SchoolCity SCSTU-377 repro steps', () => { + // Item 1: an item with BOTH an Absolute and an Exponential response part. + // Correct answer: absolute (vertex (-5,-7), edge (-3,-5)) and + // exponential through (10,30),(14,50). + // Student: absolute matches, but exponential is (10,30),(18,50) (second + // coordinate 18,50 does not match 14,50). The item must NOT be full marks. + const item1Answers = { + correctAnswer: { + marks: [ + { type: 'absolute', root: { x: -5, y: -7 }, edge: { x: -3, y: -5 } }, + { type: 'exponential', root: { x: 10, y: 30 }, edge: { x: 14, y: 50 } }, + ], + }, + }; + const item1Session = { + answer: [ + { type: 'absolute', root: { x: -5, y: -7 }, edge: { x: -3, y: -5 } }, + { type: 'exponential', root: { x: 10, y: 30 }, edge: { x: 18, y: 50 } }, + ], + }; + + it('Item 1: absolute correct + wrong exponential is not full marks (partial => 0.5)', async () => { + const question = { answers: item1Answers, scoringType: 'partial scoring' }; + const env = { mode: 'evaluate', partialScoring: true }; + const result = await outcome(await model(question, item1Session, env), item1Session, env); + + // 1 of 2 correct (absolute), exponential incorrect + expect(result.score).toEqual(0.5); + }); + + it('Item 1: absolute correct + wrong exponential is not full marks (dichotomous => 0)', async () => { + const question = { answers: item1Answers, scoringType: 'all or nothing' }; + const env = { mode: 'evaluate', partialScoring: false }; + const result = await outcome(await model(question, item1Session, env), item1Session, env); + + expect(result.score).toEqual(0); + }); + + // Item 4: single Exponential response. Correct answer (1,1),(2,3). + // Student draws (1,1),(5,4) (second coordinate 5,4 does not match 2,3). + const item4Answers = { + correctAnswer: { + marks: [{ type: 'exponential', root: { x: 1, y: 1 }, edge: { x: 2, y: 3 } }], + }, + }; + const item4Session = { + answer: [{ type: 'exponential', root: { x: 1, y: 1 }, edge: { x: 5, y: 4 } }], + }; + + it('Item 4: wrong exponential scores 0 (partial)', async () => { + const question = { answers: item4Answers, scoringType: 'partial scoring' }; + const env = { mode: 'evaluate', partialScoring: true }; + const result = await outcome(await model(question, item4Session, env), item4Session, env); + + expect(result.score).toEqual(0); + }); + + it('Item 4: wrong exponential scores 0 (dichotomous)', async () => { + const question = { answers: item4Answers, scoringType: 'all or nothing' }; + const env = { mode: 'evaluate', partialScoring: false }; + const result = await outcome(await model(question, item4Session, env), item4Session, env); + + expect(result.score).toEqual(0); + }); + + it('Item 4: matching exponential scores full marks', async () => { + const question = { answers: item4Answers, scoringType: 'all or nothing' }; + const env = { mode: 'evaluate', partialScoring: false }; + const correctSession = { + answer: [{ type: 'exponential', root: { x: 1, y: 1 }, edge: { x: 2, y: 3 } }], + }; + const result = await outcome(await model(question, correctSession, env), correctSession, env); + + expect(result.score).toEqual(1); + }); + }); + it('Lines are correctly scored (ch4126)', async () => { const m = { id: '4028e4a24ca05186014cbae62f752be7', diff --git a/packages/graphing/controller/src/__tests__/utils.test.js b/packages/graphing/controller/src/__tests__/utils.test.js index 2950ac5399..851b9bd71e 100644 --- a/packages/graphing/controller/src/__tests__/utils.test.js +++ b/packages/graphing/controller/src/__tests__/utils.test.js @@ -9,6 +9,8 @@ import { equalCircle, equalSine, equalParabola, + equalAbsolute, + equalExponential, constructSegmentsFromPoints, removeDuplicateSegments, removeInvalidSegments, @@ -536,6 +538,70 @@ describe('equalParabola', () => { ); }); +describe('equalAbsolute', () => { + // y = a * |x - h| + k, where root = (h, k) is the vertex and edge is a point on a ray. + // Two absolutes are equal only when both the vertex AND the slope a match. + test.each([ + // identical vertex + edge => equal + [{root: p0_0, edge: p1_1}, {root: p0_0, edge: p1_1}, true], + // same vertex, edge mirrored across the axis of symmetry => same |slope| => equal + // (0,0),(1,2) and (0,0),(-1,2) both describe a = 2 + [{root: p0_0, edge: {x: 1, y: 2}}, {root: p0_0, edge: {x: -1, y: 2}}, true], + // same vertex, edge further along the same ray => same slope => equal + // (0,0),(1,2) and (0,0),(2,4) both describe a = 2 + [{root: p0_0, edge: {x: 1, y: 2}}, {root: p0_0, edge: {x: 2, y: 4}}, true], + // same vertex, different slope => not equal (a = 2 vs a = 3) + [{root: p0_0, edge: {x: 1, y: 2}}, {root: p0_0, edge: {x: 1, y: 3}}, false], + // different vertex => not equal even when slope matches + [{root: p0_0, edge: {x: 1, y: 2}}, {root: p1_1, edge: {x: 2, y: 3}}, false], + [{root: pNull, edge: pUndefined}, {root: pNull, edge: pUndefined}, true], + ])('%j, %j => %s', (a1, a2, expected) => { + const result = equalAbsolute(a1, a2); + + expect(result).toEqual(expected); + }); +}); + +describe('equalExponential', () => { + // y = a * b^x, derived from two points. Two exponentials are equal only when + // both a and b match. Regression coverage for SCSTU-377 / the PGM QA scoring bug + // where a destructuring typo made every comparison return true. + test.each([ + // identical points => equal + [{root: p0_1, edge: p1_1}, {root: p0_1, edge: p1_1}, true], + // same curve y = 2 * 3^x sampled at different points => equal + // (0,2),(1,6) and (1,6),(2,18) both describe a = 2, b = 3 + [{root: {x: 0, y: 2}, edge: {x: 1, y: 6}}, {root: {x: 1, y: 6}, edge: {x: 2, y: 18}}, true], + // same curve, comparison is order-independent + [{root: {x: 0, y: 2}, edge: {x: 2, y: 18}}, {root: {x: 0, y: 2}, edge: {x: 1, y: 6}}, true], + + // --- Jira reproduction cases (must NOT be equal) --- + // Actual SCSTU-377 assessment: correct answer is (1,1),(9,9); a student + // could draw any two positive points and still receive full marks. + [ + {root: {x: 10, y: 30}, edge: {x: 18, y: 50}}, + {root: {x: 1, y: 1}, edge: {x: 9, y: 9}}, + false, + ], + // Item 1: student drew (10,30),(18,50) but correct answer is (10,30),(14,50) + [ + {root: {x: 10, y: 30}, edge: {x: 18, y: 50}}, + {root: {x: 10, y: 30}, edge: {x: 14, y: 50}}, + false, + ], + // Item 4: student drew (1,1),(5,4) but correct answer is (1,1),(2,3) + [ + {root: {x: 1, y: 1}, edge: {x: 5, y: 4}}, + {root: {x: 1, y: 1}, edge: {x: 2, y: 3}}, + false, + ], + ])('%j, %j => %s', (e1, e2, expected) => { + const result = equalExponential(e1, e2); + + expect(result).toEqual(expected); + }); +}); + describe('constructSegmentsFromPoints', () => { test.each([ [ diff --git a/packages/graphing/controller/src/utils.js b/packages/graphing/controller/src/utils.js index 93a04452f8..6a5b6788e6 100644 --- a/packages/graphing/controller/src/utils.js +++ b/packages/graphing/controller/src/utils.js @@ -316,8 +316,8 @@ export const equalExponential = (p1, p2) => { const p1edge = edgeP1 || { ...rootP1 }; const p2edge = edgeP2 || { ...rootP2 }; - const { a1, b1 } = pointsToABForExponential(rootP1, p1edge); - const { a2, b2 } = pointsToABForExponential(rootP2, p2edge); + const { a: a1, b: b1 } = pointsToABForExponential(rootP1, p1edge); + const { a: a2, b: b2 } = pointsToABForExponential(rootP2, p2edge); // if both a and b value are equal return isEqual(a2, a1) && isEqual(b2, b1);