From 99cd254de354bbebf6b6ea84e0c33241d2a18763 Mon Sep 17 00:00:00 2001 From: Jeremy Wiebe Date: Fri, 13 Dec 2024 15:03:05 -0800 Subject: [PATCH 01/13] Swap out deprecated input-number with numeric-input in some tests (#1995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Subject The `input-number` widget is deprecated and was causing issues in the work I'm doing for validation and SSS. Swapping them out for the `numeric-input` so that I have a path forward. # Test Plan `yarn test` `yarn typecheck` Issue: LEMS-2561 Author: jeremywiebe Reviewers: Myranae, handeyeco, SonicScrewdriver Required Reviewers: Approved By: handeyeco Checks: ✅ Publish npm snapshot (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), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/1995 --- .changeset/spicy-cups-join.md | 5 + .../src/__testdata__/renderer.testdata.ts | 33 ++-- .../src/__tests__/perseus-markdown.test.ts | 12 +- .../perseus/src/__tests__/renderer.test.tsx | 146 +++++++++--------- 4 files changed, 105 insertions(+), 91 deletions(-) create mode 100644 .changeset/spicy-cups-join.md diff --git a/.changeset/spicy-cups-join.md b/.changeset/spicy-cups-join.md new file mode 100644 index 0000000000..b52541c3ef --- /dev/null +++ b/.changeset/spicy-cups-join.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +TESTS: swap input-number out of renderer tests as it is deprecated diff --git a/packages/perseus/src/__testdata__/renderer.testdata.ts b/packages/perseus/src/__testdata__/renderer.testdata.ts index 9160ef2a84..b2b7b417c2 100644 --- a/packages/perseus/src/__testdata__/renderer.testdata.ts +++ b/packages/perseus/src/__testdata__/renderer.testdata.ts @@ -1,7 +1,7 @@ import type { DropdownWidget, ImageWidget, - InputNumberWidget, + NumericInputWidget, PerseusRenderer, } from "../perseus-types"; import type {RenderProps} from "../widgets/radio"; @@ -49,22 +49,31 @@ export const imageWidget: ImageWidget = { version: {major: 0, minor: 0}, }; -export const inputNumberWidget: InputNumberWidget = { +export const numericInputWidget: NumericInputWidget = { + graded: true, version: { major: 0, minor: 0, }, - type: "input-number", - graded: true, - alignment: "default", + static: false, + type: "numeric-input", options: { - maxError: 0.1, - inexact: false, - value: 0.3333333333333333, - simplify: "optional", - answerType: "rational", + coefficient: false, + static: false, + answers: [ + { + status: "correct", + maxError: null, + strict: false, + value: 1252, + simplify: "required", + message: "", + }, + ], + labelText: "", size: "normal", }, + alignment: "default", }; export const question1: PerseusRenderer = { @@ -76,7 +85,7 @@ export const question1: PerseusRenderer = { export const question2: PerseusRenderer = { content: - "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 input-number 1]] \n\n\n\n", + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 numeric-input 1]] \n\n\n\n", images: { "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": { @@ -84,7 +93,7 @@ export const question2: PerseusRenderer = { height: 200, }, }, - widgets: {"input-number 1": inputNumberWidget}, + widgets: {"numeric-input 1": numericInputWidget}, }; export const definitionItem: PerseusRenderer = { diff --git a/packages/perseus/src/__tests__/perseus-markdown.test.ts b/packages/perseus/src/__tests__/perseus-markdown.test.ts index 20d298587b..389fe132e3 100644 --- a/packages/perseus/src/__tests__/perseus-markdown.test.ts +++ b/packages/perseus/src/__tests__/perseus-markdown.test.ts @@ -298,7 +298,7 @@ describe("perseus markdown", () => { ], }, { - content: "[[☃ test 1]]+[[☃ input-number 2]]", + content: "[[☃ test 1]]+[[☃ numeric-input 2]]", expected: [ { type: "paragraph", @@ -314,15 +314,15 @@ describe("perseus markdown", () => { }, { type: "widget", - widgetType: "input-number", - id: "input-number 2", + widgetType: "numeric-input", + id: "numeric-input 2", }, ], }, ], }, { - content: "*[[☃ test 2]]* [[☃ input-number 1]]", + content: "*[[☃ test 2]]* [[☃ numeric-input 1]]", expected: [ { type: "paragraph", @@ -343,8 +343,8 @@ describe("perseus markdown", () => { }, { type: "widget", - widgetType: "input-number", - id: "input-number 1", + widgetType: "numeric-input", + id: "numeric-input 1", }, ], }, diff --git a/packages/perseus/src/__tests__/renderer.test.tsx b/packages/perseus/src/__tests__/renderer.test.tsx index 8db770a186..13b2f07645 100644 --- a/packages/perseus/src/__tests__/renderer.test.tsx +++ b/packages/perseus/src/__tests__/renderer.test.tsx @@ -9,7 +9,7 @@ import {testDependencies} from "../../../../testing/test-dependencies"; import { dropdownWidget, imageWidget, - inputNumberWidget, + numericInputWidget, question1, question2, definitionItem, @@ -20,7 +20,7 @@ import * as Dependencies from "../dependencies"; import {registerWidget} from "../widgets"; import {renderQuestion} from "../widgets/__testutils__/renderQuestion"; import {simpleGroupQuestion} from "../widgets/group/group.testdata"; -import InputNumberExport from "../widgets/input-number"; +import NumericInputExport from "../widgets/numeric-input"; import RadioWidgetExport from "../widgets/radio"; import type {PerseusRenderer, DropdownWidget} from "../perseus-types"; @@ -47,8 +47,8 @@ jest.mock("../translation-linter", () => { describe("renderer", () => { beforeAll(() => { // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: InputNumberExport is not assignable to type WidgetExports - registerWidget("input-number", InputNumberExport); + // @ts-expect-error: NumericInputExport is not assignable to type WidgetExports + registerWidget("numeric-input", NumericInputExport); // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: RadioWidgetExport is not assignable to type WidgetExports registerWidget("radio", RadioWidgetExport); @@ -884,11 +884,11 @@ describe("renderer", () => { // Arrange const question = { content: - "A dropdown [[☃ dropdown 1]]\nAn input [[☃ input-number 1]]\n\nAnd an image [[☃ image 1]].", + "A dropdown [[☃ dropdown 1]]\nAn input [[☃ numeric-input 1]]\n\nAnd an image [[☃ image 1]].", images: {}, widgets: { "dropdown 1": dropdownWidget, - "input-number 1": inputNumberWidget, + "numeric-input 1": numericInputWidget, "image 1": imageWidget, }, } as const; @@ -950,11 +950,11 @@ describe("renderer", () => { { ...question2, content: - "Enter 1 in this field: [[☃ input-number 1]].\n\n" + - "Enter 2 in this field: [[☃ input-number 2]] $60$.", + "Enter 1 in this field: [[☃ numeric-input 1]].\n\n" + + "Enter 2 in this field: [[☃ numeric-input 2]] $60$.", widgets: { - "input-number 1": question2.widgets["input-number 1"], - "input-number 2": question2.widgets["input-number 1"], + "numeric-input 1": question2.widgets["numeric-input 1"], + "numeric-input 2": question2.widgets["numeric-input 1"], }, }, {onFocusChange}, @@ -965,7 +965,7 @@ describe("renderer", () => { // Assert expect(onFocusChange).toHaveBeenCalledWith( - /* new focus path */ ["input-number 2"], + /* new focus path */ ["numeric-input 2"], /* old focus path */ null, ); }); @@ -977,11 +977,11 @@ describe("renderer", () => { { ...question2, content: - "Enter 1 in this field: [[☃ input-number 1]].\n\n" + - "Enter 2 in this field: [[☃ input-number 2]] $60$.", + "Enter 1 in this field: [[☃ numeric-input 1]].\n\n" + + "Enter 2 in this field: [[☃ numeric-input 2]] $60$.", widgets: { - "input-number 1": question2.widgets["input-number 1"], - "input-number 2": question2.widgets["input-number 1"], + "numeric-input 1": question2.widgets["numeric-input 1"], + "numeric-input 2": question2.widgets["numeric-input 1"], }, }, {onFocusChange}, @@ -997,7 +997,7 @@ describe("renderer", () => { // Assert expect(onFocusChange).toHaveBeenCalledWith( /* new focus path */ null, - /* old focus path */ ["input-number 2"], + /* old focus path */ ["numeric-input 2"], ); }); @@ -1020,7 +1020,7 @@ describe("renderer", () => { const {renderer} = renderQuestion(question2); // Act - act(() => renderer.focusPath(["input-number 1"])); + act(() => renderer.focusPath(["numeric-input 1"])); // Assert expect(screen.getByRole("textbox")).toHaveFocus(); @@ -1032,11 +1032,11 @@ describe("renderer", () => { const {renderer} = renderQuestion(question2, { onFocusChange, }); - act(() => renderer.focusPath(["input-number 1"])); + act(() => renderer.focusPath(["numeric-input 1"])); onFocusChange.mockClear(); // Act - act(() => renderer.focusPath(["input-number 1"])); + act(() => renderer.focusPath(["numeric-input 1"])); // Assert expect(onFocusChange).not.toHaveBeenCalled(); @@ -1049,25 +1049,25 @@ describe("renderer", () => { { ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": question2.widgets["input-number 1"], + "numeric-input 2": question2.widgets["numeric-input 1"], }, }, {onFocusChange}, ); - act(() => renderer.focusPath(["input-number 1"])); + act(() => renderer.focusPath(["numeric-input 1"])); onFocusChange.mockClear(); // Act - act(() => renderer.focusPath(["input-number 2"])); + act(() => renderer.focusPath(["numeric-input 2"])); // Assert expect(onFocusChange).toHaveBeenCalledWith( - ["input-number 2"], // New focus - ["input-number 1"], // Old focus + ["numeric-input 2"], // New focus + ["numeric-input 1"], // Old focus ); }); @@ -1078,11 +1078,11 @@ describe("renderer", () => { { ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": question2.widgets["input-number 1"], + "numeric-input 2": question2.widgets["numeric-input 1"], }, }, {onFocusChange}, @@ -1092,7 +1092,7 @@ describe("renderer", () => { onFocusChange.mockClear(); // Act - act(() => renderer.blurPath(["input-number 1"])); + act(() => renderer.blurPath(["numeric-input 1"])); // Assert expect(onFocusChange).not.toHaveBeenCalled(); @@ -1105,11 +1105,11 @@ describe("renderer", () => { { ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": question2.widgets["input-number 1"], + "numeric-input 2": question2.widgets["numeric-input 1"], }, }, {onFocusChange}, @@ -1125,7 +1125,7 @@ describe("renderer", () => { // Assert expect(onFocusChange).toHaveBeenCalledWith( null, // New focus - ["input-number 2"], // Old focus + ["numeric-input 2"], // Old focus ); }); @@ -1136,11 +1136,11 @@ describe("renderer", () => { { ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": question2.widgets["input-number 1"], + "numeric-input 2": question2.widgets["numeric-input 1"], }, }, {onFocusChange}, @@ -1440,18 +1440,18 @@ describe("renderer", () => { ); }); - it("should return user input", async () => { + it("[DEPRECATED] should return user input array", async () => { // Arrange const {renderer} = renderQuestion({ ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]\n\n" + + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]\n\n" + "A widget that doesn't implement getUserInput: [[☃ image 1]]", widgets: { ...question2.widgets, - "input-number 2": { - ...question2.widgets["input-number 1"], + "numeric-input 2": { + ...question2.widgets["numeric-input 1"], static: true, }, "image 1": { @@ -1489,13 +1489,13 @@ describe("renderer", () => { const {renderer} = renderQuestion({ ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]\n\n" + + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]\n\n" + "A widget that doesn't implement getUserInput: [[☃ image 1]]", widgets: { ...question2.widgets, - "input-number 2": { - ...question2.widgets["input-number 1"], + "numeric-input 2": { + ...question2.widgets["numeric-input 1"], static: true, }, "image 1": { @@ -1517,8 +1517,8 @@ describe("renderer", () => { // Assert expect(widgetIds).toStrictEqual([ - "input-number 1", - "input-number 2", + "numeric-input 1", + "numeric-input 2", "image 1", ]); }); @@ -1607,11 +1607,11 @@ describe("renderer", () => { const {renderer} = renderQuestion({ ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": question2.widgets["input-number 1"], + "numeric-input 2": question2.widgets["numeric-input 1"], }, }); await userEvent.type(screen.getAllByRole("textbox")[0], "150"); @@ -1620,7 +1620,7 @@ describe("renderer", () => { const emptyWidgets = renderer.emptyWidgets(); // Assert - expect(emptyWidgets).toStrictEqual(["input-number 2"]); + expect(emptyWidgets).toStrictEqual(["numeric-input 2"]); }); it("should not return static widgets even if empty", () => { @@ -1628,12 +1628,12 @@ describe("renderer", () => { const {renderer} = renderQuestion({ ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": { - ...question2.widgets["input-number 1"], + "numeric-input 2": { + ...question2.widgets["numeric-input 1"], static: true, }, }, @@ -1643,7 +1643,7 @@ describe("renderer", () => { const emptyWidgets = renderer.emptyWidgets(); // Assert - expect(emptyWidgets).toStrictEqual(["input-number 1"]); + expect(emptyWidgets).toStrictEqual(["numeric-input 1"]); }); it("should return widget ID for group with empty widget", () => { @@ -1693,12 +1693,12 @@ describe("renderer", () => { const {renderer} = renderQuestion({ ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": { - ...question2.widgets["input-number 1"], + "numeric-input 2": { + ...question2.widgets["numeric-input 1"], static: true, }, }, @@ -1706,7 +1706,7 @@ describe("renderer", () => { const cb = jest.fn(); // Act - act(() => renderer.setInputValue(["input-number 2"], "1000", cb)); + act(() => renderer.setInputValue(["numeric-input 2"], "1000", cb)); // Assert expect(screen.getAllByRole("textbox")[0]).toHaveValue(""); @@ -1718,12 +1718,12 @@ describe("renderer", () => { const {renderer} = renderQuestion({ ...question2, content: - "Input 1: [[☃ input-number 1]]\n\n" + - "Input 2: [[☃ input-number 2]]", + "Input 1: [[☃ numeric-input 1]]\n\n" + + "Input 2: [[☃ numeric-input 2]]", widgets: { ...question2.widgets, - "input-number 2": { - ...question2.widgets["input-number 1"], + "numeric-input 2": { + ...question2.widgets["numeric-input 1"], static: true, }, }, @@ -1731,7 +1731,7 @@ describe("renderer", () => { const cb = jest.fn(); // Act - act(() => renderer.setInputValue(["input-number 2"], "1000", cb)); + act(() => renderer.setInputValue(["numeric-input 2"], "1000", cb)); act(() => jest.runOnlyPendingTimers()); // Assert @@ -1744,14 +1744,14 @@ describe("renderer", () => { // Arrange const {renderer} = renderQuestion({ content: - "Input widget: [[\u2603 input-number 1]]\n\n" + + "Input widget: [[\u2603 numeric-input 1]]\n\n" + "Dropdown widget: [[\u2603 dropdown 1]]\n\n" + "Image widget (won't have user input): [[\u2603 image 1]]\n\n" + - "Another input widget: [[\u2603 input-number 2]]", + "Another input widget: [[\u2603 numeric-input 2]]", widgets: { "image 1": imageWidget, - "input-number 1": inputNumberWidget, - "input-number 2": inputNumberWidget, + "numeric-input 1": numericInputWidget, + "numeric-input 2": numericInputWidget, "dropdown 1": dropdownWidget, }, images: {}, @@ -1775,10 +1775,10 @@ describe("renderer", () => { "dropdown 1": { "value": 1, }, - "input-number 1": { + "numeric-input 1": { "currentValue": "100", }, - "input-number 2": { + "numeric-input 2": { "currentValue": "200", }, } From 0db68d2227118cf4de51c3ccad59b525be657cf3 Mon Sep 17 00:00:00 2001 From: Jeremy Wiebe Date: Fri, 13 Dec 2024 17:40:56 -0800 Subject: [PATCH 02/13] SSS: Hook emptyWidgets() up to validation functions (#2000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: This PR begins connecting the validation functions that have been built to the existing `emptyWidgets()` function that already exists on the `Renderer` (as well as `emptyWidgesFunctional()`). This function powers the `APIOptions.answerableCallback` which is what the exercise chrome using Perseus uses to detect if a question is at a point where it can be scored. Note that some widget's "emptiness" checks depend on scoring data and so those checks are not included in the empty checking now. Issue: LEMS-2561 ## Test plan: yarn test yarn typecheck Author: jeremywiebe Reviewers: jeremywiebe, handeyeco, Myranae Required Reviewers: Approved By: 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), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2000 --- .changeset/quiet-adults-look.md | 5 ++ .../src/__testdata__/renderer.testdata.ts | 24 +++++++ .../perseus/src/__tests__/renderer.test.tsx | 31 +++++---- packages/perseus/src/renderer-util.ts | 36 ++++++---- packages/perseus/src/types.ts | 16 +++++ packages/perseus/src/validation.types.ts | 66 ++++++++++++++++++- packages/perseus/src/widgets.ts | 7 ++ .../src/widgets/categorizer/categorizer.tsx | 4 ++ .../perseus/src/widgets/dropdown/dropdown.tsx | 4 ++ .../src/widgets/expression/expression.tsx | 4 ++ .../src/widgets/group/group.testdata.ts | 30 ++++----- packages/perseus/src/widgets/group/group.tsx | 6 +- .../src/widgets/group/validate-group.ts | 31 +++++++++ .../perseus/src/widgets/matrix/matrix.tsx | 4 ++ .../src/widgets/number-line/number-line.tsx | 4 ++ .../perseus/src/widgets/orderer/orderer.tsx | 4 ++ .../perseus/src/widgets/plotter/plotter.tsx | 4 ++ packages/perseus/src/widgets/radio/radio.ts | 4 ++ .../perseus/src/widgets/sorter/sorter.tsx | 4 ++ packages/perseus/src/widgets/table/table.tsx | 4 ++ 20 files changed, 243 insertions(+), 49 deletions(-) create mode 100644 .changeset/quiet-adults-look.md create mode 100644 packages/perseus/src/widgets/group/validate-group.ts diff --git a/.changeset/quiet-adults-look.md b/.changeset/quiet-adults-look.md new file mode 100644 index 0000000000..c1d9f4aaa0 --- /dev/null +++ b/.changeset/quiet-adults-look.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +Change empty widgets check in Renderer to depend only on data available (and not on scoring data) diff --git a/packages/perseus/src/__testdata__/renderer.testdata.ts b/packages/perseus/src/__testdata__/renderer.testdata.ts index b2b7b417c2..93a4e1e6b6 100644 --- a/packages/perseus/src/__testdata__/renderer.testdata.ts +++ b/packages/perseus/src/__testdata__/renderer.testdata.ts @@ -1,11 +1,29 @@ import type { DropdownWidget, + ExpressionWidget, ImageWidget, NumericInputWidget, PerseusRenderer, } from "../perseus-types"; import type {RenderProps} from "../widgets/radio"; +export const expressionWidget: ExpressionWidget = { + type: "expression", + options: { + answerForms: [ + { + considered: "correct", + form: true, + simplify: true, + value: "1.0", + }, + ], + buttonSets: ["basic"], + functions: [], + times: true, + }, +}; + export const dropdownWidget: DropdownWidget = { type: "dropdown", alignment: "default", @@ -96,6 +114,12 @@ export const question2: PerseusRenderer = { widgets: {"numeric-input 1": numericInputWidget}, }; +export const question3: PerseusRenderer = { + content: "Enter $1.0$ in the input field: [[\u2603 expression 1]]\n\n\n\n", + images: {}, + widgets: {"expression 1": expressionWidget}, +}; + export const definitionItem: PerseusRenderer = { content: "Mock widgets ==> [[\u2603 definition 1]] [[\u2603 definition 2]] [[\u2603 definition 3]]", diff --git a/packages/perseus/src/__tests__/renderer.test.tsx b/packages/perseus/src/__tests__/renderer.test.tsx index 13b2f07645..209f1f09bf 100644 --- a/packages/perseus/src/__tests__/renderer.test.tsx +++ b/packages/perseus/src/__tests__/renderer.test.tsx @@ -15,6 +15,7 @@ import { definitionItem, mockedRandomItem, mockedShuffledRadioProps, + question3, } from "../__testdata__/renderer.testdata"; import * as Dependencies from "../dependencies"; import {registerWidget} from "../widgets"; @@ -1605,35 +1606,36 @@ describe("renderer", () => { it("should return all empty widgets", async () => { // Arrange const {renderer} = renderQuestion({ - ...question2, + ...question3, content: - "Input 1: [[☃ numeric-input 1]]\n\n" + - "Input 2: [[☃ numeric-input 2]]", + "Input 1: [[☃ expression 1]]\n\n" + + "Input 2: [[☃ expression 2]]", widgets: { - ...question2.widgets, - "numeric-input 2": question2.widgets["numeric-input 1"], + ...question3.widgets, + "expression 2": question3.widgets["expression 1"], }, }); await userEvent.type(screen.getAllByRole("textbox")[0], "150"); + act(() => jest.runOnlyPendingTimers()); // Act const emptyWidgets = renderer.emptyWidgets(); // Assert - expect(emptyWidgets).toStrictEqual(["numeric-input 2"]); + expect(emptyWidgets).toStrictEqual(["expression 2"]); }); it("should not return static widgets even if empty", () => { // Arrange const {renderer} = renderQuestion({ - ...question2, + ...question3, content: - "Input 1: [[☃ numeric-input 1]]\n\n" + - "Input 2: [[☃ numeric-input 2]]", + "Input 1: [[☃ expression 1]]\n\n" + + "Input 2: [[☃ expression 2]]", widgets: { - ...question2.widgets, - "numeric-input 2": { - ...question2.widgets["numeric-input 1"], + ...question3.widgets, + "expression 2": { + ...question3.widgets["expression 1"], static: true, }, }, @@ -1643,7 +1645,7 @@ describe("renderer", () => { const emptyWidgets = renderer.emptyWidgets(); // Assert - expect(emptyWidgets).toStrictEqual(["numeric-input 1"]); + expect(emptyWidgets).toStrictEqual(["expression 1"]); }); it("should return widget ID for group with empty widget", () => { @@ -1663,7 +1665,7 @@ describe("renderer", () => { JSON.stringify(simpleGroupQuestion), ); simpleGroupQuestionCopy.widgets["group 1"].options.widgets[ - "numeric-input 1" + "expression 1" ].static = true; const {renderer} = renderQuestion(simpleGroupQuestionCopy); @@ -1678,6 +1680,7 @@ describe("renderer", () => { // Arrange const {renderer} = renderQuestion(simpleGroupQuestion); await userEvent.type(screen.getByRole("textbox"), "99"); + act(() => jest.runOnlyPendingTimers()); // Act const emptyWidgets = renderer.emptyWidgets(); diff --git a/packages/perseus/src/renderer-util.ts b/packages/perseus/src/renderer-util.ts index 6c05e18cf0..2a54a30a8c 100644 --- a/packages/perseus/src/renderer-util.ts +++ b/packages/perseus/src/renderer-util.ts @@ -1,12 +1,20 @@ import {mapObject} from "./interactive2/objective_"; import {scoreIsEmpty, flattenScores} from "./util/scoring"; import {getWidgetIdsFromContent} from "./widget-type-utils"; -import {getWidgetScorer, upgradeWidgetInfoToLatestVersion} from "./widgets"; +import { + getWidgetScorer, + getWidgetValidator, + upgradeWidgetInfoToLatestVersion, +} from "./widgets"; import type {PerseusRenderer, PerseusWidgetsMap} from "./perseus-types"; import type {PerseusStrings} from "./strings"; import type {PerseusScore} from "./types"; -import type {UserInput, UserInputMap} from "./validation.types"; +import type { + UserInput, + UserInputMap, + ValidationDataMap, +} from "./validation.types"; export function getUpgradedWidgetOptions( oldWidgetOptions: PerseusWidgetsMap, @@ -33,8 +41,13 @@ export function getUpgradedWidgetOptions( }); } +/** + * Checks the given user input to see if any answerable widgets have not been + * "filled in" (ie. if they're empty). Another way to think about this + * function is that its a check to see if we can score the provided input. + */ export function emptyWidgetsFunctional( - widgets: PerseusWidgetsMap, + widgets: ValidationDataMap, // This is a port of old code, I'm not sure why // we need widgetIds vs the keys of the widgets object widgetIds: Array, @@ -42,22 +55,17 @@ export function emptyWidgetsFunctional( strings: PerseusStrings, locale: string, ): ReadonlyArray { - const upgradedWidgets = getUpgradedWidgetOptions(widgets); - return widgetIds.filter((id) => { - const widget = upgradedWidgets[id]; - if (!widget || widget.static) { + const widget = widgets[id]; + if (!widget || widget.static === true) { // Static widgets shouldn't count as empty return false; } - const scorer = getWidgetScorer(widget.type); - const score = scorer?.( - userInputMap[id] as UserInput, - widget.options, - strings, - locale, - ); + const validator = getWidgetValidator(widget.type); + const userInput = userInputMap[id]; + const validationData = widget.options; + const score = validator?.(userInput, validationData, strings, locale); if (score) { return scoreIsEmpty(score); diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index 6d5d8970ca..af33410513 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -13,6 +13,7 @@ import type { UserInput, UserInputArray, UserInputMap, + ValidationData, } from "./validation.types"; import type {WidgetPromptJSON} from "./widget-ai-utils/prompt-types"; import type {KeypadAPI} from "@khanacademy/math-input"; @@ -578,6 +579,13 @@ export type WidgetTransform = ( export type ValidationResult = Extract | null; +export type WidgetValidatorFunction = ( + userInput: UserInput, + validationData: ValidationData, + strings: PerseusStrings, + locale: string, +) => ValidationResult; + export type WidgetScorerFunction = ( // The user data needed to score userInput: UserInput, @@ -632,6 +640,14 @@ export type WidgetExports< */ staticTransform?: WidgetTransform; // this is a function of some sort, + /** + * Validates the learner's guess to check if it's sufficient for scoring. + * Typically, this is basically an "emptiness" check, but for some widgets + * such as `interactive-graph` it is a check that the learner has made any + * edits (ie. the widget is not in it's origin state). + */ + validator?: WidgetValidatorFunction; + /** * A function that scores user input (the guess) for the widget. */ diff --git a/packages/perseus/src/validation.types.ts b/packages/perseus/src/validation.types.ts index 03f7139c7c..b3ca2cc08f 100644 --- a/packages/perseus/src/validation.types.ts +++ b/packages/perseus/src/validation.types.ts @@ -90,6 +90,7 @@ export type PerseusExpressionRubric = { export type PerseusExpressionUserInput = string; export type PerseusGroupRubric = PerseusGroupWidgetOptions; +export type PerseusGroupValidationData = {widgets: ValidationDataMap}; export type PerseusGroupUserInput = UserInputMap; export type PerseusGradedGroupRubric = PerseusGradedGroupWidgetOptions; @@ -264,6 +265,7 @@ export type UserInput = | PerseusDropdownUserInput | PerseusExpressionUserInput | PerseusGrapherUserInput + | PerseusGroupUserInput | PerseusIFrameUserInput | PerseusInputNumberUserInput | PerseusInteractiveGraphUserInput @@ -278,7 +280,7 @@ export type UserInput = | PerseusSorterUserInput | PerseusTableUserInput; -export type UserInputMap = {[widgetId: string]: UserInput | UserInputMap}; +export type UserInputMap = {[widgetId: string]: UserInput}; /** * deprecated prefer using UserInputMap @@ -286,3 +288,65 @@ export type UserInputMap = {[widgetId: string]: UserInput | UserInputMap}; export type UserInputArray = ReadonlyArray< UserInputArray | UserInput | null | undefined >; +export interface ValidationDataTypes { + categorizer: PerseusCategorizerValidationData; + // "cs-program": PerseusCSProgramValidationData; + // definition: PerseusDefinitionValidationData; + // dropdown: PerseusDropdownRubric; + // explanation: PerseusExplanationValidationData; + // expression: PerseusExpressionValidationData; + // grapher: PerseusGrapherValidationData; + // "graded-group-set": PerseusGradedGroupSetValidationData; + // "graded-group": PerseusGradedGroupValidationData; + group: PerseusGroupValidationData; + // iframe: PerseusIFrameValidationData; + // image: PerseusImageValidationData; + // "input-number": PerseusInputNumberValidationData; + // interaction: PerseusInteractionValidationData; + // "interactive-graph": PerseusInteractiveGraphValidationData; + // "label-image": PerseusLabelImageValidationData; + // matcher: PerseusMatcherValidationData; + // matrix: PerseusMatrixValidationData; + // measurer: PerseusMeasurerValidationData; + // "molecule-renderer": PerseusMoleculeRendererValidationData; + // "number-line": PerseusNumberLineValidationData; + // "numeric-input": PerseusNumericInputValidationData; + // orderer: PerseusOrdererValidationData; + // "passage-ref-target": PerseusRefTargetValidationData; + // "passage-ref": PerseusPassageRefValidationData; + // passage: PerseusPassageValidationData; + // "phet-simulation": PerseusPhetSimulationValidationData; + // "python-program": PerseusPythonProgramValidationData; + plotter: PerseusPlotterValidationData; + // radio: PerseusRadioValidationData; + // sorter: PerseusSorterValidationData; + // table: PerseusTableValidationData; + // video: PerseusVideoValidationData; + + // Deprecated widgets + // sequence: PerseusAutoCorrectValidationData; +} + +/** + * A map of validation data, keyed by `widgetId`. This data is used to check if + * a question is answerable. This data represents the minimal intersection of + * data that's available in the client (widget options) and server (scoring + * data) and is represented by a group of types known as "validation data". + * + * NOTE: The value in this map is intentionally a subset of WidgetOptions. + * By using the same shape (minus any unneeded data), we are able to pass a + * `PerseusWidgetsMap` or ` into any function that accepts a + * `ValidationDataMap` without any mutation of data. + */ +export type ValidationDataMap = { + [Property in keyof ValidationDataTypes as `${Property} ${number}`]: { + type: Property; + static?: boolean; + options: ValidationDataTypes[Property]; + }; +}; + +/** + * A union type of all the different widget validation data types that exist. + */ +export type ValidationData = ValidationDataTypes[keyof ValidationDataTypes]; diff --git a/packages/perseus/src/widgets.ts b/packages/perseus/src/widgets.ts index 11991cdd37..d88e09de69 100644 --- a/packages/perseus/src/widgets.ts +++ b/packages/perseus/src/widgets.ts @@ -12,6 +12,7 @@ import type { WidgetExports, WidgetTransform, WidgetScorerFunction, + WidgetValidatorFunction, } from "./types"; import type * as React from "react"; @@ -137,6 +138,12 @@ export const getWidgetExport = (name: string): WidgetExports | null => { return widgets[name] ?? null; }; +export const getWidgetValidator = ( + name: string, +): WidgetValidatorFunction | null => { + return widgets[name]?.validator ?? null; +}; + export const getWidgetScorer = (name: string): WidgetScorerFunction | null => { return widgets[name]?.scorer ?? null; }; diff --git a/packages/perseus/src/widgets/categorizer/categorizer.tsx b/packages/perseus/src/widgets/categorizer/categorizer.tsx index 27069da230..6bf02234e0 100644 --- a/packages/perseus/src/widgets/categorizer/categorizer.tsx +++ b/packages/perseus/src/widgets/categorizer/categorizer.tsx @@ -17,6 +17,7 @@ import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/categorizer/categorizer-ai-utils"; import scoreCategorizer from "./score-categorizer"; +import validateCategorizer from "./validate-categorizer"; import type {PerseusCategorizerWidgetOptions} from "../../perseus-types"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; @@ -328,4 +329,7 @@ export default { // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusCSProgramUserInput'. scorer: scoreCategorizer, + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusCSProgramUserInput'. + validator: validateCategorizer, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/dropdown/dropdown.tsx b/packages/perseus/src/widgets/dropdown/dropdown.tsx index 487ff0a364..4f2d384b3c 100644 --- a/packages/perseus/src/widgets/dropdown/dropdown.tsx +++ b/packages/perseus/src/widgets/dropdown/dropdown.tsx @@ -9,6 +9,7 @@ import {ApiOptions} from "../../perseus-api"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/dropdown/dropdown-ai-utils"; import scoreDropdown from "./score-dropdown"; +import validateDropdown from "./validate-dropdown"; import type {PerseusDropdownWidgetOptions} from "../../perseus-types"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; @@ -162,4 +163,7 @@ export default { // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusDropdownUserInput'. scorer: scoreDropdown, + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusDropdownUserInput'. + validator: validateDropdown, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/expression/expression.tsx b/packages/perseus/src/widgets/expression/expression.tsx index e2b649062d..7c95cce897 100644 --- a/packages/perseus/src/widgets/expression/expression.tsx +++ b/packages/perseus/src/widgets/expression/expression.tsx @@ -20,6 +20,7 @@ import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/expression/ import getDecimalSeparator from "./get-decimal-separator"; import scoreExpression from "./score-expression"; +import validateExpression from "./validate-expression"; import type {DependenciesContext} from "../../dependencies"; import type {PerseusExpressionWidgetOptions} from "../../perseus-types"; @@ -558,6 +559,9 @@ export default { // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusExpressionUserInput'. scorer: scoreExpression, + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusExpressionUserInput'. + validator: validateExpression, // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type 'Rubric' is not assignable to type 'PerseusExpressionRubric'. diff --git a/packages/perseus/src/widgets/group/group.testdata.ts b/packages/perseus/src/widgets/group/group.testdata.ts index 5e709c3a9d..df0f2580d0 100644 --- a/packages/perseus/src/widgets/group/group.testdata.ts +++ b/packages/perseus/src/widgets/group/group.testdata.ts @@ -159,32 +159,24 @@ export const simpleGroupQuestion: PerseusRenderer = { "group 1": { graded: true, options: { - content: "[[☃ numeric-input 1]]", + content: "[[☃ expression 1]]", images: {}, widgets: { - "numeric-input 1": { - alignment: "default", - graded: true, + "expression 1": { + type: "expression", options: { - answers: [ + answerForms: [ { - maxError: null, - message: "", - simplify: "required", - status: "correct", - strict: false, - value: 230, + considered: "correct", + form: true, + simplify: true, + value: "1.0", }, ], - coefficient: false, - labelText: "value rounded to the nearest ten", - rightAlign: false, - size: "normal", - static: false, + buttonSets: ["basic"], + functions: [], + times: true, }, - static: false, - type: "numeric-input", - version: {major: 0, minor: 0}, }, }, }, diff --git a/packages/perseus/src/widgets/group/group.tsx b/packages/perseus/src/widgets/group/group.tsx index ec5ae760d0..882c4991e4 100644 --- a/packages/perseus/src/widgets/group/group.tsx +++ b/packages/perseus/src/widgets/group/group.tsx @@ -9,6 +9,7 @@ import Renderer from "../../renderer"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/group/group-ai-utils"; import scoreGroup from "./score-group"; +import validateGroup from "./validate-group"; import type {PerseusGroupWidgetOptions} from "../../perseus-types"; import type { @@ -205,8 +206,11 @@ export default { widget: Group, traverseChildWidgets: traverseChildWidgets, // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusCSProgramUserInput'. + // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusGroupUserInput'. scorer: scoreGroup, + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusGroupUserInput'. + validator: validateGroup, hidden: true, isLintable: true, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/group/validate-group.ts b/packages/perseus/src/widgets/group/validate-group.ts new file mode 100644 index 0000000000..39417d362c --- /dev/null +++ b/packages/perseus/src/widgets/group/validate-group.ts @@ -0,0 +1,31 @@ +import {emptyWidgetsFunctional} from "../../renderer-util"; + +import type {PerseusStrings} from "../../strings"; +import type {ValidationResult} from "../../types"; +import type { + PerseusGroupUserInput, + PerseusGroupValidationData, +} from "../../validation.types"; + +function validateGroup( + userInput: PerseusGroupUserInput, + validationData: PerseusGroupValidationData, + strings: PerseusStrings, + locale: string, +): ValidationResult { + const emptyWidgets = emptyWidgetsFunctional( + validationData.widgets, + Object.keys(validationData.widgets), + userInput, + strings, + locale, + ); + + if (emptyWidgets.length === 0) { + return null; + } + + return {type: "invalid", message: null}; +} + +export default validateGroup; diff --git a/packages/perseus/src/widgets/matrix/matrix.tsx b/packages/perseus/src/widgets/matrix/matrix.tsx index eb0dc3bd64..47773730b7 100644 --- a/packages/perseus/src/widgets/matrix/matrix.tsx +++ b/packages/perseus/src/widgets/matrix/matrix.tsx @@ -16,6 +16,7 @@ import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/matrix/matrix-ai-utils"; import scoreMatrix from "./score-matrix"; +import validateMatrix from "./validate-matrix"; import type { PerseusMatrixWidgetAnswers, @@ -600,4 +601,7 @@ export default { // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusMatrixUserInput'. scorer: scoreMatrix, + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusMatrixUserInput'. + validator: validateMatrix, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/number-line/number-line.tsx b/packages/perseus/src/widgets/number-line/number-line.tsx index abfcf9c732..5da2f6ce74 100644 --- a/packages/perseus/src/widgets/number-line/number-line.tsx +++ b/packages/perseus/src/widgets/number-line/number-line.tsx @@ -15,6 +15,7 @@ import KhanMath from "../../util/math"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/number-line/number-line-ai-utils"; import scoreNumberLine from "./score-number-line"; +import validateNumberLine from "./validate-number-line"; import type {ChangeableProps} from "../../mixins/changeable"; import type {APIOptions, WidgetExports, FocusPath, Widget} from "../../types"; @@ -808,4 +809,7 @@ export default { // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusNumberLineUserInput'. scorer: scoreNumberLine, + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusNumberLineUserInput'. + validator: validateNumberLine, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/orderer/orderer.tsx b/packages/perseus/src/widgets/orderer/orderer.tsx index 6f297b0b4d..59e63d85c9 100644 --- a/packages/perseus/src/widgets/orderer/orderer.tsx +++ b/packages/perseus/src/widgets/orderer/orderer.tsx @@ -15,6 +15,7 @@ import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/orderer/orderer-ai-utils"; import {scoreOrderer} from "./score-orderer"; +import validateOrderer from "./validate-orderer"; import type {PerseusOrdererWidgetOptions} from "../../perseus-types"; import type {WidgetExports, WidgetProps, Widget} from "../../types"; @@ -785,4 +786,7 @@ export default { // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type UserInput is not assignable to type PerseusOrdererUserInput scorer: scoreOrderer, + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type UserInput is not assignable to type PerseusOrdererUserInput + validator: validateOrderer, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/plotter/plotter.tsx b/packages/perseus/src/widgets/plotter/plotter.tsx index 8e985698ac..3ae9ddc8df 100644 --- a/packages/perseus/src/widgets/plotter/plotter.tsx +++ b/packages/perseus/src/widgets/plotter/plotter.tsx @@ -14,6 +14,7 @@ import KhanMath from "../../util/math"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/plotter/plotter-ai-utils"; import scorePlotter from "./score-plotter"; +import validatePlotter from "./validate-plotter"; import type {PerseusPlotterWidgetOptions} from "../../perseus-types"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; @@ -1182,4 +1183,7 @@ export default { // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type UserInput is not assignable to type PerseusPlotterUserInput scorer: scorePlotter, + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type UserInput is not assignable to type PerseusPlotterUserInput + validator: validatePlotter, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/radio/radio.ts b/packages/perseus/src/widgets/radio/radio.ts index 528514e7c9..bc8d6e14be 100644 --- a/packages/perseus/src/widgets/radio/radio.ts +++ b/packages/perseus/src/widgets/radio/radio.ts @@ -4,6 +4,7 @@ import Util from "../../util"; import Radio from "./radio-component"; import scoreRadio from "./score-radio"; +import validateRadio from "./validate-radio"; import type {RenderProps, RadioChoiceWithMetadata} from "./radio-component"; import type {PerseusRadioWidgetOptions} from "../../perseus-types"; @@ -155,4 +156,7 @@ export default { // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type UserInput is not assignable to type PerseusRadioUserInput scorer: scoreRadio, + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type UserInput is not assignable to type PerseusRadioUserInput + validator: validateRadio, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/sorter/sorter.tsx b/packages/perseus/src/widgets/sorter/sorter.tsx index 6959507dce..7d0134b8d3 100644 --- a/packages/perseus/src/widgets/sorter/sorter.tsx +++ b/packages/perseus/src/widgets/sorter/sorter.tsx @@ -6,6 +6,7 @@ import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/sorter/sorter-ai-utils"; import scoreSorter from "./score-sorter"; +import validateSorter from "./validate-sorter"; import type {SortableOption} from "../../components/sortable"; import type {PerseusSorterWidgetOptions} from "../../perseus-types"; @@ -136,4 +137,7 @@ export default { // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type UserInput is not assignable to type PerseusSorterUserInput scorer: scoreSorter, + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type UserInput is not assignable to type PerseusSorterUserInput + validator: validateSorter, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/table/table.tsx b/packages/perseus/src/widgets/table/table.tsx index 6340cce3b2..31fb289837 100644 --- a/packages/perseus/src/widgets/table/table.tsx +++ b/packages/perseus/src/widgets/table/table.tsx @@ -11,6 +11,7 @@ import Renderer from "../../renderer"; import Util from "../../util"; import scoreTable from "./score-table"; +import validateTable from "./validate-table"; import type {ChangeableProps} from "../../mixins/changeable"; import type {PerseusTableWidgetOptions} from "../../perseus-types"; @@ -327,4 +328,7 @@ export default { // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type UserInput is not assignable to type PerseusTableUserInput scorer: scoreTable, + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type UserInput is not assignable to type PerseusTableUserInput + validator: validateTable, } satisfies WidgetExports; From 193d9138de2ef9e5c22b903784fb848ca72103eb Mon Sep 17 00:00:00 2001 From: Jeremy Wiebe Date: Mon, 16 Dec 2024 14:55:28 -0800 Subject: [PATCH 03/13] Add test to document empty expression can be a correct answer (#2003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: RendererUtil: remove unnecessary cast to UserInput Issue: LEMS-2561 ## Test plan: Author: jeremywiebe Reviewers: jeremywiebe, handeyeco, Myranae Required Reviewers: Approved By: handeyeco Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Check builds for changes in size (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), ⏹️ [cancelled] Publish npm snapshot (ubuntu-latest, 20.x), ⏹️ [cancelled] Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ⏹️ [cancelled] Cypress (ubuntu-latest, 20.x), ⏹️ [cancelled] Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ⏹️ [cancelled] Check builds for changes in size (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2003 --- .../numeric-input/score-numeric-input.test.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/perseus/src/widgets/numeric-input/score-numeric-input.test.ts b/packages/perseus/src/widgets/numeric-input/score-numeric-input.test.ts index 144fc2a63b..b0ad938871 100644 --- a/packages/perseus/src/widgets/numeric-input/score-numeric-input.test.ts +++ b/packages/perseus/src/widgets/numeric-input/score-numeric-input.test.ts @@ -6,13 +6,39 @@ import scoreNumericInput, {maybeParsePercentInput} from "./score-numeric-input"; import type {PerseusNumericInputRubric} from "../../validation.types"; -describe("static function validate", () => { +describe("scoreNumericInput", () => { beforeEach(() => { jest.spyOn(Dependencies, "getDependencies").mockReturnValue( testDependencies, ); }); + it("is correct when input is empty but answer is 1 and coefficient: true", () => { + const rubric: PerseusNumericInputRubric = { + answers: [ + { + value: 1, + status: "correct", + maxError: 0, + simplify: "optional", + strict: false, + message: "", + }, + ], + coefficient: true, + }; + + const userInput = { + // Empty input being translated to "1" depends on coefficient being + // true. + currentValue: "", + }; + + const score = scoreNumericInput(userInput, rubric, mockStrings); + + expect(score).toHaveBeenAnsweredCorrectly(); + }); + it("with a simple value", () => { const rubric: PerseusNumericInputRubric = { answers: [ From 0464a760f3b6b49e30accde9b41a320dd2ea7bed Mon Sep 17 00:00:00 2001 From: Tamara <60857422+Myranae@users.noreply.github.com> Date: Tue, 17 Dec 2024 15:04:43 -0600 Subject: [PATCH 04/13] Remove unused rubric type for CS Program (#1997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: This removes an unused rubric type. The iframe tells the widget if the user's input is correct or not, so a rubric is not needed. Additionally, validation happens after scoring, so it won't be possible to create a validation function for this widget at this time either. Issue: LEMS-2440 ## Test plan: - Confirm all checks pass - Confirm widget still works as expected via a ZND (CS Program works via iFrame, which tens not to be testable locally) Author: Myranae Reviewers: jeremywiebe, handeyeco 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 builds for changes in size (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/1997 --- .changeset/fifty-laws-hear.md | 5 +++++ packages/perseus/src/validation.types.ts | 7 ------- packages/perseus/src/widgets/cs-program/cs-program.tsx | 7 ++----- 3 files changed, 7 insertions(+), 12 deletions(-) create mode 100644 .changeset/fifty-laws-hear.md diff --git a/.changeset/fifty-laws-hear.md b/.changeset/fifty-laws-hear.md new file mode 100644 index 0000000000..4af67fb219 --- /dev/null +++ b/.changeset/fifty-laws-hear.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Remove unused CS Program rubric type diff --git a/packages/perseus/src/validation.types.ts b/packages/perseus/src/validation.types.ts index b3ca2cc08f..1cf12a0b25 100644 --- a/packages/perseus/src/validation.types.ts +++ b/packages/perseus/src/validation.types.ts @@ -63,12 +63,6 @@ export type PerseusCategorizerValidationData = { items: ReadonlyArray; }; -// 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 -// and is already included in the Props type. -export type PerseusCSProgramRubric = Empty; - export type PerseusCSProgramUserInput = { status: UserInputStatus; message: string | null; @@ -238,7 +232,6 @@ export type PerseusTableUserInput = ReadonlyArray>; export type Rubric = | PerseusCategorizerScoringData - | PerseusCSProgramRubric | PerseusDropdownRubric | PerseusExpressionRubric | PerseusGroupRubric diff --git a/packages/perseus/src/widgets/cs-program/cs-program.tsx b/packages/perseus/src/widgets/cs-program/cs-program.tsx index 1b60ef2159..317d829b9e 100644 --- a/packages/perseus/src/widgets/cs-program/cs-program.tsx +++ b/packages/perseus/src/widgets/cs-program/cs-program.tsx @@ -19,17 +19,14 @@ import scoreCSProgram from "./score-cs-program"; import type {PerseusCSProgramWidgetOptions} from "../../perseus-types"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; -import type { - PerseusCSProgramRubric, - PerseusCSProgramUserInput, -} from "../../validation.types"; +import type {PerseusCSProgramUserInput} from "../../validation.types"; import type {UnsupportedWidgetPromptJSON} from "../../widget-ai-utils/unsupported-widget"; const {updateQueryString} = Util; type RenderProps = PerseusCSProgramWidgetOptions & PerseusCSProgramUserInput; -type Props = WidgetProps; +type Props = WidgetProps; type DefaultProps = { showEditor: Props["showEditor"]; From b6623bb569c8776ad5bf4e770789e4b079e230e0 Mon Sep 17 00:00:00 2001 From: Tamara <60857422+Myranae@users.noreply.github.com> Date: Tue, 17 Dec 2024 15:20:35 -0600 Subject: [PATCH 05/13] Remove unused rubric type for iFrame (#1996) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: The initial goal was to separate out answers from the userInput, but iFrame is a unique widget in that the answers aren't actually contained in what is sent to the scoring function. Scoring happens via the iFrame, and the scoring function is told if the user is correct, incorrect, and incomplete. Additionally, validation happens after both types of scoring, so it most likely will not be possible to have a validation function for this widget either. Question: Would it be useful to change what the `scoreIframe` parameter is called as it is not actually the user's input, but a summary of the results of checking the user's input? We could revert the argument back to `state` or maybe `results`, but then would we change the type name? I wonder if that would be confusing down the road, but we do have comments explaining it somewhat. Issue: LEMS-2440 ## Test plan: - Confirm all checks pass - Confirm widget still works as expected via a ZND (iframe tends not to be testable locally) Author: Myranae Reviewers: Myranae, jeremywiebe, handeyeco Required Reviewers: Approved By: jeremywiebe, handeyeco Checks: ✅ Publish npm snapshot (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), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ⏹️ [cancelled] Publish npm snapshot (ubuntu-latest, 20.x), ⏹️ [cancelled] Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ⏹️ [cancelled] Check builds for changes in size (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ⏹️ [cancelled] Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ⏹️ [cancelled] Cypress (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/1996 --- .changeset/proud-ghosts-learn.md | 5 +++++ packages/perseus/src/validation.types.ts | 4 ---- packages/perseus/src/widgets/iframe/iframe.tsx | 3 +-- 3 files changed, 6 insertions(+), 6 deletions(-) create mode 100644 .changeset/proud-ghosts-learn.md diff --git a/.changeset/proud-ghosts-learn.md b/.changeset/proud-ghosts-learn.md new file mode 100644 index 0000000000..efe49d764d --- /dev/null +++ b/.changeset/proud-ghosts-learn.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Remove unused iframe rubric type diff --git a/packages/perseus/src/validation.types.ts b/packages/perseus/src/validation.types.ts index 1cf12a0b25..887ad3cb88 100644 --- a/packages/perseus/src/validation.types.ts +++ b/packages/perseus/src/validation.types.ts @@ -97,9 +97,6 @@ export type PerseusGrapherRubric = { export type PerseusGrapherUserInput = PerseusGrapherRubric["correct"]; -// TODO(LEMS-2440): Can possibly be removed during 2440; only userInput used -export type PerseusIFrameRubric = Empty; - export type PerseusIFrameUserInput = { status: UserInputStatus; message: string | null; @@ -238,7 +235,6 @@ export type Rubric = | PerseusGradedGroupRubric | PerseusGradedGroupSetRubric | PerseusGrapherRubric - | PerseusIFrameRubric | PerseusInputNumberRubric | PerseusInteractiveGraphRubric | PerseusLabelImageRubric diff --git a/packages/perseus/src/widgets/iframe/iframe.tsx b/packages/perseus/src/widgets/iframe/iframe.tsx index 8018a529f6..92c2b15d87 100644 --- a/packages/perseus/src/widgets/iframe/iframe.tsx +++ b/packages/perseus/src/widgets/iframe/iframe.tsx @@ -21,7 +21,6 @@ import {scoreIframe} from "./score-iframe"; import type {PerseusIFrameWidgetOptions} from "../../perseus-types"; import type {WidgetExports, WidgetProps, Widget} from "../../types"; import type { - PerseusIFrameRubric, PerseusIFrameUserInput, UserInputStatus, } from "../../validation.types"; @@ -36,7 +35,7 @@ type RenderProps = PerseusIFrameWidgetOptions & { height: string; }; -type Props = WidgetProps; +type Props = WidgetProps; type DefaultProps = { status: Props["status"]; From 0f2bec314518636e822e8ca0fc080209f4be8bfe Mon Sep 17 00:00:00 2001 From: Tamara <60857422+Myranae@users.noreply.github.com> Date: Tue, 17 Dec 2024 15:31:55 -0600 Subject: [PATCH 06/13] Refactor LabelImage to separate out answers from userInput into scoringData (#1965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: This PR updates LabelImage so that answers are no longer available in the userInput object. This allows the scoring function to have both a userInput parameter and a scoringData parameter to keep answers separate from the user's input with the goal of supporting server side scoring. PerseusLabelImageMarker and MarkerType contained the same properties. As such, I simplified the code a bit and removed PerseusLabelImageMarker. In addition, several locations were referencing the wrong types, so those were updated to reference the correct ones. Also, new tests were added confirming the output of `getUserInput` does not contain answers, that `scorePerseusItem` returns the correct results, and that the widget renders correctly if answers are not present in the JSON blob. Issue: LEMS-2440 ## Test plan: - Confirm all checks pass - Confirm widget still works as expected Author: Myranae Reviewers: Myranae, handeyeco, jeremywiebe, catandthemachines Required Reviewers: Approved By: catandthemachines, jeremywiebe Checks: ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Publish npm snapshot (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: https://github.com/Khan/perseus/pull/1965 --- .changeset/many-penguins-hug.md | 6 + .../label-image-editor.stories.tsx | 4 +- .../src/widgets/label-image-editor.tsx | 10 +- .../__stories__/question-markers.stories.tsx | 4 +- .../src/widgets/label-image/marker.tsx | 10 +- .../widgets/label-image/question-markers.tsx | 6 +- packages/perseus/src/index.ts | 2 +- packages/perseus/src/validation.types.ts | 17 +- .../label-image/__tests__/label-image.test.ts | 113 ++++++++++++- .../__tests__/label-image.testdata.ts | 106 +++++++++++++ .../src/widgets/label-image/label-image.tsx | 34 ++-- .../label-image/score-label-image.test.ts | 150 +++++++++++------- .../widgets/label-image/score-label-image.ts | 31 ++-- .../perseus/src/widgets/label-image/types.ts | 2 +- 14 files changed, 378 insertions(+), 117 deletions(-) create mode 100644 .changeset/many-penguins-hug.md diff --git a/.changeset/many-penguins-hug.md b/.changeset/many-penguins-hug.md new file mode 100644 index 0000000000..159312ba43 --- /dev/null +++ b/.changeset/many-penguins-hug.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": major +"@khanacademy/perseus-editor": patch +--- + +Refactor the LabelImage widget to separate out answers from userInput into scoringData diff --git a/packages/perseus-editor/src/widgets/__stories__/label-image-editor.stories.tsx b/packages/perseus-editor/src/widgets/__stories__/label-image-editor.stories.tsx index 3bd0773691..09046c580e 100644 --- a/packages/perseus-editor/src/widgets/__stories__/label-image-editor.stories.tsx +++ b/packages/perseus-editor/src/widgets/__stories__/label-image-editor.stories.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import LabelImageEditor from "../label-image-editor"; -import type {MarkerType} from "@khanacademy/perseus"; +import type {PerseusLabelImageWidgetOptions} from "@khanacademy/perseus"; type StoryArgs = Record; @@ -29,7 +29,7 @@ type State = { imageUrl: string; imageWidth: number; imageHeight: number; - markers: ReadonlyArray; + markers: PerseusLabelImageWidgetOptions["markers"]; }; class WithState extends React.Component { diff --git a/packages/perseus-editor/src/widgets/label-image-editor.tsx b/packages/perseus-editor/src/widgets/label-image-editor.tsx index ba4c5662b9..e9d9436552 100644 --- a/packages/perseus-editor/src/widgets/label-image-editor.tsx +++ b/packages/perseus-editor/src/widgets/label-image-editor.tsx @@ -17,7 +17,7 @@ import Behavior from "./label-image/behavior"; import QuestionMarkers from "./label-image/question-markers"; import SelectImage from "./label-image/select-image"; -import type {MarkerType} from "@khanacademy/perseus"; +import type {PerseusLabelImageWidgetOptions} from "@khanacademy/perseus"; type Props = { // List of answer choices to label question image with. @@ -28,7 +28,7 @@ type Props = { imageWidth: number; imageHeight: number; // The list of label markers on the question image. - markers: ReadonlyArray; + markers: PerseusLabelImageWidgetOptions["markers"]; // Whether multiple answer choices may be selected for markers. multipleAnswers: boolean; // Whether to hide answer choices from user instructions. @@ -176,9 +176,9 @@ class LabelImageEditor extends React.Component { this.props.onChange({choices}); }; - handleMarkersChange: (markers: ReadonlyArray) => void = ( - markers: ReadonlyArray, - ) => { + handleMarkersChange: ( + markers: PerseusLabelImageWidgetOptions["markers"], + ) => void = (markers: PerseusLabelImageWidgetOptions["markers"]) => { this.props.onChange({markers}); }; diff --git a/packages/perseus-editor/src/widgets/label-image/__stories__/question-markers.stories.tsx b/packages/perseus-editor/src/widgets/label-image/__stories__/question-markers.stories.tsx index 524e9f05c3..cf940d4242 100644 --- a/packages/perseus-editor/src/widgets/label-image/__stories__/question-markers.stories.tsx +++ b/packages/perseus-editor/src/widgets/label-image/__stories__/question-markers.stories.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import QuestionMarkers from "../question-markers"; -import type {MarkerType} from "@khanacademy/perseus"; +import type {PerseusLabelImageWidgetOptions} from "@khanacademy/perseus"; type StoryArgs = Record; @@ -31,7 +31,7 @@ const Wrapper = (props) => ( class WithState extends React.Component< Record, { - markers: ReadonlyArray; + markers: PerseusLabelImageWidgetOptions["markers"]; } > { state = { diff --git a/packages/perseus-editor/src/widgets/label-image/marker.tsx b/packages/perseus-editor/src/widgets/label-image/marker.tsx index ba5cba5652..2e6d85ddf1 100644 --- a/packages/perseus-editor/src/widgets/label-image/marker.tsx +++ b/packages/perseus-editor/src/widgets/label-image/marker.tsx @@ -14,13 +14,15 @@ import Option, {OptionGroup} from "../../components/dropdown-option"; import FormWrappedTextField from "../../components/form-wrapped-text-field"; import {gray17, gray85, gray98} from "../../styles/global-colors"; -import type {MarkerType} from "@khanacademy/perseus"; +import type {PerseusLabelImageWidgetOptions} from "@khanacademy/perseus"; -type Props = MarkerType & { +type Props = PerseusLabelImageWidgetOptions["markers"][number] & { // The list of possible answer choices. - choices: ReadonlyArray; + choices: PerseusLabelImageWidgetOptions["choices"]; // Callback for when any of the marker props are changed. - onChange: (marker: MarkerType) => void; + onChange: ( + marker: PerseusLabelImageWidgetOptions["markers"][number], + ) => void; // Callback to remove marker from the question image. onRemove: () => void; }; diff --git a/packages/perseus-editor/src/widgets/label-image/question-markers.tsx b/packages/perseus-editor/src/widgets/label-image/question-markers.tsx index 6ccc2a772f..e779671a6d 100644 --- a/packages/perseus-editor/src/widgets/label-image/question-markers.tsx +++ b/packages/perseus-editor/src/widgets/label-image/question-markers.tsx @@ -11,7 +11,7 @@ import {gray17, gray68} from "../../styles/global-colors"; import Marker from "./marker"; -import type {MarkerType} from "@khanacademy/perseus"; +import type {PerseusLabelImageWidgetOptions} from "@khanacademy/perseus"; type Props = { // The list of possible answers in a specific order. @@ -21,9 +21,9 @@ type Props = { imageWidth: number; imageHeight: number; // The list of markers placed on the question image. - markers: ReadonlyArray; + markers: PerseusLabelImageWidgetOptions["markers"]; // Callback for when any of markers change. - onChange: (markers: ReadonlyArray) => void; + onChange: (markers: PerseusLabelImageWidgetOptions["markers"]) => void; }; export default class QuestionMarkers extends React.Component { diff --git a/packages/perseus/src/index.ts b/packages/perseus/src/index.ts index bbcf1a6059..198c4b86c5 100644 --- a/packages/perseus/src/index.ts +++ b/packages/perseus/src/index.ts @@ -229,6 +229,7 @@ export type { PerseusInputNumberWidgetOptions, PerseusInteractiveGraphWidgetOptions, PerseusItem, + PerseusLabelImageWidgetOptions, PerseusPhetSimulationWidgetOptions, PerseusPlotterWidgetOptions, PerseusPythonProgramWidgetOptions, @@ -241,7 +242,6 @@ export type { } from "./perseus-types"; export type {UserInputMap} from "./validation.types"; export type {Coord} from "./interactive2/types"; -export type {MarkerType} from "./widgets/label-image/types"; export type { RendererPromptJSON, WidgetPromptJSON, diff --git a/packages/perseus/src/validation.types.ts b/packages/perseus/src/validation.types.ts index 887ad3cb88..24b6ce8199 100644 --- a/packages/perseus/src/validation.types.ts +++ b/packages/perseus/src/validation.types.ts @@ -43,7 +43,6 @@ import type { PerseusRadioChoice, PerseusGraphCorrectType, } from "./perseus-types"; -import type {InteractiveMarkerType} from "./widgets/label-image/types"; import type {Relationship} from "./widgets/number-line/number-line"; export type UserInputStatus = "correct" | "incorrect" | "incomplete"; @@ -130,12 +129,18 @@ export type PerseusInteractiveGraphRubric = { export type PerseusInteractiveGraphUserInput = PerseusGraphType; -/* TODO(LEMS-2440): Should be removed or refactored. Grading info may need - to be moved to the rubric from userInput. */ -export type PerseusLabelImageRubric = Empty; +export type PerseusLabelImageScoringData = { + markers: ReadonlyArray<{ + answers: ReadonlyArray; + label: string; + }>; +}; export type PerseusLabelImageUserInput = { - markers: ReadonlyArray; + markers: ReadonlyArray<{ + selected?: ReadonlyArray; + label: string; + }>; }; export type PerseusMatcherRubric = PerseusMatcherWidgetOptions; @@ -237,7 +242,7 @@ export type Rubric = | PerseusGrapherRubric | PerseusInputNumberRubric | PerseusInteractiveGraphRubric - | PerseusLabelImageRubric + | PerseusLabelImageScoringData | PerseusMatcherRubric | PerseusMatrixRubric | PerseusNumberLineScoringData diff --git a/packages/perseus/src/widgets/label-image/__tests__/label-image.test.ts b/packages/perseus/src/widgets/label-image/__tests__/label-image.test.ts index b105a4c185..c3d9736ddc 100644 --- a/packages/perseus/src/widgets/label-image/__tests__/label-image.test.ts +++ b/packages/perseus/src/widgets/label-image/__tests__/label-image.test.ts @@ -6,10 +6,15 @@ import { testDependenciesV2, } from "../../../../../../testing/test-dependencies"; import * as Dependencies from "../../../dependencies"; +import {scorePerseusItemTesting} from "../../../util/test-utils"; import {renderQuestion} from "../../__testutils__/renderQuestion"; import {LabelImage} from "../label-image"; -import {textQuestion} from "./label-image.testdata"; +import { + shortTextQuestion, + textQuestion, + textWithoutAnswersQuestion, +} from "./label-image.testdata"; import type {UserEvent} from "@testing-library/user-event"; @@ -717,4 +722,110 @@ describe("LabelImage", function () { }); }); }); + + describe("getUserInput", () => { + it("should return the current user input on initial render", () => { + // render component + const {renderer} = renderQuestion(textQuestion); + + const userInput = renderer.getUserInputMap(); + + expect(userInput).toEqual({ + "label-image 1": { + markers: [ + { + label: "The fourth unlabeled bar line.", + selected: undefined, + }, + { + label: "The third unlabeled bar line.", + selected: undefined, + }, + { + label: "The second unlabeled bar line.", + selected: undefined, + }, + { + label: "The first unlabeled bar line.", + selected: undefined, + }, + ], + }, + }); + }); + }); + + describe("scorePerseusItem", () => { + it("should be invalid on first render", () => { + // Arrange + const {renderer} = renderQuestion(textQuestion); + + // Act + const score = scorePerseusItemTesting( + textQuestion, + renderer.getUserInputMap(), + ); + + // Assert + expect(score).toHaveInvalidInput(); + }); + + it("can be answered correctly when correct option is picked for the marker", async () => { + // Arrange + const {renderer} = renderQuestion(shortTextQuestion); + + // Act + const markerButton = screen.getByRole("button", { + name: "The fourth unlabeled bar line.", + }); + await userEvent.click(markerButton); + + const choice = screen.getByRole("option", {name: "SUVs"}); + await userEvent.click(choice); + + const score = scorePerseusItemTesting( + shortTextQuestion, + renderer.getUserInputMap(), + ); + + // Assert + expect(score).toHaveBeenAnsweredCorrectly(); + }); + + it("can be answered incorrectly when incorrect option picked for the marker", async () => { + // Arrange + const {renderer} = renderQuestion(shortTextQuestion); + + // Act + const markerButton = screen.getByRole("button", { + name: "The fourth unlabeled bar line.", + }); + await userEvent.click(markerButton); + + const choice = screen.getByRole("option", {name: "Trucks"}); + await userEvent.click(choice); + + const score = scorePerseusItemTesting( + shortTextQuestion, + renderer.getUserInputMap(), + ); + + // Assert + expect(score).toHaveBeenAnsweredIncorrectly(); + }); + }); + + describe("textWithoutAnswersQuestion", () => { + it("should render the widget without answers", async () => { + // Arrange + renderQuestion(textWithoutAnswersQuestion); + + // Act and Assert + const markerButton = screen.getByRole("button", { + name: "The fourth unlabeled bar line.", + }); + // Confirms the widget renders and that marker buttons are present + await userEvent.click(markerButton); + }); + }); }); diff --git a/packages/perseus/src/widgets/label-image/__tests__/label-image.testdata.ts b/packages/perseus/src/widgets/label-image/__tests__/label-image.testdata.ts index bc1c92ed2c..0463877c07 100644 --- a/packages/perseus/src/widgets/label-image/__tests__/label-image.testdata.ts +++ b/packages/perseus/src/widgets/label-image/__tests__/label-image.testdata.ts @@ -62,6 +62,112 @@ export const textQuestion: PerseusRenderer = { }, }; +export const shortTextQuestion: PerseusRenderer = { + content: + "Carol created a chart and a bar graph to show how many of each type of vehicle were in her supermarket parking lot.\n\nVehicle Type | Number in the parking lot\n:- | :-: \nTrucks| $25$ \nVans | $5$ \nCars| $40$ \nSUVs | $10$ \n\n**Label each bar on the bar graph.**\n\n[[☃ label-image 1]]\n\n", + images: { + "web+graphie://ka-perseus-graphie.s3.amazonaws.com/1e28332fd2321975639ab50c9ce442e568c18421": + { + width: 341, + height: 310, + }, + }, + widgets: { + "label-image 1": { + type: "label-image", + alignment: "default", + static: false, + graded: true, + options: { + static: false, + choices: ["Trucks", "Vans", "Cars", "SUVs"], + imageAlt: + "A bar graph with four bar lines that shows the horizontal axis labeled Number in the parking lot and the vertical axis labeled Vehicle Type. The horizontal axis is labeled, from left to right: 0, 10, 20, 30, 40, and 50. The vertical axis has, from the bottom to the top, four unlabeled bar lines as follows: the first unlabeled bar line extends to the middle of 0 and 10, the second unlabeled bar line extends to 40, the third unlabeled bar line extends to the middle of 20 and 30, and fourth unlabeled bar line extends to 10.", + imageUrl: + "web+graphie://ka-perseus-graphie.s3.amazonaws.com/56c60c72e96cd353e4a8b5434506cd3a21e717af", + imageWidth: 415, + imageHeight: 314, + markers: [ + { + answers: ["SUVs"], + label: "The fourth unlabeled bar line.", + x: 25, + y: 17.7, + }, + ], + multipleAnswers: false, + hideChoicesFromInstructions: true, + }, + version: { + major: 0, + minor: 0, + }, + }, + }, +}; + +export const textWithoutAnswersQuestion: PerseusRenderer = { + content: + "Carol created a chart and a bar graph to show how many of each type of vehicle were in her supermarket parking lot.\n\nVehicle Type | Number in the parking lot\n:- | :-: \nTrucks| $25$ \nVans | $5$ \nCars| $40$ \nSUVs | $10$ \n\n**Label each bar on the bar graph.**\n\n[[☃ label-image 1]]\n\n", + images: { + "web+graphie://ka-perseus-graphie.s3.amazonaws.com/1e28332fd2321975639ab50c9ce442e568c18421": + { + width: 341, + height: 310, + }, + }, + widgets: { + "label-image 1": { + type: "label-image", + alignment: "default", + static: false, + graded: true, + options: { + static: false, + choices: ["Trucks", "Vans", "Cars", "SUVs"], + imageAlt: + "A bar graph with four bar lines that shows the horizontal axis labeled Number in the parking lot and the vertical axis labeled Vehicle Type. The horizontal axis is labeled, from left to right: 0, 10, 20, 30, 40, and 50. The vertical axis has, from the bottom to the top, four unlabeled bar lines as follows: the first unlabeled bar line extends to the middle of 0 and 10, the second unlabeled bar line extends to 40, the third unlabeled bar line extends to the middle of 20 and 30, and fourth unlabeled bar line extends to 10.", + imageUrl: + "web+graphie://ka-perseus-graphie.s3.amazonaws.com/56c60c72e96cd353e4a8b5434506cd3a21e717af", + imageWidth: 415, + imageHeight: 314, + markers: [ + { + answers: [], + label: "The fourth unlabeled bar line.", + x: 25, + y: 17.7, + }, + { + answers: [], + label: "The third unlabeled bar line.", + x: 25, + y: 35.3, + }, + { + answers: [], + label: "The second unlabeled bar line.", + x: 25, + y: 53, + }, + { + answers: [], + label: "The first unlabeled bar line.", + x: 25, + y: 70.3, + }, + ], + multipleAnswers: false, + hideChoicesFromInstructions: true, + }, + version: { + major: 0, + minor: 0, + }, + }, + }, +}; + export const mathQuestion: PerseusRenderer = { content: "Carol created a chart and a bar graph to show how many of each type of vehicle were in her supermarket parking lot.\n\nVehicle Type | Number in the parking lot\n:- | :-: \nTrucks| $25$ \nVans | $5$ \nCars| $40$ \nSUVs | $10$ \n\n**Label each bar on the bar graph.**\n\n[[☃ label-image 1]]\n\n", diff --git a/packages/perseus/src/widgets/label-image/label-image.tsx b/packages/perseus/src/widgets/label-image/label-image.tsx index e11d7eac89..dcf0a995cb 100644 --- a/packages/perseus/src/widgets/label-image/label-image.tsx +++ b/packages/perseus/src/widgets/label-image/label-image.tsx @@ -32,10 +32,7 @@ import type {DependencyProps} from "../../dependencies"; import type {ChangeableProps} from "../../mixins/changeable"; import type {PerseusLabelImageWidgetOptions} from "../../perseus-types"; import type {APIOptions, Widget, WidgetExports} from "../../types"; -import type { - PerseusLabelImageRubric, - PerseusLabelImageUserInput, -} from "../../validation.types"; +import type {PerseusLabelImageUserInput} from "../../validation.types"; import type {LabelImagePromptJSON} from "../../widget-ai-utils/label-image/label-image-ai-utils"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; import type {CSSProperties} from "aphrodite"; @@ -72,8 +69,6 @@ type Point = { type LabelImageProps = ChangeableProps & DependencyProps & - // TODO: there's some weirdness in our types between - // PerseusLabelImageMarker and InteractiveMarkerType Omit & { apiOptions: APIOptions; // The list of label markers on the question image. @@ -194,7 +189,7 @@ export class LabelImage */ static navigateToMarkerIndex( navigateDirection: Direction, - markers: ReadonlyArray, + markers: LabelImageProps["markers"], thisIndex: number, ): number { const thisMarker = markers[thisIndex]; @@ -310,21 +305,27 @@ export class LabelImage } getUserInput(): PerseusLabelImageUserInput { - const {markers} = this.props; - return {markers}; + return { + markers: this.props.markers.map((marker) => ({ + selected: marker.selected, + label: marker.label, + })), + }; } getPromptJSON(): LabelImagePromptJSON { return _getPromptJSON(this.props, this.getUserInput()); } - // TODO(LEMS-2544): Investigate impact on scoring; possibly pull out &/or remove rubric parameter. - showRationalesForCurrentlySelectedChoices(rubric: PerseusLabelImageRubric) { + // TODO(LEMS-2544): Investigate impact on scoring + // Also consider how scoreMarker is being called as it seems to require the marker.answers property. + // Removed scoringData parameter, but it gets a full widget options object from the renderer + showRationalesForCurrentlySelectedChoices() { const {markers} = this.props; const {onChange} = this.props; const updatedMarkers = markers.map((marker) => { - const score = scoreMarker(marker); + const score = scoreMarker(marker.selected, marker.answers); return { ...marker, @@ -342,7 +343,10 @@ export class LabelImage onChange({markers: updatedMarkers}, null, true); } - handleMarkerChange(index: number, marker: InteractiveMarkerType) { + handleMarkerChange( + index: number, + marker: LabelImageProps["markers"][number], + ) { const {markers, onChange} = this.props; // Replace marker with a changed version at the specified index. @@ -431,7 +435,7 @@ export class LabelImage selected: selected.length ? selected : undefined, }); } - + // TODO(LEMS-2723): Investigate if possible to change this to not require answers renderMarkers(): ReadonlyArray { const {markers, questionCompleted, preferredPopoverDirection} = this.props; @@ -476,7 +480,7 @@ export class LabelImage }[markerPosition]; } - const score = scoreMarker(marker); + const score = scoreMarker(marker.selected, marker.answers); // Once the question is answered, show markers // with correct answers, otherwise passthrough // the correctness state. diff --git a/packages/perseus/src/widgets/label-image/score-label-image.test.ts b/packages/perseus/src/widgets/label-image/score-label-image.test.ts index cc58962b27..08f4e331c3 100644 --- a/packages/perseus/src/widgets/label-image/score-label-image.test.ts +++ b/packages/perseus/src/widgets/label-image/score-label-image.test.ts @@ -1,18 +1,13 @@ import scoreLabelImage, {scoreMarker} from "./score-label-image"; -const emptyMarker = { - label: "", - answers: [], - selected: [], - x: 0, - y: 0, -} as const; +import type { + PerseusLabelImageScoringData, + PerseusLabelImageUserInput, +} from "../../validation.types"; describe("scoreMarker", function () { it("should score correct for empty marker with no user answers", function () { - const score = scoreMarker({ - ...emptyMarker, - }); + const score = scoreMarker([], []); expect(score).toEqual({ hasAnswers: false, @@ -21,10 +16,7 @@ describe("scoreMarker", function () { }); it("should score incorrect for empty marker with user answer", function () { - const score = scoreMarker({ - ...emptyMarker, - selected: ["Fiat"], - }); + const score = scoreMarker(["Fiat"], []); expect(score).toEqual({ hasAnswers: true, @@ -33,10 +25,7 @@ describe("scoreMarker", function () { }); it("should score incorrect for no user answers", function () { - const score = scoreMarker({ - ...emptyMarker, - answers: ["Lamborghini", "Fiat", "Ferrari"], - }); + const score = scoreMarker([], ["Lamborghini", "Fiat", "Ferrari"]); expect(score).toEqual({ hasAnswers: false, @@ -45,11 +34,10 @@ describe("scoreMarker", function () { }); it("should score incorrect for wrong user answers", function () { - const score = scoreMarker({ - ...emptyMarker, - answers: ["Lamborghini", "Fiat", "Ferrari"], - selected: ["Fiat", "Ferrari"], - }); + const score = scoreMarker( + ["Fiat", "Ferrari"], + ["Lamborghini", "Fiat", "Ferrari"], + ); expect(score).toEqual({ hasAnswers: true, @@ -58,11 +46,10 @@ describe("scoreMarker", function () { }); it("should score correct for user answers", function () { - const score = scoreMarker({ - ...emptyMarker, - answers: ["Lamborghini", "Fiat", "Ferrari"], - selected: ["Lamborghini", "Fiat", "Ferrari"], - }); + const score = scoreMarker( + ["Lamborghini", "Fiat", "Ferrari"], + ["Lamborghini", "Fiat", "Ferrari"], + ); expect(score).toEqual({ hasAnswers: true, @@ -73,138 +60,179 @@ describe("scoreMarker", function () { describe("scoreLabelImage", function () { it("should not grade non-interacted widget", function () { - const state = { + const userInput: PerseusLabelImageUserInput = { + markers: [{label: "England"}, {label: "Germany"}, {label: "Italy"}], + } as const; + + const scoringData: PerseusLabelImageScoringData = { markers: [ { - ...emptyMarker, label: "England", answers: ["Mini", "Morris Minor", "Reliant Robin"], }, { - ...emptyMarker, label: "Germany", answers: ["BMW", "Volkswagen", "Porsche"], }, { - ...emptyMarker, label: "Italy", answers: ["Lamborghini", "Fiat", "Ferrari"], }, ], } as const; - const score = scoreLabelImage(state); + const score = scoreLabelImage(userInput, scoringData); expect(score).toHaveInvalidInput(); }); it("should not grade widget with not all markers answered", function () { - const state = { + const userInput = { + markers: [ + {label: "England", selected: ["Fiat"]}, + {label: "Germany", selected: ["Lamborghini"]}, + {label: "Italy"}, + ], + } as const; + + const scoringData = { markers: [ { - ...emptyMarker, label: "England", - selected: ["Fiat"], + answers: [], }, { - ...emptyMarker, label: "Germany", answers: ["BMW", "Volkswagen", "Porsche"], - selected: ["Lamborghini"], }, { - ...emptyMarker, label: "Italy", answers: ["Lamborghini", "Fiat", "Ferrari"], }, ], } as const; - const score = scoreLabelImage(state); + const score = scoreLabelImage(userInput, scoringData); expect(score).toHaveInvalidInput(); }); it("should grade as incorrect for widget with no answers for markers", function () { - const state = { + const userInput = { markers: [ { - ...emptyMarker, label: "England", selected: ["Fiat"], }, { - ...emptyMarker, label: "Germany", selected: ["Lamborghini"], }, { - ...emptyMarker, label: "Italy", selected: ["Ferrari"], }, ], } as const; - const score = scoreLabelImage(state); + const scoringData = { + markers: [ + { + label: "England", + answers: [], + }, + { + label: "Germany", + answers: [], + }, + { + label: "Italy", + answers: [], + }, + ], + } as const; + + const score = scoreLabelImage(userInput, scoringData); expect(score).toHaveBeenAnsweredIncorrectly(); }); it("should grade as incorrect for widget with some wrong answers", function () { - const state = { + const userInput = { markers: [ { - ...emptyMarker, label: "England", - answers: ["Mini", "Morris Minor", "Reliant Robin"], selected: ["Mini"], }, { - ...emptyMarker, label: "Germany", - answers: ["BMW", "Volkswagen", "Porsche"], selected: ["BMW", "Volkswagen", "Porsche"], }, { - ...emptyMarker, label: "Italy", - answers: ["Lamborghini", "Fiat", "Ferrari"], selected: ["Ferrari"], }, ], } as const; - const score = scoreLabelImage(state); + const scoringData = { + markers: [ + { + label: "England", + answers: ["Mini", "Morris Minor", "Reliant Robin"], + }, + { + label: "Germany", + answers: ["BMW", "Volkswagen", "Porsche"], + }, + { + label: "Italy", + answers: ["Lamborghini", "Fiat", "Ferrari"], + }, + ], + } as const; + + const score = scoreLabelImage(userInput, scoringData); expect(score).toHaveBeenAnsweredIncorrectly(); }); it("should grade as correct for widget with all correct answers", function () { - const state = { + const userInput = { markers: [ { - ...emptyMarker, label: "England", - answers: ["Mini", "Morris Minor", "Reliant Robin"], selected: ["Mini", "Morris Minor", "Reliant Robin"], }, { - ...emptyMarker, label: "Germany", - answers: ["BMW", "Volkswagen", "Porsche"], selected: ["BMW", "Volkswagen", "Porsche"], }, { - ...emptyMarker, label: "Italy", - answers: ["Lamborghini", "Fiat", "Ferrari"], selected: ["Lamborghini", "Fiat", "Ferrari"], }, ], } as const; - const score = scoreLabelImage(state); + const scoringData = { + markers: [ + { + label: "England", + answers: ["Mini", "Morris Minor", "Reliant Robin"], + }, + { + label: "Germany", + answers: ["BMW", "Volkswagen", "Porsche"], + }, + { + label: "Italy", + answers: ["Lamborghini", "Fiat", "Ferrari"], + }, + ], + } as const; + + const score = scoreLabelImage(userInput, scoringData); expect(score).toHaveBeenAnsweredCorrectly(); }); diff --git a/packages/perseus/src/widgets/label-image/score-label-image.ts b/packages/perseus/src/widgets/label-image/score-label-image.ts index 85dcb8d0a1..2f8934ef06 100644 --- a/packages/perseus/src/widgets/label-image/score-label-image.ts +++ b/packages/perseus/src/widgets/label-image/score-label-image.ts @@ -1,7 +1,6 @@ -import type {InteractiveMarkerType} from "./types"; import type {PerseusScore} from "../../types"; import type { - PerseusLabelImageRubric, + PerseusLabelImageScoringData, PerseusLabelImageUserInput, } from "../../validation.types"; @@ -14,28 +13,26 @@ type InteractiveMarkerScore = { }; export function scoreMarker( - marker: InteractiveMarkerType, + userInput: PerseusLabelImageUserInput["markers"][number]["selected"], + scoringData: PerseusLabelImageScoringData["markers"][number]["answers"], ): InteractiveMarkerScore { const score = { hasAnswers: false, isCorrect: false, }; - if (marker.selected && marker.selected.length > 0) { + if (userInput && userInput.length > 0) { score.hasAnswers = true; } - if (marker.answers.length > 0) { - if ( - marker.selected && - marker.selected.length === marker.answers.length - ) { + if (scoringData.length > 0) { + if (userInput && userInput.length === scoringData.length) { // All correct answers are selected by the user. - score.isCorrect = marker.selected.every((choice) => - marker.answers.includes(choice), + score.isCorrect = userInput.every((choice) => + scoringData.includes(choice), ); } - } else if (!marker.selected || marker.selected.length === 0) { + } else if (!userInput || userInput.length === 0) { // Correct as no answers should be selected by the user. score.isCorrect = true; } @@ -43,16 +40,18 @@ export function scoreMarker( return score; } -// TODO(LEMS-2440): May need to pull answers out of PerseusLabelImageWidgetOptions[markers] for the rubric function scoreLabelImage( userInput: PerseusLabelImageUserInput, - rubric?: PerseusLabelImageRubric, + scoringData: PerseusLabelImageScoringData, ): PerseusScore { let numAnswered = 0; let numCorrect = 0; - for (const marker of userInput.markers) { - const score = scoreMarker(marker); + for (let i = 0; i < userInput.markers.length; i++) { + const score = scoreMarker( + userInput.markers[i].selected, + scoringData.markers[i].answers, + ); if (score.hasAnswers) { numAnswered++; diff --git a/packages/perseus/src/widgets/label-image/types.ts b/packages/perseus/src/widgets/label-image/types.ts index 8cf537615e..87651ee011 100644 --- a/packages/perseus/src/widgets/label-image/types.ts +++ b/packages/perseus/src/widgets/label-image/types.ts @@ -1,5 +1,5 @@ // Base marker, with the props that are set by the editor. -export type MarkerType = { +type MarkerType = { // The list of correct answers expected for the marker. answers: ReadonlyArray; // The marker title or description. From 55ad836c6a65526762a0a9b189305941f2bc422f Mon Sep 17 00:00:00 2001 From: Tamara <60857422+Myranae@users.noreply.github.com> Date: Wed, 18 Dec 2024 09:07:02 -0600 Subject: [PATCH 07/13] Label-image: Extract validation out of scoring (#2016) 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 PR separates that logic and updates associated tests. Issue: LEMS-2609 ## Test plan: - Confirm checks pass - Confirm widget still works as expected Author: Myranae Reviewers: Myranae, handeyeco, jeremywiebe Required Reviewers: Approved By: handeyeco Checks: ✅ Publish npm snapshot (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), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2016 --- .changeset/thirty-hornets-punch.md | 5 ++ .../label-image/score-label-image.test.ts | 63 ------------------- .../widgets/label-image/score-label-image.ts | 20 +++--- .../label-image/validate-label-image.test.ts | 29 +++++++++ .../label-image/validate-label-image.ts | 25 ++++++++ 5 files changed, 66 insertions(+), 76 deletions(-) create mode 100644 .changeset/thirty-hornets-punch.md create mode 100644 packages/perseus/src/widgets/label-image/validate-label-image.test.ts create mode 100644 packages/perseus/src/widgets/label-image/validate-label-image.ts diff --git a/.changeset/thirty-hornets-punch.md b/.changeset/thirty-hornets-punch.md new file mode 100644 index 0000000000..7ae837c155 --- /dev/null +++ b/.changeset/thirty-hornets-punch.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +Introduces a validation function for the label-image widget (extracted from label-image scoring function). diff --git a/packages/perseus/src/widgets/label-image/score-label-image.test.ts b/packages/perseus/src/widgets/label-image/score-label-image.test.ts index 08f4e331c3..9173ec28c2 100644 --- a/packages/perseus/src/widgets/label-image/score-label-image.test.ts +++ b/packages/perseus/src/widgets/label-image/score-label-image.test.ts @@ -1,10 +1,5 @@ import scoreLabelImage, {scoreMarker} from "./score-label-image"; -import type { - PerseusLabelImageScoringData, - PerseusLabelImageUserInput, -} from "../../validation.types"; - describe("scoreMarker", function () { it("should score correct for empty marker with no user answers", function () { const score = scoreMarker([], []); @@ -59,64 +54,6 @@ describe("scoreMarker", function () { }); describe("scoreLabelImage", function () { - it("should not grade non-interacted widget", function () { - const userInput: PerseusLabelImageUserInput = { - markers: [{label: "England"}, {label: "Germany"}, {label: "Italy"}], - } as const; - - const scoringData: PerseusLabelImageScoringData = { - markers: [ - { - label: "England", - answers: ["Mini", "Morris Minor", "Reliant Robin"], - }, - { - label: "Germany", - answers: ["BMW", "Volkswagen", "Porsche"], - }, - { - label: "Italy", - answers: ["Lamborghini", "Fiat", "Ferrari"], - }, - ], - } as const; - - const score = scoreLabelImage(userInput, scoringData); - - expect(score).toHaveInvalidInput(); - }); - - it("should not grade widget with not all markers answered", function () { - const userInput = { - markers: [ - {label: "England", selected: ["Fiat"]}, - {label: "Germany", selected: ["Lamborghini"]}, - {label: "Italy"}, - ], - } as const; - - const scoringData = { - markers: [ - { - label: "England", - answers: [], - }, - { - label: "Germany", - answers: ["BMW", "Volkswagen", "Porsche"], - }, - { - label: "Italy", - answers: ["Lamborghini", "Fiat", "Ferrari"], - }, - ], - } as const; - - const score = scoreLabelImage(userInput, scoringData); - - expect(score).toHaveInvalidInput(); - }); - it("should grade as incorrect for widget with no answers for markers", function () { const userInput = { markers: [ diff --git a/packages/perseus/src/widgets/label-image/score-label-image.ts b/packages/perseus/src/widgets/label-image/score-label-image.ts index 2f8934ef06..97968e32bd 100644 --- a/packages/perseus/src/widgets/label-image/score-label-image.ts +++ b/packages/perseus/src/widgets/label-image/score-label-image.ts @@ -1,3 +1,5 @@ +import validateLabelImage from "./validate-label-image"; + import type {PerseusScore} from "../../types"; import type { PerseusLabelImageScoringData, @@ -44,7 +46,11 @@ function scoreLabelImage( userInput: PerseusLabelImageUserInput, scoringData: PerseusLabelImageScoringData, ): PerseusScore { - let numAnswered = 0; + const validationError = validateLabelImage(userInput); + if (validationError) { + return validationError; + } + let numCorrect = 0; for (let i = 0; i < userInput.markers.length; i++) { @@ -53,23 +59,11 @@ function scoreLabelImage( scoringData.markers[i].answers, ); - if (score.hasAnswers) { - numAnswered++; - } - if (score.isCorrect) { numCorrect++; } } - // We expect all question markers to be answered before grading. - if (numAnswered !== userInput.markers.length) { - return { - type: "invalid", - message: null, - }; - } - return { type: "points", // Markers with no expected answers are graded as correct if user diff --git a/packages/perseus/src/widgets/label-image/validate-label-image.test.ts b/packages/perseus/src/widgets/label-image/validate-label-image.test.ts new file mode 100644 index 0000000000..3225689a00 --- /dev/null +++ b/packages/perseus/src/widgets/label-image/validate-label-image.test.ts @@ -0,0 +1,29 @@ +import validateLabelImage from "./validate-label-image"; + +import type {PerseusLabelImageUserInput} from "../../validation.types"; + +describe("scoreLabelImage", () => { + it("should not grade non-interacted widget", function () { + const userInput: PerseusLabelImageUserInput = { + markers: [{label: "England"}, {label: "Germany"}, {label: "Italy"}], + } as const; + + const validationError = validateLabelImage(userInput); + + expect(validationError).toHaveInvalidInput(); + }); + + it("should not grade widget with not all markers answered", function () { + const userInput = { + markers: [ + {label: "England", selected: ["Fiat"]}, + {label: "Germany", selected: ["Lamborghini"]}, + {label: "Italy"}, + ], + } as const; + + const validationError = validateLabelImage(userInput); + + expect(validationError).toHaveInvalidInput(); + }); +}); diff --git a/packages/perseus/src/widgets/label-image/validate-label-image.ts b/packages/perseus/src/widgets/label-image/validate-label-image.ts new file mode 100644 index 0000000000..2b76d91951 --- /dev/null +++ b/packages/perseus/src/widgets/label-image/validate-label-image.ts @@ -0,0 +1,25 @@ +import type {PerseusScore} from "../../types"; +import type {PerseusLabelImageUserInput} from "../../validation.types"; + +function validateLabelImage( + userInput: PerseusLabelImageUserInput, +): Extract | null { + let numAnswered = 0; + for (let i = 0; i < userInput.markers.length; i++) { + const userSelection = userInput.markers[i].selected; + if (userSelection && userSelection.length > 0) { + numAnswered++; + } + } + // We expect all question markers to be answered before grading. + if (numAnswered !== userInput.markers.length) { + return { + type: "invalid", + message: null, + }; + } + + return null; +} + +export default validateLabelImage; From 879d2a501e25304bd715eb73a2d615a7d06d2cd9 Mon Sep 17 00:00:00 2001 From: Tamara <60857422+Myranae@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:02:45 -0600 Subject: [PATCH 08/13] Rename usages of rubric to scoringData (#2006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: This is to rename anything related to scoring that still uses rubric to scoringData instead to be more in alignment with the vocabulary for server side scoring. Issue: LEMS-2657 ## Test plan: - Confirm all checks pass - Confirm all checks pass in a webapp draft - Check for issues with widgets affected Author: Myranae Reviewers: Myranae, handeyeco, jeremywiebe Required Reviewers: Approved By: handeyeco, jeremywiebe Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2006 --- .changeset/smooth-cheetahs-grin.md | 5 ++ packages/perseus/src/renderer.tsx | 2 +- packages/perseus/src/types.ts | 8 +- .../perseus/src/util/extract-perseus-data.ts | 6 +- packages/perseus/src/validation.types.ts | 69 ++++++++--------- .../widgets/cs-program/score-cs-program.ts | 10 +-- .../perseus/src/widgets/dropdown/dropdown.tsx | 4 +- .../widgets/dropdown/score-dropdown.test.ts | 10 +-- .../src/widgets/dropdown/score-dropdown.ts | 6 +- .../widgets/expression/expression.test.tsx | 22 ++++-- .../src/widgets/expression/expression.tsx | 12 +-- .../expression/score-expression.test.ts | 12 +-- .../widgets/expression/score-expression.ts | 12 +-- .../widgets/expression/validate-expression.ts | 2 +- .../graded-group-set/graded-group-set.tsx | 4 +- .../src/widgets/graded-group/graded-group.tsx | 6 +- .../perseus/src/widgets/grapher/grapher.tsx | 4 +- .../src/widgets/grapher/score-grapher.test.ts | 26 +++---- .../src/widgets/grapher/score-grapher.ts | 8 +- packages/perseus/src/widgets/group/group.tsx | 7 +- .../perseus/src/widgets/group/score-group.ts | 4 +- .../widgets/input-number/input-number.test.ts | 23 +++--- .../src/widgets/input-number/input-number.tsx | 18 +++-- .../input-number/score-input-number.test.ts | 22 +++--- .../input-number/score-input-number.ts | 18 ++--- .../perseus/src/widgets/interactive-graph.tsx | 6 +- .../score-interactive-graph.test.ts | 74 +++++++++---------- .../score-interactive-graph.ts | 64 ++++++++-------- .../perseus/src/widgets/matcher/matcher.tsx | 4 +- .../src/widgets/matcher/score-matcher.test.ts | 14 ++-- .../src/widgets/matcher/score-matcher.ts | 10 +-- .../perseus/src/widgets/matrix/matrix.tsx | 6 +- .../src/widgets/matrix/score-matrix.test.ts | 46 ++++++------ .../src/widgets/matrix/score-matrix.ts | 8 +- .../numeric-input/numeric-input.test.ts | 22 +++--- .../widgets/numeric-input/numeric-input.tsx | 14 ++-- .../numeric-input/score-numeric-input.test.ts | 66 ++++++++--------- .../numeric-input/score-numeric-input.ts | 10 +-- .../perseus/src/widgets/orderer/orderer.tsx | 4 +- .../src/widgets/orderer/score-orderer.test.ts | 28 +++---- .../src/widgets/orderer/score-orderer.ts | 6 +- .../src/widgets/radio/__tests__/radio.test.ts | 24 +++--- .../src/widgets/radio/radio-component.tsx | 10 +-- .../src/widgets/radio/score-radio.test.ts | 34 ++++----- .../perseus/src/widgets/radio/score-radio.ts | 21 +++--- .../src/widgets/sorter/score-sorter.test.ts | 22 +++--- .../src/widgets/sorter/score-sorter.ts | 6 +- .../perseus/src/widgets/sorter/sorter.tsx | 4 +- .../src/widgets/table/score-table.test.ts | 30 ++++---- .../perseus/src/widgets/table/score-table.ts | 6 +- packages/perseus/src/widgets/table/table.tsx | 5 +- 51 files changed, 446 insertions(+), 418 deletions(-) create mode 100644 .changeset/smooth-cheetahs-grin.md diff --git a/.changeset/smooth-cheetahs-grin.md b/.changeset/smooth-cheetahs-grin.md new file mode 100644 index 0000000000..55159d2085 --- /dev/null +++ b/.changeset/smooth-cheetahs-grin.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +Rename usages of rubric to scoringData diff --git a/packages/perseus/src/renderer.tsx b/packages/perseus/src/renderer.tsx index 09a7f7b8a0..ec4baceb2c 100644 --- a/packages/perseus/src/renderer.tsx +++ b/packages/perseus/src/renderer.tsx @@ -556,7 +556,7 @@ class Renderer const apiOptions = this.getApiOptions(); const widgetProps = this.state.widgetProps[widgetId] || {}; - // The widget needs access to its "rubric" at all times when in review + // The widget needs access to its "scoring data" at all times when in review // mode (which is really just part of its widget info). const widgetInfo = this.state.widgetInfo[widgetId]; const reviewModeRubric = diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index af33410513..678adbd9c7 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -9,7 +9,7 @@ import type { import type {PerseusStrings} from "./strings"; import type {SizeClass} from "./util/sizing-utils"; import type { - Rubric, + ScoringData, UserInput, UserInputArray, UserInputMap, @@ -590,7 +590,7 @@ export type WidgetScorerFunction = ( // The user data needed to score userInput: UserInput, // The scoring criteria to score against - rubric: Rubric, + scoringData: ScoringData, // Strings, for error messages in invalid widgets string?: PerseusStrings, // Locale, for math evaluation @@ -653,8 +653,8 @@ export type WidgetExports< */ scorer?: WidgetScorerFunction; - getOneCorrectAnswerFromRubric?: ( - rubric: Rubric, + getOneCorrectAnswerFromScoringData?: ( + scoringData: ScoringData, ) => string | null | undefined; /** diff --git a/packages/perseus/src/util/extract-perseus-data.ts b/packages/perseus/src/util/extract-perseus-data.ts index 9b99e1f263..49352c82d6 100644 --- a/packages/perseus/src/util/extract-perseus-data.ts +++ b/packages/perseus/src/util/extract-perseus-data.ts @@ -674,18 +674,18 @@ export const getAnswerFromUserInput = (widgetType: string, userInput: any) => { /* Returns the correct answer for a given widget ID and Perseus Item */ // TODO (LEMS-1835): We should fix the resonse type from getWidget to be specific. -// TODO (LEMS-1836): We should also consider adding the getOneCorrectAnswerFromRubric method to all widgets. +// TODO (LEMS-1836): We should also consider adding the getOneCorrectAnswerFromScoringData method to all widgets. export const getCorrectAnswerForWidgetId = ( widgetId: string, itemData: PerseusItem, ): string | null | undefined => { - const rubric = itemData.question.widgets[widgetId].options; + const scoringData = itemData.question.widgets[widgetId].options; const widgetMap = getWidgetsMapFromItemData(itemData); const widgetType = getWidgetTypeByWidgetId(widgetId, widgetMap) as string; const widget = Widgets.getWidgetExport(widgetType); - return widget?.getOneCorrectAnswerFromRubric?.(rubric); + return widget?.getOneCorrectAnswerFromScoringData?.(scoringData); }; /* Verify if the widget ID exists in the content string of the Perseus Item */ diff --git a/packages/perseus/src/validation.types.ts b/packages/perseus/src/validation.types.ts index 24b6ce8199..df44cd91b2 100644 --- a/packages/perseus/src/validation.types.ts +++ b/packages/perseus/src/validation.types.ts @@ -67,7 +67,7 @@ export type PerseusCSProgramUserInput = { message: string | null; }; -export type PerseusDropdownRubric = { +export type PerseusDropdownScoringData = { choices: ReadonlyArray; }; @@ -75,33 +75,34 @@ export type PerseusDropdownUserInput = { value: number; }; -export type PerseusExpressionRubric = { +export type PerseusExpressionScoringData = { answerForms: ReadonlyArray; functions: ReadonlyArray; }; export type PerseusExpressionUserInput = string; -export type PerseusGroupRubric = PerseusGroupWidgetOptions; +export type PerseusGroupScoringData = PerseusGroupWidgetOptions; export type PerseusGroupValidationData = {widgets: ValidationDataMap}; export type PerseusGroupUserInput = UserInputMap; -export type PerseusGradedGroupRubric = PerseusGradedGroupWidgetOptions; +export type PerseusGradedGroupScoringData = PerseusGradedGroupWidgetOptions; -export type PerseusGradedGroupSetRubric = PerseusGradedGroupSetWidgetOptions; +export type PerseusGradedGroupSetScoringData = + PerseusGradedGroupSetWidgetOptions; -export type PerseusGrapherRubric = { +export type PerseusGrapherScoringData = { correct: GrapherAnswerTypes; }; -export type PerseusGrapherUserInput = PerseusGrapherRubric["correct"]; +export type PerseusGrapherUserInput = PerseusGrapherScoringData["correct"]; export type PerseusIFrameUserInput = { status: UserInputStatus; message: string | null; }; -export type PerseusInputNumberRubric = { +export type PerseusInputNumberScoringData = { answerType?: | "number" | "decimal" @@ -121,7 +122,7 @@ export type PerseusInputNumberUserInput = { currentValue: string; }; -export type PerseusInteractiveGraphRubric = { +export type PerseusInteractiveGraphScoringData = { // TODO(LEMS-2344): make the type of `correct` more specific correct: PerseusGraphCorrectType; graph: PerseusGraphType; @@ -143,14 +144,14 @@ export type PerseusLabelImageUserInput = { }>; }; -export type PerseusMatcherRubric = PerseusMatcherWidgetOptions; +export type PerseusMatcherScoringData = PerseusMatcherWidgetOptions; export type PerseusMatcherUserInput = { left: ReadonlyArray; right: ReadonlyArray; }; -export type PerseusMatrixRubric = { +export type PerseusMatrixScoringData = { // A data matrix representing the "correct" answers to be entered into the matrix answers: PerseusMatrixWidgetAnswers; } & PerseusMatrixValidationData; @@ -158,7 +159,7 @@ export type PerseusMatrixRubric = { export type PerseusMatrixValidationData = Empty; export type PerseusMatrixUserInput = { - answers: PerseusMatrixRubric["answers"]; + answers: PerseusMatrixScoringData["answers"]; }; export type PerseusNumberLineScoringData = { @@ -177,7 +178,7 @@ export type PerseusNumberLineUserInput = { divisionRange: ReadonlyArray; }; -export type PerseusNumericInputRubric = { +export type PerseusNumericInputScoringData = { // A list of all the possible correct and incorrect answers answers: ReadonlyArray; // A coefficient style number allows the student to use - for -1 and an empty string to mean 1. @@ -188,7 +189,7 @@ export type PerseusNumericInputUserInput = { currentValue: string; }; -export type PerseusOrdererRubric = PerseusOrdererWidgetOptions; +export type PerseusOrdererScoringData = PerseusOrdererWidgetOptions; export type PerseusOrdererUserInput = { current: ReadonlyArray; @@ -206,7 +207,7 @@ export type PerseusPlotterValidationData = { export type PerseusPlotterUserInput = ReadonlyArray; -export type PerseusRadioRubric = { +export type PerseusRadioScoringData = { // The choices provided to the user. choices: ReadonlyArray; }; @@ -215,7 +216,7 @@ export type PerseusRadioUserInput = { choicesSelected: ReadonlyArray; }; -export type PerseusSorterRubric = { +export type PerseusSorterScoringData = { // Translatable Text; The correct answer (in the correct order). The user will see the cards in a randomized order. correct: ReadonlyArray; }; @@ -225,33 +226,33 @@ export type PerseusSorterUserInput = { changed: boolean; }; -export type PerseusTableRubric = { +export type PerseusTableScoringData = { // Translatable Text; A 2-dimensional array of text to populate the table with answers: ReadonlyArray>; }; export type PerseusTableUserInput = ReadonlyArray>; -export type Rubric = +export type ScoringData = | PerseusCategorizerScoringData - | PerseusDropdownRubric - | PerseusExpressionRubric - | PerseusGroupRubric - | PerseusGradedGroupRubric - | PerseusGradedGroupSetRubric - | PerseusGrapherRubric - | PerseusInputNumberRubric - | PerseusInteractiveGraphRubric + | PerseusDropdownScoringData + | PerseusExpressionScoringData + | PerseusGroupScoringData + | PerseusGradedGroupScoringData + | PerseusGradedGroupSetScoringData + | PerseusGrapherScoringData + | PerseusInputNumberScoringData + | PerseusInteractiveGraphScoringData | PerseusLabelImageScoringData - | PerseusMatcherRubric - | PerseusMatrixRubric + | PerseusMatcherScoringData + | PerseusMatrixScoringData | PerseusNumberLineScoringData - | PerseusNumericInputRubric - | PerseusOrdererRubric + | PerseusNumericInputScoringData + | PerseusOrdererScoringData | PerseusPlotterScoringData - | PerseusRadioRubric - | PerseusSorterRubric - | PerseusTableRubric; + | PerseusRadioScoringData + | PerseusSorterScoringData + | PerseusTableScoringData; export type UserInput = | PerseusCategorizerUserInput @@ -286,7 +287,7 @@ export interface ValidationDataTypes { categorizer: PerseusCategorizerValidationData; // "cs-program": PerseusCSProgramValidationData; // definition: PerseusDefinitionValidationData; - // dropdown: PerseusDropdownRubric; + // dropdown: PerseusDropdownValidationData; // explanation: PerseusExplanationValidationData; // expression: PerseusExpressionValidationData; // grapher: PerseusGrapherValidationData; diff --git a/packages/perseus/src/widgets/cs-program/score-cs-program.ts b/packages/perseus/src/widgets/cs-program/score-cs-program.ts index 664aed1d81..0ec653bb3b 100644 --- a/packages/perseus/src/widgets/cs-program/score-cs-program.ts +++ b/packages/perseus/src/widgets/cs-program/score-cs-program.ts @@ -1,23 +1,23 @@ import type {PerseusScore} from "../../types"; import type {PerseusCSProgramUserInput} from "../../validation.types"; -function scoreCSProgram(state: PerseusCSProgramUserInput): PerseusScore { +function scoreCSProgram(userInput: PerseusCSProgramUserInput): PerseusScore { // The iframe can tell us whether it's correct or incorrect, // and pass an optional message - if (state.status === "correct") { + if (userInput.status === "correct") { return { type: "points", earned: 1, total: 1, - message: state.message || null, + message: userInput.message || null, }; } - if (state.status === "incorrect") { + if (userInput.status === "incorrect") { return { type: "points", earned: 0, total: 1, - message: state.message || null, + message: userInput.message || null, }; } return { diff --git a/packages/perseus/src/widgets/dropdown/dropdown.tsx b/packages/perseus/src/widgets/dropdown/dropdown.tsx index 4f2d384b3c..4ac249745d 100644 --- a/packages/perseus/src/widgets/dropdown/dropdown.tsx +++ b/packages/perseus/src/widgets/dropdown/dropdown.tsx @@ -14,12 +14,12 @@ import validateDropdown from "./validate-dropdown"; import type {PerseusDropdownWidgetOptions} from "../../perseus-types"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; import type { - PerseusDropdownRubric, + PerseusDropdownScoringData, PerseusDropdownUserInput, } from "../../validation.types"; import type {DropdownPromptJSON} from "../../widget-ai-utils/dropdown/dropdown-ai-utils"; -type Props = WidgetProps & { +type Props = WidgetProps & { selected: number; }; diff --git a/packages/perseus/src/widgets/dropdown/score-dropdown.test.ts b/packages/perseus/src/widgets/dropdown/score-dropdown.test.ts index d583aba2fa..d16bf63554 100644 --- a/packages/perseus/src/widgets/dropdown/score-dropdown.test.ts +++ b/packages/perseus/src/widgets/dropdown/score-dropdown.test.ts @@ -2,7 +2,7 @@ import {question1} from "./dropdown.testdata"; import scoreDropdown from "./score-dropdown"; import type { - PerseusDropdownRubric, + PerseusDropdownScoringData, PerseusDropdownUserInput, } from "../../validation.types"; @@ -12,12 +12,12 @@ describe("scoreDropdown", () => { const userInput: PerseusDropdownUserInput = { value: 1, }; - const rubric: PerseusDropdownRubric = { + const scoringData: PerseusDropdownScoringData = { choices: question1.widgets["dropdown 1"].options.choices, }; // Act - const score = scoreDropdown(userInput, rubric); + const score = scoreDropdown(userInput, scoringData); // Assert expect(score).toHaveBeenAnsweredIncorrectly(); @@ -28,12 +28,12 @@ describe("scoreDropdown", () => { const userInput: PerseusDropdownUserInput = { value: 2, }; - const rubric: PerseusDropdownRubric = { + const scoringData: PerseusDropdownScoringData = { choices: question1.widgets["dropdown 1"].options.choices, }; // Act - const score = scoreDropdown(userInput, rubric); + const score = scoreDropdown(userInput, scoringData); // Assert expect(score).toHaveBeenAnsweredCorrectly(); diff --git a/packages/perseus/src/widgets/dropdown/score-dropdown.ts b/packages/perseus/src/widgets/dropdown/score-dropdown.ts index 76ec2f96b1..84d5cb6a0f 100644 --- a/packages/perseus/src/widgets/dropdown/score-dropdown.ts +++ b/packages/perseus/src/widgets/dropdown/score-dropdown.ts @@ -2,19 +2,19 @@ import validateDropdown from "./validate-dropdown"; import type {PerseusScore} from "../../types"; import type { - PerseusDropdownRubric, + PerseusDropdownScoringData, PerseusDropdownUserInput, } from "../../validation.types"; function scoreDropdown( userInput: PerseusDropdownUserInput, - rubric: PerseusDropdownRubric, + scoringData: PerseusDropdownScoringData, ): PerseusScore { const validationError = validateDropdown(userInput); if (validationError) { return validationError; } - const correct = rubric.choices[userInput.value - 1].correct; + const correct = scoringData.choices[userInput.value - 1].correct; return { type: "points", earned: correct ? 1 : 0, diff --git a/packages/perseus/src/widgets/expression/expression.test.tsx b/packages/perseus/src/widgets/expression/expression.test.tsx index 04cb9a6591..9f2e9a799c 100644 --- a/packages/perseus/src/widgets/expression/expression.test.tsx +++ b/packages/perseus/src/widgets/expression/expression.test.tsx @@ -265,16 +265,16 @@ describe("Expression Widget", function () { }); }); - describe("getOneCorrectAnswerFromRubric", () => { + describe("getOneCorrectAnswerFromScoringData", () => { beforeEach(() => { jest.spyOn(Dependencies, "getDependencies").mockReturnValue( testDependencies, ); }); - it("should return undefined if rubric.value is null/undefined", () => { + it("should return undefined if scoringData.value is null/undefined", () => { // Arrange - const rubric = { + const scoringData = { answerForms: [], buttonSets: [], functions: [], @@ -283,7 +283,9 @@ describe("Expression Widget", function () { // Act const result = - ExpressionWidgetExport.getOneCorrectAnswerFromRubric?.(rubric); + ExpressionWidgetExport.getOneCorrectAnswerFromScoringData?.( + scoringData, + ); // Assert expect(result).toBeUndefined(); @@ -291,7 +293,7 @@ describe("Expression Widget", function () { it("returns a correct answer when there is one correct answer", () => { // Arrange - const rubric = { + const scoringData = { answerForms: [ { value: "123", @@ -307,7 +309,9 @@ describe("Expression Widget", function () { // Act const result = - ExpressionWidgetExport.getOneCorrectAnswerFromRubric?.(rubric); + ExpressionWidgetExport.getOneCorrectAnswerFromScoringData?.( + scoringData, + ); // Assert expect(result).toEqual("123"); @@ -315,7 +319,7 @@ describe("Expression Widget", function () { it("returns the first correct answer when there are multiple correct answers", () => { // Arrange - const rubric = { + const scoringData = { answerForms: [ { value: "123", @@ -337,7 +341,9 @@ describe("Expression Widget", function () { // Act const result = - ExpressionWidgetExport.getOneCorrectAnswerFromRubric?.(rubric); + ExpressionWidgetExport.getOneCorrectAnswerFromScoringData?.( + scoringData, + ); // Assert expect(result).toEqual("123"); diff --git a/packages/perseus/src/widgets/expression/expression.tsx b/packages/perseus/src/widgets/expression/expression.tsx index 7c95cce897..7ebf9d314f 100644 --- a/packages/perseus/src/widgets/expression/expression.tsx +++ b/packages/perseus/src/widgets/expression/expression.tsx @@ -26,7 +26,7 @@ import type {DependenciesContext} from "../../dependencies"; import type {PerseusExpressionWidgetOptions} from "../../perseus-types"; import type {FocusPath, Widget, WidgetExports, WidgetProps} from "../../types"; import type { - PerseusExpressionRubric, + PerseusExpressionScoringData, PerseusExpressionUserInput, } from "../../validation.types"; import type {ExpressionPromptJSON} from "../../widget-ai-utils/expression/expression-ai-utils"; @@ -70,7 +70,7 @@ type RenderProps = { keypadConfiguration: ReturnType; }; -type ExternalProps = WidgetProps; +type ExternalProps = WidgetProps; export type Props = ExternalProps & Partial> & { @@ -564,11 +564,11 @@ export default { validator: validateExpression, // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'Rubric' is not assignable to type 'PerseusExpressionRubric'. - getOneCorrectAnswerFromRubric( - rubric: PerseusExpressionRubric, + // @ts-expect-error: Type 'ScoringData' is not assignable to type 'PerseusExpressionScoringData'. + getOneCorrectAnswerFromScoringData( + scoringData: PerseusExpressionScoringData, ): string | null | undefined { - const correctAnswers = (rubric.answerForms || []).filter( + const correctAnswers = (scoringData.answerForms || []).filter( (answerForm) => answerForm.considered === "correct", ); if (correctAnswers.length === 0) { diff --git a/packages/perseus/src/widgets/expression/score-expression.test.ts b/packages/perseus/src/widgets/expression/score-expression.test.ts index cba87292eb..2e5c99c3c4 100644 --- a/packages/perseus/src/widgets/expression/score-expression.test.ts +++ b/packages/perseus/src/widgets/expression/score-expression.test.ts @@ -4,7 +4,7 @@ import {expressionItem3Options} from "./expression.testdata"; import scoreExpression from "./score-expression"; import * as ExpressionValidator from "./validate-expression"; -import type {PerseusExpressionRubric} from "../../validation.types"; +import type {PerseusExpressionScoringData} from "../../validation.types"; describe("scoreExpression", () => { it("should be correctly answerable if validation passes", function () { @@ -12,10 +12,11 @@ describe("scoreExpression", () => { const mockValidator = jest .spyOn(ExpressionValidator, "default") .mockReturnValue(null); - const rubric: PerseusExpressionRubric = expressionItem3Options; + const scoringData: PerseusExpressionScoringData = + expressionItem3Options; // Act - const score = scoreExpression("z+1", rubric, mockStrings, "en"); + const score = scoreExpression("z+1", scoringData, mockStrings, "en"); // Assert expect(mockValidator).toHaveBeenCalledWith("z+1"); @@ -27,10 +28,11 @@ describe("scoreExpression", () => { const mockValidator = jest .spyOn(ExpressionValidator, "default") .mockReturnValue({type: "invalid", message: null}); - const rubric: PerseusExpressionRubric = expressionItem3Options; + const scoringData: PerseusExpressionScoringData = + expressionItem3Options; // Act - const score = scoreExpression("z+1", rubric, mockStrings, "en"); + const score = scoreExpression("z+1", scoringData, mockStrings, "en"); // Assert expect(mockValidator).toHaveBeenCalledWith("z+1"); diff --git a/packages/perseus/src/widgets/expression/score-expression.ts b/packages/perseus/src/widgets/expression/score-expression.ts index dfe4269d61..bc8658c867 100644 --- a/packages/perseus/src/widgets/expression/score-expression.ts +++ b/packages/perseus/src/widgets/expression/score-expression.ts @@ -13,7 +13,7 @@ import type {PerseusStrings} from "../../strings"; import type {PerseusScore} from "../../types"; import type {Score} from "../../util/answer-types"; import type { - PerseusExpressionRubric, + PerseusExpressionScoringData, PerseusExpressionUserInput, } from "../../validation.types"; @@ -37,7 +37,7 @@ import type { */ function scoreExpression( userInput: PerseusExpressionUserInput, - rubric: PerseusExpressionRubric, + scoringData: PerseusExpressionScoringData, strings: PerseusStrings, locale: string, ): PerseusScore { @@ -46,7 +46,7 @@ function scoreExpression( return validationError; } - const options = _.clone(rubric); + const options = _.clone(scoringData); _.extend(options, { decimal_separator: getDecimalSeparator(locale), }); @@ -56,7 +56,7 @@ function scoreExpression( // solution answer, not the student answer, and we don't want a // solution to work if the student is using a different language // (different from the content creation language, ie. English). - const expression = KAS.parse(answer.value, rubric); + const expression = KAS.parse(answer.value, scoringData); // An answer may not be parsed if the expression was defined // incorrectly. For example if the answer is using a symbol defined // in the function variables list for the expression. @@ -65,7 +65,7 @@ function scoreExpression( Log.error( "Unable to parse solution answer for expression", Errors.InvalidInput, - {loggedMetadata: {rubric: JSON.stringify(rubric)}}, + {loggedMetadata: {scoringData: JSON.stringify(scoringData)}}, ); return null; } @@ -95,7 +95,7 @@ function scoreExpression( let matchMessage: string | undefined; let allEmpty = true; let firstUngradedResult: Score | undefined; - for (const answerForm of rubric.answerForms || []) { + for (const answerForm of scoringData.answerForms || []) { const validator = createValidator(answerForm); if (!validator) { continue; diff --git a/packages/perseus/src/widgets/expression/validate-expression.ts b/packages/perseus/src/widgets/expression/validate-expression.ts index 89e0ef2f04..622ecfe021 100644 --- a/packages/perseus/src/widgets/expression/validate-expression.ts +++ b/packages/perseus/src/widgets/expression/validate-expression.ts @@ -4,7 +4,7 @@ import type {PerseusExpressionUserInput} from "../../validation.types"; /** * Checks user input from the expression widget to see if it is scorable. * - * Note: Most of the expression widget's validation requires the Rubric because + * Note: Most of the expression widget's validation requires the ScoringData because * of its use of KhanAnswerTypes as a core part of scoring. * * @see `scoreExpression()` for more details. diff --git a/packages/perseus/src/widgets/graded-group-set/graded-group-set.tsx b/packages/perseus/src/widgets/graded-group-set/graded-group-set.tsx index 472847bedd..0f1a2ec4a5 100644 --- a/packages/perseus/src/widgets/graded-group-set/graded-group-set.tsx +++ b/packages/perseus/src/widgets/graded-group-set/graded-group-set.tsx @@ -25,7 +25,7 @@ import type { PerseusGradedGroupWidgetOptions, } from "../../perseus-types"; import type {FocusPath, Widget, WidgetExports, WidgetProps} from "../../types"; -import type {PerseusGradedGroupSetRubric} from "../../validation.types"; +import type {PerseusGradedGroupSetScoringData} from "../../validation.types"; import type {GradedGroupSetPromptJSON} from "../../widget-ai-utils/graded-group-set/graded-group-set-ai-utils"; type IndicatorsProps = { @@ -93,7 +93,7 @@ class Indicators extends React.Component { type RenderProps = PerseusGradedGroupSetWidgetOptions; // no transform type Props = Changeable.ChangeableProps & - WidgetProps & { + WidgetProps & { trackInteraction: () => void; }; diff --git a/packages/perseus/src/widgets/graded-group/graded-group.tsx b/packages/perseus/src/widgets/graded-group/graded-group.tsx index 443b37d41e..628135a3b2 100644 --- a/packages/perseus/src/widgets/graded-group/graded-group.tsx +++ b/packages/perseus/src/widgets/graded-group/graded-group.tsx @@ -35,7 +35,7 @@ import type { WidgetExports, WidgetProps, } from "../../types"; -import type {PerseusGradedGroupRubric} from "../../validation.types"; +import type {PerseusGradedGroupScoringData} from "../../validation.types"; import type {GradedGroupPromptJSON} from "../../widget-ai-utils/graded-group/graded-group-ai-utils"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; @@ -70,7 +70,7 @@ type RenderProps = PerseusGradedGroupWidgetOptions; // exports has no 'transform type Props = WidgetProps< RenderProps, - PerseusGradedGroupRubric, + PerseusGradedGroupScoringData, TrackingGradedGroupExtraArguments > & { inGradedGroupSet?: boolean; // Set by graded-group-set.jsx, @@ -102,7 +102,7 @@ type State = { // via defaultProps. 0 as any as WidgetProps< PerseusGradedGroupWidgetOptions, - PerseusGradedGroupRubric + PerseusGradedGroupScoringData > satisfies PropsFor; // A Graded Group is more or less a Group widget that displays a check diff --git a/packages/perseus/src/widgets/grapher/grapher.tsx b/packages/perseus/src/widgets/grapher/grapher.tsx index 20e01b3680..53ea6b1036 100644 --- a/packages/perseus/src/widgets/grapher/grapher.tsx +++ b/packages/perseus/src/widgets/grapher/grapher.tsx @@ -37,7 +37,7 @@ import type {PerseusGrapherWidgetOptions} from "../../perseus-types"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; import type {GridDimensions} from "../../util"; import type { - PerseusGrapherRubric, + PerseusGrapherScoringData, PerseusGrapherUserInput, } from "../../validation.types"; import type {GrapherPromptJSON} from "../../widget-ai-utils/grapher/grapher-ai-utils"; @@ -349,7 +349,7 @@ type RenderProps = { plot?: any; }; -type ExternalProps = WidgetProps; +type ExternalProps = WidgetProps; type Props = ExternalProps & { // plot is always provided by default props diff --git a/packages/perseus/src/widgets/grapher/score-grapher.test.ts b/packages/perseus/src/widgets/grapher/score-grapher.test.ts index b4d94b7ae7..d1f355d659 100644 --- a/packages/perseus/src/widgets/grapher/score-grapher.test.ts +++ b/packages/perseus/src/widgets/grapher/score-grapher.test.ts @@ -2,12 +2,12 @@ import scoreGrapher from "./score-grapher"; import type {Coord} from "../../interactive2/types"; import type { - PerseusGrapherRubric, + PerseusGrapherScoringData, PerseusGrapherUserInput, } from "../../validation.types"; describe("scoreGrapher", () => { - it("is incorrect when user input type doesn't match rubric type", () => { + it("is incorrect when user input type doesn't match scoring data type", () => { const asymptote: [Coord, Coord] = [ [-10, -10], [10, 10], @@ -24,7 +24,7 @@ describe("scoreGrapher", () => { coords, }; - const rubric: PerseusGrapherRubric = { + const scoringData: PerseusGrapherScoringData = { correct: { type: "logarithm", asymptote, @@ -33,7 +33,7 @@ describe("scoreGrapher", () => { }; // Act - const result = scoreGrapher(userInput, rubric); + const result = scoreGrapher(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); @@ -59,7 +59,7 @@ describe("scoreGrapher", () => { coords: null, }; - const rubric: PerseusGrapherRubric = { + const scoringData: PerseusGrapherScoringData = { correct: { type: "exponential", asymptote, @@ -68,7 +68,7 @@ describe("scoreGrapher", () => { }; // Act - const result = scoreGrapher(userInput, rubric); + const result = scoreGrapher(userInput, scoringData); // Assert expect(result).toHaveInvalidInput(); @@ -89,7 +89,7 @@ describe("scoreGrapher", () => { coords, }; - const rubric: PerseusGrapherRubric = { + const scoringData: PerseusGrapherScoringData = { correct: { type: "linear", coords, @@ -97,7 +97,7 @@ describe("scoreGrapher", () => { }; // Act - const result = scoreGrapher(userInput, rubric); + const result = scoreGrapher(userInput, scoringData); // Assert expect(result).toHaveInvalidInput(); @@ -115,7 +115,7 @@ describe("scoreGrapher", () => { coords, }; - const rubric: PerseusGrapherRubric = { + const scoringData: PerseusGrapherScoringData = { correct: { type: "linear", coords, @@ -123,13 +123,13 @@ describe("scoreGrapher", () => { }; // Act - const result = scoreGrapher(userInput, rubric); + const result = scoreGrapher(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredCorrectly(); }); - it("can be answered incorrectly when user input and rubric coords don't match", () => { + it("can be answered incorrectly when user input and scoring data coords don't match", () => { // Arrange const userInput: PerseusGrapherUserInput = { type: "linear", @@ -139,7 +139,7 @@ describe("scoreGrapher", () => { ], }; - const rubric: PerseusGrapherRubric = { + const scoringData: PerseusGrapherScoringData = { correct: { type: "linear", coords: [ @@ -150,7 +150,7 @@ describe("scoreGrapher", () => { }; // Act - const result = scoreGrapher(userInput, rubric); + const result = scoreGrapher(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); diff --git a/packages/perseus/src/widgets/grapher/score-grapher.ts b/packages/perseus/src/widgets/grapher/score-grapher.ts index 5d439c8b44..6c606f7524 100644 --- a/packages/perseus/src/widgets/grapher/score-grapher.ts +++ b/packages/perseus/src/widgets/grapher/score-grapher.ts @@ -5,7 +5,7 @@ import {functionForType} from "./util"; import type {GrapherAnswerTypes} from "../../perseus-types"; import type {PerseusScore} from "../../types"; import type { - PerseusGrapherRubric, + PerseusGrapherScoringData, PerseusGrapherUserInput, } from "../../validation.types"; @@ -31,9 +31,9 @@ function getCoefficientsByType( function scoreGrapher( userInput: PerseusGrapherUserInput, - rubric: PerseusGrapherRubric, + scoringData: PerseusGrapherScoringData, ): PerseusScore { - if (userInput.type !== rubric.correct.type) { + if (userInput.type !== scoringData.correct.type) { return { type: "points", earned: 0, @@ -53,7 +53,7 @@ function scoreGrapher( // Get new function handler for grading const grader = functionForType(userInput.type); const guessCoeffs = getCoefficientsByType(userInput); - const correctCoeffs = getCoefficientsByType(rubric.correct); + const correctCoeffs = getCoefficientsByType(scoringData.correct); if (guessCoeffs == null || correctCoeffs == null) { return { diff --git a/packages/perseus/src/widgets/group/group.tsx b/packages/perseus/src/widgets/group/group.tsx index 882c4991e4..f94fdd19c3 100644 --- a/packages/perseus/src/widgets/group/group.tsx +++ b/packages/perseus/src/widgets/group/group.tsx @@ -20,11 +20,14 @@ import type { WidgetExports, WidgetProps, } from "../../types"; -import type {PerseusGroupRubric, UserInputArray} from "../../validation.types"; +import type { + PerseusGroupScoringData, + UserInputArray, +} from "../../validation.types"; import type {GroupPromptJSON} from "../../widget-ai-utils/group/group-ai-utils"; type RenderProps = PerseusGroupWidgetOptions; // exports has no 'transform' -type Props = WidgetProps; +type Props = WidgetProps; type DefaultProps = { content: Props["content"]; widgets: Props["widgets"]; diff --git a/packages/perseus/src/widgets/group/score-group.ts b/packages/perseus/src/widgets/group/score-group.ts index 06eb2406fb..77e4015f57 100644 --- a/packages/perseus/src/widgets/group/score-group.ts +++ b/packages/perseus/src/widgets/group/score-group.ts @@ -4,7 +4,7 @@ import {flattenScores} from "../../util/scoring"; import type {PerseusStrings} from "../../strings"; import type {PerseusScore} from "../../types"; import type { - PerseusGroupRubric, + PerseusGroupScoringData, PerseusGroupUserInput, } from "../../validation.types"; @@ -12,7 +12,7 @@ import type { // it. As such, scoring a group means scoring all widgets it contains. function scoreGroup( userInput: PerseusGroupUserInput, - options: PerseusGroupRubric, + options: PerseusGroupScoringData, strings: PerseusStrings, locale: string, ): PerseusScore { diff --git a/packages/perseus/src/widgets/input-number/input-number.test.ts b/packages/perseus/src/widgets/input-number/input-number.test.ts index 1d7c2daab9..85a944d828 100644 --- a/packages/perseus/src/widgets/input-number/input-number.test.ts +++ b/packages/perseus/src/widgets/input-number/input-number.test.ts @@ -285,49 +285,52 @@ describe("invalid", function () { }); }); -describe("getOneCorrectAnswerFromRubric", () => { +describe("getOneCorrectAnswerFromScoringData", () => { beforeEach(() => { jest.spyOn(Dependencies, "getDependencies").mockReturnValue( testDependencies, ); }); - it("should return undefined if rubric.value is null/undefined", () => { + it("should return undefined if scoringData.value is null/undefined", () => { // Arrange - const rubric: Record = {}; + const scoringData: Record = {}; // Act - const result = InputNumber.getOneCorrectAnswerFromRubric?.(rubric); + const result = + InputNumber.getOneCorrectAnswerFromScoringData?.(scoringData); // Assert expect(result).toBeUndefined(); }); - it("should return rubric.value if inexact is false", () => { + it("should return scoringData.value if inexact is false", () => { // Arrange - const rubric = { + const scoringData = { value: 0, maxError: 0.1, inexact: false, } as const; // Act - const result = InputNumber.getOneCorrectAnswerFromRubric?.(rubric); + const result = + InputNumber.getOneCorrectAnswerFromScoringData?.(scoringData); // Assert expect(result).toEqual("0"); }); - it("should return rubric.value with an error band if inexact is true", () => { + it("should return scoringData.value with an error band if inexact is true", () => { // Arrange - const rubric = { + const scoringData = { value: 0, maxError: 0.1, inexact: true, } as const; // Act - const result = InputNumber.getOneCorrectAnswerFromRubric?.(rubric); + const result = + InputNumber.getOneCorrectAnswerFromScoringData?.(scoringData); // Assert expect(result).toEqual("0 ± 0.1"); diff --git a/packages/perseus/src/widgets/input-number/input-number.tsx b/packages/perseus/src/widgets/input-number/input-number.tsx index 12ac2ab6fe..d3cc8fc9d6 100644 --- a/packages/perseus/src/widgets/input-number/input-number.tsx +++ b/packages/perseus/src/widgets/input-number/input-number.tsx @@ -16,7 +16,7 @@ import type {PerseusInputNumberWidgetOptions} from "../../perseus-types"; import type {PerseusStrings} from "../../strings"; import type {Path, Widget, WidgetExports, WidgetProps} from "../../types"; import type { - PerseusInputNumberRubric, + PerseusInputNumberScoringData, PerseusInputNumberUserInput, } from "../../validation.types"; import type {InputNumberPromptJSON} from "../../widget-ai-utils/input-number/input-number-ai-utils"; @@ -58,7 +58,7 @@ type RenderProps = { rightAlign: PerseusInputNumberWidgetOptions["rightAlign"]; }; -type ExternalProps = WidgetProps; +type ExternalProps = WidgetProps; type Props = ExternalProps & { apiOptions: NonNullable; linterContext: NonNullable; @@ -67,7 +67,7 @@ type Props = ExternalProps & { currentValue: string; // NOTE(kevinb): This was the only default prop that is listed as // not-required in PerseusInputNumberWidgetOptions. - answerType: NonNullable; + answerType: NonNullable; }; type DefaultProps = { @@ -288,13 +288,15 @@ export default { // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusInputNumberUserInput'. scorer: scoreInputNumber, - getOneCorrectAnswerFromRubric(rubric: any): string | null | undefined { - if (rubric.value == null) { + getOneCorrectAnswerFromScoringData( + scoringData: any, + ): string | null | undefined { + if (scoringData.value == null) { return; } - let answerString = String(rubric.value); - if (rubric.inexact && rubric.maxError) { - answerString += " \u00B1 " + rubric.maxError; + let answerString = String(scoringData.value); + if (scoringData.inexact && scoringData.maxError) { + answerString += " \u00B1 " + scoringData.maxError; } return answerString; }, diff --git a/packages/perseus/src/widgets/input-number/score-input-number.test.ts b/packages/perseus/src/widgets/input-number/score-input-number.test.ts index fd589c3f0e..b792484cc7 100644 --- a/packages/perseus/src/widgets/input-number/score-input-number.test.ts +++ b/packages/perseus/src/widgets/input-number/score-input-number.test.ts @@ -2,11 +2,11 @@ import {mockStrings} from "../../strings"; import scoreInputNumber from "./score-input-number"; -import type {PerseusInputNumberRubric} from "../../validation.types"; +import type {PerseusInputNumberScoringData} from "../../validation.types"; describe("scoreInputNumber", () => { it("scores correct answer correctly", () => { - const rubric: PerseusInputNumberRubric = { + const scoringData: PerseusInputNumberScoringData = { maxError: 0.1, inexact: false, value: 1, @@ -18,13 +18,13 @@ describe("scoreInputNumber", () => { currentValue: "1", } as const; - const score = scoreInputNumber(useInput, rubric, mockStrings); + const score = scoreInputNumber(useInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredCorrectly(); }); it("scores incorrect answer correctly", () => { - const rubric: PerseusInputNumberRubric = { + const scoringData: PerseusInputNumberScoringData = { maxError: 0.1, inexact: false, value: 1, @@ -36,13 +36,13 @@ describe("scoreInputNumber", () => { currentValue: "2", } as const; - const score = scoreInputNumber(useInput, rubric, mockStrings); + const score = scoreInputNumber(useInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredIncorrectly(); }); it("shows as invalid with a nonsense answer", () => { - const rubric: PerseusInputNumberRubric = { + const scoringData: PerseusInputNumberScoringData = { maxError: 0.1, inexact: false, value: 1, @@ -54,7 +54,7 @@ describe("scoreInputNumber", () => { currentValue: "sadasdfas", } as const; - const score = scoreInputNumber(useInput, rubric, mockStrings); + const score = scoreInputNumber(useInput, scoringData, mockStrings); expect(score).toHaveInvalidInput( "We could not understand your answer. Please check your answer for extra text or symbols.", @@ -68,7 +68,7 @@ describe("scoreInputNumber", () => { // important to the test. // https://khanacademy.atlassian.net/browse/LC-691 it("doesn't default to validating pi", () => { - const rubric: PerseusInputNumberRubric = { + const scoringData: PerseusInputNumberScoringData = { maxError: 0.1, inexact: false, value: 241.90263432641407, @@ -82,7 +82,7 @@ describe("scoreInputNumber", () => { currentValue: "241.91", } as const; - const score = scoreInputNumber(userInput, rubric, mockStrings); + const score = scoreInputNumber(userInput, scoringData, mockStrings); expect(score.message).not.toBe( "Your answer is close, but yyou may " + @@ -95,7 +95,7 @@ describe("scoreInputNumber", () => { }); it("validates against pi if provided in answerType", () => { - const rubric: PerseusInputNumberRubric = { + const scoringData: PerseusInputNumberScoringData = { maxError: 0.1, inexact: false, value: 241.90263432641407, @@ -107,7 +107,7 @@ describe("scoreInputNumber", () => { currentValue: "77 pi", } as const; - const score = scoreInputNumber(userInput, rubric, mockStrings); + const score = scoreInputNumber(userInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredCorrectly(); }); diff --git a/packages/perseus/src/widgets/input-number/score-input-number.ts b/packages/perseus/src/widgets/input-number/score-input-number.ts index 30ae3a6956..122c4a80ce 100644 --- a/packages/perseus/src/widgets/input-number/score-input-number.ts +++ b/packages/perseus/src/widgets/input-number/score-input-number.ts @@ -3,7 +3,7 @@ import KhanAnswerTypes from "../../util/answer-types"; import type {PerseusStrings} from "../../strings"; import type { - PerseusInputNumberRubric, + PerseusInputNumberScoringData, PerseusInputNumberUserInput, } from "../../validation.types"; import type {PerseusScore} from "@khanacademy/perseus"; @@ -47,24 +47,24 @@ export const answerTypes = { function scoreInputNumber( userInput: PerseusInputNumberUserInput, - rubric: PerseusInputNumberRubric, + scoringData: PerseusInputNumberScoringData, strings: PerseusStrings, ): PerseusScore { - if (rubric.answerType == null) { - rubric.answerType = "number"; + if (scoringData.answerType == null) { + scoringData.answerType = "number"; } // note(matthewc): this will get immediately parsed again by // `KhanAnswerTypes.number.convertToPredicate`, but a string is // expected here - const stringValue = `${rubric.value}`; + const stringValue = `${scoringData.value}`; const val = KhanAnswerTypes.number.createValidatorFunctional( stringValue, { - simplify: rubric.simplify, - inexact: rubric.inexact || undefined, - maxError: rubric.maxError, - forms: answerTypes[rubric.answerType].forms, + simplify: scoringData.simplify, + inexact: scoringData.inexact || undefined, + maxError: scoringData.maxError, + forms: answerTypes[scoringData.answerType].forms, }, strings, ); diff --git a/packages/perseus/src/widgets/interactive-graph.tsx b/packages/perseus/src/widgets/interactive-graph.tsx index a73cf7ef72..eb791ecce3 100644 --- a/packages/perseus/src/widgets/interactive-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graph.tsx @@ -55,7 +55,7 @@ import type { SineCoefficient, } from "../util/geometry"; import type { - PerseusInteractiveGraphRubric, + PerseusInteractiveGraphScoringData, PerseusInteractiveGraphUserInput, } from "../validation.types"; import type {InteractiveGraphPromptJSON} from "../widget-ai-utils/interactive-graph/interactive-graph-ai-utils"; @@ -219,7 +219,7 @@ type RenderProps = { */ fullGraphAriaDescription?: string; }; // There's no transform function in exports -type Props = WidgetProps; +type Props = WidgetProps; type State = any; type DefaultProps = { labels: ReadonlyArray; @@ -240,7 +240,7 @@ type DefaultProps = { // which receive defaults via defaultProps. 0 as any as WidgetProps< PerseusInteractiveGraphWidgetOptions, - PerseusInteractiveGraphRubric + PerseusInteractiveGraphScoringData > satisfies PropsFor; // TODO: there's another, very similar getSinusoidCoefficients function diff --git a/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.test.ts b/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.test.ts index 6695f5c1eb..17c1dcdee0 100644 --- a/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.test.ts +++ b/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.test.ts @@ -5,12 +5,12 @@ import {clone} from "../../../../../testing/object-utils"; import scoreInteractiveGraph from "./score-interactive-graph"; import type {PerseusGraphType} from "../../perseus-types"; -import type {PerseusInteractiveGraphRubric} from "../../validation.types"; +import type {PerseusInteractiveGraphScoringData} from "../../validation.types"; describe("InteractiveGraph scoring on a segment question", () => { it("marks the answer invalid if guess.coords is missing", () => { const guess: PerseusGraphType = {type: "segment"}; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: { type: "segment", }, @@ -25,7 +25,7 @@ describe("InteractiveGraph scoring on a segment question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveInvalidInput(); }); @@ -41,7 +41,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -54,7 +54,7 @@ describe("InteractiveGraph scoring on a segment question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveBeenAnsweredIncorrectly(); }); @@ -70,7 +70,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -83,7 +83,7 @@ describe("InteractiveGraph scoring on a segment question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveBeenAnsweredCorrectly(); }); @@ -98,7 +98,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -111,7 +111,7 @@ describe("InteractiveGraph scoring on a segment question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveBeenAnsweredCorrectly(); }); @@ -127,7 +127,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -140,7 +140,7 @@ describe("InteractiveGraph scoring on a segment question", () => { }, }; - scoreInteractiveGraph(guess, rubric); + scoreInteractiveGraph(guess, scoringData); expect(guess.coords).toEqual([ [ @@ -150,7 +150,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ]); }); - it("does not modify the `rubric` data", () => { + it("does not modify `scoringData`", () => { const guess: PerseusGraphType = { type: "segment", coords: [ @@ -160,7 +160,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -173,12 +173,12 @@ describe("InteractiveGraph scoring on a segment question", () => { }, }; - scoreInteractiveGraph(guess, rubric); + scoreInteractiveGraph(guess, scoringData); - // Narrow the type of `rubric.correct` to segment graph; otherwise TS + // Narrow the type of `scoringData.correct` to segment graph; otherwise TS // thinks it might not have a `coords` property. - invariant(rubric.correct.type === "segment"); - expect(rubric.correct.coords).toEqual([ + invariant(scoringData.correct.type === "segment"); + expect(scoringData.correct.coords).toEqual([ [ [1, 1], [0, 0], @@ -190,7 +190,7 @@ describe("InteractiveGraph scoring on a segment question", () => { describe("InteractiveGraph scoring on an angle question", () => { it("marks the answer invalid if guess.coords is missing", () => { const guess: PerseusGraphType = {type: "angle"}; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "angle"}, correct: { type: "angle", @@ -204,7 +204,7 @@ describe("InteractiveGraph scoring on an angle question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveInvalidInput(); }); @@ -213,7 +213,7 @@ describe("InteractiveGraph scoring on an angle question", () => { describe("InteractiveGraph scoring on a point question", () => { it("marks the answer invalid if guess.coords is missing", () => { const guess: PerseusGraphType = {type: "point"}; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "point"}, correct: { type: "point", @@ -221,7 +221,7 @@ describe("InteractiveGraph scoring on a point question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveInvalidInput(); }); @@ -234,17 +234,17 @@ describe("InteractiveGraph scoring on a point question", () => { coords: [[0, 0]], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: { type: "point", }, - // @ts-expect-error: Testing exception for invalid rubric + // @ts-expect-error: Testing exception for invalid scoringData correct: { type: "point", }, }; - expect(() => scoreInteractiveGraph(guess, rubric)).toThrowError(); + expect(() => scoreInteractiveGraph(guess, scoringData)).toThrowError(); }); it("does not award points if guess.coords is wrong", () => { @@ -252,7 +252,7 @@ describe("InteractiveGraph scoring on a point question", () => { type: "point", coords: [[9, 9]], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "point"}, correct: { type: "point", @@ -260,7 +260,7 @@ describe("InteractiveGraph scoring on a point question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveBeenAnsweredIncorrectly(); }); @@ -270,7 +270,7 @@ describe("InteractiveGraph scoring on a point question", () => { type: "point", coords: [[7, 8]], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "point"}, correct: { type: "point", @@ -278,7 +278,7 @@ describe("InteractiveGraph scoring on a point question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveBeenAnsweredCorrectly(); }); @@ -291,7 +291,7 @@ describe("InteractiveGraph scoring on a point question", () => { [5, 6], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "point"}, correct: { type: "point", @@ -302,7 +302,7 @@ describe("InteractiveGraph scoring on a point question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveBeenAnsweredCorrectly(); }); @@ -315,7 +315,7 @@ describe("InteractiveGraph scoring on a point question", () => { [5, 6], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "point"}, correct: { type: "point", @@ -328,12 +328,12 @@ describe("InteractiveGraph scoring on a point question", () => { const guessClone = clone(guess); - scoreInteractiveGraph(guess, rubric); + scoreInteractiveGraph(guess, scoringData); expect(guess).toEqual(guessClone); }); - it("does not modify the `rubric` data", () => { + it("does not modify `scoringData`", () => { const guess: PerseusGraphType = { type: "point", coords: [ @@ -341,7 +341,7 @@ describe("InteractiveGraph scoring on a point question", () => { [5, 6], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "point"}, correct: { type: "point", @@ -352,10 +352,10 @@ describe("InteractiveGraph scoring on a point question", () => { }, }; - const rubricClone = clone(rubric); + const scoringDataClone = clone(scoringData); - scoreInteractiveGraph(guess, rubric); + scoreInteractiveGraph(guess, scoringData); - expect(rubric).toEqual(rubricClone); + expect(scoringData).toEqual(scoringDataClone); }); }); diff --git a/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts b/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts index 0525f2add5..d704d8183d 100644 --- a/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts +++ b/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts @@ -16,7 +16,7 @@ import {getClockwiseAngle} from "./math/angles"; import type {PerseusScore} from "../../types"; import type { - PerseusInteractiveGraphRubric, + PerseusInteractiveGraphScoringData, PerseusInteractiveGraphUserInput, } from "../../validation.types"; @@ -25,10 +25,10 @@ const deepEq = Util.deepEq; function scoreInteractiveGraph( userInput: PerseusInteractiveGraphUserInput, - rubric: PerseusInteractiveGraphRubric, + scoringData: PerseusInteractiveGraphScoringData, ): PerseusScore { // None-type graphs are not graded - if (userInput.type === "none" && rubric.correct.type === "none") { + if (userInput.type === "none" && scoringData.correct.type === "none") { return { type: "points", earned: 0, @@ -47,14 +47,14 @@ function scoreInteractiveGraph( (userInput.center && userInput.radius), ); - if (userInput.type === rubric.correct.type && hasValue) { + if (userInput.type === scoringData.correct.type && hasValue) { if ( userInput.type === "linear" && - rubric.correct.type === "linear" && + scoringData.correct.type === "linear" && userInput.coords != null ) { const guess = userInput.coords; - const correct = rubric.correct.coords; + const correct = scoringData.correct.coords; // If both of the guess points are on the correct line, it's // correct. @@ -71,11 +71,11 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "linear-system" && - rubric.correct.type === "linear-system" && + scoringData.correct.type === "linear-system" && userInput.coords != null ) { const guess = userInput.coords; - const correct = rubric.correct.coords; + const correct = scoringData.correct.coords; if ( (collinear(correct[0][0], correct[0][1], guess[0][0]) && @@ -96,13 +96,13 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "quadratic" && - rubric.correct.type === "quadratic" && + scoringData.correct.type === "quadratic" && userInput.coords != null ) { // If the parabola coefficients match, it's correct. const guessCoeffs = getQuadraticCoefficients(userInput.coords); const correctCoeffs = getQuadraticCoefficients( - rubric.correct.coords, + scoringData.correct.coords, ); if (deepEq(guessCoeffs, correctCoeffs)) { return { @@ -114,12 +114,12 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "sinusoid" && - rubric.correct.type === "sinusoid" && + scoringData.correct.type === "sinusoid" && userInput.coords != null ) { const guessCoeffs = getSinusoidCoefficients(userInput.coords); const correctCoeffs = getSinusoidCoefficients( - rubric.correct.coords, + scoringData.correct.coords, ); const canonicalGuessCoeffs = canonicalSineCoefficients(guessCoeffs); @@ -136,11 +136,11 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "circle" && - rubric.correct.type === "circle" + scoringData.correct.type === "circle" ) { if ( - deepEq(userInput.center, rubric.correct.center) && - eq(userInput.radius, rubric.correct.radius) + deepEq(userInput.center, scoringData.correct.center) && + eq(userInput.radius, scoringData.correct.radius) ) { return { type: "points", @@ -151,12 +151,12 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "point" && - rubric.correct.type === "point" && + scoringData.correct.type === "point" && userInput.coords != null ) { - let correct = rubric.correct.coords; + let correct = scoringData.correct.coords; if (correct == null) { - throw new Error("Point graph rubric has null coords"); + throw new Error("Point graph scoringData has null coords"); } const guess = userInput.coords.slice(); correct = correct.slice(); @@ -177,18 +177,18 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "polygon" && - rubric.correct.type === "polygon" && + scoringData.correct.type === "polygon" && userInput.coords != null ) { const guess = userInput.coords.slice(); - const correct = rubric.correct.coords.slice(); + const correct = scoringData.correct.coords.slice(); let match; - if (rubric.correct.match === "similar") { + if (scoringData.correct.match === "similar") { match = similar(guess, correct, Number.POSITIVE_INFINITY); - } else if (rubric.correct.match === "congruent") { + } else if (scoringData.correct.match === "congruent") { match = similar(guess, correct, knumber.DEFAULT_TOLERANCE); - } else if (rubric.correct.match === "approx") { + } else if (scoringData.correct.match === "approx") { match = similar(guess, correct, 0.1); } else { /* exact */ @@ -207,11 +207,11 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "segment" && - rubric.correct.type === "segment" && + scoringData.correct.type === "segment" && userInput.coords != null ) { let guess = Util.deepClone(userInput.coords); - let correct = Util.deepClone(rubric.correct.coords); + let correct = Util.deepClone(scoringData.correct.coords); guess = _.invoke(guess, "sort").sort(); correct = _.invoke(correct, "sort").sort(); if (deepEq(guess, correct)) { @@ -224,11 +224,11 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "ray" && - rubric.correct.type === "ray" && + scoringData.correct.type === "ray" && userInput.coords != null ) { const guess = userInput.coords; - const correct = rubric.correct.coords; + const correct = scoringData.correct.coords; if ( deepEq(guess[0], correct[0]) && collinear(correct[0], correct[1], guess[1]) @@ -242,14 +242,14 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "angle" && - rubric.correct.type === "angle" + scoringData.correct.type === "angle" ) { const guess = userInput.coords; - const correct = rubric.correct.coords; - const allowReflexAngles = rubric.correct.allowReflexAngles; + const correct = scoringData.correct.coords; + const allowReflexAngles = scoringData.correct.allowReflexAngles; let match; - if (rubric.correct.match === "congruent") { + if (scoringData.correct.match === "congruent") { const angles = _.map([guess, correct], function (coords) { if (!coords) { return false; @@ -282,7 +282,7 @@ function scoreInteractiveGraph( // The input wasn't correct, so check if it's a blank input or if it's // actually just wrong - if (!hasValue || _.isEqual(userInput, rubric.graph)) { + if (!hasValue || _.isEqual(userInput, scoringData.graph)) { // We're where we started. return { type: "invalid", diff --git a/packages/perseus/src/widgets/matcher/matcher.tsx b/packages/perseus/src/widgets/matcher/matcher.tsx index 85bbf3ffaa..34a51d46ac 100644 --- a/packages/perseus/src/widgets/matcher/matcher.tsx +++ b/packages/perseus/src/widgets/matcher/matcher.tsx @@ -17,7 +17,7 @@ import type {SortableOption} from "../../components/sortable"; import type {PerseusMatcherWidgetOptions} from "../../perseus-types"; import type {WidgetExports, WidgetProps, Widget} from "../../types"; import type { - PerseusMatcherRubric, + PerseusMatcherScoringData, PerseusMatcherUserInput, } from "../../validation.types"; import type {MatcherPromptJSON} from "../../widget-ai-utils/matcher/matcher-ai-utils"; @@ -27,7 +27,7 @@ const HACKY_CSS_CLASSNAME = "perseus-widget-matcher"; type RenderProps = PerseusMatcherWidgetOptions; -type Props = WidgetProps; +type Props = WidgetProps; type DefaultProps = { left: Props["left"]; diff --git a/packages/perseus/src/widgets/matcher/score-matcher.test.ts b/packages/perseus/src/widgets/matcher/score-matcher.test.ts index de517dd1f8..cdc53f8ae8 100644 --- a/packages/perseus/src/widgets/matcher/score-matcher.test.ts +++ b/packages/perseus/src/widgets/matcher/score-matcher.test.ts @@ -1,7 +1,7 @@ import scoreMatcher from "./score-matcher"; import type { - PerseusMatcherRubric, + PerseusMatcherScoringData, PerseusMatcherUserInput, } from "../../validation.types"; @@ -13,7 +13,7 @@ describe("scoreMatcher", () => { right: ["cool", "beans"], }; - const rubric: PerseusMatcherRubric = { + const scoringData: PerseusMatcherScoringData = { labels: ["One", "Two"], left: ["1", "0+1"], right: ["2", "0+2"], @@ -22,7 +22,7 @@ describe("scoreMatcher", () => { }; // Act - const result = scoreMatcher(userInput, rubric); + const result = scoreMatcher(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); @@ -30,7 +30,7 @@ describe("scoreMatcher", () => { it("can be answered correctly", () => { // Arrange - const rubric: PerseusMatcherRubric = { + const scoringData: PerseusMatcherScoringData = { labels: ["One", "Two"], left: ["1", "0+1"], right: ["2", "0+2"], @@ -39,12 +39,12 @@ describe("scoreMatcher", () => { }; const userInput: PerseusMatcherUserInput = { - left: [...rubric.left], - right: [...rubric.right], + left: [...scoringData.left], + right: [...scoringData.right], }; // Act - const result = scoreMatcher(userInput, rubric); + const result = scoreMatcher(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredCorrectly(); diff --git a/packages/perseus/src/widgets/matcher/score-matcher.ts b/packages/perseus/src/widgets/matcher/score-matcher.ts index 524a56f294..8f73a85643 100644 --- a/packages/perseus/src/widgets/matcher/score-matcher.ts +++ b/packages/perseus/src/widgets/matcher/score-matcher.ts @@ -2,17 +2,17 @@ import _ from "underscore"; import type {PerseusScore} from "../../types"; import type { - PerseusMatcherRubric, + PerseusMatcherScoringData, PerseusMatcherUserInput, } from "../../validation.types"; function scoreMatcher( - state: PerseusMatcherUserInput, - rubric: PerseusMatcherRubric, + userInput: PerseusMatcherUserInput, + scoringData: PerseusMatcherScoringData, ): PerseusScore { const correct = - _.isEqual(state.left, rubric.left) && - _.isEqual(state.right, rubric.right); + _.isEqual(userInput.left, scoringData.left) && + _.isEqual(userInput.right, scoringData.right); return { type: "points", diff --git a/packages/perseus/src/widgets/matrix/matrix.tsx b/packages/perseus/src/widgets/matrix/matrix.tsx index 47773730b7..4b2ac44073 100644 --- a/packages/perseus/src/widgets/matrix/matrix.tsx +++ b/packages/perseus/src/widgets/matrix/matrix.tsx @@ -24,7 +24,7 @@ import type { } from "../../perseus-types"; import type {WidgetExports, WidgetProps, Widget, FocusPath} from "../../types"; import type { - PerseusMatrixRubric, + PerseusMatrixScoringData, PerseusMatrixUserInput, } from "../../validation.types"; import type {MatrixPromptJSON} from "../../widget-ai-utils/matrix/matrix-ai-utils"; @@ -119,7 +119,7 @@ type ExternalProps = WidgetProps< // Whether this is meant to statically display the answers (true) or be used as an input field, graded against the answers static?: boolean | undefined; }, - PerseusMatrixRubric + PerseusMatrixScoringData >; // Assert that the PerseusMatrixWidgetOptions parsed from JSON can be passed @@ -130,7 +130,7 @@ type ExternalProps = WidgetProps< // defaultProps. 0 as any as WidgetProps< PerseusMatrixWidgetOptions, - PerseusMatrixRubric + PerseusMatrixScoringData > satisfies PropsFor; type Props = ExternalProps & { diff --git a/packages/perseus/src/widgets/matrix/score-matrix.test.ts b/packages/perseus/src/widgets/matrix/score-matrix.test.ts index 8a4d5f7d59..0a1a4af862 100644 --- a/packages/perseus/src/widgets/matrix/score-matrix.test.ts +++ b/packages/perseus/src/widgets/matrix/score-matrix.test.ts @@ -4,7 +4,7 @@ import scoreMatrix from "./score-matrix"; import * as MatrixValidator from "./validate-matrix"; import type { - PerseusMatrixRubric, + PerseusMatrixScoringData, PerseusMatrixUserInput, } from "../../validation.types"; @@ -15,7 +15,7 @@ describe("scoreMatrix", () => { .spyOn(MatrixValidator, "default") .mockReturnValue(null); - const rubric: PerseusMatrixRubric = { + const scoringData: PerseusMatrixScoringData = { answers: [ [0, 1, 2], [3, 4, 5], @@ -24,16 +24,16 @@ describe("scoreMatrix", () => { }; const userInput: PerseusMatrixUserInput = { - answers: rubric.answers, + answers: scoringData.answers, }; // Act - const score = scoreMatrix(userInput, rubric, mockStrings); + const score = scoreMatrix(userInput, scoringData, mockStrings); // Assert expect(mockValidator).toHaveBeenCalledWith( userInput, - rubric, + scoringData, mockStrings, ); expect(score).toHaveBeenAnsweredCorrectly(); @@ -45,7 +45,7 @@ describe("scoreMatrix", () => { .spyOn(MatrixValidator, "default") .mockReturnValue({type: "invalid", message: null}); - const rubric: PerseusMatrixRubric = { + const scoringData: PerseusMatrixScoringData = { answers: [ [0, 1, 2], [3, 4, 5], @@ -54,16 +54,16 @@ describe("scoreMatrix", () => { }; const userInput: PerseusMatrixUserInput = { - answers: rubric.answers, + answers: scoringData.answers, }; // Act - const score = scoreMatrix(userInput, rubric, mockStrings); + const score = scoreMatrix(userInput, scoringData, mockStrings); // Assert expect(mockValidator).toHaveBeenCalledWith( userInput, - rubric, + scoringData, mockStrings, ); expect(score).toHaveInvalidInput(); @@ -71,7 +71,7 @@ describe("scoreMatrix", () => { it("can be answered correctly", () => { // Arrange - const rubric: PerseusMatrixRubric = { + const scoringData: PerseusMatrixScoringData = { answers: [ [0, 1, 2], [3, 4, 5], @@ -80,11 +80,11 @@ describe("scoreMatrix", () => { }; const userInput: PerseusMatrixUserInput = { - answers: rubric.answers, + answers: scoringData.answers, }; // Act - const result = scoreMatrix(userInput, rubric, mockStrings); + const result = scoreMatrix(userInput, scoringData, mockStrings); // Assert expect(result).toHaveBeenAnsweredCorrectly(); @@ -92,7 +92,7 @@ describe("scoreMatrix", () => { it("can be answered incorrectly", () => { // Arrange - const rubric: PerseusMatrixRubric = { + const scoringData: PerseusMatrixScoringData = { answers: [ [0, 1, 2], [3, 4, 5], @@ -109,7 +109,7 @@ describe("scoreMatrix", () => { }; // Act - const result = scoreMatrix(userInput, rubric, mockStrings); + const result = scoreMatrix(userInput, scoringData, mockStrings); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); @@ -117,7 +117,7 @@ describe("scoreMatrix", () => { it("is invalid when there's an empty cell: null", () => { // Arrange - const rubric: PerseusMatrixRubric = { + const scoringData: PerseusMatrixScoringData = { answers: [ [0, 1, 2], [3, 4, 5], @@ -137,7 +137,7 @@ describe("scoreMatrix", () => { }; // Act - const result = scoreMatrix(userInput, rubric, mockStrings); + const result = scoreMatrix(userInput, scoringData, mockStrings); // Assert expect(result).toHaveInvalidInput(); @@ -145,7 +145,7 @@ describe("scoreMatrix", () => { it("is invalid when there's an empty cell: empty string", () => { // Arrange - const rubric: PerseusMatrixRubric = { + const scoringData: PerseusMatrixScoringData = { answers: [ [0, 1, 2], [3, 4, 5], @@ -165,7 +165,7 @@ describe("scoreMatrix", () => { }; // Act - const result = scoreMatrix(userInput, rubric, mockStrings); + const result = scoreMatrix(userInput, scoringData, mockStrings); // Assert expect(result).toHaveInvalidInput(); @@ -173,7 +173,7 @@ describe("scoreMatrix", () => { it("is considered incorrect when the size is wrong", () => { // Arrange - const rubric: PerseusMatrixRubric = { + const scoringData: PerseusMatrixScoringData = { answers: [ [0, 1, 2], [3, 4, 5], @@ -182,7 +182,7 @@ describe("scoreMatrix", () => { }; const correctUserInput: PerseusMatrixUserInput = { - answers: rubric.answers, + answers: scoringData.answers, }; const incorrectUserInput: PerseusMatrixUserInput = { @@ -190,18 +190,18 @@ describe("scoreMatrix", () => { // This is so we can check that it's considered incorrect // if it has the wrong length, even though it otherwise // would be a partial match. - answers: [...rubric.answers, [8, 6, 7]], + answers: [...scoringData.answers, [8, 6, 7]], }; // Act const correctResult = scoreMatrix( correctUserInput, - rubric, + scoringData, mockStrings, ); const incorrectResult = scoreMatrix( incorrectUserInput, - rubric, + scoringData, mockStrings, ); diff --git a/packages/perseus/src/widgets/matrix/score-matrix.ts b/packages/perseus/src/widgets/matrix/score-matrix.ts index ef78b3b5b7..88f73b8c69 100644 --- a/packages/perseus/src/widgets/matrix/score-matrix.ts +++ b/packages/perseus/src/widgets/matrix/score-matrix.ts @@ -8,21 +8,21 @@ import validateMatrix from "./validate-matrix"; import type {PerseusStrings} from "../../strings"; import type {PerseusScore} from "../../types"; import type { - PerseusMatrixRubric, + PerseusMatrixScoringData, PerseusMatrixUserInput, } from "../../validation.types"; function scoreMatrix( userInput: PerseusMatrixUserInput, - rubric: PerseusMatrixRubric, + scoringData: PerseusMatrixScoringData, strings: PerseusStrings, ): PerseusScore { - const validationResult = validateMatrix(userInput, rubric, strings); + const validationResult = validateMatrix(userInput, scoringData, strings); if (validationResult != null) { return validationResult; } - const solution = rubric.answers; + const solution = scoringData.answers; const supplied = userInput.answers; const solutionSize = getMatrixSize(solution); const suppliedSize = getMatrixSize(supplied); diff --git a/packages/perseus/src/widgets/numeric-input/numeric-input.test.ts b/packages/perseus/src/widgets/numeric-input/numeric-input.test.ts index d177fdb34e..c47a268078 100644 --- a/packages/perseus/src/widgets/numeric-input/numeric-input.test.ts +++ b/packages/perseus/src/widgets/numeric-input/numeric-input.test.ts @@ -17,7 +17,7 @@ import { withCoefficient, } from "./numeric-input.testdata"; -import type {PerseusNumericInputRubric} from "../../validation.types"; +import type {PerseusNumericInputScoringData} from "../../validation.types"; import type {UserEvent} from "@testing-library/user-event"; describe("numeric-input widget", () => { @@ -159,44 +159,44 @@ describe("numeric-input widget", () => { }); }); -describe("static function getOneCorrectAnswerFromRubric", () => { +describe("static function getOneCorrectAnswerFromScoringData", () => { beforeEach(() => { jest.spyOn(Dependencies, "getDependencies").mockReturnValue( testDependencies, ); }); - it("can get one correct answer from a rubric with multiple answers", () => { + it("can get one correct answer from scoring data with multiple answers", () => { const widget = multipleAnswersWithDecimals.widgets["numeric-input 1"]; const widgetOptions = widget && widget.options; const answers: ReadonlyArray = (widgetOptions && widgetOptions.answers) || []; const singleAnswer = - NumericInputWidgetExport.getOneCorrectAnswerFromRubric?.({ + NumericInputWidgetExport.getOneCorrectAnswerFromScoringData?.({ answers, coefficient: false, }); expect(singleAnswer).toBe("12.2"); }); - it("can get one correct answer from a rubric with one answer", () => { + it("can get one correct answer from scoring data with one answer", () => { const widget = question1.widgets["numeric-input 1"]; const widgetOptions = widget && widget.options; const answers: ReadonlyArray = (widgetOptions && widgetOptions.answers) || []; const singleAnswer = - NumericInputWidgetExport.getOneCorrectAnswerFromRubric?.({ + NumericInputWidgetExport.getOneCorrectAnswerFromScoringData?.({ answers, coefficient: false, }); expect(singleAnswer).toBe("1252"); }); - it("can not get a correct answer from a rubric with no answer", () => { + it("can not get a correct answer from scoring data with no answer", () => { const answers: Array = []; const singleAnswer = - NumericInputWidgetExport.getOneCorrectAnswerFromRubric?.({ + NumericInputWidgetExport.getOneCorrectAnswerFromScoringData?.({ answers, coefficient: false, }); @@ -204,7 +204,7 @@ describe("static function getOneCorrectAnswerFromRubric", () => { }); it("supports error bars", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { status: "correct", @@ -219,7 +219,9 @@ describe("static function getOneCorrectAnswerFromRubric", () => { coefficient: true, }; const singleAnswer = - NumericInputWidgetExport.getOneCorrectAnswerFromRubric?.(rubric); + NumericInputWidgetExport.getOneCorrectAnswerFromScoringData?.( + scoringData, + ); expect(singleAnswer).toBe("1 ± 0.2"); }); }); diff --git a/packages/perseus/src/widgets/numeric-input/numeric-input.tsx b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx index bd8bf29dc6..3138fec300 100644 --- a/packages/perseus/src/widgets/numeric-input/numeric-input.tsx +++ b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx @@ -19,7 +19,7 @@ import type { import type {PerseusStrings} from "../../strings"; import type {FocusPath, Widget, WidgetExports, WidgetProps} from "../../types"; import type { - PerseusNumericInputRubric, + PerseusNumericInputScoringData, PerseusNumericInputUserInput, } from "../../validation.types"; import type {NumericInputPromptJSON} from "../../widget-ai-utils/numeric-input/prompt-utils"; @@ -47,7 +47,7 @@ const formExamples: { type ExternalProps = WidgetProps< PerseusNumericInputWidgetOptions, - PerseusNumericInputRubric + PerseusNumericInputScoringData >; type Props = ExternalProps & { @@ -80,7 +80,7 @@ type DefaultProps = { // via defaultProps. 0 as any as WidgetProps< PerseusNumericInputWidgetOptions, - PerseusNumericInputRubric + PerseusNumericInputScoringData > satisfies PropsFor; type State = { @@ -382,11 +382,11 @@ export default { scorer: scoreNumericInput, // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'Rubric' is not assignable to type 'PerseusNumericInputRubric' - getOneCorrectAnswerFromRubric( - rubric: PerseusNumericInputRubric, + // @ts-expect-error: Type 'ScoringData' is not assignable to type 'PerseusNumericInputScoringData' + getOneCorrectAnswerFromScoringData( + scoringData: PerseusNumericInputScoringData, ): string | null | undefined { - const correctAnswers = rubric.answers.filter( + const correctAnswers = scoringData.answers.filter( (answer) => answer.status === "correct", ); const answerStrings = correctAnswers.map((answer) => { diff --git a/packages/perseus/src/widgets/numeric-input/score-numeric-input.test.ts b/packages/perseus/src/widgets/numeric-input/score-numeric-input.test.ts index b0ad938871..ddf569c325 100644 --- a/packages/perseus/src/widgets/numeric-input/score-numeric-input.test.ts +++ b/packages/perseus/src/widgets/numeric-input/score-numeric-input.test.ts @@ -4,7 +4,7 @@ import {mockStrings} from "../../strings"; import scoreNumericInput, {maybeParsePercentInput} from "./score-numeric-input"; -import type {PerseusNumericInputRubric} from "../../validation.types"; +import type {PerseusNumericInputScoringData} from "../../validation.types"; describe("scoreNumericInput", () => { beforeEach(() => { @@ -14,7 +14,7 @@ describe("scoreNumericInput", () => { }); it("is correct when input is empty but answer is 1 and coefficient: true", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1, @@ -34,13 +34,13 @@ describe("scoreNumericInput", () => { currentValue: "", }; - const score = scoreNumericInput(userInput, rubric, mockStrings); + const score = scoreNumericInput(userInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredCorrectly(); }); it("with a simple value", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1, @@ -58,13 +58,13 @@ describe("scoreNumericInput", () => { currentValue: "1", } as const; - const score = scoreNumericInput(userInput, rubric, mockStrings); + const score = scoreNumericInput(userInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredCorrectly(); }); it("with nonsense", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1, @@ -82,7 +82,7 @@ describe("scoreNumericInput", () => { currentValue: "sadasdfas", } as const; - const score = scoreNumericInput(userInput, rubric, mockStrings); + const score = scoreNumericInput(userInput, scoringData, mockStrings); expect(score).toHaveInvalidInput( "We could not understand your answer. Please check your answer for extra text or symbols.", @@ -96,7 +96,7 @@ describe("scoreNumericInput", () => { // important to the test. // https://khanacademy.atlassian.net/browse/LC-691 it("doesn't default to validating pi", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { maxError: null, @@ -117,7 +117,7 @@ describe("scoreNumericInput", () => { currentValue: "45.282", } as const; - const score = scoreNumericInput(userInput, rubric, mockStrings); + const score = scoreNumericInput(userInput, scoringData, mockStrings); expect(score.message).not.toBe( "Your answer is close, but you may " + @@ -130,7 +130,7 @@ describe("scoreNumericInput", () => { }); it("still validates against pi if provided in answerForms", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { maxError: null, @@ -149,13 +149,13 @@ describe("scoreNumericInput", () => { currentValue: "99 pi", } as const; - const score = scoreNumericInput(userInput, rubric, mockStrings); + const score = scoreNumericInput(userInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredCorrectly(); }); it("with a strict answer", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1, @@ -173,13 +173,13 @@ describe("scoreNumericInput", () => { currentValue: "1.0", } as const; - const score = scoreNumericInput(userInput, rubric, mockStrings); + const score = scoreNumericInput(userInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredCorrectly(); }); it("with a strict answer and max error is outside range", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1, @@ -197,13 +197,13 @@ describe("scoreNumericInput", () => { currentValue: "1.3", } as const; - const score = scoreNumericInput(userInput, rubric, mockStrings); + const score = scoreNumericInput(userInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredIncorrectly(); }); it("with a strict answer and max error is inside range", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1, @@ -221,14 +221,14 @@ describe("scoreNumericInput", () => { currentValue: "1.12", } as const; - const score = scoreNumericInput(userInput, rubric, mockStrings); + const score = scoreNumericInput(userInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredCorrectly(); }); it("respects the order of answer options when scoring", () => { // Arrange - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ // "4" is a wrong answer { @@ -256,7 +256,7 @@ describe("scoreNumericInput", () => { const wrongInput = { currentValue: "4", } as const; - let score = scoreNumericInput(wrongInput, rubric, mockStrings); + let score = scoreNumericInput(wrongInput, scoringData, mockStrings); // Assert - "wrong" expect(score).toHaveBeenAnsweredIncorrectly(); @@ -265,7 +265,7 @@ describe("scoreNumericInput", () => { const correctInput = { currentValue: "14", } as const; - score = scoreNumericInput(correctInput, rubric, mockStrings); + score = scoreNumericInput(correctInput, scoringData, mockStrings); // Assert - "correct" expect(score).toHaveBeenAnsweredCorrectly(); @@ -273,7 +273,7 @@ describe("scoreNumericInput", () => { it("defaults to 1 or -1 when user input is empty/incomplete", () => { // Arrange - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1, @@ -299,7 +299,7 @@ describe("scoreNumericInput", () => { const emptyInput = { currentValue: "", } as const; - let score = scoreNumericInput(emptyInput, rubric, mockStrings); + let score = scoreNumericInput(emptyInput, scoringData, mockStrings); // Assert - "empty" expect(score).toHaveBeenAnsweredCorrectly(); @@ -308,14 +308,14 @@ describe("scoreNumericInput", () => { const incompleteInput = { currentValue: "-", } as const; - score = scoreNumericInput(incompleteInput, rubric, mockStrings); + score = scoreNumericInput(incompleteInput, scoringData, mockStrings); // Assert - "incomplete" expect(score).toHaveBeenAnsweredCorrectly(); }); it("rejects responses formatted as a percentage when any answer has no value field", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { // This answer is missing its value field. @@ -340,7 +340,7 @@ describe("scoreNumericInput", () => { const score = scoreNumericInput( {currentValue: "50%"}, - rubric, + scoringData, mockStrings, ); @@ -348,7 +348,7 @@ describe("scoreNumericInput", () => { }); it("converts a percentage input value to a decimal", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 0.2, @@ -364,7 +364,7 @@ describe("scoreNumericInput", () => { const score = scoreNumericInput( {currentValue: "20%"}, - rubric, + scoringData, mockStrings, ); @@ -375,7 +375,7 @@ describe("scoreNumericInput", () => { // TODO(benchristel): This seems like incorrect behavior. I've added // this test to characterize the current behavior. Feel free to // delete/change it if it's in your way. - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1.2, @@ -391,7 +391,7 @@ describe("scoreNumericInput", () => { const score = scoreNumericInput( {currentValue: "120%"}, - rubric, + scoringData, mockStrings, ); @@ -402,7 +402,7 @@ describe("scoreNumericInput", () => { // TODO(benchristel): This seems like incorrect behavior. I've added // this test to characterize the current behavior. Feel free to // delete/change it if it's in your way. - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1.1, @@ -418,7 +418,7 @@ describe("scoreNumericInput", () => { const score = scoreNumericInput( {currentValue: "1.1%"}, - rubric, + scoringData, mockStrings, ); @@ -426,7 +426,7 @@ describe("scoreNumericInput", () => { }); it("rejects answers with an extra, incorrect percent sign if < 1", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 0.9, @@ -442,7 +442,7 @@ describe("scoreNumericInput", () => { const score = scoreNumericInput( {currentValue: "0.9%"}, - rubric, + scoringData, mockStrings, ); diff --git a/packages/perseus/src/widgets/numeric-input/score-numeric-input.ts b/packages/perseus/src/widgets/numeric-input/score-numeric-input.ts index cf23c5ad28..df12f20ef3 100644 --- a/packages/perseus/src/widgets/numeric-input/score-numeric-input.ts +++ b/packages/perseus/src/widgets/numeric-input/score-numeric-input.ts @@ -6,7 +6,7 @@ import type {PerseusStrings} from "../../strings"; import type {PerseusScore} from "../../types"; import type {Score} from "../../util/answer-types"; import type { - PerseusNumericInputRubric, + PerseusNumericInputScoringData, PerseusNumericInputUserInput, } from "../../validation.types"; @@ -67,7 +67,7 @@ export function maybeParsePercentInput( function scoreNumericInput( userInput: PerseusNumericInputUserInput, - rubric: PerseusNumericInputRubric, + scoringData: PerseusNumericInputScoringData, strings: PerseusStrings, ): PerseusScore { const defaultAnswerForms = answerFormButtons @@ -109,7 +109,7 @@ function scoreNumericInput( // If `currentValue` is not TeX, this should be a no-op. const currentValue = ParseTex(userInput.currentValue); - const normalizedAnswerExpected = rubric.answers + const normalizedAnswerExpected = scoringData.answers .filter((answer) => answer.status === "correct") .every( (answer) => @@ -118,7 +118,7 @@ function scoreNumericInput( // The coefficient is an attribute of the widget let localValue: string | number = currentValue; - if (rubric.coefficient) { + if (scoringData.coefficient) { if (!localValue) { localValue = 1; } else if (localValue === "-") { @@ -127,7 +127,7 @@ function scoreNumericInput( } const matchedAnswer: | (PerseusNumericInputAnswer & {score: Score}) - | undefined = rubric.answers + | undefined = scoringData.answers .map((answer) => { const validateFn = createValidator(answer); const score = validateFn( diff --git a/packages/perseus/src/widgets/orderer/orderer.tsx b/packages/perseus/src/widgets/orderer/orderer.tsx index 59e63d85c9..fc2082a8ae 100644 --- a/packages/perseus/src/widgets/orderer/orderer.tsx +++ b/packages/perseus/src/widgets/orderer/orderer.tsx @@ -20,7 +20,7 @@ import validateOrderer from "./validate-orderer"; import type {PerseusOrdererWidgetOptions} from "../../perseus-types"; import type {WidgetExports, WidgetProps, Widget} from "../../types"; import type { - PerseusOrdererRubric, + PerseusOrdererScoringData, PerseusOrdererUserInput, } from "../../validation.types"; import type {OrdererPromptJSON} from "../../widget-ai-utils/orderer/orderer-ai-utils"; @@ -300,7 +300,7 @@ type RenderProps = PerseusOrdererWidgetOptions & { current: any; }; -type OrdererProps = WidgetProps; +type OrdererProps = WidgetProps; type OrdererDefaultProps = { current: OrdererProps["current"]; diff --git a/packages/perseus/src/widgets/orderer/score-orderer.test.ts b/packages/perseus/src/widgets/orderer/score-orderer.test.ts index a5595f5249..9b0deadb07 100644 --- a/packages/perseus/src/widgets/orderer/score-orderer.test.ts +++ b/packages/perseus/src/widgets/orderer/score-orderer.test.ts @@ -3,14 +3,14 @@ import {scoreOrderer} from "./score-orderer"; import * as OrdererValidator from "./validate-orderer"; import type { - PerseusOrdererRubric, + PerseusOrdererScoringData, PerseusOrdererUserInput, } from "../../validation.types"; 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", () => { + it("is correct when the userInput is in the same order and is the same length as the scoringData's correctOption content items", () => { // Arrange - const rubric: PerseusOrdererRubric = + const scoringData: PerseusOrdererScoringData = question1.widgets["orderer 1"].options; const userInput: PerseusOrdererUserInput = { @@ -20,15 +20,15 @@ describe("scoreOrderer", () => { }; // Act - const result = scoreOrderer(userInput, rubric); + const result = scoreOrderer(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredCorrectly(); }); - it("is incorrect when the userInput is not in the same order as the rubric's correctOption content items", () => { + it("is incorrect when the userInput is not in the same order as the scoringData's correctOption content items", () => { // Arrange - const rubric: PerseusOrdererRubric = + const scoringData: PerseusOrdererScoringData = question1.widgets["orderer 1"].options; const userInput: PerseusOrdererUserInput = { @@ -36,15 +36,15 @@ describe("scoreOrderer", () => { }; // Act - const result = scoreOrderer(userInput, rubric); + const result = scoreOrderer(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); }); - it("is incorrect when the userInput is not the same length as the rubric's correctOption content items", () => { + it("is incorrect when the userInput is not the same length as the scoringData's correctOption content items", () => { // Arrange - const rubric: PerseusOrdererRubric = + const scoringData: PerseusOrdererScoringData = question1.widgets["orderer 1"].options; const userInput: PerseusOrdererUserInput = { @@ -52,7 +52,7 @@ describe("scoreOrderer", () => { }; // Act - const result = scoreOrderer(userInput, rubric); + const result = scoreOrderer(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); @@ -64,7 +64,7 @@ describe("scoreOrderer", () => { .spyOn(OrdererValidator, "default") .mockReturnValue(null); - const rubric: PerseusOrdererRubric = + const scoringData: PerseusOrdererScoringData = question1.widgets["orderer 1"].options; const userInput: PerseusOrdererUserInput = { @@ -73,7 +73,7 @@ describe("scoreOrderer", () => { ), }; // Act - const result = scoreOrderer(userInput, rubric); + const result = scoreOrderer(userInput, scoringData); // Assert expect(mockValidator).toHaveBeenCalledWith(userInput); @@ -89,7 +89,7 @@ describe("scoreOrderer", () => { message: null, }); - const rubric: PerseusOrdererRubric = + const scoringData: PerseusOrdererScoringData = question1.widgets["orderer 1"].options; const userInput: PerseusOrdererUserInput = { @@ -97,7 +97,7 @@ describe("scoreOrderer", () => { }; // Act - const result = scoreOrderer(userInput, rubric); + const result = scoreOrderer(userInput, scoringData); // Assert expect(mockValidator).toHaveBeenCalledWith(userInput); diff --git a/packages/perseus/src/widgets/orderer/score-orderer.ts b/packages/perseus/src/widgets/orderer/score-orderer.ts index a1d5336ccf..c3912014c1 100644 --- a/packages/perseus/src/widgets/orderer/score-orderer.ts +++ b/packages/perseus/src/widgets/orderer/score-orderer.ts @@ -4,13 +4,13 @@ import validateOrderer from "./validate-orderer"; import type {PerseusScore} from "../../types"; import type { - PerseusOrdererRubric, + PerseusOrdererScoringData, PerseusOrdererUserInput, } from "../../validation.types"; export function scoreOrderer( userInput: PerseusOrdererUserInput, - rubric: PerseusOrdererRubric, + scoringData: PerseusOrdererScoringData, ): PerseusScore { const validateError = validateOrderer(userInput); if (validateError) { @@ -19,7 +19,7 @@ export function scoreOrderer( const correct = _.isEqual( userInput.current, - rubric.correctOptions.map((option) => option.content), + scoringData.correctOptions.map((option) => option.content), ); return { diff --git a/packages/perseus/src/widgets/radio/__tests__/radio.test.ts b/packages/perseus/src/widgets/radio/__tests__/radio.test.ts index 10486318f1..0d337d6612 100644 --- a/packages/perseus/src/widgets/radio/__tests__/radio.test.ts +++ b/packages/perseus/src/widgets/radio/__tests__/radio.test.ts @@ -953,7 +953,7 @@ describe("Radio Widget", () => { /** * (LEMS-2435) We want to be sure that we're able to score shuffled * Radio widgets outside of the component which means `getUserInput` - * should return the same order that the rubric provides + * should return the same order that the scoring data provides */ it("can be scored correctly when shuffled", async () => { // Arrange @@ -966,8 +966,8 @@ describe("Radio Widget", () => { const userInput = renderer.getUserInput()[0] as PerseusRadioUserInput; - const rubric = shuffledQuestion.widgets["radio 1"].options; - const widgetScore = scoreRadio(userInput, rubric, mockStrings); + const scoringData = shuffledQuestion.widgets["radio 1"].options; + const widgetScore = scoreRadio(userInput, scoringData, mockStrings); const rendererScore = scorePerseusItemTesting( shuffledQuestion, renderer.getUserInputMap(), @@ -981,7 +981,7 @@ describe("Radio Widget", () => { /** * (LEMS-2435) We want to be sure that we're able to score shuffled * Radio widgets outside of the component which means `getUserInput` - * should return the same order that the rubric provides + * should return the same order that the scoring data provides */ it("can be scored incorrectly when shuffled", async () => { // Arrange @@ -994,8 +994,8 @@ describe("Radio Widget", () => { const userInput = renderer.getUserInput()[0] as PerseusRadioUserInput; - const rubric = shuffledQuestion.widgets["radio 1"].options; - const widgetScore = scoreRadio(userInput, rubric, mockStrings); + const scoringData = shuffledQuestion.widgets["radio 1"].options; + const widgetScore = scoreRadio(userInput, scoringData, mockStrings); const rendererScore = scorePerseusItemTesting( shuffledQuestion, renderer.getUserInputMap(), @@ -1009,7 +1009,7 @@ describe("Radio Widget", () => { /** * (LEMS-2435) We want to be sure that we're able to score shuffled * Radio widgets outside of the component which means `getUserInput` - * should return the same order that the rubric provides + * should return the same order that the scoring data provides */ it("can be scored correctly when shuffled with none of the above", async () => { // Arrange @@ -1022,8 +1022,8 @@ describe("Radio Widget", () => { const userInput = renderer.getUserInput()[0] as PerseusRadioUserInput; - const rubric = shuffledNoneQuestion.widgets["radio 1"].options; - const widgetScore = scoreRadio(userInput, rubric, mockStrings); + const scoringData = shuffledNoneQuestion.widgets["radio 1"].options; + const widgetScore = scoreRadio(userInput, scoringData, mockStrings); const rendererScore = scorePerseusItemTesting( shuffledNoneQuestion, renderer.getUserInputMap(), @@ -1037,7 +1037,7 @@ describe("Radio Widget", () => { /** * (LEMS-2435) We want to be sure that we're able to score shuffled * Radio widgets outside of the component which means `getUserInput` - * should return the same order that the rubric provides + * should return the same order that the scoring data provides */ it("can be scored incorrectly when shuffled with none of the above", async () => { // Arrange @@ -1050,8 +1050,8 @@ describe("Radio Widget", () => { const userInput = renderer.getUserInput()[0] as PerseusRadioUserInput; - const rubric = shuffledNoneQuestion.widgets["radio 1"].options; - const widgetScore = scoreRadio(userInput, rubric, mockStrings); + const scoringData = shuffledNoneQuestion.widgets["radio 1"].options; + const widgetScore = scoreRadio(userInput, scoringData, mockStrings); const rendererScore = scorePerseusItemTesting( shuffledQuestion, renderer.getUserInputMap(), diff --git a/packages/perseus/src/widgets/radio/radio-component.tsx b/packages/perseus/src/widgets/radio/radio-component.tsx index 7feefac6c7..679d6ea219 100644 --- a/packages/perseus/src/widgets/radio/radio-component.tsx +++ b/packages/perseus/src/widgets/radio/radio-component.tsx @@ -18,7 +18,7 @@ import type { } from "../../perseus-types"; import type {WidgetProps, ChoiceState, Widget} from "../../types"; import type { - PerseusRadioRubric, + PerseusRadioScoringData, PerseusRadioUserInput, } from "../../validation.types"; import type {RadioPromptJSON} from "../../widget-ai-utils/radio/radio-ai-utils"; @@ -39,7 +39,7 @@ export type RenderProps = { values?: ReadonlyArray; }; -type Props = WidgetProps; +type Props = WidgetProps; type DefaultProps = Required< Pick< @@ -268,12 +268,12 @@ class Radio extends React.Component implements Widget { */ showRationalesForCurrentlySelectedChoices: ( arg1: PerseusRadioWidgetOptions, - ) => void = (rubric) => { + ) => void = (scoringData) => { const {choiceStates} = this.props; if (choiceStates) { const score = scoreRadio( this.getUserInput(), - rubric, + scoringData, this.context.strings, ); const widgetCorrect = @@ -417,7 +417,7 @@ class Radio extends React.Component implements Widget { // Current versions of the radio widget always pass in the // "correct" value through the choices. Old serialized state // for radio widgets doesn't have this though, so we have to - // pull the correctness out of the review mode rubric. This + // pull the correctness out of the review mode scoring data. This // only works because all of the places we use // `restoreSerializedState()` also turn on reviewMode, but is // fine for now. diff --git a/packages/perseus/src/widgets/radio/score-radio.test.ts b/packages/perseus/src/widgets/radio/score-radio.test.ts index 0ad4c50b1c..bb1bbdb85b 100644 --- a/packages/perseus/src/widgets/radio/score-radio.test.ts +++ b/packages/perseus/src/widgets/radio/score-radio.test.ts @@ -3,7 +3,7 @@ import {mockStrings} from "../../strings"; import scoreRadio from "./score-radio"; import type { - PerseusRadioRubric, + PerseusRadioScoringData, PerseusRadioUserInput, } from "../../validation.types"; @@ -13,7 +13,7 @@ describe("scoreRadio", () => { choicesSelected: [true, false, false, false], }; - const rubric: PerseusRadioRubric = { + const scoringData: PerseusRadioScoringData = { choices: [ {content: "Choice 1", correct: true}, {content: "Choice 2", correct: true}, @@ -22,7 +22,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric, mockStrings); + const score = scoreRadio(userInput, scoringData, mockStrings); expect(score).toHaveInvalidInput(); }); @@ -32,7 +32,7 @@ describe("scoreRadio", () => { choicesSelected: [true, false, false, false, true], }; - const rubric: PerseusRadioRubric = { + const scoringData: PerseusRadioScoringData = { choices: [ {content: "Choice 1", correct: true}, {content: "Choice 2", correct: false}, @@ -46,7 +46,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric, mockStrings); + const score = scoreRadio(userInput, scoringData, mockStrings); expect(score).toHaveInvalidInput(); }); @@ -56,7 +56,7 @@ describe("scoreRadio", () => { choicesSelected: [true, false, false, false], }; - const rubric: PerseusRadioRubric = { + const scoringData: PerseusRadioScoringData = { choices: [ {content: "Choice 1", correct: true}, {content: "Choice 2", correct: false}, @@ -65,7 +65,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric, mockStrings); + const score = scoreRadio(userInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredCorrectly(); }); @@ -75,7 +75,7 @@ describe("scoreRadio", () => { choicesSelected: [false, false, false, true], }; - const rubric: PerseusRadioRubric = { + const scoringData: PerseusRadioScoringData = { choices: [ {content: "Choice 1", correct: true}, {content: "Choice 2", correct: false}, @@ -84,7 +84,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric, mockStrings); + const score = scoreRadio(userInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredIncorrectly(); }); @@ -94,7 +94,7 @@ describe("scoreRadio", () => { choicesSelected: [true, true, false, false], }; - const rubric: PerseusRadioRubric = { + const scoringData: PerseusRadioScoringData = { choices: [ {content: "Choice 1", correct: true}, {content: "Choice 2", correct: true}, @@ -103,7 +103,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric, mockStrings); + const score = scoreRadio(userInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredCorrectly(); }); @@ -113,7 +113,7 @@ describe("scoreRadio", () => { choicesSelected: [true, false, false, true], }; - const rubric: PerseusRadioRubric = { + const scoringData: PerseusRadioScoringData = { choices: [ {content: "Choice 1", correct: true}, {content: "Choice 2", correct: true}, @@ -122,7 +122,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric, mockStrings); + const score = scoreRadio(userInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredIncorrectly(); }); @@ -132,7 +132,7 @@ describe("scoreRadio", () => { choicesSelected: [false, false, false, false, true], }; - const rubric: PerseusRadioRubric = { + const scoringData: PerseusRadioScoringData = { choices: [ {content: "Choice 1", correct: false}, {content: "Choice 2", correct: false}, @@ -142,7 +142,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric, mockStrings); + const score = scoreRadio(userInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredCorrectly(); }); @@ -152,7 +152,7 @@ describe("scoreRadio", () => { choicesSelected: [false, false, false, false, true], }; - const rubric: PerseusRadioRubric = { + const scoringData: PerseusRadioScoringData = { choices: [ {content: "Choice 1", correct: true}, {content: "Choice 2", correct: false}, @@ -162,7 +162,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric, mockStrings); + const score = scoreRadio(userInput, scoringData, mockStrings); expect(score).toHaveBeenAnsweredIncorrectly(); }); diff --git a/packages/perseus/src/widgets/radio/score-radio.ts b/packages/perseus/src/widgets/radio/score-radio.ts index 51586d3c41..61e6918fa9 100644 --- a/packages/perseus/src/widgets/radio/score-radio.ts +++ b/packages/perseus/src/widgets/radio/score-radio.ts @@ -3,13 +3,13 @@ import validateRadio from "./validate-radio"; import type {PerseusStrings} from "../../strings"; import type {PerseusScore} from "../../types"; import type { - PerseusRadioRubric, + PerseusRadioScoringData, PerseusRadioUserInput, } from "../../validation.types"; function scoreRadio( userInput: PerseusRadioUserInput, - rubric: PerseusRadioRubric, + scoringData: PerseusRadioScoringData, strings: PerseusStrings, ): PerseusScore { const validationError = validateRadio(userInput); @@ -21,9 +21,12 @@ function scoreRadio( return sum + (selected ? 1 : 0); }, 0); - const numCorrect: number = rubric.choices.reduce((sum, currentChoice) => { - return currentChoice.correct ? sum + 1 : sum; - }, 0); + const numCorrect: number = scoringData.choices.reduce( + (sum, currentChoice) => { + return currentChoice.correct ? sum + 1 : sum; + }, + 0, + ); if (numCorrect > 1 && numSelected !== numCorrect) { return { @@ -33,7 +36,7 @@ function scoreRadio( // If NOTA and some other answer are checked, ... } - const noneOfTheAboveSelected = rubric.choices.some( + const noneOfTheAboveSelected = scoringData.choices.some( (choice, index) => choice.isNoneOfTheAbove && userInput.choicesSelected[index], ); @@ -47,12 +50,12 @@ function scoreRadio( const correct = userInput.choicesSelected.every((selected, i) => { let isCorrect: boolean; - if (rubric.choices[i].isNoneOfTheAbove) { - isCorrect = rubric.choices.every((choice, j) => { + if (scoringData.choices[i].isNoneOfTheAbove) { + isCorrect = scoringData.choices.every((choice, j) => { return i === j || !choice.correct; }); } else { - isCorrect = !!rubric.choices[i].correct; + isCorrect = !!scoringData.choices[i].correct; } return isCorrect === selected; }); diff --git a/packages/perseus/src/widgets/sorter/score-sorter.test.ts b/packages/perseus/src/widgets/sorter/score-sorter.test.ts index 8420df08e0..bf8a30ae92 100644 --- a/packages/perseus/src/widgets/sorter/score-sorter.test.ts +++ b/packages/perseus/src/widgets/sorter/score-sorter.test.ts @@ -2,40 +2,40 @@ import scoreSorter from "./score-sorter"; import * as SorterValidator from "./validate-sorter"; import type { - PerseusSorterRubric, + PerseusSorterScoringData, PerseusSorterUserInput, } from "../../validation.types"; describe("scoreSorter", () => { - it("is correct when the user input values are in the order defined in the rubric", () => { + it("is correct when the user input values are in the order defined in the scoringData", () => { // Arrange const userInput: PerseusSorterUserInput = { options: ["$0.005$ kilograms", "$15$ grams", "$55$ grams"], changed: true, }; - const rubric: PerseusSorterRubric = { + const scoringData: PerseusSorterScoringData = { correct: ["$0.005$ kilograms", "$15$ grams", "$55$ grams"], }; // Act - const result = scoreSorter(userInput, rubric); + const result = scoreSorter(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredCorrectly(); }); - it("is incorrect when the user input values are not in the order defined in the rubric", () => { + it("is incorrect when the user input values are not in the order defined in the scoringData", () => { // Arrange const userInput: PerseusSorterUserInput = { options: ["$15$ grams", "$55$ grams", "$0.005$ kilograms"], changed: true, }; - const rubric: PerseusSorterRubric = { + const scoringData: PerseusSorterScoringData = { correct: ["$0.005$ kilograms", "$15$ grams", "$55$ grams"], }; // Act - const result = scoreSorter(userInput, rubric); + const result = scoreSorter(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); @@ -52,12 +52,12 @@ describe("scoreSorter", () => { options: ["$15$ grams", "$55$ grams", "$0.005$ kilograms"], changed: false, }; - const rubric: PerseusSorterRubric = { + const scoringData: PerseusSorterScoringData = { correct: ["$0.005$ kilograms", "$15$ grams", "$55$ grams"], }; // Act - const result = scoreSorter(userInput, rubric); + const result = scoreSorter(userInput, scoringData); // Assert expect(mockValidate).toHaveBeenCalledWith(userInput); @@ -75,12 +75,12 @@ describe("scoreSorter", () => { options: ["$0.005$ kilograms", "$15$ grams", "$55$ grams"], changed: true, }; - const rubric: PerseusSorterRubric = { + const scoringData: PerseusSorterScoringData = { correct: ["$0.005$ kilograms", "$15$ grams", "$55$ grams"], }; // Act - const result = scoreSorter(userInput, rubric); + const result = scoreSorter(userInput, scoringData); // Assert expect(mockValidate).toHaveBeenCalledWith(userInput); diff --git a/packages/perseus/src/widgets/sorter/score-sorter.ts b/packages/perseus/src/widgets/sorter/score-sorter.ts index f86579d3e9..8eda73e365 100644 --- a/packages/perseus/src/widgets/sorter/score-sorter.ts +++ b/packages/perseus/src/widgets/sorter/score-sorter.ts @@ -4,20 +4,20 @@ import validateSorter from "./validate-sorter"; import type {PerseusScore} from "../../types"; import type { - PerseusSorterRubric, + PerseusSorterScoringData, PerseusSorterUserInput, } from "../../validation.types"; function scoreSorter( userInput: PerseusSorterUserInput, - rubric: PerseusSorterRubric, + scoringData: PerseusSorterScoringData, ): PerseusScore { const validationError = validateSorter(userInput); if (validationError) { return validationError; } - const correct = Util.deepEq(userInput.options, rubric.correct); + const correct = Util.deepEq(userInput.options, scoringData.correct); return { type: "points", earned: correct ? 1 : 0, diff --git a/packages/perseus/src/widgets/sorter/sorter.tsx b/packages/perseus/src/widgets/sorter/sorter.tsx index 7d0134b8d3..d3f042dfb3 100644 --- a/packages/perseus/src/widgets/sorter/sorter.tsx +++ b/packages/perseus/src/widgets/sorter/sorter.tsx @@ -12,7 +12,7 @@ import type {SortableOption} from "../../components/sortable"; import type {PerseusSorterWidgetOptions} from "../../perseus-types"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; import type { - PerseusSorterRubric, + PerseusSorterScoringData, PerseusSorterUserInput, } from "../../validation.types"; import type {SorterPromptJSON} from "../../widget-ai-utils/sorter/sorter-ai-utils"; @@ -21,7 +21,7 @@ const {shuffle} = Util; type RenderProps = PerseusSorterWidgetOptions; -type Props = WidgetProps; +type Props = WidgetProps; type DefaultProps = { correct: Props["correct"]; diff --git a/packages/perseus/src/widgets/table/score-table.test.ts b/packages/perseus/src/widgets/table/score-table.test.ts index bab0aabfa3..5e3a59a063 100644 --- a/packages/perseus/src/widgets/table/score-table.test.ts +++ b/packages/perseus/src/widgets/table/score-table.test.ts @@ -4,7 +4,7 @@ import scoreTable from "./score-table"; import * as TableValidator from "./validate-table"; import type { - PerseusTableRubric, + PerseusTableScoringData, PerseusTableUserInput, } from "../../validation.types"; @@ -20,7 +20,7 @@ describe("scoreTable", () => { ["3", "4"], ]; - const rubric: PerseusTableRubric = { + const scoringData: PerseusTableScoringData = { answers: [ ["1", "2"], ["3", "4"], @@ -28,7 +28,7 @@ describe("scoreTable", () => { }; // Act - const score = scoreTable(userInput, rubric, mockStrings); + const score = scoreTable(userInput, scoringData, mockStrings); // Assert expect(mockValidator).toHaveBeenCalledWith(userInput); @@ -46,7 +46,7 @@ describe("scoreTable", () => { ["3", "4"], ]; - const rubric: PerseusTableRubric = { + const scoringData: PerseusTableScoringData = { answers: [ ["1", "2"], ["3", "4"], @@ -54,7 +54,7 @@ describe("scoreTable", () => { }; // Act - const score = scoreTable(userInput, rubric, mockStrings); + const score = scoreTable(userInput, scoringData, mockStrings); // Assert expect(mockValidator).toHaveBeenCalledWith(userInput); @@ -68,7 +68,7 @@ describe("scoreTable", () => { ["3", "4"], ]; - const rubric: PerseusTableRubric = { + const scoringData: PerseusTableScoringData = { answers: [ ["1", "2"], ["3", "4"], @@ -76,7 +76,7 @@ describe("scoreTable", () => { }; // Act - const result = scoreTable(userInput, rubric, mockStrings); + const result = scoreTable(userInput, scoringData, mockStrings); // Assert expect(result).toHaveInvalidInput(); @@ -90,7 +90,7 @@ describe("scoreTable", () => { ["5", "6"], ]; - const rubric: PerseusTableRubric = { + const scoringData: PerseusTableScoringData = { answers: [ ["1", "2"], ["3", "4"], @@ -98,7 +98,7 @@ describe("scoreTable", () => { }; // Act - const result = scoreTable(userInput, rubric, mockStrings); + const result = scoreTable(userInput, scoringData, mockStrings); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); @@ -111,7 +111,7 @@ describe("scoreTable", () => { ["3", "5"], ]; - const rubric: PerseusTableRubric = { + const scoringData: PerseusTableScoringData = { answers: [ ["1", "2"], ["3", "4"], @@ -119,7 +119,7 @@ describe("scoreTable", () => { }; // Act - const result = scoreTable(userInput, rubric, mockStrings); + const result = scoreTable(userInput, scoringData, mockStrings); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); @@ -132,7 +132,7 @@ describe("scoreTable", () => { ["3", "4"], ]; - const rubric: PerseusTableRubric = { + const scoringData: PerseusTableScoringData = { answers: [ ["1", "2"], ["3", "4"], @@ -140,7 +140,7 @@ describe("scoreTable", () => { }; // Act - const result = scoreTable(userInput, rubric, mockStrings); + const result = scoreTable(userInput, scoringData, mockStrings); // Assert expect(result).toHaveBeenAnsweredCorrectly(); @@ -153,7 +153,7 @@ describe("scoreTable", () => { ["3.0", "4.0"], ]; - const rubric: PerseusTableRubric = { + const scoringData: PerseusTableScoringData = { answers: [ ["1", "2"], ["3", "4"], @@ -161,7 +161,7 @@ describe("scoreTable", () => { }; // Act - const result = scoreTable(userInput, rubric, mockStrings); + const result = scoreTable(userInput, scoringData, mockStrings); // Assert expect(result).toHaveBeenAnsweredCorrectly(); diff --git a/packages/perseus/src/widgets/table/score-table.ts b/packages/perseus/src/widgets/table/score-table.ts index fd828ec023..38af967e59 100644 --- a/packages/perseus/src/widgets/table/score-table.ts +++ b/packages/perseus/src/widgets/table/score-table.ts @@ -8,13 +8,13 @@ import validateTable from "./validate-table"; import type {PerseusStrings} from "../../strings"; import type {PerseusScore} from "../../types"; import type { - PerseusTableRubric, + PerseusTableScoringData, PerseusTableUserInput, } from "../../validation.types"; function scoreTable( userInput: PerseusTableUserInput, - rubric: PerseusTableRubric, + scoringData: PerseusTableScoringData, strings: PerseusStrings, ): PerseusScore { const validationResult = validateTable(userInput); @@ -23,7 +23,7 @@ function scoreTable( } const supplied = filterNonEmpty(userInput); - const solution = filterNonEmpty(rubric.answers); + const solution = filterNonEmpty(scoringData.answers); if (supplied.length !== solution.length) { return { type: "points", diff --git a/packages/perseus/src/widgets/table/table.tsx b/packages/perseus/src/widgets/table/table.tsx index 31fb289837..bb84989d56 100644 --- a/packages/perseus/src/widgets/table/table.tsx +++ b/packages/perseus/src/widgets/table/table.tsx @@ -17,7 +17,7 @@ import type {ChangeableProps} from "../../mixins/changeable"; import type {PerseusTableWidgetOptions} from "../../perseus-types"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; import type { - PerseusTableRubric, + PerseusTableScoringData, PerseusTableUserInput, } from "../../validation.types"; @@ -28,7 +28,8 @@ type RenderProps = PerseusTableWidgetOptions & { Editor: any; }; -type Props = ChangeableProps & WidgetProps; +type Props = ChangeableProps & + WidgetProps; type DefaultProps = { apiOptions: Props["apiOptions"]; From e6f7cc91ec9601bb7df7e8e6846349c114d09cd0 Mon Sep 17 00:00:00 2001 From: Tamara <60857422+Myranae@users.noreply.github.com> Date: Thu, 19 Dec 2024 15:00:36 -0600 Subject: [PATCH 09/13] Fix some naming discrepancies and simplify Matcher scoring type (#2038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: Noticed some discrepanies in naming relating to calling the validation function and also noticed some scoring types still reference their widgetOptions. The scoring types for the group widgets still references widgetOptions, but here I updated the scoring type for Matcher to be more specific to what is actually used. Issue: LEMS-2734 ## Test plan: - Confirm all checks pass - Will do more manual testing after merging to the feature branch Author: Myranae Reviewers: handeyeco, jeremywiebe Required Reviewers: Approved By: handeyeco 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: https://github.com/Khan/perseus/pull/2038 --- .changeset/nine-planes-relax.md | 5 +++++ packages/perseus/src/validation.types.ts | 8 ++++++-- packages/perseus/src/widgets/group/score-group.ts | 6 +++--- .../perseus/src/widgets/matcher/score-matcher.test.ts | 6 ------ packages/perseus/src/widgets/matrix/score-matrix.ts | 6 +++--- packages/perseus/src/widgets/orderer/score-orderer.ts | 6 +++--- 6 files changed, 20 insertions(+), 17 deletions(-) create mode 100644 .changeset/nine-planes-relax.md diff --git a/.changeset/nine-planes-relax.md b/.changeset/nine-planes-relax.md new file mode 100644 index 0000000000..f12f75f72a --- /dev/null +++ b/.changeset/nine-planes-relax.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +Fix some naming discrepancies related to validation and simplify Matcher ScoringData type diff --git a/packages/perseus/src/validation.types.ts b/packages/perseus/src/validation.types.ts index df44cd91b2..ee67027731 100644 --- a/packages/perseus/src/validation.types.ts +++ b/packages/perseus/src/validation.types.ts @@ -36,7 +36,6 @@ import type { PerseusGradedGroupWidgetOptions, PerseusGraphType, PerseusGroupWidgetOptions, - PerseusMatcherWidgetOptions, PerseusMatrixWidgetAnswers, PerseusNumericInputAnswer, PerseusOrdererWidgetOptions, @@ -144,7 +143,12 @@ export type PerseusLabelImageUserInput = { }>; }; -export type PerseusMatcherScoringData = PerseusMatcherWidgetOptions; +export type PerseusMatcherScoringData = { + // Translatable Text; Static concepts to show in the left column. e.g. ["Fruit", "Color", "Clothes"] + left: ReadonlyArray; + // Translatable Markup; Values that represent the concepts to be correlated with the concepts. e.g. ["Red", "Shirt", "Banana"] + right: ReadonlyArray; +}; export type PerseusMatcherUserInput = { left: ReadonlyArray; diff --git a/packages/perseus/src/widgets/group/score-group.ts b/packages/perseus/src/widgets/group/score-group.ts index 77e4015f57..bebddea4e5 100644 --- a/packages/perseus/src/widgets/group/score-group.ts +++ b/packages/perseus/src/widgets/group/score-group.ts @@ -12,13 +12,13 @@ import type { // it. As such, scoring a group means scoring all widgets it contains. function scoreGroup( userInput: PerseusGroupUserInput, - options: PerseusGroupScoringData, + scoringData: PerseusGroupScoringData, strings: PerseusStrings, locale: string, ): PerseusScore { const scores = scoreWidgetsFunctional( - options.widgets, - Object.keys(options.widgets), + scoringData.widgets, + Object.keys(scoringData.widgets), userInput, strings, locale, diff --git a/packages/perseus/src/widgets/matcher/score-matcher.test.ts b/packages/perseus/src/widgets/matcher/score-matcher.test.ts index cdc53f8ae8..380bd0aa7f 100644 --- a/packages/perseus/src/widgets/matcher/score-matcher.test.ts +++ b/packages/perseus/src/widgets/matcher/score-matcher.test.ts @@ -14,11 +14,8 @@ describe("scoreMatcher", () => { }; const scoringData: PerseusMatcherScoringData = { - labels: ["One", "Two"], left: ["1", "0+1"], right: ["2", "0+2"], - orderMatters: false, - padding: false, }; // Act @@ -31,11 +28,8 @@ describe("scoreMatcher", () => { it("can be answered correctly", () => { // Arrange const scoringData: PerseusMatcherScoringData = { - labels: ["One", "Two"], left: ["1", "0+1"], right: ["2", "0+2"], - orderMatters: false, - padding: false, }; const userInput: PerseusMatcherUserInput = { diff --git a/packages/perseus/src/widgets/matrix/score-matrix.ts b/packages/perseus/src/widgets/matrix/score-matrix.ts index 88f73b8c69..3e17be06f7 100644 --- a/packages/perseus/src/widgets/matrix/score-matrix.ts +++ b/packages/perseus/src/widgets/matrix/score-matrix.ts @@ -17,9 +17,9 @@ function scoreMatrix( scoringData: PerseusMatrixScoringData, strings: PerseusStrings, ): PerseusScore { - const validationResult = validateMatrix(userInput, scoringData, strings); - if (validationResult != null) { - return validationResult; + const validationError = validateMatrix(userInput, scoringData, strings); + if (validationError != null) { + return validationError; } const solution = scoringData.answers; diff --git a/packages/perseus/src/widgets/orderer/score-orderer.ts b/packages/perseus/src/widgets/orderer/score-orderer.ts index c3912014c1..160a62944a 100644 --- a/packages/perseus/src/widgets/orderer/score-orderer.ts +++ b/packages/perseus/src/widgets/orderer/score-orderer.ts @@ -12,9 +12,9 @@ export function scoreOrderer( userInput: PerseusOrdererUserInput, scoringData: PerseusOrdererScoringData, ): PerseusScore { - const validateError = validateOrderer(userInput); - if (validateError) { - return validateError; + const validationError = validateOrderer(userInput); + if (validationError) { + return validationError; } const correct = _.isEqual( From a1e22a4e3cc752fb8b768d4441b9cf79e777b37f Mon Sep 17 00:00:00 2001 From: Jeremy Wiebe Date: Fri, 20 Dec 2024 10:53:27 -0800 Subject: [PATCH 10/13] SSS: Improve types for validation (#2002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: This PR begins working out the types for Scoring, Validation, and how they relate to our full widget options types. There are currently three "trees" of types that are shaped as a map of `widgetId` to something. They are: * Full widget options (starting from `PerseusRenderer`) * Scoring data - used to score the learner's guess (user input) * Validation data - a shared subset (of Render and Scoring data) used to do empty widget checking (aka validation). This helps the frontend to know if the question is scorable yet. Finally, there is also a widget map known as User Input. This map is a map of widget ids from the item to the user input the learner has entered so far. Issue: LEMS-2561 ## Test plan: `yarn typecheck` (especially, the new `validation.typetest.ts` file!) `yarn test` (just to be sure) Author: jeremywiebe Reviewers: jeremywiebe, Myranae, handeyeco Required Reviewers: Approved By: Myranae 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), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2002 --- .changeset/few-rings-cover.md | 5 + docs/architecture.md | 12 +- .../src/__tests__/validation.typetest.ts | 25 +++ packages/perseus/src/perseus-types.ts | 48 ++++- packages/perseus/src/validation.types.ts | 180 +++++++++--------- packages/perseus/src/widgets/group/group.tsx | 3 +- 6 files changed, 175 insertions(+), 98 deletions(-) create mode 100644 .changeset/few-rings-cover.md create mode 100644 packages/perseus/src/__tests__/validation.typetest.ts diff --git a/.changeset/few-rings-cover.md b/.changeset/few-rings-cover.md new file mode 100644 index 0000000000..acd3bbc1b3 --- /dev/null +++ b/.changeset/few-rings-cover.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +Add and improve types for scoring and validation diff --git a/docs/architecture.md b/docs/architecture.md index b57ea3ab44..92b402197d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,10 +11,10 @@ base Markdown syntax: 1. Widgets - Perseus can render custom widgets (in the form of React components) which conform to a special API that enables the user to - interact with the widget and for the widget to check taht input for - correctness against a rubric. Widgets are denoted using the following - Markdown syntax: `[[☃️ widget-id ]]` (where `widget-id` represents a - generated ID that is unique within the Perseus instance. + interact with the widget and for the widget to check that input for + correctness against a set of scoring data. Widgets are denoted using the + following Markdown syntax: `[[☃️ widget-id ]]` (where `widget-id` + represents a generated ID that is unique within the Perseus instance. 1. Math - Perseus can also render beautiful math using MathJax. Math is denoted using an opening and close dollar sign (eg. `$y = mx + b$`). @@ -181,7 +181,7 @@ the widgets options type (ie. the type `T` wrapped in `WidgetOptions` from In a few rare cases, this type is defined as the sum of RenderProps wrapped in `WidgetOptions`. -### `Rubric` +### `Scoring Data` This type defines the data that the scoring function needs in order to score the learner's guess (aka user input). @@ -189,7 +189,7 @@ the learner's guess (aka user input). ### `Props` Finally, `Props` form the entire set of props that widget's component supports. -Typically it is defined as `type Props = WidgetProps`. In +Typically it is defined as `type Props = WidgetProps`. In cases where there are `RenderProps` that are optional that are provided via `DefaultProps`, this `Props` type "redefines" these props as `myProp: NonNullable;`. diff --git a/packages/perseus/src/__tests__/validation.typetest.ts b/packages/perseus/src/__tests__/validation.typetest.ts new file mode 100644 index 0000000000..510acee62e --- /dev/null +++ b/packages/perseus/src/__tests__/validation.typetest.ts @@ -0,0 +1,25 @@ +/** + * This file contains TypeScript type "tests" which ensure that types needed + * for scoring and validation stay in sync with other types in the system. + * + * If you make a change and `Extends<>` starts to complain, that will usually + * mean you've made a change that will cause runtime breakages in scoring or + * validation. ie. The types that should be compatible are no longer + * compatible. Read the TypeScript error message closely and it should point + * you in the right direction. + */ +import type {PerseusRenderer} from "../perseus-types"; +import type {ScoringDataMap, ValidationDataMap} from "../validation.types"; + +/** + * An utility type that verifies that the given type `E` extends the type `T`. + * This is useful for asserting that one type remains a compatible subset of + * the other. + */ +type Extends = (T) => E; + +// We can use a 'widgets' map from a PerseusRenderer as a ValidationDataMap +type _ = Extends; + +// We can use a ScoringDataMap as a ValidationDataMap +type __ = Extends; diff --git a/packages/perseus/src/perseus-types.ts b/packages/perseus/src/perseus-types.ts index 630ef58abc..6655b21323 100644 --- a/packages/perseus/src/perseus-types.ts +++ b/packages/perseus/src/perseus-types.ts @@ -37,6 +37,50 @@ export type Size = [width: number, height: number]; export type CollinearTuple = [Vector2, Vector2]; export type ShowSolutions = "all" | "selected" | "none"; +/** + * A utility type that constructs a widget map from a "registry interface". + * The keys of the registry should be the widget type (aka, "categorizer" or + * "radio", etc) and the value should be the option type stored in the value + * of the map. + * + * You can think of this as a type that generates another type. We use + * "registry interfaces" as a way to keep a set of widget types to their data + * type in several places in Perseus. This type then allows us to generate a + * map type that maps a widget id to its data type and keep strong typing by + * widget id. + * + * For example, given a fictitious registry such as this: + * + * ``` + * interface DummyRegistry { + * categorizer: { categories: ReadonlyArray }; + * dropdown: { choices: ReadonlyArray }: + * } + * ``` + * + * If we create a DummyMap using this helper: + * + * ``` + * type DummyMap = MakeWidgetMap; + * ``` + * + * We'll get a map that looks like this: + * + * ``` + * type DummyMap = { + * `categorizer ${number}`: { categories: ReadonlyArray }; + * `dropdown ${number}`: { choices: ReadonlyArray }; + * } + * ``` + * + * We use interfaces for the registries so that they can be extended in cases + * where the consuming app brings along their own widgets. Interfaces in + * TypeScript are always open (ie. you can extend them) whereas types aren't. + */ +export type MakeWidgetMap = { + [Property in keyof TRegistry as `${Property & string} ${number}`]: TRegistry[Property]; +}; + /** * Our core set of Perseus widgets. * @@ -131,9 +175,7 @@ export interface PerseusWidgetTypes { * @see {@link PerseusWidgetTypes} additional widgets can be added to this map type * by augmenting the PerseusWidgetTypes with new widget types! */ -export type PerseusWidgetsMap = { - [Property in keyof PerseusWidgetTypes as `${Property} ${number}`]: PerseusWidgetTypes[Property]; -}; +export type PerseusWidgetsMap = MakeWidgetMap; /** * A "PerseusItem" is a classic Perseus item. It is rendered by the diff --git a/packages/perseus/src/validation.types.ts b/packages/perseus/src/validation.types.ts index ee67027731..0e82243aad 100644 --- a/packages/perseus/src/validation.types.ts +++ b/packages/perseus/src/validation.types.ts @@ -5,19 +5,20 @@ * * These types are: * - * `PerseusUserInput`: the data returned by the widget that the user - * entered. This is referred to as the 'guess' in some older parts of Perseus. + * * `PerseusUserInput`: the data from the widget that represents the + * data the user entered. This is referred to as the 'guess' in some older + * parts of Perseus. * - * `PerseusValidationData`: the data needed to do validation of the - * user input. Validation refers to the different checks that we can do both on - * the client-side (before submitting user input for scoring) and on the - * server-side (when we score it). As such, it cannot contain any of the - * sensitive scoring data that would reveal the answer. + * * `PerseusValidationData`: the data needed to do validation of the + * user input. Validation refers to the different checks that we can do + * both on the client-side (before submitting user input for scoring) and + * on the server-side (when we score it). As such, it cannot contain any of + * the sensitive scoring data that would reveal the answer. * - * `PerseusScoringData` (nee `PerseusRubric`): the data needed - * to score the user input. By convention, this type is defined as the set of - * sensitive answer data and then intersected with - * `PerseusValidationData`. + * * `PerseusScoringData` (nee `PerseusRubric`): the data + * needed to score the user input. By convention, this type is defined as + * the set of sensitive answer data and then intersected with + * `PerseusValidationData`. * * For example: * ``` @@ -41,6 +42,7 @@ import type { PerseusOrdererWidgetOptions, PerseusRadioChoice, PerseusGraphCorrectType, + MakeWidgetMap, } from "./perseus-types"; import type {Relationship} from "./widgets/number-line/number-line"; @@ -237,49 +239,83 @@ export type PerseusTableScoringData = { export type PerseusTableUserInput = ReadonlyArray>; -export type ScoringData = - | PerseusCategorizerScoringData - | PerseusDropdownScoringData - | PerseusExpressionScoringData - | PerseusGroupScoringData - | PerseusGradedGroupScoringData - | PerseusGradedGroupSetScoringData - | PerseusGrapherScoringData - | PerseusInputNumberScoringData - | PerseusInteractiveGraphScoringData - | PerseusLabelImageScoringData - | PerseusMatcherScoringData - | PerseusMatrixScoringData - | PerseusNumberLineScoringData - | PerseusNumericInputScoringData - | PerseusOrdererScoringData - | PerseusPlotterScoringData - | PerseusRadioScoringData - | PerseusSorterScoringData - | PerseusTableScoringData; - -export type UserInput = - | PerseusCategorizerUserInput - | PerseusCSProgramUserInput - | PerseusDropdownUserInput - | PerseusExpressionUserInput - | PerseusGrapherUserInput - | PerseusGroupUserInput - | PerseusIFrameUserInput - | PerseusInputNumberUserInput - | PerseusInteractiveGraphUserInput - | PerseusLabelImageUserInput - | PerseusMatcherUserInput - | PerseusMatrixUserInput - | PerseusNumberLineUserInput - | PerseusNumericInputUserInput - | PerseusOrdererUserInput - | PerseusPlotterUserInput - | PerseusRadioUserInput - | PerseusSorterUserInput - | PerseusTableUserInput; - -export type UserInputMap = {[widgetId: string]: UserInput}; +export interface ScoringDataRegistry { + categorizer: PerseusCategorizerScoringData; + dropdown: PerseusDropdownScoringData; + expression: PerseusExpressionScoringData; + grapher: PerseusGrapherScoringData; + "graded-group-set": PerseusGradedGroupSetScoringData; + "graded-group": PerseusGradedGroupScoringData; + group: PerseusGroupScoringData; + image: PerseusLabelImageScoringData; + "input-number": PerseusInputNumberScoringData; + "interactive-graph": PerseusInteractiveGraphScoringData; + "label-image": PerseusLabelImageScoringData; + matcher: PerseusMatcherScoringData; + matrix: PerseusMatrixScoringData; + "number-line": PerseusNumberLineScoringData; + "numeric-input": PerseusNumericInputScoringData; + orderer: PerseusOrdererScoringData; + plotter: PerseusPlotterScoringData; + radio: PerseusRadioScoringData; + sorter: PerseusSorterScoringData; + table: PerseusTableScoringData; +} + +/** + * A map of scoring data (previously referred to as "rubric"), keyed by + * `widgetId`. This data is used to score a learner's guess for a PerseusItem. + * + * NOTE: The value in this map is intentionally a subset of WidgetOptions. + * By using the same shape (minus any unneeded render data), we are able to + * share functionality that understands how to traverse maps of `widget id` to + * `options`. + */ +export type ScoringDataMap = { + [Property in keyof ScoringDataRegistry as `${Property} ${number}`]: { + type: Property; + static?: boolean; + options: ScoringDataRegistry[Property]; + }; +}; + +export type ScoringData = ScoringDataRegistry[keyof ScoringDataRegistry]; + +/** + * This is an interface so that it can be extended if a widget is created + * outside of this Perseus package. See `PerseusWidgetTypes` for a full + * explanation. + */ +interface UserInputRegistry { + categorizer: PerseusCategorizerUserInput; + "cs-program": PerseusCSProgramUserInput; + dropdown: PerseusDropdownUserInput; + expression: PerseusExpressionUserInput; + grapher: PerseusGrapherUserInput; + group: PerseusGroupUserInput; + iframe: PerseusIFrameUserInput; + "input-number": PerseusInputNumberUserInput; + "interactive-graph": PerseusInteractiveGraphUserInput; + "label-image": PerseusLabelImageUserInput; + matcher: PerseusMatcherUserInput; + matrix: PerseusMatrixUserInput; + "number-line": PerseusNumberLineUserInput; + "numeric-input": PerseusNumericInputUserInput; + orderer: PerseusOrdererUserInput; + plotter: PerseusPlotterUserInput; + radio: PerseusRadioUserInput; + sorter: PerseusSorterUserInput; + table: PerseusTableUserInput; +} + +/** A union type of all the widget user input types */ +export type UserInput = UserInputRegistry[keyof UserInputRegistry]; + +/** + * A map of widget IDs to user input types (strongly typed based on the format + * of the widget ID). + */ +export type UserInputMap = MakeWidgetMap; /** * deprecated prefer using UserInputMap @@ -287,43 +323,11 @@ export type UserInputMap = {[widgetId: string]: UserInput}; export type UserInputArray = ReadonlyArray< UserInputArray | UserInput | null | undefined >; + export interface ValidationDataTypes { categorizer: PerseusCategorizerValidationData; - // "cs-program": PerseusCSProgramValidationData; - // definition: PerseusDefinitionValidationData; - // dropdown: PerseusDropdownValidationData; - // explanation: PerseusExplanationValidationData; - // expression: PerseusExpressionValidationData; - // grapher: PerseusGrapherValidationData; - // "graded-group-set": PerseusGradedGroupSetValidationData; - // "graded-group": PerseusGradedGroupValidationData; group: PerseusGroupValidationData; - // iframe: PerseusIFrameValidationData; - // image: PerseusImageValidationData; - // "input-number": PerseusInputNumberValidationData; - // interaction: PerseusInteractionValidationData; - // "interactive-graph": PerseusInteractiveGraphValidationData; - // "label-image": PerseusLabelImageValidationData; - // matcher: PerseusMatcherValidationData; - // matrix: PerseusMatrixValidationData; - // measurer: PerseusMeasurerValidationData; - // "molecule-renderer": PerseusMoleculeRendererValidationData; - // "number-line": PerseusNumberLineValidationData; - // "numeric-input": PerseusNumericInputValidationData; - // orderer: PerseusOrdererValidationData; - // "passage-ref-target": PerseusRefTargetValidationData; - // "passage-ref": PerseusPassageRefValidationData; - // passage: PerseusPassageValidationData; - // "phet-simulation": PerseusPhetSimulationValidationData; - // "python-program": PerseusPythonProgramValidationData; plotter: PerseusPlotterValidationData; - // radio: PerseusRadioValidationData; - // sorter: PerseusSorterValidationData; - // table: PerseusTableValidationData; - // video: PerseusVideoValidationData; - - // Deprecated widgets - // sequence: PerseusAutoCorrectValidationData; } /** @@ -332,7 +336,7 @@ export interface ValidationDataTypes { * data that's available in the client (widget options) and server (scoring * data) and is represented by a group of types known as "validation data". * - * NOTE: The value in this map is intentionally a subset of WidgetOptions. + * NOTE: The value in this map is intentionally a subset of WidgetOptions. * By using the same shape (minus any unneeded data), we are able to pass a * `PerseusWidgetsMap` or ` into any function that accepts a * `ValidationDataMap` without any mutation of data. diff --git a/packages/perseus/src/widgets/group/group.tsx b/packages/perseus/src/widgets/group/group.tsx index f94fdd19c3..1f3820a430 100644 --- a/packages/perseus/src/widgets/group/group.tsx +++ b/packages/perseus/src/widgets/group/group.tsx @@ -23,6 +23,7 @@ import type { import type { PerseusGroupScoringData, UserInputArray, + UserInputMap, } from "../../validation.types"; import type {GroupPromptJSON} from "../../widget-ai-utils/group/group-ai-utils"; @@ -59,7 +60,7 @@ class Group extends React.Component implements Widget { return Changeable.change.apply(this, args); }; - getUserInputMap() { + getUserInputMap(): UserInputMap | undefined { return this.rendererRef?.getUserInputMap(); } From 1a75ca628405dbd9cbe8ee21d7a9039a78327c47 Mon Sep 17 00:00:00 2001 From: Jeremy Wiebe Date: Tue, 21 Jan 2025 10:43:43 -0800 Subject: [PATCH 11/13] [Client Validation] Fixes after merging main (#2122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: I recently merged `main` and the introduction of `MockWidget` caused some tests to break. I've fixed them in this PR and adjusted the MockWidget to be simpler (ie. not use KhanAnswerTypes) as I don't _think_ that's needed. I also fixed up some related types so that the MockWidget does not exist in our production Widget types, only in tests. The new `MockWidget` illustrates this "test-only" expansion of the widget types. Issue: LEMS-2561 ## Test plan: `yarn test` `yarn typecheck` Author: jeremywiebe Reviewers: jeremywiebe, handeyeco, benchristel, Myranae Required Reviewers: Approved By: handeyeco Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2122 --- .changeset/eight-squids-repair.md | 7 +++ packages/perseus-core/src/data-schema.ts | 59 ++++--------------- .../src/__testdata__/renderer.testdata.ts | 3 +- .../server-item-renderer.testdata.ts | 11 ++-- .../src/__tests__/renderer-api.test.tsx | 2 +- packages/perseus/src/types.ts | 2 +- .../mock-widget/mock-widget.test.ts | 5 +- .../mock-widget/prompt-utils.test.ts | 2 +- .../mock-widget/prompt-utils.ts | 2 +- .../widgets/mock-widgets/mock-widget-types.ts | 27 +++++++++ .../src/widgets/mock-widgets/mock-widget.tsx | 26 ++++---- .../widgets/mock-widgets/score-mock-widget.ts | 25 +++----- .../mock-widgets/validate-mock-widget.test.ts | 21 +++++++ .../mock-widgets/validate-mock-widget.ts | 17 ++++++ 14 files changed, 120 insertions(+), 89 deletions(-) create mode 100644 .changeset/eight-squids-repair.md create mode 100644 packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts create mode 100644 packages/perseus/src/widgets/mock-widgets/validate-mock-widget.test.ts create mode 100644 packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts diff --git a/.changeset/eight-squids-repair.md b/.changeset/eight-squids-repair.md new file mode 100644 index 0000000000..f9fe6e58d6 --- /dev/null +++ b/.changeset/eight-squids-repair.md @@ -0,0 +1,7 @@ +--- +"@khanacademy/perseus": patch +"@khanacademy/perseus-core": patch +"@khanacademy/perseus-editor": patch +--- + +Type and test fixes for new MockWidget (isolating to be seen only in tests) diff --git a/packages/perseus-core/src/data-schema.ts b/packages/perseus-core/src/data-schema.ts index 8f544f4ea9..d802e8e8e7 100644 --- a/packages/perseus-core/src/data-schema.ts +++ b/packages/perseus-core/src/data-schema.ts @@ -102,7 +102,7 @@ export type MakeWidgetMap = { * `PerseusWidgets` with the one defined below. * * ```typescript - * declare module "@khanacademy/perseus" { + * declare module "@khanacademy/perseus-core" { * interface PerseusWidgetTypes { * // A new widget * "new-awesomeness": MyAwesomeNewWidget; @@ -144,7 +144,6 @@ export interface PerseusWidgetTypes { matcher: MatcherWidget; matrix: MatrixWidget; measurer: MeasurerWidget; - "mock-widget": MockWidget; "molecule-renderer": MoleculeRendererWidget; "number-line": NumberLineWidget; "numeric-input": NumericInputWidget; @@ -181,6 +180,18 @@ export interface PerseusWidgetTypes { */ export type PerseusWidgetsMap = MakeWidgetMap; +/** + * PerseusWidget is a union of all the different types of widget options that + * Perseus knows about. + * + * Thanks to it being based on PerseusWidgetTypes interface, this union is + * automatically extended to include widgets used in tests without those widget + * option types seeping into our production types. + * + * @see MockWidget for an example + */ +export type PerseusWidget = PerseusWidgetTypes[keyof PerseusWidgetTypes]; + /** * A "PerseusItem" is a classic Perseus item. It is rendered by the * `ServerItemRenderer` and the layout is pre-set. @@ -346,8 +357,6 @@ export type MatrixWidget = WidgetOptions<'matrix', PerseusMatrixWidgetOptions>; // prettier-ignore export type MeasurerWidget = WidgetOptions<'measurer', PerseusMeasurerWidgetOptions>; // prettier-ignore -export type MockWidget = WidgetOptions<'mock-widget', MockWidgetOptions>; -// prettier-ignore export type NumberLineWidget = WidgetOptions<'number-line', PerseusNumberLineWidgetOptions>; // prettier-ignore export type NumericInputWidget = WidgetOptions<'numeric-input', PerseusNumericInputWidgetOptions>; @@ -380,43 +389,6 @@ export type VideoWidget = WidgetOptions<'video', PerseusVideoWidgetOptions>; //prettier-ignore export type DeprecatedStandinWidget = WidgetOptions<'deprecated-standin', object>; -export type PerseusWidget = - | CategorizerWidget - | CSProgramWidget - | DefinitionWidget - | DropdownWidget - | ExplanationWidget - | ExpressionWidget - | GradedGroupSetWidget - | GradedGroupWidget - | GrapherWidget - | GroupWidget - | IFrameWidget - | ImageWidget - | InputNumberWidget - | InteractionWidget - | InteractiveGraphWidget - | LabelImageWidget - | MatcherWidget - | MatrixWidget - | MeasurerWidget - | MockWidget - | MoleculeRendererWidget - | NumberLineWidget - | NumericInputWidget - | OrdererWidget - | PassageRefWidget - | PassageWidget - | PhetSimulationWidget - | PlotterWidget - | PythonProgramWidget - | RadioWidget - | RefTargetWidget - | SorterWidget - | TableWidget - | VideoWidget - | DeprecatedStandinWidget; - /** * A background image applied to various widgets. */ @@ -1720,11 +1692,6 @@ export type PerseusVideoWidgetOptions = { static?: boolean; }; -export type MockWidgetOptions = { - static?: boolean; - value: string; -}; - export type PerseusInputNumberWidgetOptions = { answerType?: | "number" diff --git a/packages/perseus/src/__testdata__/renderer.testdata.ts b/packages/perseus/src/__testdata__/renderer.testdata.ts index c95e1271de..aae892f155 100644 --- a/packages/perseus/src/__testdata__/renderer.testdata.ts +++ b/packages/perseus/src/__testdata__/renderer.testdata.ts @@ -1,10 +1,9 @@ +import type {MockWidget} from "../widgets/mock-widgets/mock-widget-types"; import type {RenderProps} from "../widgets/radio"; import type { DropdownWidget, ExpressionWidget, ImageWidget, - NumericInputWidget, - MockWidget, PerseusRenderer, } from "@khanacademy/perseus-core"; diff --git a/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts b/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts index b341fc4567..a2f8ae716d 100644 --- a/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts +++ b/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts @@ -7,9 +7,10 @@ import { type ExpressionWidget, type RadioWidget, type NumericInputWidget, - type MockWidget, } from "@khanacademy/perseus-core"; +import type {MockWidget} from "../widgets/mock-widgets/mock-widget-types"; + export const itemWithNumericInput: PerseusItem = { question: { content: @@ -40,7 +41,7 @@ export const itemWithNumericInput: PerseusItem = { labelText: "What's the answer?", size: "normal", }, - } as NumericInputWidget, + } satisfies NumericInputWidget, }, }, hints: [ @@ -64,7 +65,7 @@ export const itemWithMockWidget: PerseusItem = { options: { value: "3", }, - } as MockWidget, + } satisfies MockWidget, }, }, hints: [ @@ -158,14 +159,14 @@ export const itemWithTwoMockWidgets: PerseusItem = { options: { value: "3", }, - } as MockWidget, + } satisfies MockWidget, "mock-widget 2": { type: "mock-widget", graded: true, options: { value: "3", }, - } as MockWidget, + } satisfies MockWidget, }, }, hints: [ diff --git a/packages/perseus/src/__tests__/renderer-api.test.tsx b/packages/perseus/src/__tests__/renderer-api.test.tsx index 9693f48074..a26f3381d4 100644 --- a/packages/perseus/src/__tests__/renderer-api.test.tsx +++ b/packages/perseus/src/__tests__/renderer-api.test.tsx @@ -21,7 +21,7 @@ import mockWidget1Item from "./test-items/mock-widget-1-item"; import mockWidget2Item from "./test-items/mock-widget-2-item"; import tableItem from "./test-items/table-item"; -import type {PerseusMockWidgetUserInput} from "../widgets/mock-widgets/mock-widget"; +import type {PerseusMockWidgetUserInput} from "../widgets/mock-widgets/mock-widget-types"; import type {UserEvent} from "@testing-library/user-event"; const itemWidget = mockWidget1Item; diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index 5e213fd10c..d9151b31f8 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -213,7 +213,7 @@ export const MafsGraphTypeFlags = [ /** * APIOptions provides different ways to customize the behaviour of Perseus. * - * @see APIOptionsWithDefaults + * @see {@link APIOptionsWithDefaults} */ export type APIOptions = Readonly<{ isArticle?: boolean; diff --git a/packages/perseus/src/widget-ai-utils/mock-widget/mock-widget.test.ts b/packages/perseus/src/widget-ai-utils/mock-widget/mock-widget.test.ts index 366f502c92..6e7dd045bb 100644 --- a/packages/perseus/src/widget-ai-utils/mock-widget/mock-widget.test.ts +++ b/packages/perseus/src/widget-ai-utils/mock-widget/mock-widget.test.ts @@ -5,7 +5,8 @@ import {registerWidget} from "../../widgets"; import {renderQuestion} from "../../widgets/__testutils__/renderQuestion"; import MockWidgetExport from "../../widgets/mock-widgets/mock-widget"; -import type {PerseusRenderer, MockWidget} from "@khanacademy/perseus-core"; +import type {MockWidget} from "../../widgets/mock-widgets/mock-widget-types"; +import type {PerseusRenderer} from "@khanacademy/perseus-core"; import type {UserEvent} from "@testing-library/user-event"; const question: PerseusRenderer = { @@ -25,7 +26,7 @@ const question: PerseusRenderer = { value: "42", }, alignment: "default", - } as MockWidget, + } satisfies MockWidget, }, }; diff --git a/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.test.ts b/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.test.ts index 185be7ae32..08081be58d 100644 --- a/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.test.ts +++ b/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.test.ts @@ -1,6 +1,6 @@ import {getPromptJSON} from "./prompt-utils"; -import type {PerseusMockWidgetUserInput} from "../../widgets/mock-widgets/mock-widget"; +import type {PerseusMockWidgetUserInput} from "../../widgets/mock-widgets/mock-widget-types"; describe("InputNumber getPromptJSON", () => { it("it returns JSON with the expected format and fields", () => { diff --git a/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.ts b/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.ts index 2ecbe0fdb5..241bfcc9d3 100644 --- a/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.ts +++ b/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.ts @@ -1,5 +1,5 @@ -import type {PerseusMockWidgetUserInput} from "../../widgets/mock-widgets/mock-widget"; import type mockWidget from "../../widgets/mock-widgets/mock-widget"; +import type {PerseusMockWidgetUserInput} from "../../widgets/mock-widgets/mock-widget-types"; import type React from "react"; export type MockWidgetPromptJSON = { diff --git a/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts b/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts new file mode 100644 index 0000000000..e6a8aa2416 --- /dev/null +++ b/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts @@ -0,0 +1,27 @@ +import type {WidgetOptions} from "@khanacademy/perseus-core"; + +export type MockWidget = WidgetOptions<"mock-widget", MockWidgetOptions>; + +export type MockWidgetOptions = { + static?: boolean; + value: string; +}; + +export type PerseusMockWidgetRubric = { + value: string; +}; + +export type PerseusMockWidgetUserInput = { + currentValue: string; +}; + +// Extend the widget registries for testing +// See @khanacademy/perseus-core's PerseusWidgetTypes for a full explanation. +// Basically, we're extending the interface from that package so that our +// testing code knows of the MockWidget. In production code, there's no +// knowledge of the mock widget. +declare module "@khanacademy/perseus-core" { + export interface PerseusWidgetTypes { + "mock-widget": MockWidget; + } +} diff --git a/packages/perseus/src/widgets/mock-widgets/mock-widget.tsx b/packages/perseus/src/widgets/mock-widgets/mock-widget.tsx index d1a4287163..11d1c53339 100644 --- a/packages/perseus/src/widgets/mock-widgets/mock-widget.tsx +++ b/packages/perseus/src/widgets/mock-widgets/mock-widget.tsx @@ -6,18 +6,15 @@ import * as React from "react"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/mock-widget/prompt-utils"; import scoreMockWidget from "./score-mock-widget"; +import validateMockWidget from "./validate-mock-widget"; +import type { + MockWidgetOptions, + PerseusMockWidgetRubric, + PerseusMockWidgetUserInput, +} from "./mock-widget-types"; import type {WidgetExports, WidgetProps, Widget, FocusPath} from "../../types"; import type {MockWidgetPromptJSON} from "../../widget-ai-utils/mock-widget/prompt-utils"; -import type {MockWidgetOptions} from "@khanacademy/perseus-core"; - -export type PerseusMockWidgetRubric = { - value: string; -}; - -export type PerseusMockWidgetUserInput = { - currentValue: string; -}; type ExternalProps = WidgetProps; @@ -41,7 +38,7 @@ type Props = ExternalProps & { * * You can register this widget for your tests by calling `registerWidget("mock-widget", MockWidget);` */ -export class MockWidget extends React.Component implements Widget { +class MockWidgetComponent extends React.Component implements Widget { static defaultProps: DefaultProps = { currentValue: "", }; @@ -93,7 +90,7 @@ export class MockWidget extends React.Component implements Widget { }; getUserInput(): PerseusMockWidgetUserInput { - return MockWidget.getUserInputFromProps(this.props); + return MockWidgetComponent.getUserInputFromProps(this.props); } handleChange: ( @@ -131,9 +128,12 @@ const styles = StyleSheet.create({ export default { name: "mock-widget", displayName: "Mock Widget", - widget: MockWidget, + widget: MockWidgetComponent, isLintable: true, // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type 'UserInput' is not assignable to type 'MockWidget'. scorer: scoreMockWidget, -} satisfies WidgetExports; + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusMockWidgetUserInput'. + validator: validateMockWidget, +} satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts b/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts index e9a6302011..12a193c4ac 100644 --- a/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts +++ b/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts @@ -1,9 +1,9 @@ -import {KhanAnswerTypes} from "@khanacademy/perseus-score"; +import validateMockWidget from "./validate-mock-widget"; import type { PerseusMockWidgetUserInput, PerseusMockWidgetRubric, -} from "./mock-widget"; +} from "./mock-widget-types"; import type {PerseusStrings} from "../../strings"; import type {PerseusScore} from "@khanacademy/perseus"; @@ -12,25 +12,16 @@ function scoreMockWidget( rubric: PerseusMockWidgetRubric, strings: PerseusStrings, ): PerseusScore { - const stringValue = `${rubric.value}`; - const val = KhanAnswerTypes.number.createValidatorFunctional( - stringValue, - strings, - ); - - const result = val(userInput.currentValue); - - if (result.empty) { - return { - type: "invalid", - message: result.message, - }; + const validationResult = validateMockWidget(userInput); + if (validationResult != null) { + return validationResult; } + return { type: "points", - earned: result.correct ? 1 : 0, + earned: userInput.currentValue === rubric.value ? 1 : 0, total: 1, - message: result.message, + message: "", }; } diff --git a/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.test.ts b/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.test.ts new file mode 100644 index 0000000000..614d0263e2 --- /dev/null +++ b/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.test.ts @@ -0,0 +1,21 @@ +import validateMockWidget from "./validate-mock-widget"; + +import type {PerseusMockWidgetUserInput} from "./mock-widget-types"; + +describe("mock-widget", () => { + it("should be invalid if no value provided", () => { + const input: PerseusMockWidgetUserInput = {currentValue: ""}; + + const result = validateMockWidget(input); + + expect(result).toHaveInvalidInput(); + }); + + it("should be valid if a value provided", () => { + const input: PerseusMockWidgetUserInput = {currentValue: "a"}; + + const result = validateMockWidget(input); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts b/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts new file mode 100644 index 0000000000..ce02a642ce --- /dev/null +++ b/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts @@ -0,0 +1,17 @@ +import type {PerseusMockWidgetUserInput} from "./mock-widget-types"; +import type {ValidationResult} from "../../types"; + +function validateMockWidget( + userInput: PerseusMockWidgetUserInput, +): ValidationResult { + if (userInput.currentValue == null || userInput.currentValue === "") { + return { + type: "invalid", + message: "", + }; + } + + return null; +} + +export default validateMockWidget; From 4c10af109245ac10846ef1d0c6fad2a095c11d0b Mon Sep 17 00:00:00 2001 From: Jeremy Wiebe Date: Tue, 21 Jan 2025 11:00:09 -0800 Subject: [PATCH 12/13] [Client Validation] Use emptyWidgetsFunctional in scoring (#2083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: https://github.com/Khan/perseus/pull/2083 --- .changeset/pink-pumas-hug.md | 5 + packages/perseus/src/renderer-util.test.ts | 166 ++++++++++++++++++--- packages/perseus/src/renderer-util.ts | 31 ++-- 3 files changed, 164 insertions(+), 38 deletions(-) create mode 100644 .changeset/pink-pumas-hug.md diff --git a/.changeset/pink-pumas-hug.md b/.changeset/pink-pumas-hug.md new file mode 100644 index 0000000000..d37b81ea10 --- /dev/null +++ b/.changeset/pink-pumas-hug.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +Use empty widgets check in scoring function diff --git a/packages/perseus/src/renderer-util.test.ts b/packages/perseus/src/renderer-util.test.ts index 2bdfc26fca..0a5fe26905 100644 --- a/packages/perseus/src/renderer-util.test.ts +++ b/packages/perseus/src/renderer-util.test.ts @@ -1,4 +1,4 @@ -import {screen} from "@testing-library/react"; +import {act, screen} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; import { @@ -6,6 +6,7 @@ import { testDependenciesV2, } from "../../../testing/test-dependencies"; +import {question1} from "./__testdata__/renderer.testdata"; import * as Dependencies from "./dependencies"; import { emptyWidgetsFunctional, @@ -15,7 +16,7 @@ import { import {mockStrings} from "./strings"; import {registerAllWidgetsForTesting} from "./util/register-all-widgets-for-testing"; import {renderQuestion} from "./widgets/__testutils__/renderQuestion"; -import {question1} from "./widgets/group/group.testdata"; +import DropdownWidgetExport from "./widgets/dropdown"; import type {UserInputMap} from "./validation.types"; import type { @@ -78,6 +79,7 @@ function getLegacyExpressionWidget() { }, }; } + describe("renderer utils", () => { beforeAll(() => { registerAllWidgetsForTesting(); @@ -743,23 +745,151 @@ describe("renderer utils", () => { }); }); + it("should return empty if any validator returns empty", () => { + // Act + const validatorSpy = jest + .spyOn(DropdownWidgetExport, "validator") + // 1st call - Empty + .mockReturnValueOnce({ + type: "invalid", + message: null, + }) + // 2nd call - Not empty + .mockReturnValueOnce(null); + const scoringSpy = jest + .spyOn(DropdownWidgetExport, "scorer") + .mockReturnValueOnce({type: "points", total: 1, earned: 1}); + + // Act + const score = scorePerseusItem( + { + content: + question1.content + + question1.content.replace("dropdown 1", "dropdown 2"), + widgets: { + "dropdown 1": question1.widgets["dropdown 1"], + "dropdown 2": question1.widgets["dropdown 1"], + }, + images: {}, + }, + {}, + mockStrings, + "en", + ); + + // Assert + expect(validatorSpy).toHaveBeenCalledTimes(2); + // Scoring is only called if validation passes + expect(scoringSpy).toHaveBeenCalledTimes(1); + expect(score).toEqual({type: "invalid", message: null}); + }); + + it("should score item if all validators return null", () => { + // Arrange + const validatorSpy = jest + .spyOn(DropdownWidgetExport, "validator") + .mockReturnValue(null); + const scoreSpy = jest + .spyOn(DropdownWidgetExport, "scorer") + .mockReturnValue({ + type: "points", + total: 1, + earned: 1, + message: null, + }); + + // Act + const score = scorePerseusItem( + { + content: + question1.content + + question1.content.replace("dropdown 1", "dropdown 2"), + widgets: { + "dropdown 1": question1.widgets["dropdown 1"], + "dropdown 2": question1.widgets["dropdown 1"], + }, + images: {}, + }, + {"dropdown 1": {value: 0}}, + mockStrings, + "en", + ); + + // Assert + expect(validatorSpy).toHaveBeenCalledTimes(2); + expect(scoreSpy).toHaveBeenCalledTimes(2); + expect(score).toEqual({ + type: "points", + total: 2, + earned: 2, + message: null, + }); + }); + + it("should return correct, with no points earned, if widget is static", () => { + const validatorSpy = jest.spyOn(DropdownWidgetExport, "validator"); + + const score = scorePerseusItem( + { + ...question1, + widgets: { + "dropdown 1": { + ...question1.widgets["dropdown 1"], + static: true, + }, + }, + }, + {"dropdown 1": {value: 1}}, + mockStrings, + "en", + ); + + expect(validatorSpy).not.toHaveBeenCalled(); + expect(score).toHaveBeenAnsweredCorrectly({ + shouldHavePoints: false, + }); + }); + + it("should ignore widgets that aren't referenced in content", () => { + const validatorSpy = jest.spyOn(DropdownWidgetExport, "validator"); + const score = scorePerseusItem( + { + content: + "This content references [[☃ dropdown 1]] but not dropdown 2!", + widgets: { + ...question1.widgets, + "dropdown 2": { + ...question1.widgets["dropdown 1"], + }, + }, + images: {}, + }, + {"dropdown 1": {value: 2}}, + mockStrings, + "en", + ); + + expect(validatorSpy).toHaveBeenCalledTimes(1); + expect(score).toHaveBeenAnsweredCorrectly({ + shouldHavePoints: true, + }); + }); + it("should return score from contained Renderer", async () => { // Arrange const {renderer} = renderQuestion(question1); - // Answer all widgets correctly - await userEvent.click(screen.getAllByRole("radio")[4]); - // Note(jeremy): If we don't tab away from the radio button in this - // test, it seems like the userEvent typing doesn't land in the first - // text field. - await userEvent.tab(); - await userEvent.type( - screen.getByRole("textbox", {name: /nearest ten/}), - "230", - ); - await userEvent.type( - screen.getByRole("textbox", {name: /nearest hundred/}), - "200", + + // Answer correctly + await userEvent.click(screen.getByRole("combobox")); + await act(() => jest.runOnlyPendingTimers()); + + await userEvent.click( + screen.getByRole("option", { + name: "less than or equal to", + }), ); + + // Act const userInput = renderer.getUserInputMap(); const score = scorePerseusItem( question1, @@ -770,12 +900,6 @@ describe("renderer utils", () => { // Assert expect(score).toHaveBeenAnsweredCorrectly(); - expect(score).toEqual({ - earned: 3, - message: null, - total: 3, - type: "points", - }); }); }); }); diff --git a/packages/perseus/src/renderer-util.ts b/packages/perseus/src/renderer-util.ts index 9b7826a6aa..b0a059042c 100644 --- a/packages/perseus/src/renderer-util.ts +++ b/packages/perseus/src/renderer-util.ts @@ -1,4 +1,8 @@ -import {mapObject} from "@khanacademy/perseus-core"; +import { + mapObject, + type PerseusRenderer, + type PerseusWidgetsMap, +} from "@khanacademy/perseus-core"; import {scoreIsEmpty, flattenScores} from "./util/scoring"; import {getWidgetIdsFromContent} from "./widget-type-utils"; @@ -10,15 +14,7 @@ import { import type {PerseusStrings} from "./strings"; import type {PerseusScore} from "./types"; -import type { - UserInput, - UserInputMap, - ValidationDataMap, -} from "./validation.types"; -import type { - PerseusRenderer, - PerseusWidgetsMap, -} from "@khanacademy/perseus-core"; +import type {ValidationDataMap, UserInputMap} from "./validation.types"; export function getUpgradedWidgetOptions( oldWidgetOptions: PerseusWidgetsMap, @@ -54,7 +50,7 @@ export function emptyWidgetsFunctional( widgets: ValidationDataMap, // This is a port of old code, I'm not sure why // we need widgetIds vs the keys of the widgets object - widgetIds: Array, + widgetIds: ReadonlyArray, userInputMap: UserInputMap, strings: PerseusStrings, locale: string, @@ -128,13 +124,14 @@ export function scoreWidgetsFunctional( } const userInput = userInputMap[id]; + const validator = getWidgetValidator(widget.type); const scorer = getWidgetScorer(widget.type); - const score = scorer?.( - userInput as UserInput, - widget.options, - strings, - locale, - ); + + // We do validation (empty checks) first and then scoring. If + // validation fails, it's result is itself a PerseusScore. + const score = + validator?.(userInput, widget.options, strings, locale) ?? + scorer?.(userInput, widget.options, strings, locale); if (score != null) { widgetScores[id] = score; } From 1105fa5e5aea68e76f9d5959baff515faa893c28 Mon Sep 17 00:00:00 2001 From: Jeremy Wiebe Date: Tue, 21 Jan 2025 15:16:56 -0800 Subject: [PATCH 13/13] Remove unused parameter in `validateMatrix` --- .../perseus-score/src/widgets/matrix/validate-matrix.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/perseus-score/src/widgets/matrix/validate-matrix.ts b/packages/perseus-score/src/widgets/matrix/validate-matrix.ts index 2697a2333e..cf7e688401 100644 --- a/packages/perseus-score/src/widgets/matrix/validate-matrix.ts +++ b/packages/perseus-score/src/widgets/matrix/validate-matrix.ts @@ -4,7 +4,6 @@ import ErrorCodes from "../../error-codes"; import type { PerseusMatrixUserInput, - PerseusMatrixValidationData, ValidationResult, } from "../../validation.types"; @@ -16,10 +15,7 @@ import type { * * @see `scoreMatrix()` for more details. */ -function validateMatrix( - userInput: PerseusMatrixUserInput, - validationData: PerseusMatrixValidationData, -): ValidationResult { +function validateMatrix(userInput: PerseusMatrixUserInput): ValidationResult { const supplied = userInput.answers; const suppliedSize = getMatrixSize(supplied);