Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions packages/graphing/controller/src/__tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
66 changes: 66 additions & 0 deletions packages/graphing/controller/src/__tests__/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
equalCircle,
equalSine,
equalParabola,
equalAbsolute,
equalExponential,
constructSegmentsFromPoints,
removeDuplicateSegments,
removeInvalidSegments,
Expand Down Expand Up @@ -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([
[
Expand Down
4 changes: 2 additions & 2 deletions packages/graphing/controller/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading