Skip to content

Commit 4c10af1

Browse files
authored
[Client Validation] Use emptyWidgetsFunctional in scoring (#2083)
This PR completes the work of extracting validation logic from scoring logic. This retains most of the validation that used to be intermingled with scoring. This means that even when we strip scoring data from the widget options, we'll still be able to check if an answer is scorable. Notably: `input-number` and `numeric-input` missed the train here. Both of these widgets use `KhanAnswerTypes` right at the beginning of scoring. Further, the `coefficient` answer type [allows](https://github.com/Khan/perseus/blob/f7160d66f6e0185dd11d8b802ad88f94cf4b92dd/packages/perseus/src/util/answer-types.ts#L394) an empty value (`""`) and bare negative (`"-"`) to be treated as answers by coercing them to `1` and `-1` respectively. This means that we cannot do any validation/empty checking for these widgets because we need the full `KhanAnswerTypes` logic (which requires scoring data to work). Issue: LEMS-2561 # Test plan: `yarn test` `yarn typecheck` Author: jeremywiebe Reviewers: handeyeco, Myranae, jeremywiebe Required Reviewers: Approved By: handeyeco, Myranae Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x) Pull Request URL: #2083
1 parent 1a75ca6 commit 4c10af1

File tree

3 files changed

+164
-38
lines changed

3 files changed

+164
-38
lines changed

.changeset/pink-pumas-hug.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/perseus": minor
3+
---
4+
5+
Use empty widgets check in scoring function

packages/perseus/src/renderer-util.test.ts

+145-21
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import {screen} from "@testing-library/react";
1+
import {act, screen} from "@testing-library/react";
22
import {userEvent as userEventLib} from "@testing-library/user-event";
33

44
import {
55
testDependencies,
66
testDependenciesV2,
77
} from "../../../testing/test-dependencies";
88

9+
import {question1} from "./__testdata__/renderer.testdata";
910
import * as Dependencies from "./dependencies";
1011
import {
1112
emptyWidgetsFunctional,
@@ -15,7 +16,7 @@ import {
1516
import {mockStrings} from "./strings";
1617
import {registerAllWidgetsForTesting} from "./util/register-all-widgets-for-testing";
1718
import {renderQuestion} from "./widgets/__testutils__/renderQuestion";
18-
import {question1} from "./widgets/group/group.testdata";
19+
import DropdownWidgetExport from "./widgets/dropdown";
1920

2021
import type {UserInputMap} from "./validation.types";
2122
import type {
@@ -78,6 +79,7 @@ function getLegacyExpressionWidget() {
7879
},
7980
};
8081
}
82+
8183
describe("renderer utils", () => {
8284
beforeAll(() => {
8385
registerAllWidgetsForTesting();
@@ -743,23 +745,151 @@ describe("renderer utils", () => {
743745
});
744746
});
745747

748+
it("should return empty if any validator returns empty", () => {
749+
// Act
750+
const validatorSpy = jest
751+
.spyOn(DropdownWidgetExport, "validator")
752+
// 1st call - Empty
753+
.mockReturnValueOnce({
754+
type: "invalid",
755+
message: null,
756+
})
757+
// 2nd call - Not empty
758+
.mockReturnValueOnce(null);
759+
const scoringSpy = jest
760+
.spyOn(DropdownWidgetExport, "scorer")
761+
.mockReturnValueOnce({type: "points", total: 1, earned: 1});
762+
763+
// Act
764+
const score = scorePerseusItem(
765+
{
766+
content:
767+
question1.content +
768+
question1.content.replace("dropdown 1", "dropdown 2"),
769+
widgets: {
770+
"dropdown 1": question1.widgets["dropdown 1"],
771+
"dropdown 2": question1.widgets["dropdown 1"],
772+
},
773+
images: {},
774+
},
775+
{},
776+
mockStrings,
777+
"en",
778+
);
779+
780+
// Assert
781+
expect(validatorSpy).toHaveBeenCalledTimes(2);
782+
// Scoring is only called if validation passes
783+
expect(scoringSpy).toHaveBeenCalledTimes(1);
784+
expect(score).toEqual({type: "invalid", message: null});
785+
});
786+
787+
it("should score item if all validators return null", () => {
788+
// Arrange
789+
const validatorSpy = jest
790+
.spyOn(DropdownWidgetExport, "validator")
791+
.mockReturnValue(null);
792+
const scoreSpy = jest
793+
.spyOn(DropdownWidgetExport, "scorer")
794+
.mockReturnValue({
795+
type: "points",
796+
total: 1,
797+
earned: 1,
798+
message: null,
799+
});
800+
801+
// Act
802+
const score = scorePerseusItem(
803+
{
804+
content:
805+
question1.content +
806+
question1.content.replace("dropdown 1", "dropdown 2"),
807+
widgets: {
808+
"dropdown 1": question1.widgets["dropdown 1"],
809+
"dropdown 2": question1.widgets["dropdown 1"],
810+
},
811+
images: {},
812+
},
813+
{"dropdown 1": {value: 0}},
814+
mockStrings,
815+
"en",
816+
);
817+
818+
// Assert
819+
expect(validatorSpy).toHaveBeenCalledTimes(2);
820+
expect(scoreSpy).toHaveBeenCalledTimes(2);
821+
expect(score).toEqual({
822+
type: "points",
823+
total: 2,
824+
earned: 2,
825+
message: null,
826+
});
827+
});
828+
829+
it("should return correct, with no points earned, if widget is static", () => {
830+
const validatorSpy = jest.spyOn(DropdownWidgetExport, "validator");
831+
832+
const score = scorePerseusItem(
833+
{
834+
...question1,
835+
widgets: {
836+
"dropdown 1": {
837+
...question1.widgets["dropdown 1"],
838+
static: true,
839+
},
840+
},
841+
},
842+
{"dropdown 1": {value: 1}},
843+
mockStrings,
844+
"en",
845+
);
846+
847+
expect(validatorSpy).not.toHaveBeenCalled();
848+
expect(score).toHaveBeenAnsweredCorrectly({
849+
shouldHavePoints: false,
850+
});
851+
});
852+
853+
it("should ignore widgets that aren't referenced in content", () => {
854+
const validatorSpy = jest.spyOn(DropdownWidgetExport, "validator");
855+
const score = scorePerseusItem(
856+
{
857+
content:
858+
"This content references [[☃ dropdown 1]] but not dropdown 2!",
859+
widgets: {
860+
...question1.widgets,
861+
"dropdown 2": {
862+
...question1.widgets["dropdown 1"],
863+
},
864+
},
865+
images: {},
866+
},
867+
{"dropdown 1": {value: 2}},
868+
mockStrings,
869+
"en",
870+
);
871+
872+
expect(validatorSpy).toHaveBeenCalledTimes(1);
873+
expect(score).toHaveBeenAnsweredCorrectly({
874+
shouldHavePoints: true,
875+
});
876+
});
877+
746878
it("should return score from contained Renderer", async () => {
747879
// Arrange
748880
const {renderer} = renderQuestion(question1);
749-
// Answer all widgets correctly
750-
await userEvent.click(screen.getAllByRole("radio")[4]);
751-
// Note(jeremy): If we don't tab away from the radio button in this
752-
// test, it seems like the userEvent typing doesn't land in the first
753-
// text field.
754-
await userEvent.tab();
755-
await userEvent.type(
756-
screen.getByRole("textbox", {name: /nearest ten/}),
757-
"230",
758-
);
759-
await userEvent.type(
760-
screen.getByRole("textbox", {name: /nearest hundred/}),
761-
"200",
881+
882+
// Answer correctly
883+
await userEvent.click(screen.getByRole("combobox"));
884+
await act(() => jest.runOnlyPendingTimers());
885+
886+
await userEvent.click(
887+
screen.getByRole("option", {
888+
name: "less than or equal to",
889+
}),
762890
);
891+
892+
// Act
763893
const userInput = renderer.getUserInputMap();
764894
const score = scorePerseusItem(
765895
question1,
@@ -770,12 +900,6 @@ describe("renderer utils", () => {
770900

771901
// Assert
772902
expect(score).toHaveBeenAnsweredCorrectly();
773-
expect(score).toEqual({
774-
earned: 3,
775-
message: null,
776-
total: 3,
777-
type: "points",
778-
});
779903
});
780904
});
781905
});

packages/perseus/src/renderer-util.ts

+14-17
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import {mapObject} from "@khanacademy/perseus-core";
1+
import {
2+
mapObject,
3+
type PerseusRenderer,
4+
type PerseusWidgetsMap,
5+
} from "@khanacademy/perseus-core";
26

37
import {scoreIsEmpty, flattenScores} from "./util/scoring";
48
import {getWidgetIdsFromContent} from "./widget-type-utils";
@@ -10,15 +14,7 @@ import {
1014

1115
import type {PerseusStrings} from "./strings";
1216
import type {PerseusScore} from "./types";
13-
import type {
14-
UserInput,
15-
UserInputMap,
16-
ValidationDataMap,
17-
} from "./validation.types";
18-
import type {
19-
PerseusRenderer,
20-
PerseusWidgetsMap,
21-
} from "@khanacademy/perseus-core";
17+
import type {ValidationDataMap, UserInputMap} from "./validation.types";
2218

2319
export function getUpgradedWidgetOptions(
2420
oldWidgetOptions: PerseusWidgetsMap,
@@ -54,7 +50,7 @@ export function emptyWidgetsFunctional(
5450
widgets: ValidationDataMap,
5551
// This is a port of old code, I'm not sure why
5652
// we need widgetIds vs the keys of the widgets object
57-
widgetIds: Array<string>,
53+
widgetIds: ReadonlyArray<string>,
5854
userInputMap: UserInputMap,
5955
strings: PerseusStrings,
6056
locale: string,
@@ -128,13 +124,14 @@ export function scoreWidgetsFunctional(
128124
}
129125

130126
const userInput = userInputMap[id];
127+
const validator = getWidgetValidator(widget.type);
131128
const scorer = getWidgetScorer(widget.type);
132-
const score = scorer?.(
133-
userInput as UserInput,
134-
widget.options,
135-
strings,
136-
locale,
137-
);
129+
130+
// We do validation (empty checks) first and then scoring. If
131+
// validation fails, it's result is itself a PerseusScore.
132+
const score =
133+
validator?.(userInput, widget.options, strings, locale) ??
134+
scorer?.(userInput, widget.options, strings, locale);
138135
if (score != null) {
139136
widgetScores[id] = score;
140137
}

0 commit comments

Comments
 (0)