From f43edd42ccfacd1500d2f73ccb0d3f8dce777173 Mon Sep 17 00:00:00 2001 From: Tamara <60857422+Myranae@users.noreply.github.com> Date: Tue, 26 Nov 2024 14:27:44 -0600 Subject: [PATCH] Orderer: Extract validation out of scoring (#1869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: To complete server-side scoring, we are separating out validation logic from scoring logic. This separates that logic and updates associated tests. Issue: LEMS-2603 ## Test plan: - Confirm all checks pass - Confirm widget still works as expected Author: Myranae Reviewers: jeremywiebe, handeyeco, Myranae Required Reviewers: Approved By: jeremywiebe, handeyeco Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ gerald, ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ gerald, ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ gerald Pull Request URL: https://github.com/Khan/perseus/pull/1869 --- .changeset/sharp-radios-burn.md | 5 +++ .../src/widgets/orderer/score-orderer.test.ts | 43 ++++++++++++++++--- .../src/widgets/orderer/score-orderer.ts | 12 +++--- .../widgets/orderer/validate-orderer.test.ts | 31 +++++++++++++ .../src/widgets/orderer/validate-orderer.ts | 23 ++++++++++ 5 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 .changeset/sharp-radios-burn.md create mode 100644 packages/perseus/src/widgets/orderer/validate-orderer.test.ts create mode 100644 packages/perseus/src/widgets/orderer/validate-orderer.ts diff --git a/.changeset/sharp-radios-burn.md b/.changeset/sharp-radios-burn.md new file mode 100644 index 0000000000..8517ff1850 --- /dev/null +++ b/.changeset/sharp-radios-burn.md @@ -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. diff --git a/packages/perseus/src/widgets/orderer/score-orderer.test.ts b/packages/perseus/src/widgets/orderer/score-orderer.test.ts index dd4478a188..a5595f5249 100644 --- a/packages/perseus/src/widgets/orderer/score-orderer.test.ts +++ b/packages/perseus/src/widgets/orderer/score-orderer.test.ts @@ -1,12 +1,13 @@ import {question1} from "./orderer.testdata"; import {scoreOrderer} from "./score-orderer"; +import * as OrdererValidator from "./validate-orderer"; import type { PerseusOrdererRubric, PerseusOrdererUserInput, } from "../../validation.types"; -describe("ordererValiator", () => { +describe("scoreOrderer", () => { it("is correct when the userInput is in the same order and is the same length as the rubric's correctOption content items", () => { // Arrange const rubric: PerseusOrdererRubric = @@ -21,7 +22,7 @@ describe("ordererValiator", () => { // Act const result = scoreOrderer(userInput, rubric); - // assert + // Assert expect(result).toHaveBeenAnsweredCorrectly(); }); @@ -37,7 +38,7 @@ describe("ordererValiator", () => { // Act const result = scoreOrderer(userInput, rubric); - // assert + // Assert expect(result).toHaveBeenAnsweredIncorrectly(); }); @@ -53,12 +54,41 @@ describe("ordererValiator", () => { // Act const result = scoreOrderer(userInput, rubric); - // assert + // Assert expect(result).toHaveBeenAnsweredIncorrectly(); }); - it("is invalid when the when the user has not started ordering the options and current is empty", () => { + it("should be correctly answerable if validation passes", () => { // Arrange + const mockValidator = jest + .spyOn(OrdererValidator, "default") + .mockReturnValue(null); + + const rubric: PerseusOrdererRubric = + question1.widgets["orderer 1"].options; + + const userInput: PerseusOrdererUserInput = { + current: question1.widgets["orderer 1"].options.correctOptions.map( + (option) => option.content, + ), + }; + // Act + const result = scoreOrderer(userInput, rubric); + + // Assert + expect(mockValidator).toHaveBeenCalledWith(userInput); + expect(result).toHaveBeenAnsweredCorrectly(); + }); + + it("should return an invalid response if validation fails", () => { + // Arrange + const mockValidator = jest + .spyOn(OrdererValidator, "default") + .mockReturnValue({ + type: "invalid", + message: null, + }); + const rubric: PerseusOrdererRubric = question1.widgets["orderer 1"].options; @@ -69,7 +99,8 @@ describe("ordererValiator", () => { // Act const result = scoreOrderer(userInput, rubric); - // assert + // Assert + expect(mockValidator).toHaveBeenCalledWith(userInput); expect(result).toHaveInvalidInput(); }); }); diff --git a/packages/perseus/src/widgets/orderer/score-orderer.ts b/packages/perseus/src/widgets/orderer/score-orderer.ts index a887bb5381..a1d5336ccf 100644 --- a/packages/perseus/src/widgets/orderer/score-orderer.ts +++ b/packages/perseus/src/widgets/orderer/score-orderer.ts @@ -1,5 +1,7 @@ import _ from "underscore"; +import validateOrderer from "./validate-orderer"; + import type {PerseusScore} from "../../types"; import type { PerseusOrdererRubric, @@ -10,16 +12,14 @@ export function scoreOrderer( userInput: PerseusOrdererUserInput, rubric: PerseusOrdererRubric, ): PerseusScore { - if (userInput.current.length === 0) { - return { - type: "invalid", - message: null, - }; + const validateError = validateOrderer(userInput); + if (validateError) { + return validateError; } const correct = _.isEqual( userInput.current, - _.pluck(rubric.correctOptions, "content"), + rubric.correctOptions.map((option) => option.content), ); return { diff --git a/packages/perseus/src/widgets/orderer/validate-orderer.test.ts b/packages/perseus/src/widgets/orderer/validate-orderer.test.ts new file mode 100644 index 0000000000..7f48781dda --- /dev/null +++ b/packages/perseus/src/widgets/orderer/validate-orderer.test.ts @@ -0,0 +1,31 @@ +import validateOrderer from "./validate-orderer"; + +import type {PerseusOrdererUserInput} from "../../validation.types"; + +describe("validateOrderer", () => { + it("is invalid when the user has not started ordering the options and current is empty", () => { + // Arrange + const userInput: PerseusOrdererUserInput = { + current: [], + }; + + // Act + const result = validateOrderer(userInput); + + // Assert + expect(result).toHaveInvalidInput(); + }); + + it("is null when the user has started ordering the options and current has at least one option", () => { + // Arrange + const userInput: PerseusOrdererUserInput = { + current: ["$10.9$"], + }; + + // Act + const result = validateOrderer(userInput); + + // Assert + expect(result).toBeNull(); + }); +}); diff --git a/packages/perseus/src/widgets/orderer/validate-orderer.ts b/packages/perseus/src/widgets/orderer/validate-orderer.ts new file mode 100644 index 0000000000..1d28599271 --- /dev/null +++ b/packages/perseus/src/widgets/orderer/validate-orderer.ts @@ -0,0 +1,23 @@ +import type {PerseusScore} from "../../types"; +import type {PerseusOrdererUserInput} from "../../validation.types"; + +/** + * Checks user input from the orderer widget to see if the user has started + * ordering the options, making the widget scorable. + * @param userInput + * @see `scoreOrderer` for more details. + */ +function validateOrderer( + userInput: PerseusOrdererUserInput, +): Extract | null { + if (userInput.current.length === 0) { + return { + type: "invalid", + message: null, + }; + } + + return null; +} + +export default validateOrderer;