Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

This is a feature-branch pull-request from release/server-side-scoring to main #1926

Merged
merged 9 commits into from
Nov 27, 2024
5 changes: 5 additions & 0 deletions .changeset/famous-horses-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": minor
---

Introduces a validation function for the dropdown widget (extracted from dropdown scoring function).
5 changes: 5 additions & 0 deletions .changeset/green-ghosts-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": minor
---

Split out validation function for the `categorizer` widget. This can be used to check if the user selected an answer for every row, confirming the question is ready to be scored.
5 changes: 5 additions & 0 deletions .changeset/honest-avocados-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": minor
---

Introduces a validation function for the numeric-input widget (extracted from numeric-input scoring function).
5 changes: 5 additions & 0 deletions .changeset/nice-fans-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": minor
---

Introduces a validation function for the plotter widget (extracted from the scoring function).
5 changes: 5 additions & 0 deletions .changeset/sharp-radios-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": minor
---

Split out validation function for the `orderer` widget. This can be used to check if the user has ordered at least one option, confirming the question is ready to be scored.
5 changes: 5 additions & 0 deletions .changeset/spotty-moles-reply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": minor
---

Introduces a validation function for the radio widget (extracted from the scoring function), though not all validation logic can be extracted.
5 changes: 5 additions & 0 deletions .changeset/sweet-trainers-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": minor
---

Split out validation function for the `sorter` widget. This can be used to check if the user has made any changes to the sorting order, confirming whether or not the question can be scored.
27 changes: 20 additions & 7 deletions packages/perseus/src/validation.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ import type {
PerseusNumberLineWidgetOptions,
PerseusNumericInputAnswer,
PerseusOrdererWidgetOptions,
PerseusPlotterWidgetOptions,
PerseusRadioChoice,
PerseusGraphCorrectType,
} from "./perseus-types";
Expand All @@ -50,15 +49,21 @@ import type {Relationship} from "./widgets/number-line/number-line";

export type UserInputStatus = "correct" | "incorrect" | "incomplete";

export type PerseusCategorizerRubric = {
export type PerseusCategorizerScoringData = {
// The correct answers where index relates to the items and value relates
// to the category. e.g. [0, 1, 0, 1, 2]
values: ReadonlyArray<number>;
};
} & PerseusCategorizerValidationData;

export type PerseusCategorizerUserInput = {
values: PerseusCategorizerRubric["values"];
values: PerseusCategorizerScoringData["values"];
};

export type PerseusCategorizerValidationData = {
// Translatable text; a list of items to categorize. e.g. ["banana", "yellow", "apple", "purple", "shirt"]
items: ReadonlyArray<string>;
};

// TODO(LEMS-2440): Can possibly be removed during 2440?
// This is not used for grading at all. The only place it is used is to define
// Props type in cs-program.tsx, but RenderProps already contains WidgetOptions
Expand Down Expand Up @@ -188,7 +193,15 @@ export type PerseusOrdererUserInput = {
current: ReadonlyArray<string>;
};

export type PerseusPlotterRubric = PerseusPlotterWidgetOptions;
export type PerseusPlotterScoringData = {
// The Y values that represent the correct answer expected
correct: ReadonlyArray<number>;
} & PerseusPlotterValidationData;

export type PerseusPlotterValidationData = {
// The Y values the graph should start with
starting: ReadonlyArray<number>;
};

export type PerseusPlotterUserInput = ReadonlyArray<number>;

Expand Down Expand Up @@ -219,7 +232,7 @@ export type PerseusTableRubric = {
export type PerseusTableUserInput = ReadonlyArray<ReadonlyArray<string>>;

export type Rubric =
| PerseusCategorizerRubric
| PerseusCategorizerScoringData
| PerseusCSProgramRubric
| PerseusDropdownRubric
| PerseusExpressionRubric
Expand All @@ -236,7 +249,7 @@ export type Rubric =
| PerseusNumberLineRubric
| PerseusNumericInputRubric
| PerseusOrdererRubric
| PerseusPlotterRubric
| PerseusPlotterScoringData
| PerseusRadioRubric
| PerseusSorterRubric
| PerseusTableRubric;
Expand Down
4 changes: 2 additions & 2 deletions packages/perseus/src/widgets/categorizer/categorizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ import scoreCategorizer from "./score-categorizer";
import type {PerseusCategorizerWidgetOptions} from "../../perseus-types";
import type {Widget, WidgetExports, WidgetProps} from "../../types";
import type {
PerseusCategorizerRubric,
PerseusCategorizerScoringData,
PerseusCategorizerUserInput,
} from "../../validation.types";
import type {CategorizerPromptJSON} from "../../widget-ai-utils/categorizer/categorizer-ai-utils";

type Props = WidgetProps<RenderProps, PerseusCategorizerRubric> & {
type Props = WidgetProps<RenderProps, PerseusCategorizerScoringData> & {
values: ReadonlyArray<string>;
};

Expand Down
27 changes: 7 additions & 20 deletions packages/perseus/src/widgets/categorizer/score-categorizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,34 @@ import {mockStrings} from "../../strings";

import scoreCategorizer from "./score-categorizer";

import type {PerseusCategorizerRubric} from "../../validation.types";
import type {PerseusCategorizerScoringData} from "../../validation.types";

describe("scoreCategorizer", () => {
it("gives points when the answer is correct", () => {
const rubric: PerseusCategorizerRubric = {
const scoringData: PerseusCategorizerScoringData = {
values: [1, 3],
items: ["apples", "oranges"],
};

const userInput = {
values: [1, 3],
} as const;
const score = scoreCategorizer(userInput, rubric, mockStrings);
const score = scoreCategorizer(userInput, scoringData, mockStrings);

expect(score).toHaveBeenAnsweredCorrectly();
});

it("does not give points when incorrectly answered", () => {
const rubric: PerseusCategorizerRubric = {
const scoringData: PerseusCategorizerScoringData = {
values: [1, 3],
items: ["apples", "oranges"],
};

const userInput = {
values: [2, 3],
} as const;
const score = scoreCategorizer(userInput, rubric, mockStrings);
const score = scoreCategorizer(userInput, scoringData, mockStrings);

expect(score).toHaveBeenAnsweredIncorrectly();
});

it("tells the learner its not complete if not selected", () => {
const rubric: PerseusCategorizerRubric = {
values: [1, 3],
};

const userInput = {
values: [2],
} as const;
const score = scoreCategorizer(userInput, rubric, mockStrings);

expect(score).toHaveInvalidInput(
"Make sure you select something for every row.",
);
});
});
27 changes: 14 additions & 13 deletions packages/perseus/src/widgets/categorizer/score-categorizer.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,32 @@
import validateCategorizer from "./validate-categorizer";

import type {PerseusStrings} from "../../strings";
import type {PerseusScore} from "../../types";
import type {
PerseusCategorizerRubric,
PerseusCategorizerScoringData,
PerseusCategorizerUserInput,
} from "../../validation.types";

function scoreCategorizer(
userInput: PerseusCategorizerUserInput,
rubric: PerseusCategorizerRubric,
scoringData: PerseusCategorizerScoringData,
strings: PerseusStrings,
): PerseusScore {
let completed = true;
const validationError = validateCategorizer(
userInput,
scoringData,
strings,
);
if (validationError) {
return validationError;
}

let allCorrect = true;
rubric.values.forEach((value, i) => {
if (userInput.values[i] == null) {
completed = false;
}
scoringData.values.forEach((value, i) => {
if (userInput.values[i] !== value) {
allCorrect = false;
}
});
if (!completed) {
return {
type: "invalid",
message: strings.invalidSelection,
};
}
return {
type: "points",
earned: allCorrect ? 1 : 0,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {mockStrings} from "../../strings";

import validateCategorizer from "./validate-categorizer";

import type {PerseusCategorizerValidationData} from "../../validation.types";

describe("validateCategorizer", () => {
it("tells the learner its not complete if not selected", () => {
const validationData: PerseusCategorizerValidationData = {
items: ["apples", "oranges"],
};

const userInput = {
values: [2],
} as const;
const score = validateCategorizer(
userInput,
validationData,
mockStrings,
);

expect(score).toHaveInvalidInput(
"Make sure you select something for every row.",
);
});

it("returns null if the userInput is valid", () => {
const validationData: PerseusCategorizerValidationData = {
items: ["apples", "oranges"],
};

const userInput = {
values: [2, 4],
} as const;
const score = validateCategorizer(
userInput,
validationData,
mockStrings,
);

expect(score).toBeNull();
});
});
34 changes: 34 additions & 0 deletions packages/perseus/src/widgets/categorizer/validate-categorizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type {PerseusStrings} from "../../strings";
import type {PerseusScore} from "../../types";
import type {
PerseusCategorizerUserInput,
PerseusCategorizerValidationData,
} from "../../validation.types";

/**
* Checks userInput from the categorizer widget to see if the user has selected
* a category for each item.
* @param userInput - The user's input corresponding to an array of indices that
* represent the selected category for each row/item.
* @param validationData - An array of strings corresponding to each row/item
* @param strings - Used to provide a validation message
*/
function validateCategorizer(
userInput: PerseusCategorizerUserInput,
validationData: PerseusCategorizerValidationData,
strings: PerseusStrings,
): Extract<PerseusScore, {type: "invalid"}> | null {
const incomplete = validationData.items.some(
(_, i) => userInput.values[i] == null,
);

if (incomplete) {
return {
type: "invalid",
message: strings.invalidSelection,
};
}
return null;
}

export default validateCategorizer;
24 changes: 4 additions & 20 deletions packages/perseus/src/widgets/dropdown/score-dropdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,6 @@ import type {
} from "../../validation.types";

describe("scoreDropdown", () => {
it("returns invalid for user input of 0", () => {
// Arrange
const userInput: PerseusDropdownUserInput = {
value: 0,
};
const rubric: PerseusDropdownRubric = {
choices: question1.widgets["dropdown 1"].options.choices,
};

// Act
const result = scoreDropdown(userInput, rubric);

// Assert
expect(result).toHaveInvalidInput();
});

it("returns 0 points for incorrect answer", () => {
// Arrange
const userInput: PerseusDropdownUserInput = {
Expand All @@ -33,10 +17,10 @@ describe("scoreDropdown", () => {
};

// Act
const result = scoreDropdown(userInput, rubric);
const score = scoreDropdown(userInput, rubric);

// Assert
expect(result).toHaveBeenAnsweredIncorrectly();
expect(score).toHaveBeenAnsweredIncorrectly();
});

it("returns 1 point for correct answer", () => {
Expand All @@ -49,9 +33,9 @@ describe("scoreDropdown", () => {
};

// Act
const result = scoreDropdown(userInput, rubric);
const score = scoreDropdown(userInput, rubric);

// Assert
expect(result).toHaveBeenAnsweredCorrectly();
expect(score).toHaveBeenAnsweredCorrectly();
});
});
13 changes: 6 additions & 7 deletions packages/perseus/src/widgets/dropdown/score-dropdown.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import validateDropdown from "./validate-dropdown";

import type {PerseusScore} from "../../types";
import type {
PerseusDropdownRubric,
Expand All @@ -8,14 +10,11 @@ function scoreDropdown(
userInput: PerseusDropdownUserInput,
rubric: PerseusDropdownRubric,
): PerseusScore {
const selected = userInput.value;
if (selected === 0) {
return {
type: "invalid",
message: null,
};
const validationError = validateDropdown(userInput);
if (validationError) {
return validationError;
}
const correct = rubric.choices[selected - 1].correct;
const correct = rubric.choices[userInput.value - 1].correct;
return {
type: "points",
earned: correct ? 1 : 0,
Expand Down
Loading
Loading