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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/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-core/src/data-schema.ts b/packages/perseus-core/src/data-schema.ts index 32d5d3a03b..d802e8e8e7 100644 --- a/packages/perseus-core/src/data-schema.ts +++ b/packages/perseus-core/src/data-schema.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. * @@ -58,7 +102,7 @@ export type ShowSolutions = "all" | "selected" | "none"; * `PerseusWidgets` with the one defined below. * * ```typescript - * declare module "@khanacademy/perseus" { + * declare module "@khanacademy/perseus-core" { * interface PerseusWidgetTypes { * // A new widget * "new-awesomeness": MyAwesomeNewWidget; @@ -100,7 +144,6 @@ export interface PerseusWidgetTypes { matcher: MatcherWidget; matrix: MatrixWidget; measurer: MeasurerWidget; - "mock-widget": MockWidget; "molecule-renderer": MoleculeRendererWidget; "number-line": NumberLineWidget; "numeric-input": NumericInputWidget; @@ -135,9 +178,19 @@ 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; + +/** + * 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 @@ -304,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>; @@ -338,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. */ @@ -1678,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-editor/src/widgets/__stories__/label-image-editor.stories.tsx b/packages/perseus-editor/src/widgets/__stories__/label-image-editor.stories.tsx index f592fd329b..e45c409661 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-core"; +import type {PerseusLabelImageWidgetOptions} from "@khanacademy/perseus-core"; 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 19863df233..dd38c4a7bd 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-core"; +import type {PerseusLabelImageWidgetOptions} from "@khanacademy/perseus-core"; 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 e61699d261..8830ab943e 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-core"; +import type {PerseusLabelImageWidgetOptions} from "@khanacademy/perseus-core"; 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 654df23c72..1c6808dd10 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-core"; +import type {PerseusLabelImageWidgetOptions} from "@khanacademy/perseus-core"; -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 650b8abb71..9983bc071b 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-core"; +import type {PerseusLabelImageWidgetOptions} from "@khanacademy/perseus-core"; 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-score/src/index.ts b/packages/perseus-score/src/index.ts index 4378337722..99b56aaac4 100644 --- a/packages/perseus-score/src/index.ts +++ b/packages/perseus-score/src/index.ts @@ -3,9 +3,12 @@ export type {Score} from "./util/answer-types"; export {default as ErrorCodes} from "./error-codes"; export type * from "./validation.types"; export {default as scoreCategorizer} from "./widgets/categorizer/score-categorizer"; +export {default as validateCategorizer} from "./widgets/categorizer/validate-categorizer"; export {default as scoreCSProgram} from "./widgets/cs-program/score-cs-program"; export {default as scoreDropdown} from "./widgets/dropdown/score-dropdown"; +export {default as validateDropdown} from "./widgets/dropdown/validate-dropdown"; export {default as scoreExpression} from "./widgets/expression/score-expression"; +export {default as validateExpression} from "./widgets/expression/validate-expression"; export {default as scoreGrapher} from "./widgets/grapher/score-grapher"; export {default as scoreIframe} from "./widgets/iframe/score-iframe"; export {default as scoreInteractiveGraph} from "./widgets/interactive-graph/score-interactive-graph"; @@ -15,13 +18,20 @@ export { } from "./widgets/label-image/score-label-image"; export {default as scoreMatcher} from "./widgets/matcher/score-matcher"; export {default as scoreMatrix} from "./widgets/matrix/score-matrix"; +export {default as validateMatrix} from "./widgets/matrix/validate-matrix"; export {default as scoreNumberLine} from "./widgets/number-line/score-number-line"; +export {default as validateNumberLine} from "./widgets/number-line/validate-number-line"; export {default as scoreNumericInput} from "./widgets/numeric-input/score-numeric-input"; export {default as scoreOrderer} from "./widgets/orderer/score-orderer"; +export {default as validateOrderer} from "./widgets/orderer/validate-orderer"; export {default as scorePlotter} from "./widgets/plotter/score-plotter"; +export {default as validatePlotter} from "./widgets/plotter/validate-plotter"; export {default as scoreRadio} from "./widgets/radio/score-radio"; +export {default as validateRadio} from "./widgets/radio/validate-radio"; export {default as scoreSorter} from "./widgets/sorter/score-sorter"; +export {default as validateSorter} from "./widgets/sorter/validate-sorter"; export {default as scoreTable} from "./widgets/table/score-table"; +export {default as validateTable} from "./widgets/table/validate-table"; export { default as scoreInputNumber, inputNumberAnswerTypes, diff --git a/packages/perseus-score/src/validation.types.ts b/packages/perseus-score/src/validation.types.ts index 45394e4b52..7606954890 100644 --- a/packages/perseus-score/src/validation.types.ts +++ b/packages/perseus-score/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: * ``` @@ -36,13 +37,12 @@ import type { PerseusGradedGroupWidgetOptions, PerseusGraphType, PerseusGroupWidgetOptions, - PerseusMatcherWidgetOptions, PerseusMatrixWidgetAnswers, PerseusNumericInputAnswer, PerseusOrdererWidgetOptions, PerseusRadioChoice, PerseusGraphCorrectType, - InteractiveMarkerType, + MakeWidgetMap, Relationship, } from "@khanacademy/perseus-core"; @@ -78,18 +78,12 @@ 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; }; -export type PerseusDropdownRubric = { +export type PerseusDropdownScoringData = { choices: ReadonlyArray; }; @@ -97,35 +91,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"]; - -// TODO(LEMS-2440): Can possibly be removed during 2440; only userInput used -export type PerseusIFrameRubric = Empty; +export type PerseusGrapherUserInput = PerseusGrapherScoringData["correct"]; export type PerseusIFrameUserInput = { status: UserInputStatus; message: string | null; }; -export type PerseusInputNumberRubric = { +export type PerseusInputNumberScoringData = { answerType?: | "number" | "decimal" @@ -145,7 +138,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; @@ -153,22 +146,33 @@ 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; +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; right: ReadonlyArray; }; -export type PerseusMatrixRubric = { +export type PerseusMatrixScoringData = { // A data matrix representing the "correct" answers to be entered into the matrix answers: PerseusMatrixWidgetAnswers; } & PerseusMatrixValidationData; @@ -176,15 +180,7 @@ export type PerseusMatrixRubric = { export type PerseusMatrixValidationData = Empty; export type PerseusMatrixUserInput = { - answers: PerseusMatrixRubric["answers"]; -}; - -export type PerseusMockWidgetRubric = { - value: string; -}; - -export type PerseusMockWidgetUserInput = { - currentValue: string; + answers: PerseusMatrixScoringData["answers"]; }; export type PerseusNumberLineScoringData = { @@ -203,7 +199,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. @@ -214,7 +210,7 @@ export type PerseusNumericInputUserInput = { currentValue: string; }; -export type PerseusOrdererRubric = PerseusOrdererWidgetOptions; +export type PerseusOrdererScoringData = PerseusOrdererWidgetOptions; export type PerseusOrdererUserInput = { current: ReadonlyArray; @@ -232,7 +228,7 @@ export type PerseusPlotterValidationData = { export type PerseusPlotterUserInput = ReadonlyArray; -export type PerseusRadioRubric = { +export type PerseusRadioScoringData = { // The choices provided to the user. choices: ReadonlyArray; }; @@ -241,7 +237,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; }; @@ -251,58 +247,92 @@ 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 = - | PerseusCategorizerScoringData - | PerseusCSProgramRubric - | PerseusDropdownRubric - | PerseusExpressionRubric - | PerseusGroupRubric - | PerseusGradedGroupRubric - | PerseusGradedGroupSetRubric - | PerseusGrapherRubric - | PerseusIFrameRubric - | PerseusInputNumberRubric - | PerseusInteractiveGraphRubric - | PerseusLabelImageRubric - | PerseusMatcherRubric - | PerseusMatrixRubric - | PerseusMockWidgetRubric - | PerseusNumberLineScoringData - | PerseusNumericInputRubric - | PerseusOrdererRubric - | PerseusPlotterScoringData - | PerseusRadioRubric - | PerseusSorterRubric - | PerseusTableRubric; -export type UserInput = - | PerseusCategorizerUserInput - | PerseusCSProgramUserInput - | PerseusDropdownUserInput - | PerseusExpressionUserInput - | PerseusGrapherUserInput - | PerseusIFrameUserInput - | PerseusInputNumberUserInput - | PerseusInteractiveGraphUserInput - | PerseusLabelImageUserInput - | PerseusMatcherUserInput - | PerseusMatrixUserInput - | PerseusMockWidgetUserInput - | PerseusNumberLineUserInput - | PerseusNumericInputUserInput - | PerseusOrdererUserInput - | PerseusPlotterUserInput - | PerseusRadioUserInput - | PerseusSorterUserInput - | PerseusTableUserInput; - -export type UserInputMap = {[widgetId: string]: UserInput | UserInputMap}; +export interface ScoringDataRegistry { + categorizer: PerseusCategorizerScoringData; + dropdown: PerseusDropdownScoringData; + expression: PerseusExpressionScoringData; + "graded-group-set": PerseusGradedGroupSetScoringData; + "graded-group": PerseusGradedGroupScoringData; + grapher: PerseusGrapherScoringData; + 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; +} + +// | PerseusMockWidgetUserInput + +/** 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 @@ -310,3 +340,33 @@ export type UserInputMap = {[widgetId: string]: UserInput | UserInputMap}; export type UserInputArray = ReadonlyArray< UserInputArray | UserInput | null | undefined >; + +export interface ValidationDataTypes { + categorizer: PerseusCategorizerValidationData; + group: PerseusGroupValidationData; + plotter: PerseusPlotterValidationData; +} + +/** + * 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-score/src/validation.typetest.ts b/packages/perseus-score/src/validation.typetest.ts new file mode 100644 index 0000000000..590f0b55e4 --- /dev/null +++ b/packages/perseus-score/src/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 {ScoringDataMap, ValidationDataMap} from "./validation.types"; +import type {PerseusRenderer} from "@khanacademy/perseus-core"; + +/** + * 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-score/src/widgets/cs-program/score-cs-program.ts b/packages/perseus-score/src/widgets/cs-program/score-cs-program.ts index a26866b7d0..41076f8d23 100644 --- a/packages/perseus-score/src/widgets/cs-program/score-cs-program.ts +++ b/packages/perseus-score/src/widgets/cs-program/score-cs-program.ts @@ -3,24 +3,23 @@ import type { PerseusScore, } from "../../validation.types"; -// TODO: merge this with scoreIframe, it's the same code -function scoreCSProgram(state: PerseusCSProgramUserInput): PerseusScore { +function scoreCSProgram(userInput: PerseusCSProgramUserInput): PerseusScore { // The CS program 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-score/src/widgets/dropdown/score-dropdown.test.ts b/packages/perseus-score/src/widgets/dropdown/score-dropdown.test.ts index 5c04428d8f..791b937f49 100644 --- a/packages/perseus-score/src/widgets/dropdown/score-dropdown.test.ts +++ b/packages/perseus-score/src/widgets/dropdown/score-dropdown.test.ts @@ -1,7 +1,7 @@ import scoreDropdown from "./score-dropdown"; import type { - PerseusDropdownRubric, + PerseusDropdownScoringData, PerseusDropdownUserInput, } from "../../validation.types"; @@ -11,7 +11,7 @@ describe("scoreDropdown", () => { const userInput: PerseusDropdownUserInput = { value: 1, }; - const rubric: PerseusDropdownRubric = { + const scoringData: PerseusDropdownScoringData = { choices: [ { content: "greater than or equal to", @@ -25,7 +25,7 @@ describe("scoreDropdown", () => { }; // Act - const score = scoreDropdown(userInput, rubric); + const score = scoreDropdown(userInput, scoringData); // Assert expect(score).toHaveBeenAnsweredIncorrectly(); @@ -36,7 +36,7 @@ describe("scoreDropdown", () => { const userInput: PerseusDropdownUserInput = { value: 2, }; - const rubric: PerseusDropdownRubric = { + const scoringData: PerseusDropdownScoringData = { choices: [ { content: "greater than or equal to", @@ -50,7 +50,7 @@ describe("scoreDropdown", () => { }; // Act - const score = scoreDropdown(userInput, rubric); + const score = scoreDropdown(userInput, scoringData); // Assert expect(score).toHaveBeenAnsweredCorrectly(); diff --git a/packages/perseus-score/src/widgets/dropdown/score-dropdown.ts b/packages/perseus-score/src/widgets/dropdown/score-dropdown.ts index 4779c5ce47..eaa56e38b4 100644 --- a/packages/perseus-score/src/widgets/dropdown/score-dropdown.ts +++ b/packages/perseus-score/src/widgets/dropdown/score-dropdown.ts @@ -1,20 +1,20 @@ import validateDropdown from "./validate-dropdown"; import type { - PerseusDropdownRubric, + PerseusDropdownScoringData, PerseusDropdownUserInput, PerseusScore, } 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-score/src/widgets/expression/score-expression.test.ts b/packages/perseus-score/src/widgets/expression/score-expression.test.ts index 2bab201af6..2b0f14ca9a 100644 --- a/packages/perseus-score/src/widgets/expression/score-expression.test.ts +++ b/packages/perseus-score/src/widgets/expression/score-expression.test.ts @@ -2,10 +2,7 @@ import scoreExpression from "./score-expression"; import {expressionItem3Options} from "./score-expression.testdata"; import * as ExpressionValidator from "./validate-expression"; -import type {PerseusExpressionRubric} from "@khanacademy/perseus-score"; - -// TODO: remove strings as a param for scorers -const mockStrings = {}; +import type {PerseusExpressionScoringData} from "@khanacademy/perseus-score"; describe("scoreExpression", () => { it("should be correctly answerable if validation passes", function () { @@ -13,10 +10,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, "en"); // Assert expect(mockValidator).toHaveBeenCalledWith("z+1"); @@ -28,10 +26,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, "en"); // Assert expect(mockValidator).toHaveBeenCalledWith("z+1"); @@ -39,102 +38,52 @@ describe("scoreExpression", () => { }); it("should handle defined ungraded answer case with no error callback", function () { - const err = scoreExpression( - "x+1", - expressionItem3Options, - mockStrings, - "en", - ); + const err = scoreExpression("x+1", expressionItem3Options, "en"); expect(err).toHaveInvalidInput(); }); it("should handle invalid expression answer with no error callback", function () { - const err = scoreExpression( - "x+^1", - expressionItem3Options, - mockStrings, - "en", - ); + const err = scoreExpression("x+^1", expressionItem3Options, "en"); expect(err).toHaveInvalidInput(); }); it("should handle listed incorrect answers as wrong", function () { - const result = scoreExpression( - "y+1", - expressionItem3Options, - mockStrings, - "en", - ); + const result = scoreExpression("y+1", expressionItem3Options, "en"); expect(result).toHaveBeenAnsweredIncorrectly(); }); it("should handle unlisted answers as wrong", function () { - const result = scoreExpression( - "2+2", - expressionItem3Options, - mockStrings, - "en", - ); + const result = scoreExpression("2+2", expressionItem3Options, "en"); expect(result).toHaveBeenAnsweredIncorrectly(); }); it("should handle correct answers", function () { - const result = scoreExpression( - "z+1", - expressionItem3Options, - mockStrings, - "en", - ); + const result = scoreExpression("z+1", expressionItem3Options, "en"); expect(result).toHaveBeenAnsweredCorrectly(); }); it("should handle multiple correct answers", function () { // First possible correct answer - const result1 = scoreExpression( - "z+1", - expressionItem3Options, - mockStrings, - "en", - ); + const result1 = scoreExpression("z+1", expressionItem3Options, "en"); expect(result1).toHaveBeenAnsweredCorrectly(); // Second possible correct answer - const result2 = scoreExpression( - "a+1", - expressionItem3Options, - mockStrings, - "en", - ); + const result2 = scoreExpression("a+1", expressionItem3Options, "en"); expect(result2).toHaveBeenAnsweredCorrectly(); }); it("should handle correct answers with period decimal separator", function () { - const result = scoreExpression( - "z+1.0", - expressionItem3Options, - mockStrings, - "en", - ); + const result = scoreExpression("z+1.0", expressionItem3Options, "en"); expect(result).toHaveBeenAnsweredCorrectly(); }); it("should handle correct answers with comma decimal separator", function () { - const result = scoreExpression( - "z+1,0", - expressionItem3Options, - mockStrings, - "fr", - ); + const result = scoreExpression("z+1,0", expressionItem3Options, "fr"); expect(result).toHaveBeenAnsweredCorrectly(); }); it("should handle incorrect answers with period decimal separator", function () { - const result = scoreExpression( - "z+1,0", - expressionItem3Options, - mockStrings, - "en", - ); + const result = scoreExpression("z+1,0", expressionItem3Options, "en"); expect(result).toHaveInvalidInput(); }); }); diff --git a/packages/perseus-score/src/widgets/expression/score-expression.ts b/packages/perseus-score/src/widgets/expression/score-expression.ts index 94e3569264..a5bb9a4511 100644 --- a/packages/perseus-score/src/widgets/expression/score-expression.ts +++ b/packages/perseus-score/src/widgets/expression/score-expression.ts @@ -12,7 +12,7 @@ import validateExpression from "./validate-expression"; import type {Score} from "../../util/answer-types"; import type { - PerseusExpressionRubric, + PerseusExpressionScoringData, PerseusExpressionUserInput, PerseusScore, } from "../../validation.types"; @@ -38,9 +38,7 @@ import type {PerseusExpressionAnswerForm} from "@khanacademy/perseus-core"; */ function scoreExpression( userInput: PerseusExpressionUserInput, - rubric: PerseusExpressionRubric, - // TODO: remove strings as a param for scorers - strings: any, + scoringData: PerseusExpressionScoringData, locale: string, ): PerseusScore { const validationError = validateExpression(userInput); @@ -48,7 +46,7 @@ function scoreExpression( return validationError; } - const options = _.clone(rubric); + const options = _.clone(scoringData); _.extend(options, { decimal_separator: getDecimalSeparator(locale), }); @@ -58,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. @@ -67,6 +65,7 @@ function scoreExpression( throw new PerseusError( "Unable to parse solution answer for expression", Errors.InvalidInput, + {metadata: {scoringData: JSON.stringify(scoringData)}}, ); } @@ -94,7 +93,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-score/src/widgets/expression/validate-expression.ts b/packages/perseus-score/src/widgets/expression/validate-expression.ts index 3941aae140..af6689a51c 100644 --- a/packages/perseus-score/src/widgets/expression/validate-expression.ts +++ b/packages/perseus-score/src/widgets/expression/validate-expression.ts @@ -6,7 +6,7 @@ import type { /** * 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-score/src/widgets/grapher/score-grapher.test.ts b/packages/perseus-score/src/widgets/grapher/score-grapher.test.ts index db06034e43..b36fb61fc1 100644 --- a/packages/perseus-score/src/widgets/grapher/score-grapher.test.ts +++ b/packages/perseus-score/src/widgets/grapher/score-grapher.test.ts @@ -1,13 +1,13 @@ import scoreGrapher from "./score-grapher"; import type { + PerseusGrapherScoringData, PerseusGrapherUserInput, - PerseusGrapherRubric, } from "../../validation.types"; import type {Coord} from "@khanacademy/perseus-core"; 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(); @@ -56,7 +56,7 @@ describe("scoreGrapher", () => { coords: null, }; - const rubric: PerseusGrapherRubric = { + const scoringData: PerseusGrapherScoringData = { correct: { type: "exponential", asymptote, @@ -65,7 +65,7 @@ describe("scoreGrapher", () => { }; // Act - const result = scoreGrapher(userInput, rubric); + const result = scoreGrapher(userInput, scoringData); // Assert expect(result).toHaveInvalidInput(); @@ -86,7 +86,7 @@ describe("scoreGrapher", () => { coords, }; - const rubric: PerseusGrapherRubric = { + const scoringData: PerseusGrapherScoringData = { correct: { type: "linear", coords, @@ -94,7 +94,7 @@ describe("scoreGrapher", () => { }; // Act - const result = scoreGrapher(userInput, rubric); + const result = scoreGrapher(userInput, scoringData); // Assert expect(result).toHaveInvalidInput(); @@ -117,7 +117,7 @@ describe("scoreGrapher", () => { ], }; - const rubric: PerseusGrapherRubric = { + const rubric: PerseusGrapherScoringData = { correct: { type: "linear", coords: null, @@ -143,7 +143,7 @@ describe("scoreGrapher", () => { coords, }; - const rubric: PerseusGrapherRubric = { + const scoringData: PerseusGrapherScoringData = { correct: { type: "linear", coords, @@ -151,13 +151,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", @@ -167,7 +167,7 @@ describe("scoreGrapher", () => { ], }; - const rubric: PerseusGrapherRubric = { + const scoringData: PerseusGrapherScoringData = { correct: { type: "linear", coords: [ @@ -178,7 +178,7 @@ describe("scoreGrapher", () => { }; // Act - const result = scoreGrapher(userInput, rubric); + const result = scoreGrapher(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); diff --git a/packages/perseus-score/src/widgets/grapher/score-grapher.ts b/packages/perseus-score/src/widgets/grapher/score-grapher.ts index 79b93f64e8..9e8aa3e775 100644 --- a/packages/perseus-score/src/widgets/grapher/score-grapher.ts +++ b/packages/perseus-score/src/widgets/grapher/score-grapher.ts @@ -1,8 +1,8 @@ import {Errors, PerseusError, GrapherUtil} from "@khanacademy/perseus-core"; import type { + PerseusGrapherScoringData, PerseusGrapherUserInput, - PerseusGrapherRubric, PerseusScore, } from "../../validation.types"; import type {GrapherAnswerTypes} from "@khanacademy/perseus-core"; @@ -32,9 +32,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, @@ -54,7 +54,7 @@ function scoreGrapher( // Get new function handler for grading const grader = GrapherUtil.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-score/src/widgets/input-number/score-input-number.test.ts b/packages/perseus-score/src/widgets/input-number/score-input-number.test.ts index 9c4e0c4d01..7fcbdf6c7c 100644 --- a/packages/perseus-score/src/widgets/input-number/score-input-number.test.ts +++ b/packages/perseus-score/src/widgets/input-number/score-input-number.test.ts @@ -1,13 +1,13 @@ import scoreInputNumber from "./score-input-number"; import type { - PerseusInputNumberRubric, + PerseusInputNumberScoringData, PerseusInputNumberUserInput, -} from "@khanacademy/perseus-score"; +} from "../../validation.types"; describe("scoreInputNumber", () => { it("scores correct answer correctly", () => { - const rubric: PerseusInputNumberRubric = { + const scoringData: PerseusInputNumberScoringData = { maxError: 0.1, inexact: false, value: 1, @@ -19,13 +19,13 @@ describe("scoreInputNumber", () => { currentValue: "1", }; - const score = scoreInputNumber(useInput, rubric); + const score = scoreInputNumber(useInput, scoringData); expect(score).toHaveBeenAnsweredCorrectly(); }); it("scores incorrect answer correctly", () => { - const rubric: PerseusInputNumberRubric = { + const scoringData: PerseusInputNumberScoringData = { maxError: 0.1, inexact: false, value: 1, @@ -37,13 +37,13 @@ describe("scoreInputNumber", () => { currentValue: "2", }; - const score = scoreInputNumber(useInput, rubric); + const score = scoreInputNumber(useInput, scoringData); expect(score).toHaveBeenAnsweredIncorrectly(); }); it("shows as invalid with a nonsense answer", () => { - const rubric: PerseusInputNumberRubric = { + const scoringData: PerseusInputNumberScoringData = { maxError: 0.1, inexact: false, value: 1, @@ -55,7 +55,7 @@ describe("scoreInputNumber", () => { currentValue: "sadasdfas", }; - const score = scoreInputNumber(useInput, rubric); + const score = scoreInputNumber(useInput, scoringData); expect(score).toHaveInvalidInput("EXTRA_SYMBOLS_ERROR"); }); @@ -67,7 +67,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, @@ -81,7 +81,7 @@ describe("scoreInputNumber", () => { currentValue: "241.91", }; - const score = scoreInputNumber(userInput, rubric); + const score = scoreInputNumber(userInput, scoringData); expect(score.message).not.toBe( "Your answer is close, but yyou may " + @@ -94,7 +94,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, @@ -106,13 +106,13 @@ describe("scoreInputNumber", () => { currentValue: "77 pi", }; - const score = scoreInputNumber(userInput, rubric); + const score = scoreInputNumber(userInput, scoringData); expect(score).toHaveBeenAnsweredCorrectly(); }); it("should handle invalid answers with no error callback", function () { - const rubric: PerseusInputNumberRubric = { + const rubric: PerseusInputNumberScoringData = { value: "2^{-2}-3", simplify: "optional", }; diff --git a/packages/perseus-score/src/widgets/input-number/score-input-number.ts b/packages/perseus-score/src/widgets/input-number/score-input-number.ts index bcefbf5783..d79157864a 100644 --- a/packages/perseus-score/src/widgets/input-number/score-input-number.ts +++ b/packages/perseus-score/src/widgets/input-number/score-input-number.ts @@ -2,7 +2,7 @@ import KhanAnswerTypes from "../../util/answer-types"; import {parseTex} from "../../util/tex-wrangler"; import type { - PerseusInputNumberRubric, + PerseusInputNumberScoringData, PerseusInputNumberUserInput, PerseusScore, } from "../../validation.types"; @@ -44,21 +44,21 @@ export const inputNumberAnswerTypes = { function scoreInputNumber( userInput: PerseusInputNumberUserInput, - rubric: PerseusInputNumberRubric, + scoringData: PerseusInputNumberScoringData, ): 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: inputNumberAnswerTypes[rubric.answerType].forms, + simplify: scoringData.simplify, + inexact: scoringData.inexact || undefined, + maxError: scoringData.maxError, + forms: inputNumberAnswerTypes[scoringData.answerType].forms, }); // We may have received TeX; try to parse it before grading. diff --git a/packages/perseus-score/src/widgets/interactive-graph/score-interactive-graph.test.ts b/packages/perseus-score/src/widgets/interactive-graph/score-interactive-graph.test.ts index a1c530ff08..adad2e9222 100644 --- a/packages/perseus-score/src/widgets/interactive-graph/score-interactive-graph.test.ts +++ b/packages/perseus-score/src/widgets/interactive-graph/score-interactive-graph.test.ts @@ -3,13 +3,13 @@ import _ from "underscore"; import scoreInteractiveGraph from "./score-interactive-graph"; -import type {PerseusInteractiveGraphRubric} from "../../validation.types"; +import type {PerseusInteractiveGraphScoringData} from "../../validation.types"; import type {PerseusGraphType} from "@khanacademy/perseus-core"; 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", }, @@ -24,7 +24,7 @@ describe("InteractiveGraph scoring on a segment question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveInvalidInput(); }); @@ -40,7 +40,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -53,7 +53,7 @@ describe("InteractiveGraph scoring on a segment question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveBeenAnsweredIncorrectly(); }); @@ -69,7 +69,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -82,7 +82,7 @@ describe("InteractiveGraph scoring on a segment question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveBeenAnsweredCorrectly(); }); @@ -97,7 +97,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -110,7 +110,7 @@ describe("InteractiveGraph scoring on a segment question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveBeenAnsweredCorrectly(); }); @@ -126,7 +126,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -139,7 +139,7 @@ describe("InteractiveGraph scoring on a segment question", () => { }, }; - scoreInteractiveGraph(guess, rubric); + scoreInteractiveGraph(guess, scoringData); expect(guess.coords).toEqual([ [ @@ -149,7 +149,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: [ @@ -159,7 +159,7 @@ describe("InteractiveGraph scoring on a segment question", () => { ], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "segment"}, correct: { type: "segment", @@ -172,12 +172,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], @@ -189,7 +189,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", @@ -203,7 +203,7 @@ describe("InteractiveGraph scoring on an angle question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveInvalidInput(); }); @@ -212,7 +212,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", @@ -220,7 +220,7 @@ describe("InteractiveGraph scoring on a point question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveInvalidInput(); }); @@ -233,17 +233,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", () => { @@ -251,7 +251,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", @@ -259,7 +259,7 @@ describe("InteractiveGraph scoring on a point question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveBeenAnsweredIncorrectly(); }); @@ -269,7 +269,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", @@ -277,7 +277,7 @@ describe("InteractiveGraph scoring on a point question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveBeenAnsweredCorrectly(); }); @@ -290,7 +290,7 @@ describe("InteractiveGraph scoring on a point question", () => { [5, 6], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "point"}, correct: { type: "point", @@ -301,7 +301,7 @@ describe("InteractiveGraph scoring on a point question", () => { }, }; - const result = scoreInteractiveGraph(guess, rubric); + const result = scoreInteractiveGraph(guess, scoringData); expect(result).toHaveBeenAnsweredCorrectly(); }); @@ -314,7 +314,7 @@ describe("InteractiveGraph scoring on a point question", () => { [5, 6], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "point"}, correct: { type: "point", @@ -327,12 +327,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: [ @@ -340,7 +340,7 @@ describe("InteractiveGraph scoring on a point question", () => { [5, 6], ], }; - const rubric: PerseusInteractiveGraphRubric = { + const scoringData: PerseusInteractiveGraphScoringData = { graph: {type: "point"}, correct: { type: "point", @@ -351,10 +351,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-score/src/widgets/interactive-graph/score-interactive-graph.ts b/packages/perseus-score/src/widgets/interactive-graph/score-interactive-graph.ts index d78804c196..82f2b929e8 100644 --- a/packages/perseus-score/src/widgets/interactive-graph/score-interactive-graph.ts +++ b/packages/perseus-score/src/widgets/interactive-graph/score-interactive-graph.ts @@ -12,8 +12,8 @@ import { import _ from "underscore"; import type { + PerseusInteractiveGraphScoringData, PerseusScore, - PerseusInteractiveGraphRubric, PerseusInteractiveGraphUserInput, } from "@khanacademy/perseus-score"; @@ -23,10 +23,10 @@ const {getSinusoidCoefficients, getQuadraticCoefficients} = coefficients; 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, @@ -45,14 +45,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. @@ -69,11 +69,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]) && @@ -94,13 +94,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 (approximateDeepEqual(guessCoeffs, correctCoeffs)) { return { @@ -112,12 +112,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); @@ -139,11 +139,14 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "circle" && - rubric.correct.type === "circle" + scoringData.correct.type === "circle" ) { if ( - approximateDeepEqual(userInput.center, rubric.correct.center) && - approximateEqual(userInput.radius, rubric.correct.radius) + approximateDeepEqual( + userInput.center, + scoringData.correct.center, + ) && + approximateEqual(userInput.radius, scoringData.correct.radius) ) { return { type: "points", @@ -154,12 +157,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(); @@ -180,18 +183,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 */ @@ -210,11 +213,11 @@ function scoreInteractiveGraph( } } else if ( userInput.type === "segment" && - rubric.correct.type === "segment" && + scoringData.correct.type === "segment" && userInput.coords != null ) { let guess = deepClone(userInput.coords); - let correct = deepClone(rubric.correct.coords); + let correct = deepClone(scoringData.correct.coords); guess = _.invoke(guess, "sort").sort(); correct = _.invoke(correct, "sort").sort(); if (approximateDeepEqual(guess, correct)) { @@ -227,11 +230,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 ( approximateDeepEqual(guess[0], correct[0]) && collinear(correct[0], correct[1], guess[1]) @@ -245,14 +248,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; @@ -285,7 +288,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-score/src/widgets/label-image/score-label-image.test.ts b/packages/perseus-score/src/widgets/label-image/score-label-image.test.ts index 4085586f04..27439ca287 100644 --- a/packages/perseus-score/src/widgets/label-image/score-label-image.test.ts +++ b/packages/perseus-score/src/widgets/label-image/score-label-image.test.ts @@ -1,18 +1,8 @@ import scoreLabelImage, {scoreLabelImageMarker} from "./score-label-image"; -const emptyMarker = { - label: "", - answers: [], - selected: [], - x: 0, - y: 0, -} as const; - describe("scoreLabelImageMarker", function () { it("should score correct for empty marker with no user answers", function () { - const score = scoreLabelImageMarker({ - ...emptyMarker, - }); + const score = scoreLabelImageMarker([], []); expect(score).toEqual({ hasAnswers: false, @@ -21,10 +11,7 @@ describe("scoreLabelImageMarker", function () { }); it("should score incorrect for empty marker with user answer", function () { - const score = scoreLabelImageMarker({ - ...emptyMarker, - selected: ["Fiat"], - }); + const score = scoreLabelImageMarker(["Fiat"], []); expect(score).toEqual({ hasAnswers: true, @@ -33,10 +20,10 @@ describe("scoreLabelImageMarker", function () { }); it("should score incorrect for no user answers", function () { - const score = scoreLabelImageMarker({ - ...emptyMarker, - answers: ["Lamborghini", "Fiat", "Ferrari"], - }); + const score = scoreLabelImageMarker( + [], + ["Lamborghini", "Fiat", "Ferrari"], + ); expect(score).toEqual({ hasAnswers: false, @@ -45,11 +32,10 @@ describe("scoreLabelImageMarker", function () { }); it("should score incorrect for wrong user answers", function () { - const score = scoreLabelImageMarker({ - ...emptyMarker, - answers: ["Lamborghini", "Fiat", "Ferrari"], - selected: ["Fiat", "Ferrari"], - }); + const score = scoreLabelImageMarker( + ["Fiat", "Ferrari"], + ["Lamborghini", "Fiat", "Ferrari"], + ); expect(score).toEqual({ hasAnswers: true, @@ -58,11 +44,10 @@ describe("scoreLabelImageMarker", function () { }); it("should score correct for user answers", function () { - const score = scoreLabelImageMarker({ - ...emptyMarker, - answers: ["Lamborghini", "Fiat", "Ferrari"], - selected: ["Lamborghini", "Fiat", "Ferrari"], - }); + const score = scoreLabelImageMarker( + ["Lamborghini", "Fiat", "Ferrari"], + ["Lamborghini", "Fiat", "Ferrari"], + ); expect(score).toEqual({ hasAnswers: true, @@ -72,139 +57,122 @@ describe("scoreLabelImageMarker", function () { }); describe("scoreLabelImage", function () { - it("should not grade non-interacted widget", function () { - const state = { + it("should grade as incorrect for widget with no answers for markers", function () { + const userInput = { markers: [ { - ...emptyMarker, label: "England", - answers: ["Mini", "Morris Minor", "Reliant Robin"], + selected: ["Fiat"], }, { - ...emptyMarker, label: "Germany", - answers: ["BMW", "Volkswagen", "Porsche"], + selected: ["Lamborghini"], }, { - ...emptyMarker, label: "Italy", - answers: ["Lamborghini", "Fiat", "Ferrari"], + selected: ["Ferrari"], }, ], } as const; - const score = scoreLabelImage(state); - - expect(score).toHaveInvalidInput(); - }); - - it("should not grade widget with not all markers answered", function () { - const state = { + const scoringData = { markers: [ { - ...emptyMarker, label: "England", - selected: ["Fiat"], + answers: [], }, { - ...emptyMarker, label: "Germany", - answers: ["BMW", "Volkswagen", "Porsche"], - selected: ["Lamborghini"], + answers: [], }, { - ...emptyMarker, label: "Italy", - answers: ["Lamborghini", "Fiat", "Ferrari"], + answers: [], }, ], } as const; - const score = scoreLabelImage(state); + const score = scoreLabelImage(userInput, scoringData); - expect(score).toHaveInvalidInput(); + expect(score).toHaveBeenAnsweredIncorrectly(); }); - it("should grade as incorrect for widget with no answers for markers", function () { - const state = { + it("should grade as incorrect for widget with some wrong answers", function () { + const userInput = { markers: [ { - ...emptyMarker, label: "England", - selected: ["Fiat"], + selected: ["Mini"], }, { - ...emptyMarker, label: "Germany", - selected: ["Lamborghini"], + selected: ["BMW", "Volkswagen", "Porsche"], }, { - ...emptyMarker, label: "Italy", selected: ["Ferrari"], }, ], } as const; - const score = scoreLabelImage(state); - - expect(score).toHaveBeenAnsweredIncorrectly(); - }); - - it("should grade as incorrect for widget with some wrong answers", function () { - const state = { + const scoringData = { 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 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-score/src/widgets/label-image/score-label-image.ts b/packages/perseus-score/src/widgets/label-image/score-label-image.ts index 998262b267..caf3d1330e 100644 --- a/packages/perseus-score/src/widgets/label-image/score-label-image.ts +++ b/packages/perseus-score/src/widgets/label-image/score-label-image.ts @@ -1,9 +1,10 @@ +import validateLabelImage from "./validate-label-image"; + import type { PerseusLabelImageUserInput, - PerseusLabelImageRubric, + PerseusLabelImageScoringData, PerseusScore, } from "../../validation.types"; -import type {InteractiveMarkerType} from "@khanacademy/perseus-core"; // Question state for marker as result of user selected answers. type InteractiveMarkerScore = { @@ -14,28 +15,26 @@ type InteractiveMarkerScore = { }; export function scoreLabelImageMarker( - 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,34 +42,28 @@ export function scoreLabelImageMarker( 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; + const validationError = validateLabelImage(userInput); + if (validationError) { + return validationError; + } + let numCorrect = 0; - for (const marker of userInput.markers) { - const score = scoreLabelImageMarker(marker); - - if (score.hasAnswers) { - numAnswered++; - } + for (let i = 0; i < userInput.markers.length; i++) { + const score = scoreLabelImageMarker( + userInput.markers[i].selected, + scoringData.markers[i].answers, + ); 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-score/src/widgets/label-image/validate-label-image.test.ts b/packages/perseus-score/src/widgets/label-image/validate-label-image.test.ts new file mode 100644 index 0000000000..3225689a00 --- /dev/null +++ b/packages/perseus-score/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-score/src/widgets/label-image/validate-label-image.ts b/packages/perseus-score/src/widgets/label-image/validate-label-image.ts new file mode 100644 index 0000000000..122abafa19 --- /dev/null +++ b/packages/perseus-score/src/widgets/label-image/validate-label-image.ts @@ -0,0 +1,27 @@ +import type { + PerseusLabelImageUserInput, + ValidationResult, +} from "../../validation.types"; + +function validateLabelImage( + userInput: PerseusLabelImageUserInput, +): ValidationResult { + 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; diff --git a/packages/perseus-score/src/widgets/matcher/score-matcher.test.ts b/packages/perseus-score/src/widgets/matcher/score-matcher.test.ts index de517dd1f8..380bd0aa7f 100644 --- a/packages/perseus-score/src/widgets/matcher/score-matcher.test.ts +++ b/packages/perseus-score/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,16 +13,13 @@ describe("scoreMatcher", () => { right: ["cool", "beans"], }; - const rubric: PerseusMatcherRubric = { - labels: ["One", "Two"], + const scoringData: PerseusMatcherScoringData = { left: ["1", "0+1"], right: ["2", "0+2"], - orderMatters: false, - padding: false, }; // Act - const result = scoreMatcher(userInput, rubric); + const result = scoreMatcher(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); @@ -30,21 +27,18 @@ describe("scoreMatcher", () => { it("can be answered correctly", () => { // Arrange - const rubric: PerseusMatcherRubric = { - labels: ["One", "Two"], + const scoringData: PerseusMatcherScoringData = { left: ["1", "0+1"], right: ["2", "0+2"], - orderMatters: false, - padding: false, }; 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-score/src/widgets/matcher/score-matcher.ts b/packages/perseus-score/src/widgets/matcher/score-matcher.ts index f90f3fe019..f5398e8120 100644 --- a/packages/perseus-score/src/widgets/matcher/score-matcher.ts +++ b/packages/perseus-score/src/widgets/matcher/score-matcher.ts @@ -1,18 +1,18 @@ import _ from "underscore"; import type { - PerseusMatcherRubric, + PerseusMatcherScoringData, PerseusMatcherUserInput, PerseusScore, } 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-score/src/widgets/matrix/score-matrix.test.ts b/packages/perseus-score/src/widgets/matrix/score-matrix.test.ts index 115022239d..491f2403b5 100644 --- a/packages/perseus-score/src/widgets/matrix/score-matrix.test.ts +++ b/packages/perseus-score/src/widgets/matrix/score-matrix.test.ts @@ -2,7 +2,7 @@ import scoreMatrix from "./score-matrix"; import * as MatrixValidator from "./validate-matrix"; import type { - PerseusMatrixRubric, + PerseusMatrixScoringData, PerseusMatrixUserInput, } from "../../validation.types"; @@ -13,7 +13,7 @@ describe("scoreMatrix", () => { .spyOn(MatrixValidator, "default") .mockReturnValue(null); - const rubric: PerseusMatrixRubric = { + const scoringData: PerseusMatrixScoringData = { answers: [ [0, 1, 2], [3, 4, 5], @@ -22,14 +22,14 @@ describe("scoreMatrix", () => { }; const userInput: PerseusMatrixUserInput = { - answers: rubric.answers, + answers: scoringData.answers, }; // Act - const score = scoreMatrix(userInput, rubric); + const score = scoreMatrix(userInput, scoringData); // Assert - expect(mockValidator).toHaveBeenCalledWith(userInput, rubric); + expect(mockValidator).toHaveBeenCalledWith(userInput); expect(score).toHaveBeenAnsweredCorrectly(); }); @@ -39,7 +39,7 @@ describe("scoreMatrix", () => { .spyOn(MatrixValidator, "default") .mockReturnValue({type: "invalid", message: null}); - const rubric: PerseusMatrixRubric = { + const scoringData: PerseusMatrixScoringData = { answers: [ [0, 1, 2], [3, 4, 5], @@ -48,20 +48,20 @@ describe("scoreMatrix", () => { }; const userInput: PerseusMatrixUserInput = { - answers: rubric.answers, + answers: scoringData.answers, }; // Act - const score = scoreMatrix(userInput, rubric); + const score = scoreMatrix(userInput, scoringData); // Assert - expect(mockValidator).toHaveBeenCalledWith(userInput, rubric); + expect(mockValidator).toHaveBeenCalledWith(userInput); expect(score).toHaveInvalidInput(); }); it("can be answered correctly", () => { // Arrange - const rubric: PerseusMatrixRubric = { + const scoringData: PerseusMatrixScoringData = { answers: [ [0, 1, 2], [3, 4, 5], @@ -70,11 +70,11 @@ describe("scoreMatrix", () => { }; const userInput: PerseusMatrixUserInput = { - answers: rubric.answers, + answers: scoringData.answers, }; // Act - const result = scoreMatrix(userInput, rubric); + const result = scoreMatrix(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredCorrectly(); @@ -82,7 +82,7 @@ describe("scoreMatrix", () => { it("can be answered incorrectly", () => { // Arrange - const rubric: PerseusMatrixRubric = { + const scoringData: PerseusMatrixScoringData = { answers: [ [0, 1, 2], [3, 4, 5], @@ -99,7 +99,7 @@ describe("scoreMatrix", () => { }; // Act - const result = scoreMatrix(userInput, rubric); + const result = scoreMatrix(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); @@ -107,7 +107,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], @@ -127,7 +127,7 @@ describe("scoreMatrix", () => { }; // Act - const result = scoreMatrix(userInput, rubric); + const result = scoreMatrix(userInput, scoringData); // Assert expect(result).toHaveInvalidInput(); @@ -135,7 +135,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], @@ -155,7 +155,7 @@ describe("scoreMatrix", () => { }; // Act - const result = scoreMatrix(userInput, rubric); + const result = scoreMatrix(userInput, scoringData); // Assert expect(result).toHaveInvalidInput(); @@ -163,7 +163,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], @@ -172,7 +172,7 @@ describe("scoreMatrix", () => { }; const correctUserInput: PerseusMatrixUserInput = { - answers: rubric.answers, + answers: scoringData.answers, }; const incorrectUserInput: PerseusMatrixUserInput = { @@ -180,12 +180,12 @@ 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); - const incorrectResult = scoreMatrix(incorrectUserInput, rubric); + const correctResult = scoreMatrix(correctUserInput, scoringData); + const incorrectResult = scoreMatrix(incorrectUserInput, scoringData); // Assert expect(correctResult).toHaveBeenAnsweredCorrectly(); diff --git a/packages/perseus-score/src/widgets/matrix/score-matrix.ts b/packages/perseus-score/src/widgets/matrix/score-matrix.ts index 02962ac248..42b21e14f9 100644 --- a/packages/perseus-score/src/widgets/matrix/score-matrix.ts +++ b/packages/perseus-score/src/widgets/matrix/score-matrix.ts @@ -6,21 +6,21 @@ import KhanAnswerTypes from "../../util/answer-types"; import validateMatrix from "./validate-matrix"; import type { + PerseusMatrixScoringData, PerseusMatrixUserInput, - PerseusMatrixRubric, PerseusScore, } from "../../validation.types"; function scoreMatrix( userInput: PerseusMatrixUserInput, - rubric: PerseusMatrixRubric, + scoringData: PerseusMatrixScoringData, ): PerseusScore { - const validationResult = validateMatrix(userInput, rubric); - if (validationResult != null) { - return validationResult; + const validationError = validateMatrix(userInput); + if (validationError != null) { + return validationError; } - 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-score/src/widgets/matrix/validate-matrix.test.ts b/packages/perseus-score/src/widgets/matrix/validate-matrix.test.ts index 3100e7fdee..438c6e9e97 100644 --- a/packages/perseus-score/src/widgets/matrix/validate-matrix.test.ts +++ b/packages/perseus-score/src/widgets/matrix/validate-matrix.test.ts @@ -10,7 +10,7 @@ describe("matrixValidator", () => { }; // Act - const result = validateMatrix(userInput, {}); + const result = validateMatrix(userInput); // Assert expect(result).toHaveInvalidInput(); @@ -23,7 +23,7 @@ describe("matrixValidator", () => { }; // Act - const result = validateMatrix(userInput, {}); + const result = validateMatrix(userInput); // Assert expect(result).toHaveInvalidInput(); @@ -40,7 +40,7 @@ describe("matrixValidator", () => { }; // Act - const result = validateMatrix(userInput, {}); + const result = validateMatrix(userInput); // Assert expect(result).toBeNull(); 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); diff --git a/packages/perseus-score/src/widgets/numeric-input/score-numeric-input.test.ts b/packages/perseus-score/src/widgets/numeric-input/score-numeric-input.test.ts index c070aaf5c1..c9a7903782 100644 --- a/packages/perseus-score/src/widgets/numeric-input/score-numeric-input.test.ts +++ b/packages/perseus-score/src/widgets/numeric-input/score-numeric-input.test.ts @@ -1,10 +1,36 @@ import scoreNumericInput, {maybeParsePercentInput} from "./score-numeric-input"; -import type {PerseusNumericInputRubric} from "../../validation.types"; +import type {PerseusNumericInputScoringData} from "../../validation.types"; + +describe("scoreNumericInput", () => { + it("is correct when input is empty but answer is 1 and coefficient: true", () => { + const scoringData: PerseusNumericInputScoringData = { + 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, scoringData); + + expect(score).toHaveBeenAnsweredCorrectly(); + }); -describe("static function validate", () => { it("with a simple value", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1, @@ -22,13 +48,13 @@ describe("static function validate", () => { currentValue: "1", } as const; - const score = scoreNumericInput(userInput, rubric); + const score = scoreNumericInput(userInput, scoringData); expect(score).toHaveBeenAnsweredCorrectly(); }); it("with nonsense", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1, @@ -46,7 +72,7 @@ describe("static function validate", () => { currentValue: "sadasdfas", } as const; - const score = scoreNumericInput(userInput, rubric); + const score = scoreNumericInput(userInput, scoringData); expect(score).toHaveInvalidInput("EXTRA_SYMBOLS_ERROR"); }); @@ -58,7 +84,7 @@ describe("static function validate", () => { // 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, @@ -79,7 +105,7 @@ describe("static function validate", () => { currentValue: "45.282", } as const; - const score = scoreNumericInput(userInput, rubric); + const score = scoreNumericInput(userInput, scoringData); expect(score.message).not.toBe( "Your answer is close, but you may " + @@ -92,7 +118,7 @@ describe("static function validate", () => { }); it("still validates against pi if provided in answerForms", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { maxError: null, @@ -111,13 +137,13 @@ describe("static function validate", () => { currentValue: "99 pi", } as const; - const score = scoreNumericInput(userInput, rubric); + const score = scoreNumericInput(userInput, scoringData); expect(score).toHaveBeenAnsweredCorrectly(); }); it("with a strict answer", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1, @@ -135,13 +161,13 @@ describe("static function validate", () => { currentValue: "1.0", } as const; - const score = scoreNumericInput(userInput, rubric); + const score = scoreNumericInput(userInput, scoringData); expect(score).toHaveBeenAnsweredCorrectly(); }); it("with a strict answer and max error is outside range", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1, @@ -159,13 +185,13 @@ describe("static function validate", () => { currentValue: "1.3", } as const; - const score = scoreNumericInput(userInput, rubric); + const score = scoreNumericInput(userInput, scoringData); expect(score).toHaveBeenAnsweredIncorrectly(); }); it("with a strict answer and max error is inside range", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1, @@ -183,14 +209,14 @@ describe("static function validate", () => { currentValue: "1.12", } as const; - const score = scoreNumericInput(userInput, rubric); + const score = scoreNumericInput(userInput, scoringData); 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 { @@ -218,7 +244,7 @@ describe("static function validate", () => { const wrongInput = { currentValue: "4", } as const; - let score = scoreNumericInput(wrongInput, rubric); + let score = scoreNumericInput(wrongInput, scoringData); // Assert - "wrong" expect(score).toHaveBeenAnsweredIncorrectly(); @@ -227,7 +253,7 @@ describe("static function validate", () => { const correctInput = { currentValue: "14", } as const; - score = scoreNumericInput(correctInput, rubric); + score = scoreNumericInput(correctInput, scoringData); // Assert - "correct" expect(score).toHaveBeenAnsweredCorrectly(); @@ -235,7 +261,7 @@ describe("static function validate", () => { it("defaults to 1 or -1 when user input is empty/incomplete", () => { // Arrange - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 1, @@ -261,7 +287,7 @@ describe("static function validate", () => { const emptyInput = { currentValue: "", } as const; - let score = scoreNumericInput(emptyInput, rubric); + let score = scoreNumericInput(emptyInput, scoringData); // Assert - "empty" expect(score).toHaveBeenAnsweredCorrectly(); @@ -270,7 +296,7 @@ describe("static function validate", () => { const incompleteInput = { currentValue: "-", } as const; - score = scoreNumericInput(incompleteInput, rubric); + score = scoreNumericInput(incompleteInput, scoringData); // Assert - "incomplete" expect(score).toHaveBeenAnsweredCorrectly(); @@ -281,7 +307,7 @@ describe("static function validate", () => { // behavior. Ideally, answers should always have a value field, but // some don't, so this test documents how we handle that. // TODO(benchristel): Fix the data so we can remove this test. - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { // This answer is missing its value field. @@ -304,7 +330,7 @@ describe("static function validate", () => { coefficient: true, }; - const score = scoreNumericInput({currentValue: "50%"}, rubric); + const score = scoreNumericInput({currentValue: "50%"}, scoringData); expect(score).toHaveBeenAnsweredIncorrectly(); }); @@ -314,7 +340,7 @@ describe("static function validate", () => { // behavior. Ideally, answers should always have a value field, but // some don't, so this test documents how we handle that. // TODO(benchristel): Fix the data so we can remove this test. - const rubric: PerseusNumericInputRubric = { + const rubric: PerseusNumericInputScoringData = { answers: [ { value: null, @@ -343,7 +369,7 @@ describe("static function validate", () => { }); it("converts a percentage input value to a decimal", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 0.2, @@ -357,7 +383,7 @@ describe("static function validate", () => { coefficient: true, }; - const score = scoreNumericInput({currentValue: "20%"}, rubric); + const score = scoreNumericInput({currentValue: "20%"}, scoringData); expect(score).toHaveBeenAnsweredCorrectly(); }); @@ -366,7 +392,7 @@ describe("static function validate", () => { // 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, @@ -380,7 +406,7 @@ describe("static function validate", () => { coefficient: true, }; - const score = scoreNumericInput({currentValue: "120%"}, rubric); + const score = scoreNumericInput({currentValue: "120%"}, scoringData); expect(score).toHaveBeenAnsweredIncorrectly(); }); @@ -389,7 +415,7 @@ describe("static function validate", () => { // 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, @@ -403,13 +429,13 @@ describe("static function validate", () => { coefficient: true, }; - const score = scoreNumericInput({currentValue: "1.1%"}, rubric); + const score = scoreNumericInput({currentValue: "1.1%"}, scoringData); expect(score).toHaveBeenAnsweredCorrectly(); }); it("rejects answers with an extra, incorrect percent sign if < 1", () => { - const rubric: PerseusNumericInputRubric = { + const scoringData: PerseusNumericInputScoringData = { answers: [ { value: 0.9, @@ -423,7 +449,7 @@ describe("static function validate", () => { coefficient: true, }; - const score = scoreNumericInput({currentValue: "0.9%"}, rubric); + const score = scoreNumericInput({currentValue: "0.9%"}, scoringData); expect(score).toHaveBeenAnsweredIncorrectly(); }); diff --git a/packages/perseus-score/src/widgets/numeric-input/score-numeric-input.ts b/packages/perseus-score/src/widgets/numeric-input/score-numeric-input.ts index 8b9e3c58f1..792f7b1184 100644 --- a/packages/perseus-score/src/widgets/numeric-input/score-numeric-input.ts +++ b/packages/perseus-score/src/widgets/numeric-input/score-numeric-input.ts @@ -3,7 +3,7 @@ import {parseTex} from "../../util/tex-wrangler"; import type {Score} from "../../util/answer-types"; import type { - PerseusNumericInputRubric, + PerseusNumericInputScoringData, PerseusNumericInputUserInput, PerseusScore, } from "../../validation.types"; @@ -67,7 +67,7 @@ export function maybeParsePercentInput( function scoreNumericInput( userInput: PerseusNumericInputUserInput, - rubric: PerseusNumericInputRubric, + scoringData: PerseusNumericInputScoringData, ): PerseusScore { const defaultAnswerForms = answerFormButtons .map((e) => e["value"]) @@ -104,13 +104,13 @@ 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) => answer.value != null && Math.abs(answer.value) <= 1); // 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 === "-") { @@ -119,7 +119,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-score/src/widgets/orderer/score-orderer.test.ts b/packages/perseus-score/src/widgets/orderer/score-orderer.test.ts index 4f7695833f..84f7aeb986 100644 --- a/packages/perseus-score/src/widgets/orderer/score-orderer.test.ts +++ b/packages/perseus-score/src/widgets/orderer/score-orderer.test.ts @@ -2,11 +2,11 @@ import scoreOrderer from "./score-orderer"; import * as OrdererValidator from "./validate-orderer"; import type { - PerseusOrdererRubric, + PerseusOrdererScoringData, PerseusOrdererUserInput, } from "../../validation.types"; -function generateOrdererRubric(): PerseusOrdererRubric { +function generateOrdererScoringData(): PerseusOrdererScoringData { return { otherOptions: [], layout: "horizontal", @@ -25,46 +25,49 @@ function generateOrdererRubric(): PerseusOrdererRubric { } 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 = generateOrdererRubric(); + const scoringData: PerseusOrdererScoringData = + generateOrdererScoringData(); const userInput: PerseusOrdererUserInput = { - current: rubric.correctOptions.map((e) => e.content), + current: scoringData.correctOptions.map((e) => e.content), }; // 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 = generateOrdererRubric(); + const scoringData: PerseusOrdererScoringData = + generateOrdererScoringData(); const userInput: PerseusOrdererUserInput = { - current: rubric.options.map((e) => e.content), + current: scoringData.options.map((e) => e.content), }; // 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 = generateOrdererRubric(); + const scoringData: PerseusOrdererScoringData = + generateOrdererScoringData(); const userInput: PerseusOrdererUserInput = { - current: rubric.correctOptions.map((e) => e.content).slice(1), + current: scoringData.correctOptions.map((e) => e.content).slice(1), }; // Act - const result = scoreOrderer(userInput, rubric); + const result = scoreOrderer(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); @@ -76,13 +79,14 @@ describe("scoreOrderer", () => { .spyOn(OrdererValidator, "default") .mockReturnValue(null); - const rubric: PerseusOrdererRubric = generateOrdererRubric(); + const scoringData: PerseusOrdererScoringData = + generateOrdererScoringData(); const userInput: PerseusOrdererUserInput = { - current: rubric.correctOptions.map((e) => e.content), + current: scoringData.correctOptions.map((e) => e.content), }; // Act - const result = scoreOrderer(userInput, rubric); + const result = scoreOrderer(userInput, scoringData); // Assert expect(mockValidator).toHaveBeenCalledWith(userInput); @@ -98,14 +102,15 @@ describe("scoreOrderer", () => { message: null, }); - const rubric: PerseusOrdererRubric = generateOrdererRubric(); + const scoringData: PerseusOrdererScoringData = + generateOrdererScoringData(); const userInput: PerseusOrdererUserInput = { current: [], }; // Act - const result = scoreOrderer(userInput, rubric); + const result = scoreOrderer(userInput, scoringData); // Assert expect(mockValidator).toHaveBeenCalledWith(userInput); diff --git a/packages/perseus-score/src/widgets/orderer/score-orderer.ts b/packages/perseus-score/src/widgets/orderer/score-orderer.ts index 5057dd0569..f2603559df 100644 --- a/packages/perseus-score/src/widgets/orderer/score-orderer.ts +++ b/packages/perseus-score/src/widgets/orderer/score-orderer.ts @@ -3,23 +3,23 @@ import _ from "underscore"; import validateOrderer from "./validate-orderer"; import type { - PerseusOrdererRubric, + PerseusOrdererScoringData, PerseusOrdererUserInput, PerseusScore, } from "../../validation.types"; function scoreOrderer( userInput: PerseusOrdererUserInput, - rubric: PerseusOrdererRubric, + scoringData: PerseusOrdererScoringData, ): PerseusScore { - const validateError = validateOrderer(userInput); - if (validateError) { - return validateError; + const validationError = validateOrderer(userInput); + if (validationError) { + return validationError; } const correct = _.isEqual( userInput.current, - rubric.correctOptions.map((option) => option.content), + scoringData.correctOptions.map((option) => option.content), ); return { diff --git a/packages/perseus-score/src/widgets/radio/score-radio.test.ts b/packages/perseus-score/src/widgets/radio/score-radio.test.ts index aaad164794..a002cc8b5f 100644 --- a/packages/perseus-score/src/widgets/radio/score-radio.test.ts +++ b/packages/perseus-score/src/widgets/radio/score-radio.test.ts @@ -1,7 +1,7 @@ import scoreRadio from "./score-radio"; import type { - PerseusRadioRubric, + PerseusRadioScoringData, PerseusRadioUserInput, } from "../../validation.types"; @@ -11,7 +11,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}, @@ -20,7 +20,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric); + const score = scoreRadio(userInput, scoringData); expect(score).toHaveInvalidInput(); }); @@ -30,7 +30,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}, @@ -44,7 +44,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric); + const score = scoreRadio(userInput, scoringData); expect(score).toHaveInvalidInput(); }); @@ -54,7 +54,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}, @@ -63,7 +63,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric); + const score = scoreRadio(userInput, scoringData); expect(score).toHaveBeenAnsweredCorrectly(); }); @@ -73,7 +73,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}, @@ -82,7 +82,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric); + const score = scoreRadio(userInput, scoringData); expect(score).toHaveBeenAnsweredIncorrectly(); }); @@ -92,7 +92,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}, @@ -101,7 +101,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric); + const score = scoreRadio(userInput, scoringData); expect(score).toHaveBeenAnsweredCorrectly(); }); @@ -111,7 +111,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}, @@ -120,7 +120,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric); + const score = scoreRadio(userInput, scoringData); expect(score).toHaveBeenAnsweredIncorrectly(); }); @@ -130,7 +130,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}, @@ -140,7 +140,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric); + const score = scoreRadio(userInput, scoringData); expect(score).toHaveBeenAnsweredCorrectly(); }); @@ -150,7 +150,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}, @@ -160,7 +160,7 @@ describe("scoreRadio", () => { ], }; - const score = scoreRadio(userInput, rubric); + const score = scoreRadio(userInput, scoringData); expect(score).toHaveBeenAnsweredIncorrectly(); }); diff --git a/packages/perseus-score/src/widgets/radio/score-radio.ts b/packages/perseus-score/src/widgets/radio/score-radio.ts index eb04198d67..ecf0972b7f 100644 --- a/packages/perseus-score/src/widgets/radio/score-radio.ts +++ b/packages/perseus-score/src/widgets/radio/score-radio.ts @@ -3,14 +3,14 @@ import ErrorCodes from "../../error-codes"; import validateRadio from "./validate-radio"; import type { - PerseusRadioRubric, + PerseusRadioScoringData, PerseusRadioUserInput, PerseusScore, } from "../../validation.types"; function scoreRadio( userInput: PerseusRadioUserInput, - rubric: PerseusRadioRubric, + scoringData: PerseusRadioScoringData, ): PerseusScore { const validationError = validateRadio(userInput); if (validationError) { @@ -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-score/src/widgets/sorter/score-sorter.test.ts b/packages/perseus-score/src/widgets/sorter/score-sorter.test.ts index 5569f4032d..bf8a30ae92 100644 --- a/packages/perseus-score/src/widgets/sorter/score-sorter.test.ts +++ b/packages/perseus-score/src/widgets/sorter/score-sorter.test.ts @@ -2,40 +2,40 @@ import scoreSorter from "./score-sorter"; import * as SorterValidator from "./validate-sorter"; import type { + PerseusSorterScoringData, PerseusSorterUserInput, - PerseusSorterRubric, } 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-score/src/widgets/sorter/score-sorter.ts b/packages/perseus-score/src/widgets/sorter/score-sorter.ts index ce7570b8f9..7fd6de8f15 100644 --- a/packages/perseus-score/src/widgets/sorter/score-sorter.ts +++ b/packages/perseus-score/src/widgets/sorter/score-sorter.ts @@ -4,21 +4,24 @@ import _ from "underscore"; import validateSorter from "./validate-sorter"; import type { + PerseusSorterScoringData, PerseusSorterUserInput, - PerseusSorterRubric, PerseusScore, } from "../../validation.types"; function scoreSorter( userInput: PerseusSorterUserInput, - rubric: PerseusSorterRubric, + scoringData: PerseusSorterScoringData, ): PerseusScore { const validationError = validateSorter(userInput); if (validationError) { return validationError; } - const correct = approximateDeepEqual(userInput.options, rubric.correct); + const correct = approximateDeepEqual( + userInput.options, + scoringData.correct, + ); return { type: "points", earned: correct ? 1 : 0, diff --git a/packages/perseus-score/src/widgets/table/score-table.test.ts b/packages/perseus-score/src/widgets/table/score-table.test.ts index abfae150e6..996571823e 100644 --- a/packages/perseus-score/src/widgets/table/score-table.test.ts +++ b/packages/perseus-score/src/widgets/table/score-table.test.ts @@ -2,7 +2,7 @@ import scoreTable from "./score-table"; import * as TableValidator from "./validate-table"; import type { - PerseusTableRubric, + PerseusTableScoringData, PerseusTableUserInput, } from "../../validation.types"; @@ -18,7 +18,7 @@ describe("scoreTable", () => { ["3", "4"], ]; - const rubric: PerseusTableRubric = { + const scoringData: PerseusTableScoringData = { answers: [ ["1", "2"], ["3", "4"], @@ -26,7 +26,7 @@ describe("scoreTable", () => { }; // Act - const score = scoreTable(userInput, rubric); + const score = scoreTable(userInput, scoringData); // Assert expect(mockValidator).toHaveBeenCalledWith(userInput); @@ -44,7 +44,7 @@ describe("scoreTable", () => { ["3", "4"], ]; - const rubric: PerseusTableRubric = { + const scoringData: PerseusTableScoringData = { answers: [ ["1", "2"], ["3", "4"], @@ -52,7 +52,7 @@ describe("scoreTable", () => { }; // Act - const score = scoreTable(userInput, rubric); + const score = scoreTable(userInput, scoringData); // Assert expect(mockValidator).toHaveBeenCalledWith(userInput); @@ -66,7 +66,7 @@ describe("scoreTable", () => { ["3", "4"], ]; - const rubric: PerseusTableRubric = { + const scoringData: PerseusTableScoringData = { answers: [ ["1", "2"], ["3", "4"], @@ -74,7 +74,7 @@ describe("scoreTable", () => { }; // Act - const result = scoreTable(userInput, rubric); + const result = scoreTable(userInput, scoringData); // Assert expect(result).toHaveInvalidInput(); @@ -88,7 +88,7 @@ describe("scoreTable", () => { ["5", "6"], ]; - const rubric: PerseusTableRubric = { + const scoringData: PerseusTableScoringData = { answers: [ ["1", "2"], ["3", "4"], @@ -96,7 +96,7 @@ describe("scoreTable", () => { }; // Act - const result = scoreTable(userInput, rubric); + const result = scoreTable(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); @@ -109,7 +109,7 @@ describe("scoreTable", () => { ["3", "5"], ]; - const rubric: PerseusTableRubric = { + const scoringData: PerseusTableScoringData = { answers: [ ["1", "2"], ["3", "4"], @@ -117,7 +117,7 @@ describe("scoreTable", () => { }; // Act - const result = scoreTable(userInput, rubric); + const result = scoreTable(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); @@ -130,7 +130,7 @@ describe("scoreTable", () => { ["3", "4"], ]; - const rubric: PerseusTableRubric = { + const scoringData: PerseusTableScoringData = { answers: [ ["1", "2"], ["3", "4"], @@ -138,7 +138,7 @@ describe("scoreTable", () => { }; // Act - const result = scoreTable(userInput, rubric); + const result = scoreTable(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredCorrectly(); @@ -151,7 +151,7 @@ describe("scoreTable", () => { ["3.0", "4.0"], ]; - const rubric: PerseusTableRubric = { + const scoringData: PerseusTableScoringData = { answers: [ ["1", "2"], ["3", "4"], @@ -159,7 +159,7 @@ describe("scoreTable", () => { }; // Act - const result = scoreTable(userInput, rubric); + const result = scoreTable(userInput, scoringData); // Assert expect(result).toHaveBeenAnsweredCorrectly(); diff --git a/packages/perseus-score/src/widgets/table/score-table.ts b/packages/perseus-score/src/widgets/table/score-table.ts index 778eb5a0cd..8a3a582109 100644 --- a/packages/perseus-score/src/widgets/table/score-table.ts +++ b/packages/perseus-score/src/widgets/table/score-table.ts @@ -4,14 +4,14 @@ import {filterNonEmpty} from "./utils"; import validateTable from "./validate-table"; import type { + PerseusTableScoringData, PerseusScore, - PerseusTableRubric, PerseusTableUserInput, } from "../../validation.types"; function scoreTable( userInput: PerseusTableUserInput, - rubric: PerseusTableRubric, + scoringData: PerseusTableScoringData, ): PerseusScore { const validationResult = validateTable(userInput); if (validationResult != null) { @@ -19,7 +19,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/__testdata__/renderer.testdata.ts b/packages/perseus/src/__testdata__/renderer.testdata.ts index da2d500602..aae892f155 100644 --- a/packages/perseus/src/__testdata__/renderer.testdata.ts +++ b/packages/perseus/src/__testdata__/renderer.testdata.ts @@ -1,11 +1,29 @@ +import type {MockWidget} from "../widgets/mock-widgets/mock-widget-types"; import type {RenderProps} from "../widgets/radio"; import type { DropdownWidget, + ExpressionWidget, ImageWidget, - MockWidget, PerseusRenderer, } from "@khanacademy/perseus-core"; +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", @@ -50,16 +68,16 @@ export const imageWidget: ImageWidget = { }; export const mockWidget: MockWidget = { - version: { - major: 0, - minor: 0, - }, type: "mock-widget", graded: true, alignment: "default", options: { value: "0.3333333333333333", }, + version: { + major: 0, + minor: 0, + }, }; export const question1: PerseusRenderer = { 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 ef9c44f961..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 "@khanacademy/perseus-score"; +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/__tests__/renderer.test.tsx b/packages/perseus/src/__tests__/renderer.test.tsx index 62ed3c2d92..aaa5603377 100644 --- a/packages/perseus/src/__tests__/renderer.test.tsx +++ b/packages/perseus/src/__tests__/renderer.test.tsx @@ -1422,7 +1422,7 @@ describe("renderer", () => { ); }); - it("should return user input", async () => { + it("[DEPRECATED] should return user input array", async () => { // Arrange const {renderer} = renderQuestion({ ...question2, @@ -1597,6 +1597,7 @@ describe("renderer", () => { }, }); await userEvent.type(screen.getAllByRole("textbox")[0], "150"); + act(() => jest.runOnlyPendingTimers()); // Act const emptyWidgets = renderer.emptyWidgets(); @@ -1645,7 +1646,7 @@ describe("renderer", () => { JSON.stringify(simpleGroupQuestion), ); simpleGroupQuestionCopy.widgets["group 1"].options.widgets[ - "numeric-input 1" + "expression 1" ].static = true; const {renderer} = renderQuestion(simpleGroupQuestionCopy); @@ -1660,6 +1661,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.test.ts b/packages/perseus/src/renderer-util.test.ts index 7153127126..56a73059af 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 { DropdownWidget, @@ -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 09b1a7a56c..f0aba3c102 100644 --- a/packages/perseus/src/renderer-util.ts +++ b/packages/perseus/src/renderer-util.ts @@ -1,7 +1,11 @@ import {getWidgetIdsFromContent, mapObject} from "@khanacademy/perseus-core"; import {scoreIsEmpty, flattenScores} from "./util/scoring"; -import {getWidgetScorer, upgradeWidgetInfoToLatestVersion} from "./widgets"; +import { + getWidgetScorer, + getWidgetValidator, + upgradeWidgetInfoToLatestVersion, +} from "./widgets"; import type {PerseusStrings} from "./strings"; import type { @@ -9,15 +13,14 @@ import type { PerseusWidgetsMap, } from "@khanacademy/perseus-core"; import type { - PerseusScore, - UserInput, UserInputMap, + PerseusScore, + ValidationDataMap, } from "@khanacademy/perseus-score"; export function getUpgradedWidgetOptions( oldWidgetOptions: PerseusWidgetsMap, ): PerseusWidgetsMap { - // @ts-expect-error - TS2322 - Type '(props: Props) => Partial>' is not assignable to type '(props: Props) => { [key: string]: PerseusWidget; }'. return mapObject(oldWidgetOptions, (widgetInfo, widgetId) => { if (!widgetInfo.type || !widgetInfo.alignment) { const newValues: Record = {}; @@ -35,35 +38,36 @@ export function getUpgradedWidgetOptions( widgetInfo = {...widgetInfo, ...newValues}; } - return upgradeWidgetInfoToLatestVersion(widgetInfo); + // TODO(LEMS-2656): remove TS suppression + return upgradeWidgetInfoToLatestVersion(widgetInfo) as any; }); } +/** + * 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, + widgetIds: ReadonlyArray, userInputMap: UserInputMap, 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); @@ -122,13 +126,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; } diff --git a/packages/perseus/src/renderer.tsx b/packages/perseus/src/renderer.tsx index 8199bd2f95..545917b1a6 100644 --- a/packages/perseus/src/renderer.tsx +++ b/packages/perseus/src/renderer.tsx @@ -558,7 +558,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 f2aa1b5776..6d1b332a59 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -17,10 +17,12 @@ import type { import type {LinterContextProps} from "@khanacademy/perseus-linter"; import type { PerseusScore, - Rubric, + ScoringData, UserInput, UserInputArray, UserInputMap, + ValidationData, + ValidationResult, } from "@khanacademy/perseus-score"; import type {Result} from "@khanacademy/wonder-blocks-data"; import type * as React from "react"; @@ -197,7 +199,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; @@ -514,11 +516,18 @@ export type WidgetTransform = ( problemNumber?: number, ) => any; +export type WidgetValidatorFunction = ( + userInput: UserInput, + validationData: ValidationData, + strings: PerseusStrings, + locale: string, +) => ValidationResult; + 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 @@ -574,6 +583,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. */ @@ -585,8 +602,8 @@ export type WidgetExports< */ getPublicWidgetOptions?: PublicWidgetOptionsFunction; - 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 829bbf3032..c496d18748 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/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 f9236e652e..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 "@khanacademy/perseus-score"; +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 4773ff7ef6..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 mockWidget from "../../widgets/mock-widgets/mock-widget"; -import type {PerseusMockWidgetUserInput} from "@khanacademy/perseus-score"; +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.ts b/packages/perseus/src/widgets.ts index 8675d955c3..4308b0f501 100644 --- a/packages/perseus/src/widgets.ts +++ b/packages/perseus/src/widgets.ts @@ -10,6 +10,7 @@ import type { WidgetExports, WidgetTransform, WidgetScorerFunction, + WidgetValidatorFunction, PublicWidgetOptionsFunction, } from "./types"; import type {PerseusWidget, Version} from "@khanacademy/perseus-core"; @@ -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 c9b4d9ab96..d982b294df 100644 --- a/packages/perseus/src/widgets/categorizer/categorizer.tsx +++ b/packages/perseus/src/widgets/categorizer/categorizer.tsx @@ -1,6 +1,9 @@ /* eslint-disable @khanacademy/ts-no-error-suppressions */ import {linterContextDefault} from "@khanacademy/perseus-linter"; -import {scoreCategorizer} from "@khanacademy/perseus-score"; +import { + scoreCategorizer, + validateCategorizer, +} from "@khanacademy/perseus-score"; import {StyleSheet, css} from "aphrodite"; import classNames from "classnames"; import * as React from "react"; @@ -329,5 +332,8 @@ 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, getPublicWidgetOptions: getCategorizerPublicWidgetOptions, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/cs-program/cs-program.tsx b/packages/perseus/src/widgets/cs-program/cs-program.tsx index 41dda4b33c..e652ed6e5f 100644 --- a/packages/perseus/src/widgets/cs-program/cs-program.tsx +++ b/packages/perseus/src/widgets/cs-program/cs-program.tsx @@ -2,11 +2,7 @@ * This widget is for embedding Khan Academy CS programs. */ -import { - scoreCSProgram, - type PerseusCSProgramRubric, - type PerseusCSProgramUserInput, -} from "@khanacademy/perseus-score"; +import {scoreCSProgram} from "@khanacademy/perseus-score"; import {StyleSheet, css} from "aphrodite"; import $ from "jquery"; import * as React from "react"; @@ -23,12 +19,13 @@ import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/cs-program/ import type {Widget, WidgetExports, WidgetProps} from "../../types"; import type {UnsupportedWidgetPromptJSON} from "../../widget-ai-utils/unsupported-widget"; import type {PerseusCSProgramWidgetOptions} from "@khanacademy/perseus-core"; +import type {PerseusCSProgramUserInput} from "@khanacademy/perseus-score"; const {updateQueryString} = Util; type RenderProps = PerseusCSProgramWidgetOptions & PerseusCSProgramUserInput; -type Props = WidgetProps; +type Props = WidgetProps; type DefaultProps = { showEditor: Props["showEditor"]; diff --git a/packages/perseus/src/widgets/dropdown/dropdown.tsx b/packages/perseus/src/widgets/dropdown/dropdown.tsx index 7a4356a5db..5e0204111a 100644 --- a/packages/perseus/src/widgets/dropdown/dropdown.tsx +++ b/packages/perseus/src/widgets/dropdown/dropdown.tsx @@ -1,4 +1,4 @@ -import {scoreDropdown} from "@khanacademy/perseus-score"; +import {scoreDropdown, validateDropdown} from "@khanacademy/perseus-score"; import {Id, View} from "@khanacademy/wonder-blocks-core"; import {SingleSelect, OptionItem} from "@khanacademy/wonder-blocks-dropdown"; import {LabelLarge} from "@khanacademy/wonder-blocks-typography"; @@ -14,11 +14,11 @@ import type {Widget, WidgetExports, WidgetProps} from "../../types"; import type {DropdownPromptJSON} from "../../widget-ai-utils/dropdown/dropdown-ai-utils"; import type {PerseusDropdownWidgetOptions} from "@khanacademy/perseus-core"; import type { - PerseusDropdownRubric, + PerseusDropdownScoringData, PerseusDropdownUserInput, } from "@khanacademy/perseus-score"; -type Props = WidgetProps & { +type Props = WidgetProps & { selected: number; }; @@ -173,4 +173,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.test.tsx b/packages/perseus/src/widgets/expression/expression.test.tsx index ed140e8bde..b378520767 100644 --- a/packages/perseus/src/widgets/expression/expression.test.tsx +++ b/packages/perseus/src/widgets/expression/expression.test.tsx @@ -269,16 +269,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: [], @@ -287,7 +287,9 @@ describe("Expression Widget", function () { // Act const result = - ExpressionWidgetExport.getOneCorrectAnswerFromRubric?.(rubric); + ExpressionWidgetExport.getOneCorrectAnswerFromScoringData?.( + scoringData, + ); // Assert expect(result).toBeUndefined(); @@ -295,7 +297,7 @@ describe("Expression Widget", function () { it("returns a correct answer when there is one correct answer", () => { // Arrange - const rubric = { + const scoringData = { answerForms: [ { value: "123", @@ -311,7 +313,9 @@ describe("Expression Widget", function () { // Act const result = - ExpressionWidgetExport.getOneCorrectAnswerFromRubric?.(rubric); + ExpressionWidgetExport.getOneCorrectAnswerFromScoringData?.( + scoringData, + ); // Assert expect(result).toEqual("123"); @@ -319,7 +323,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", @@ -341,7 +345,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 67128cd5c7..c6a7719e73 100644 --- a/packages/perseus/src/widgets/expression/expression.tsx +++ b/packages/perseus/src/widgets/expression/expression.tsx @@ -6,11 +6,7 @@ import { type PerseusExpressionWidgetOptions, } from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; -import { - scoreExpression, - type PerseusExpressionRubric, - type PerseusExpressionUserInput, -} from "@khanacademy/perseus-score"; +import {scoreExpression, validateExpression} from "@khanacademy/perseus-score"; import {View} from "@khanacademy/wonder-blocks-core"; import Tooltip from "@khanacademy/wonder-blocks-tooltip"; import {LabelSmall} from "@khanacademy/wonder-blocks-typography"; @@ -29,9 +25,13 @@ import a11y from "../../util/a11y"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/expression/expression-ai-utils"; import type {DependenciesContext} from "../../dependencies"; -import type {FocusPath, Widget, WidgetExports, WidgetProps} from "../../types"; +import type {WidgetProps, Widget, FocusPath, WidgetExports} from "../../types"; import type {ExpressionPromptJSON} from "../../widget-ai-utils/expression/expression-ai-utils"; import type {Keys as Key, KeypadConfiguration} from "@khanacademy/math-input"; +import type { + PerseusExpressionScoringData, + PerseusExpressionUserInput, +} from "@khanacademy/perseus-score"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; type InputPath = ReadonlyArray; @@ -71,7 +71,7 @@ type RenderProps = { keypadConfiguration: ReturnType; }; -type ExternalProps = WidgetProps; +type ExternalProps = WidgetProps; export type Props = ExternalProps & Partial> & { @@ -539,13 +539,16 @@ 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'. - 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/graded-group-set/graded-group-set.tsx b/packages/perseus/src/widgets/graded-group-set/graded-group-set.tsx index e2c259a286..f48dd09295 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 @@ -26,7 +26,7 @@ import type { PerseusGradedGroupSetWidgetOptions, PerseusGradedGroupWidgetOptions, } from "@khanacademy/perseus-core"; -import type {PerseusGradedGroupSetRubric} from "@khanacademy/perseus-score"; +import type {PerseusGradedGroupSetScoringData} from "@khanacademy/perseus-score"; type IndicatorsProps = { currentGroup: number; @@ -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 c06c09c3df..4d5f87667d 100644 --- a/packages/perseus/src/widgets/graded-group/graded-group.tsx +++ b/packages/perseus/src/widgets/graded-group/graded-group.tsx @@ -37,7 +37,7 @@ import type { import type {GradedGroupPromptJSON} from "../../widget-ai-utils/graded-group/graded-group-ai-utils"; import type {PerseusGradedGroupWidgetOptions} from "@khanacademy/perseus-core"; import type { - PerseusGradedGroupRubric, + PerseusGradedGroupScoringData, PerseusScore, } from "@khanacademy/perseus-score"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; @@ -73,7 +73,7 @@ type RenderProps = PerseusGradedGroupWidgetOptions; // exports has no 'transform type Props = WidgetProps< RenderProps, - PerseusGradedGroupRubric, + PerseusGradedGroupScoringData, TrackingGradedGroupExtraArguments > & { inGradedGroupSet?: boolean; // Set by graded-group-set.jsx, @@ -105,7 +105,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 f7e7f3e0f8..aa1432b859 100644 --- a/packages/perseus/src/widgets/grapher/grapher.tsx +++ b/packages/perseus/src/widgets/grapher/grapher.tsx @@ -4,11 +4,7 @@ import { point as kpoint, } from "@khanacademy/kmath"; import {GrapherUtil} from "@khanacademy/perseus-core"; -import { - scoreGrapher, - type PerseusGrapherRubric, - type PerseusGrapherUserInput, -} from "@khanacademy/perseus-score"; +import {scoreGrapher} from "@khanacademy/perseus-score"; import * as React from "react"; import _ from "underscore"; @@ -45,6 +41,10 @@ import type { MarkingsType, PerseusGrapherWidgetOptions, } from "@khanacademy/perseus-core"; +import type { + PerseusGrapherScoringData, + PerseusGrapherUserInput, +} from "@khanacademy/perseus-score"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; // @ts-expect-error - TS2339 - Property 'MovablePoint' does not exist on type 'typeof Graphie'. @@ -354,7 +354,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/group/group.testdata.ts b/packages/perseus/src/widgets/group/group.testdata.ts index 60b2c4e09c..6d78d688f0 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 30e411ab08..90a8400093 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 { APIOptions, @@ -21,12 +22,13 @@ import type { import type {GroupPromptJSON} from "../../widget-ai-utils/group/group-ai-utils"; import type {PerseusGroupWidgetOptions} from "@khanacademy/perseus-core"; import type { - PerseusGroupRubric, + PerseusGroupScoringData, UserInputArray, + UserInputMap, } from "@khanacademy/perseus-score"; type RenderProps = PerseusGroupWidgetOptions; // exports has no 'transform' -type Props = WidgetProps; +type Props = WidgetProps; type DefaultProps = { content: Props["content"]; widgets: Props["widgets"]; @@ -58,7 +60,7 @@ class Group extends React.Component implements Widget { return Changeable.change.apply(this, args); }; - getUserInputMap() { + getUserInputMap(): UserInputMap | undefined { return this.rendererRef?.getUserInputMap(); } @@ -208,8 +210,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/score-group.ts b/packages/perseus/src/widgets/group/score-group.ts index afe02b1aa4..dd594d748e 100644 --- a/packages/perseus/src/widgets/group/score-group.ts +++ b/packages/perseus/src/widgets/group/score-group.ts @@ -3,22 +3,22 @@ import {flattenScores} from "../../util/scoring"; import type {PerseusStrings} from "../../strings"; import type { - PerseusScore, - PerseusGroupRubric, + PerseusGroupScoringData, PerseusGroupUserInput, + PerseusScore, } from "@khanacademy/perseus-score"; // The `group` widget is basically a widget hosting a full Perseus system in // it. As such, scoring a group means scoring all widgets it contains. function scoreGroup( userInput: PerseusGroupUserInput, - options: PerseusGroupRubric, + 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/group/validate-group.ts b/packages/perseus/src/widgets/group/validate-group.ts new file mode 100644 index 0000000000..7562950d49 --- /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 { + PerseusGroupUserInput, + PerseusGroupValidationData, + ValidationResult, +} from "@khanacademy/perseus-score"; + +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/iframe/iframe.tsx b/packages/perseus/src/widgets/iframe/iframe.tsx index 3104c2cefe..e28d557328 100644 --- a/packages/perseus/src/widgets/iframe/iframe.tsx +++ b/packages/perseus/src/widgets/iframe/iframe.tsx @@ -7,12 +7,7 @@ * but could also be used for embedding viz's hosted elsewhere. */ -import { - scoreIframe, - type PerseusIFrameRubric, - type PerseusIFrameUserInput, - type UserInputStatus, -} from "@khanacademy/perseus-score"; +import {scoreIframe} from "@khanacademy/perseus-score"; import $ from "jquery"; import * as React from "react"; import _ from "underscore"; @@ -25,6 +20,10 @@ import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/iframe/ifra import type {WidgetExports, WidgetProps, Widget} from "../../types"; import type {UnsupportedWidgetPromptJSON} from "../../widget-ai-utils/unsupported-widget"; import type {PerseusIFrameWidgetOptions} from "@khanacademy/perseus-core"; +import type { + PerseusIFrameUserInput, + UserInputStatus, +} from "@khanacademy/perseus-score"; const {updateQueryString} = Util; @@ -35,7 +34,7 @@ type RenderProps = PerseusIFrameWidgetOptions & { height: string; }; -type Props = WidgetProps; +type Props = WidgetProps; type DefaultProps = { status: Props["status"]; 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 5dc4b9500e..ba54ae0fd2 100644 --- a/packages/perseus/src/widgets/input-number/input-number.test.ts +++ b/packages/perseus/src/widgets/input-number/input-number.test.ts @@ -252,49 +252,52 @@ describe("input-number", 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 bcb0af99fd..70482d484a 100644 --- a/packages/perseus/src/widgets/input-number/input-number.tsx +++ b/packages/perseus/src/widgets/input-number/input-number.tsx @@ -2,8 +2,6 @@ import {linterContextDefault} from "@khanacademy/perseus-linter"; import { inputNumberAnswerTypes, scoreInputNumber, - type PerseusInputNumberRubric, - type PerseusInputNumberUserInput, } from "@khanacademy/perseus-score"; import {spacing} from "@khanacademy/wonder-blocks-tokens"; import {StyleSheet} from "aphrodite"; @@ -20,6 +18,10 @@ import type {PerseusStrings} from "../../strings"; import type {Path, Widget, WidgetExports, WidgetProps} from "../../types"; import type {InputNumberPromptJSON} from "../../widget-ai-utils/input-number/input-number-ai-utils"; import type {PerseusInputNumberWidgetOptions} from "@khanacademy/perseus-core"; +import type { + PerseusInputNumberScoringData, + PerseusInputNumberUserInput, +} from "@khanacademy/perseus-score"; const formExamples = { integer: function (options, strings: PerseusStrings) { @@ -58,7 +60,7 @@ type RenderProps = { rightAlign: PerseusInputNumberWidgetOptions["rightAlign"]; }; -type ExternalProps = WidgetProps; +type ExternalProps = WidgetProps; type Props = ExternalProps & { apiOptions: NonNullable; linterContext: NonNullable; @@ -67,7 +69,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 +290,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/interactive-graph.tsx b/packages/perseus/src/widgets/interactive-graph.tsx index c3b3433ff1..04d2d6bff1 100644 --- a/packages/perseus/src/widgets/interactive-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graph.tsx @@ -10,11 +10,7 @@ import { Errors, PerseusError, } from "@khanacademy/perseus-core"; -import { - scoreInteractiveGraph, - type PerseusInteractiveGraphRubric, - type PerseusInteractiveGraphUserInput, -} from "@khanacademy/perseus-score"; +import {scoreInteractiveGraph} from "@khanacademy/perseus-score"; import $ from "jquery"; import debounce from "lodash.debounce"; import * as React from "react"; @@ -56,6 +52,10 @@ import type { PerseusImageBackground, MarkingsType, } from "@khanacademy/perseus-core"; +import type { + PerseusInteractiveGraphScoringData, + PerseusInteractiveGraphUserInput, +} from "@khanacademy/perseus-score"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; const {getClockwiseAngle} = angles; @@ -230,7 +230,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; @@ -251,7 +251,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/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 e2799b696d..fc606511cd 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 527a08c317..fe258517ec 100644 --- a/packages/perseus/src/widgets/label-image/label-image.tsx +++ b/packages/perseus/src/widgets/label-image/label-image.tsx @@ -9,8 +9,6 @@ import { scoreLabelImageMarker, scoreLabelImage, - type PerseusLabelImageRubric, - type PerseusLabelImageUserInput, } from "@khanacademy/perseus-score"; import Clickable from "@khanacademy/wonder-blocks-clickable"; import {View} from "@khanacademy/wonder-blocks-core"; @@ -40,6 +38,7 @@ import type { InteractiveMarkerType, PerseusLabelImageWidgetOptions, } from "@khanacademy/perseus-core"; +import type {PerseusLabelImageUserInput} from "@khanacademy/perseus-score"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; import type {CSSProperties} from "aphrodite"; @@ -75,8 +74,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. @@ -197,7 +194,7 @@ export class LabelImage */ static navigateToMarkerIndex( navigateDirection: Direction, - markers: ReadonlyArray, + markers: LabelImageProps["markers"], thisIndex: number, ): number { const thisMarker = markers[thisIndex]; @@ -313,21 +310,30 @@ 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 = scoreLabelImageMarker(marker); + const score = scoreLabelImageMarker( + marker.selected, + marker.answers, + ); return { ...marker, @@ -345,7 +351,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. @@ -434,7 +443,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; @@ -479,7 +488,10 @@ export class LabelImage }[markerPosition]; } - const score = scoreLabelImageMarker(marker); + const score = scoreLabelImageMarker( + 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/matcher/matcher.tsx b/packages/perseus/src/widgets/matcher/matcher.tsx index 740ca9c7ff..be02c2760f 100644 --- a/packages/perseus/src/widgets/matcher/matcher.tsx +++ b/packages/perseus/src/widgets/matcher/matcher.tsx @@ -1,9 +1,5 @@ import {linterContextDefault} from "@khanacademy/perseus-linter"; -import { - scoreMatcher, - type PerseusMatcherRubric, - type PerseusMatcherUserInput, -} from "@khanacademy/perseus-score"; +import {scoreMatcher} from "@khanacademy/perseus-score"; import {CircularSpinner} from "@khanacademy/wonder-blocks-progress-spinner"; import {StyleSheet, css} from "aphrodite"; import * as React from "react"; @@ -20,13 +16,17 @@ import type {SortableOption} from "../../components/sortable"; import type {WidgetExports, WidgetProps, Widget} from "../../types"; import type {MatcherPromptJSON} from "../../widget-ai-utils/matcher/matcher-ai-utils"; import type {PerseusMatcherWidgetOptions} from "@khanacademy/perseus-core"; +import type { + PerseusMatcherScoringData, + PerseusMatcherUserInput, +} from "@khanacademy/perseus-score"; const {shuffle, seededRNG} = Util; 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/matrix/matrix.tsx b/packages/perseus/src/widgets/matrix/matrix.tsx index 6a55a0ee88..7361a49716 100644 --- a/packages/perseus/src/widgets/matrix/matrix.tsx +++ b/packages/perseus/src/widgets/matrix/matrix.tsx @@ -4,11 +4,7 @@ import { type PerseusMatrixWidgetOptions, } from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; -import { - scoreMatrix, - type PerseusMatrixRubric, - type PerseusMatrixUserInput, -} from "@khanacademy/perseus-score"; +import {scoreMatrix, validateMatrix} from "@khanacademy/perseus-score"; import {StyleSheet} from "aphrodite"; import classNames from "classnames"; import * as React from "react"; @@ -25,8 +21,12 @@ import Renderer from "../../renderer"; import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/matrix/matrix-ai-utils"; -import type {WidgetExports, WidgetProps, Widget, FocusPath} from "../../types"; +import type {FocusPath, Widget, WidgetExports, WidgetProps} from "../../types"; import type {MatrixPromptJSON} from "../../widget-ai-utils/matrix/matrix-ai-utils"; +import type { + PerseusMatrixScoringData, + PerseusMatrixUserInput, +} from "@khanacademy/perseus-score"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; const {assert} = InteractiveUtil; @@ -94,7 +94,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 @@ -105,7 +105,7 @@ type ExternalProps = WidgetProps< // defaultProps. 0 as any as WidgetProps< PerseusMatrixWidgetOptions, - PerseusMatrixRubric + PerseusMatrixScoringData > satisfies PropsFor; type Props = ExternalProps & { @@ -576,4 +576,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/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 ca2606ceed..327b93ab23 100644 --- a/packages/perseus/src/widgets/mock-widgets/mock-widget.tsx +++ b/packages/perseus/src/widgets/mock-widgets/mock-widget.tsx @@ -6,14 +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 {WidgetExports, WidgetProps, Widget, FocusPath} from "../../types"; -import type {MockWidgetPromptJSON} from "../../widget-ai-utils/mock-widget/prompt-utils"; -import type {MockWidgetOptions} from "@khanacademy/perseus-core"; import type { + MockWidgetOptions, PerseusMockWidgetRubric, PerseusMockWidgetUserInput, -} from "@khanacademy/perseus-score"; +} from "./mock-widget-types"; +import type {WidgetProps, Widget, FocusPath, WidgetExports} from "../../types"; +import type {MockWidgetPromptJSON} from "../../widget-ai-utils/mock-widget/prompt-utils"; type ExternalProps = WidgetProps; @@ -37,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: "", }; @@ -89,7 +90,7 @@ export class MockWidget extends React.Component implements Widget { }; getUserInput(): PerseusMockWidgetUserInput { - return MockWidget.getUserInputFromProps(this.props); + return MockWidgetComponent.getUserInputFromProps(this.props); } handleChange: ( @@ -127,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 defb522db6..e735878a51 100644 --- a/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts +++ b/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts @@ -1,36 +1,27 @@ -import {KhanAnswerTypes} from "@khanacademy/perseus-score"; +import validateMockWidget from "./validate-mock-widget"; -import type {PerseusStrings} from "../../strings"; import type { PerseusMockWidgetUserInput, PerseusMockWidgetRubric, - PerseusScore, -} from "@khanacademy/perseus-score"; +} from "./mock-widget-types"; +import type {PerseusStrings} from "../../strings"; +import type {PerseusScore} from "@khanacademy/perseus-score"; function scoreMockWidget( userInput: PerseusMockWidgetUserInput, 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..bf588946af --- /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 "@khanacademy/perseus-score"; + +function validateMockWidget( + userInput: PerseusMockWidgetUserInput, +): ValidationResult { + if (userInput.currentValue == null || userInput.currentValue === "") { + return { + type: "invalid", + message: "", + }; + } + + return null; +} + +export default validateMockWidget; diff --git a/packages/perseus/src/widgets/number-line/number-line.tsx b/packages/perseus/src/widgets/number-line/number-line.tsx index bb7ef6807d..97924ff29d 100644 --- a/packages/perseus/src/widgets/number-line/number-line.tsx +++ b/packages/perseus/src/widgets/number-line/number-line.tsx @@ -1,8 +1,5 @@ import {number as knumber, KhanMath} from "@khanacademy/kmath"; -import { - scoreNumberLine, - type PerseusNumberLineUserInput, -} from "@khanacademy/perseus-score"; +import {scoreNumberLine, validateNumberLine} from "@khanacademy/perseus-score"; import * as React from "react"; import ReactDOM from "react-dom"; import _ from "underscore"; @@ -21,6 +18,7 @@ import type {ChangeableProps} from "../../mixins/changeable"; import type {APIOptions, WidgetExports, FocusPath, Widget} from "../../types"; import type {NumberLinePromptJSON} from "../../widget-ai-utils/number-line/number-line-ai-utils"; import type {Relationship} from "@khanacademy/perseus-core"; +import type {PerseusNumberLineUserInput} from "@khanacademy/perseus-score"; // @ts-expect-error - TS2339 - Property 'MovablePoint' does not exist on type 'typeof Graphie'. const MovablePoint = Graphie.MovablePoint; @@ -807,4 +805,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/numeric-input/numeric-input.test.ts b/packages/perseus/src/widgets/numeric-input/numeric-input.test.ts index 2174f313cd..8d6f4e1ccf 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 "@khanacademy/perseus-score"; +import type {PerseusNumericInputScoringData} from "@khanacademy/perseus-score"; 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 c30d110efa..e3731b9356 100644 --- a/packages/perseus/src/widgets/numeric-input/numeric-input.tsx +++ b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx @@ -1,10 +1,6 @@ import {KhanMath} from "@khanacademy/kmath"; import {linterContextDefault} from "@khanacademy/perseus-linter"; -import { - scoreNumericInput, - type PerseusNumericInputRubric, - type PerseusNumericInputUserInput, -} from "@khanacademy/perseus-score"; +import {scoreNumericInput} from "@khanacademy/perseus-score"; import {StyleSheet} from "aphrodite"; import * as React from "react"; import _ from "underscore"; @@ -22,6 +18,10 @@ import type { PerseusNumericInputWidgetOptions, PerseusNumericInputAnswerForm, } from "@khanacademy/perseus-core"; +import type { + PerseusNumericInputScoringData, + PerseusNumericInputUserInput, +} from "@khanacademy/perseus-score"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; const formExamples: { @@ -46,7 +46,7 @@ const formExamples: { type ExternalProps = WidgetProps< PerseusNumericInputWidgetOptions, - PerseusNumericInputRubric + PerseusNumericInputScoringData >; type Props = ExternalProps & { @@ -79,7 +79,7 @@ type DefaultProps = { // via defaultProps. 0 as any as WidgetProps< PerseusNumericInputWidgetOptions, - PerseusNumericInputRubric + PerseusNumericInputScoringData > satisfies PropsFor; type State = { @@ -381,11 +381,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/orderer/orderer.tsx b/packages/perseus/src/widgets/orderer/orderer.tsx index faa41334e6..aa6161b625 100644 --- a/packages/perseus/src/widgets/orderer/orderer.tsx +++ b/packages/perseus/src/widgets/orderer/orderer.tsx @@ -2,11 +2,7 @@ /* eslint-disable @babel/no-invalid-this, react/no-unsafe */ import {Errors} from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; -import { - scoreOrderer, - type PerseusOrdererRubric, - type PerseusOrdererUserInput, -} from "@khanacademy/perseus-score"; +import {scoreOrderer, validateOrderer} from "@khanacademy/perseus-score"; import $ from "jquery"; import * as React from "react"; import ReactDOM from "react-dom"; @@ -19,10 +15,14 @@ import Renderer from "../../renderer"; import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/orderer/orderer-ai-utils"; -import type {WidgetExports, WidgetProps, Widget} from "../../types"; +import type {Widget, WidgetExports, WidgetProps} from "../../types"; import type {OrdererPromptJSON} from "../../widget-ai-utils/orderer/orderer-ai-utils"; import type {PerseusOrdererWidgetOptions} from "@khanacademy/perseus-core"; import type {LinterContextProps} from "@khanacademy/perseus-linter"; +import type { + PerseusOrdererScoringData, + PerseusOrdererUserInput, +} from "@khanacademy/perseus-score"; type PlaceholderCardProps = { width: number | null | undefined; @@ -298,7 +298,7 @@ type RenderProps = PerseusOrdererWidgetOptions & { current: any; }; -type OrdererProps = WidgetProps; +type OrdererProps = WidgetProps; type OrdererDefaultProps = { current: OrdererProps["current"]; @@ -784,4 +784,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 3eb871496b..d254154e26 100644 --- a/packages/perseus/src/widgets/plotter/plotter.tsx +++ b/packages/perseus/src/widgets/plotter/plotter.tsx @@ -1,10 +1,6 @@ /* eslint-disable react/no-unsafe */ import {KhanMath} from "@khanacademy/kmath"; -import { - scorePlotter, - type PerseusPlotterScoringData, - type PerseusPlotterUserInput, -} from "@khanacademy/perseus-score"; +import {scorePlotter, validatePlotter} from "@khanacademy/perseus-score"; import $ from "jquery"; import * as React from "react"; import ReactDOM from "react-dom"; @@ -21,6 +17,10 @@ import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/plotter/plo import type {Widget, WidgetExports, WidgetProps} from "../../types"; import type {UnsupportedWidgetPromptJSON} from "../../widget-ai-utils/unsupported-widget"; import type {PerseusPlotterWidgetOptions} from "@khanacademy/perseus-core"; +import type { + PerseusPlotterScoringData, + PerseusPlotterUserInput, +} from "@khanacademy/perseus-score"; type RenderProps = PerseusPlotterWidgetOptions; @@ -1181,4 +1181,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/__tests__/radio.test.ts b/packages/perseus/src/widgets/radio/__tests__/radio.test.ts index a3410225b9..6f65e0e79a 100644 --- a/packages/perseus/src/widgets/radio/__tests__/radio.test.ts +++ b/packages/perseus/src/widgets/radio/__tests__/radio.test.ts @@ -952,7 +952,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 @@ -965,8 +965,8 @@ describe("Radio Widget", () => { const userInput = renderer.getUserInput()[0] as PerseusRadioUserInput; - const rubric = shuffledQuestion.widgets["radio 1"].options; - const widgetScore = scoreRadio(userInput, rubric); + const scoringData = shuffledQuestion.widgets["radio 1"].options; + const widgetScore = scoreRadio(userInput, scoringData); const rendererScore = scorePerseusItemTesting( shuffledQuestion, renderer.getUserInputMap(), @@ -980,7 +980,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 @@ -993,8 +993,8 @@ describe("Radio Widget", () => { const userInput = renderer.getUserInput()[0] as PerseusRadioUserInput; - const rubric = shuffledQuestion.widgets["radio 1"].options; - const widgetScore = scoreRadio(userInput, rubric); + const scoringData = shuffledQuestion.widgets["radio 1"].options; + const widgetScore = scoreRadio(userInput, scoringData); const rendererScore = scorePerseusItemTesting( shuffledQuestion, renderer.getUserInputMap(), @@ -1008,7 +1008,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 @@ -1021,8 +1021,8 @@ describe("Radio Widget", () => { const userInput = renderer.getUserInput()[0] as PerseusRadioUserInput; - const rubric = shuffledNoneQuestion.widgets["radio 1"].options; - const widgetScore = scoreRadio(userInput, rubric); + const scoringData = shuffledNoneQuestion.widgets["radio 1"].options; + const widgetScore = scoreRadio(userInput, scoringData); const rendererScore = scorePerseusItemTesting( shuffledNoneQuestion, renderer.getUserInputMap(), @@ -1036,7 +1036,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 @@ -1049,8 +1049,8 @@ describe("Radio Widget", () => { const userInput = renderer.getUserInput()[0] as PerseusRadioUserInput; - const rubric = shuffledNoneQuestion.widgets["radio 1"].options; - const widgetScore = scoreRadio(userInput, rubric); + const scoringData = shuffledNoneQuestion.widgets["radio 1"].options; + const widgetScore = scoreRadio(userInput, scoringData); 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 501b08f993..929a3f703e 100644 --- a/packages/perseus/src/widgets/radio/radio-component.tsx +++ b/packages/perseus/src/widgets/radio/radio-component.tsx @@ -1,9 +1,5 @@ import {linterContextDefault} from "@khanacademy/perseus-linter"; -import { - scoreRadio, - type PerseusRadioRubric, - type PerseusRadioUserInput, -} from "@khanacademy/perseus-score"; +import {scoreRadio} from "@khanacademy/perseus-score"; import * as React from "react"; import {PerseusI18nContext} from "../../components/i18n-context"; @@ -22,6 +18,10 @@ import type { PerseusRadioWidgetOptions, ShowSolutions, } from "@khanacademy/perseus-core"; +import type { + PerseusRadioScoringData, + PerseusRadioUserInput, +} from "@khanacademy/perseus-score"; // RenderProps is the return type for radio.jsx#transform export type RenderProps = { @@ -39,7 +39,7 @@ export type RenderProps = { values?: ReadonlyArray; }; -type Props = WidgetProps; +type Props = WidgetProps; type DefaultProps = Required< Pick< @@ -268,10 +268,10 @@ 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); + const score = scoreRadio(this.getUserInput(), scoringData); const widgetCorrect = score.type === "points" && score.total === score.earned; @@ -413,7 +413,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/radio.ts b/packages/perseus/src/widgets/radio/radio.ts index c832796a7b..5bf9bee5e5 100644 --- a/packages/perseus/src/widgets/radio/radio.ts +++ b/packages/perseus/src/widgets/radio/radio.ts @@ -1,4 +1,4 @@ -import {scoreRadio} from "@khanacademy/perseus-score"; +import {scoreRadio, validateRadio} from "@khanacademy/perseus-score"; import _ from "underscore"; import Util from "../../util"; @@ -155,4 +155,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 baacc2201b..46017d1b15 100644 --- a/packages/perseus/src/widgets/sorter/sorter.tsx +++ b/packages/perseus/src/widgets/sorter/sorter.tsx @@ -1,9 +1,5 @@ import {linterContextDefault} from "@khanacademy/perseus-linter"; -import { - scoreSorter, - type PerseusSorterRubric, - type PerseusSorterUserInput, -} from "@khanacademy/perseus-score"; +import {scoreSorter, validateSorter} from "@khanacademy/perseus-score"; import * as React from "react"; import Sortable from "../../components/sortable"; @@ -14,12 +10,16 @@ import type {SortableOption} from "../../components/sortable"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; import type {SorterPromptJSON} from "../../widget-ai-utils/sorter/sorter-ai-utils"; import type {PerseusSorterWidgetOptions} from "@khanacademy/perseus-core"; +import type { + PerseusSorterScoringData, + PerseusSorterUserInput, +} from "@khanacademy/perseus-score"; const {shuffle} = Util; type RenderProps = PerseusSorterWidgetOptions; -type Props = WidgetProps; +type Props = WidgetProps; type DefaultProps = { correct: Props["correct"]; @@ -135,4 +135,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 44c25afef3..43db5c64c5 100644 --- a/packages/perseus/src/widgets/table/table.tsx +++ b/packages/perseus/src/widgets/table/table.tsx @@ -1,9 +1,5 @@ import {linterContextDefault} from "@khanacademy/perseus-linter"; -import { - scoreTable, - type PerseusTableRubric, - type PerseusTableUserInput, -} from "@khanacademy/perseus-score"; +import {scoreTable, validateTable} from "@khanacademy/perseus-score"; import * as React from "react"; import ReactDOM from "react-dom"; import _ from "underscore"; @@ -18,6 +14,10 @@ import Util from "../../util"; import type {ChangeableProps} from "../../mixins/changeable"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; import type {PerseusTableWidgetOptions} from "@khanacademy/perseus-core"; +import type { + PerseusTableScoringData, + PerseusTableUserInput, +} from "@khanacademy/perseus-score"; const {assert} = InteractiveUtil; @@ -26,7 +26,8 @@ type RenderProps = PerseusTableWidgetOptions & { Editor: any; }; -type Props = ChangeableProps & WidgetProps; +type Props = ChangeableProps & + WidgetProps; type DefaultProps = { apiOptions: Props["apiOptions"]; @@ -326,4 +327,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;