From 8aa40283fc3b378cd9cb15708ab9e8860b58e3c0 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 10:16:31 -0600 Subject: [PATCH 01/25] [LEMS-2737/move-scoring-logic] WIP: start the move --- dev/flipbook.tsx | 3 +- dev/package.json | 1 + packages/perseus-core/src/index.ts | 6 + packages/perseus-core/src/types.ts | 9 + packages/perseus-core/src/widgets/upgrade.ts | 192 +++++ .../src/components/widget-editor.tsx | 4 +- packages/perseus-score/src/index.ts | 3 + packages/perseus-score/src/score.test.ts | 470 ++++++++++++ packages/perseus-score/src/score.ts | 184 +++++ packages/perseus-score/src/validate.test.ts | 286 +++++++ packages/perseus-score/src/validate.ts | 34 + packages/perseus-score/util/test-helpers.ts | 55 ++ packages/perseus/src/components/svg-image.tsx | 4 +- packages/perseus/src/index.ts | 2 - packages/perseus/src/renderability.ts | 10 +- packages/perseus/src/renderer-util.test.ts | 717 +----------------- packages/perseus/src/renderer-util.ts | 144 +--- packages/perseus/src/renderer.tsx | 28 +- packages/perseus/src/traversal.ts | 8 +- packages/perseus/src/types.ts | 10 +- packages/perseus/src/util/scoring.test.ts | 117 +-- packages/perseus/src/util/scoring.ts | 109 --- packages/perseus/src/util/test-utils.ts | 10 +- packages/perseus/src/widgets.ts | 159 +--- .../widgets/categorizer/categorizer.test.ts | 5 +- .../widgets/expression/expression.test.tsx | 5 +- .../perseus/src/widgets/group/group.test.tsx | 4 +- .../perseus/src/widgets/group/score-group.ts | 16 +- .../src/widgets/group/validate-group.ts | 14 +- .../passage/__tests__/passage.test.tsx | 4 +- testing/renderer-with-debug-ui.tsx | 5 +- .../server-item-renderer-with-debug-ui.tsx | 10 +- 32 files changed, 1311 insertions(+), 1317 deletions(-) create mode 100644 packages/perseus-core/src/widgets/upgrade.ts create mode 100644 packages/perseus-score/src/score.test.ts create mode 100644 packages/perseus-score/src/score.ts create mode 100644 packages/perseus-score/src/validate.test.ts create mode 100644 packages/perseus-score/src/validate.ts create mode 100644 packages/perseus-score/util/test-helpers.ts diff --git a/dev/flipbook.tsx b/dev/flipbook.tsx index 1944dffebf..fada467749 100644 --- a/dev/flipbook.tsx +++ b/dev/flipbook.tsx @@ -1,4 +1,5 @@ /* eslint monorepo/no-internal-import: "off", monorepo/no-relative-import: "off", import/no-relative-packages: "off" */ +import {scorePerseusItem} from "@khanacademy/perseus-score"; import Banner from "@khanacademy/wonder-blocks-banner"; import Button from "@khanacademy/wonder-blocks-button"; import {View} from "@khanacademy/wonder-blocks-core"; @@ -18,7 +19,6 @@ import {useEffect, useMemo, useReducer, useRef, useState} from "react"; import {Renderer} from "../packages/perseus/src"; import {SvgImage} from "../packages/perseus/src/components"; -import {scorePerseusItem} from "../packages/perseus/src/renderer-util"; import {mockStrings} from "../packages/perseus/src/strings"; import {isCorrect} from "../packages/perseus/src/util/scoring"; import {trueForAllMafsSupportedGraphTypes} from "../packages/perseus/src/widgets/interactive-graphs/mafs-supported-graph-types"; @@ -324,7 +324,6 @@ function GradableRenderer(props: QuestionRendererProps) { const score = scorePerseusItem( question, rendererRef.current.getUserInputMap(), - mockStrings, "en", ); setScore(score); diff --git a/dev/package.json b/dev/package.json index 065d083070..259ccb08ac 100644 --- a/dev/package.json +++ b/dev/package.json @@ -19,6 +19,7 @@ "@khanacademy/math-input": "^22.2.2", "@khanacademy/perseus-core": "3.3.0", "@khanacademy/perseus-linter": "^1.2.14", + "@khanacademy/perseus-score": "^2.0.0", "@khanacademy/pure-markdown": "^0.3.23", "@khanacademy/simple-markdown": "^0.13.16", "@khanacademy/wonder-blocks-banner": "4.0.5", diff --git a/packages/perseus-core/src/index.ts b/packages/perseus-core/src/index.ts index 00b19d8a91..3b74899dbf 100644 --- a/packages/perseus-core/src/index.ts +++ b/packages/perseus-core/src/index.ts @@ -6,6 +6,7 @@ export type { MarkerType, InteractiveMarkerType, Relationship, + Alignment, } from "./types"; export type {ErrorKind} from "./error/errors"; export type {FunctionTypeMappingKeys} from "./utils/grapher-util"; @@ -110,6 +111,11 @@ export type {TableDefaultWidgetOptions} from "./widgets/table"; export {default as videoLogic} from "./widgets/video"; export type {VideoDefaultWidgetOptions} from "./widgets/video"; +export { + getUpgradedWidgetOptions, + upgradeWidgetInfoToLatestVersion, +} from "./widgets/upgrade"; + export type * from "./widgets/logic-export.types"; export {default as getOrdererPublicWidgetOptions} from "./widgets/orderer/orderer-util"; diff --git a/packages/perseus-core/src/types.ts b/packages/perseus-core/src/types.ts index 0369c2b9a5..a2e2512e70 100644 --- a/packages/perseus-core/src/types.ts +++ b/packages/perseus-core/src/types.ts @@ -51,3 +51,12 @@ export type InteractiveMarkerType = MarkerType & { // Used for NumberLine // TODO: can this be merged with PerseusNumberLineWidgetOptions.correctRel? export type Relationship = "lt" | "gt" | "le" | "ge"; + +export type Alignment = + | "default" + | "block" + | "inline-block" + | "inline" + | "float-left" + | "float-right" + | "full-width"; diff --git a/packages/perseus-core/src/widgets/upgrade.ts b/packages/perseus-core/src/widgets/upgrade.ts new file mode 100644 index 0000000000..faba3c1194 --- /dev/null +++ b/packages/perseus-core/src/widgets/upgrade.ts @@ -0,0 +1,192 @@ +import _ from "underscore"; + +import {Errors} from "../error/errors"; +import {PerseusError} from "../error/perseus-error"; +import {mapObject} from "../utils/objective_"; + +import type {PerseusWidget, PerseusWidgetsMap} from "../data-schema"; +import type {Alignment} from "../types"; + +const DEFAULT_STATIC = false; + +// START: STOPSHIP / HACK +// do we really need this?? +const widgetSupportedAlignments = { + "cs-program": ["block", "full-width"], + image: ["block", "full-width"], + video: ["block", "float-left", "float-right", "full-width"], +}; + +// NOTE(kevinb): "default" is not one in `validAlignments`. +const DEFAULT_SUPPORTED_ALIGNMENTS = ["default"]; + +/** + * Handling for the optional alignments for widgets + * See widget-container.jsx for details on how alignments are implemented. + */ + +/** + * Returns the list of supported alignments for the given (string) widget + * type. This is used primarily at editing time to display the choices + * for the user. + * + * Supported alignments are given as an array of strings in the exports of + * a widget's module. + */ +export const getSupportedAlignments = ( + type: string, +): ReadonlyArray => { + const supportedAlignments = widgetSupportedAlignments[type]; + return supportedAlignments || DEFAULT_SUPPORTED_ALIGNMENTS; +}; +// END: STOPSHIP / HACK + +export const upgradeWidgetInfoToLatestVersion = ( + oldWidgetInfo: PerseusWidget, +): PerseusWidget => { + const type = oldWidgetInfo.type; + // NOTE(jeremy): This looks like it could be replaced by fixing types so + // that `type` is non-optional. But we're seeing this in Sentry today so I + // suspect we have legacy data (potentially unpublished) and we should + // figure that out before depending solely on types. + if (!_.isString(type)) { + throw new PerseusError( + "widget type must be a string, but was: " + type, + Errors.Internal, + ); + } + const widgetExports = widgets[type]; + + if (widgetExports == null) { + // If we have a widget that isn't registered, we can't upgrade it + // TODO(aria): Figure out what the best thing to do here would be + return oldWidgetInfo; + } + + // Unversioned widgets (pre-July 2014) are all implicitly 0.0 + const initialVersion = oldWidgetInfo.version || {major: 0, minor: 0}; + const latestVersion = widgetExports.version || {major: 0, minor: 0}; + + // If the widget version is later than what we understand (major + // version is higher than latest, or major versions are equal and minor + // version is higher than latest), don't perform any upgrades. + if ( + initialVersion.major > latestVersion.major || + (initialVersion.major === latestVersion.major && + initialVersion.minor > latestVersion.minor) + ) { + return oldWidgetInfo; + } + + // We do a clone here so that it's safe to mutate the input parameter + // in propUpgrades functions (which I will probably accidentally do at + // some point, and we would like to not break when that happens). + let newEditorProps = _.clone(oldWidgetInfo.options) || {}; + + const upgradePropsMap = widgetExports.propUpgrades || {}; + + // Empty props usually mean a newly created widget by the editor, + // and are always considerered up-to-date. + // Mostly, we'd rather not run upgrade functions on props that are + // not complete. + if (_.keys(newEditorProps).length !== 0) { + // We loop through all the versions after the current version of + // the loaded widget, up to and including the latest version of the + // loaded widget, and run the upgrade function to bring our loaded + // widget's props up to that version. + // There is a little subtlety here in that we call + // upgradePropsMap[1] to upgrade *to* version 1, + // (not from version 1). + for ( + let nextVersion = initialVersion.major + 1; + nextVersion <= latestVersion.major; + nextVersion++ + ) { + if (upgradePropsMap[String(nextVersion)]) { + newEditorProps = + upgradePropsMap[String(nextVersion)](newEditorProps); + } else { + // This is a Log.error because it is unlikely to be hit in + // local testing, and a Log.error is slightly less scary in + // prod than a `throw new Error` + Log.error( + "No upgrade found for widget. Cannot render.", + Errors.Internal, + { + loggedMetadata: { + type, + fromMajorVersion: nextVersion - 1, + toMajorVersion: nextVersion, + }, + }, + ); + // But try to keep going anyways (yolo!) + // (Throwing an error here would just break the page + // silently anyways, so that doesn't seem much better + // than a halfhearted attempt to continue, however + // shallow...) + } + } + } + + // Minor version upgrades (eg. new optional props) don't have + // transform functions. Instead, we fill in the new props with their + // defaults. + const defaultProps = type in editors ? editors[type].defaultProps : {}; + newEditorProps = { + ...defaultProps, + ...newEditorProps, + }; + + let alignment = oldWidgetInfo.alignment; + + // Widgets that support multiple alignments will "lock in" the + // alignment to the alignment that would be listed first in the + // select box. If the widget only supports one alignment, the + // alignment value will likely just end up as "default". + if (alignment == null || alignment === "default") { + alignment = getSupportedAlignments(type)[0]; + } + + let widgetStatic = oldWidgetInfo.static; + + if (widgetStatic == null) { + widgetStatic = DEFAULT_STATIC; + } + + return _.extend({}, oldWidgetInfo, { + // maintain other info, like type + // After upgrading we guarantee that the version is up-to-date + version: latestVersion, + // Default graded to true (so null/undefined becomes true): + graded: oldWidgetInfo.graded != null ? oldWidgetInfo.graded : true, + alignment: alignment, + static: widgetStatic, + options: newEditorProps, + }); +}; + +export function getUpgradedWidgetOptions( + oldWidgetOptions: PerseusWidgetsMap, +): PerseusWidgetsMap { + return mapObject(oldWidgetOptions, (widgetInfo, widgetId) => { + if (!widgetInfo.type || !widgetInfo.alignment) { + const newValues: Record = {}; + + if (!widgetInfo.type) { + // TODO: why does widget have no type? + // We don't want to derive type from widget ID + // see: LEMS-1845 + newValues.type = widgetId.split(" ")[0]; + } + + if (!widgetInfo.alignment) { + newValues.alignment = "default"; + } + + widgetInfo = {...widgetInfo, ...newValues}; + } + // TODO(LEMS-2656): remove TS suppression + return upgradeWidgetInfoToLatestVersion(widgetInfo) as any; + }); +} diff --git a/packages/perseus-editor/src/components/widget-editor.tsx b/packages/perseus-editor/src/components/widget-editor.tsx index 13bbf7228f..40c57b213d 100644 --- a/packages/perseus-editor/src/components/widget-editor.tsx +++ b/packages/perseus-editor/src/components/widget-editor.tsx @@ -18,8 +18,8 @@ import {iconChevronRight} from "../styles/icon-paths"; import SectionControlButton from "./section-control-button"; import type Editor from "../editor"; -import type {APIOptions, Alignment} from "@khanacademy/perseus"; -import type {PerseusWidget} from "@khanacademy/perseus-core"; +import type {APIOptions} from "@khanacademy/perseus"; +import type {Alignment, PerseusWidget} from "@khanacademy/perseus-core"; const {InlineIcon} = components; diff --git a/packages/perseus-score/src/index.ts b/packages/perseus-score/src/index.ts index 99b56aaac4..22034b8222 100644 --- a/packages/perseus-score/src/index.ts +++ b/packages/perseus-score/src/index.ts @@ -36,3 +36,6 @@ export { default as scoreInputNumber, inputNumberAnswerTypes, } from "./widgets/input-number/score-input-number"; + +export {scorePerseusItem, scoreWidgetsFunctional, flattenScores} from "./score"; +export {emptyWidgetsFunctional} from "./validate"; diff --git a/packages/perseus-score/src/score.test.ts b/packages/perseus-score/src/score.test.ts new file mode 100644 index 0000000000..596af2838f --- /dev/null +++ b/packages/perseus-score/src/score.test.ts @@ -0,0 +1,470 @@ +import { + getExpressionWidget, + getLegacyExpressionWidget, + getTestDropdownWidget, +} from "../util/test-helpers"; + +import {flattenScores, scoreWidgetsFunctional} from "./score"; + +import type {UserInputMap} from "./validation.types"; +import type {PerseusWidgetsMap} from "@khanacademy/perseus-core"; + +describe("flattenScores", () => { + it("defaults to an empty score", () => { + const result = flattenScores({}); + + expect(result).toHaveBeenAnsweredCorrectly({shouldHavePoints: false}); + expect(result).toEqual({ + type: "points", + total: 0, + earned: 0, + message: null, + }); + }); + + it("defaults to single score if there is only one", () => { + const result = flattenScores({ + "radio 1": { + type: "points", + total: 1, + earned: 1, + message: null, + }, + }); + + expect(result).toHaveBeenAnsweredCorrectly(); + expect(result).toEqual({ + type: "points", + total: 1, + earned: 1, + message: null, + }); + }); + + it("returns an invalid score if any are invalid", () => { + const result = flattenScores({ + "radio 1": { + type: "points", + total: 1, + earned: 1, + message: null, + }, + "radio 2": { + type: "invalid", + message: null, + }, + }); + + expect(result).toHaveInvalidInput(); + expect(result).toEqual({ + type: "invalid", + message: null, + }); + }); + + it("tallies scores if multiple widgets have points", () => { + const result = flattenScores({ + "radio 1": { + type: "points", + total: 1, + earned: 1, + message: null, + }, + "radio 2": { + type: "points", + total: 1, + earned: 1, + message: null, + }, + "radio 3": { + type: "points", + total: 1, + earned: 1, + message: null, + }, + }); + + expect(result).toHaveBeenAnsweredCorrectly(); + expect(result).toEqual({ + type: "points", + total: 3, + earned: 3, + message: null, + }); + }); + + it("doesn't count incorrect widgets", () => { + const result = flattenScores({ + "radio 1": { + type: "points", + total: 1, + earned: 1, + message: null, + }, + "radio 2": { + type: "points", + total: 1, + earned: 1, + message: null, + }, + "radio 3": { + type: "points", + total: 1, + earned: 0, + message: null, + }, + }); + + expect(result).toEqual({ + type: "points", + total: 3, + earned: 2, + message: null, + }); + }); +}); + +describe("scoreWidgetsFunctional", () => { + it("returns an empty object when there's no widgets", () => { + // Arrange / Act + const result = scoreWidgetsFunctional({}, [], {}, "en"); + + // Assert + expect(result).toEqual({}); + }); + + it("returns invalid if widget is unanswered", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1"]; + const userInputMap: UserInputMap = { + "dropdown 1": { + value: 0, + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result["dropdown 1"]).toHaveInvalidInput(); + }); + + it("can determine if a widget was answered correctly", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1"]; + const userInputMap: UserInputMap = { + "dropdown 1": { + value: 1, + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result["dropdown 1"]).toHaveBeenAnsweredCorrectly(); + }); + + it("can determine if a widget was answered incorrectly", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1"]; + const userInputMap: UserInputMap = { + "dropdown 1": { + value: 2, + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result["dropdown 1"]).toHaveBeenAnsweredIncorrectly(); + }); + + it("can handle multiple widgets", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + "dropdown 2": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1", "dropdown 2"]; + const userInputMap: UserInputMap = { + "dropdown 1": { + value: 1, + }, + "dropdown 2": { + value: 2, + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result["dropdown 1"]).toHaveBeenAnsweredCorrectly(); + expect(result["dropdown 2"]).toHaveBeenAnsweredIncorrectly(); + }); + + it("skips widgets not in widgetIds", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + "dropdown 2": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1"]; + const userInputMap: UserInputMap = { + "dropdown 1": { + value: 1, + }, + "dropdown 2": { + value: 2, + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result["dropdown 1"]).toHaveBeenAnsweredCorrectly(); + expect(result["dropdown 2"]).toBe(undefined); + }); + + it("returns invalid if a widget in a group is unanswered", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "group 1": { + type: "group", + options: { + content: "[[☃ dropdown 1]]", + widgets: { + "dropdown 1": getTestDropdownWidget(), + }, + images: {}, + }, + }, + }; + const widgetIds: Array = ["group 1"]; + const userInputMap: UserInputMap = { + "group 1": { + "dropdown 1": { + value: 0, + }, + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result["group 1"]).toHaveInvalidInput(); + }); + + it("can score correct widget in group", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "group 1": { + type: "group", + options: { + content: "[[☃ dropdown 1]]", + widgets: { + "dropdown 1": getTestDropdownWidget(), + }, + images: {}, + }, + }, + }; + const widgetIds: Array = ["group 1"]; + const userInputMap: UserInputMap = { + "group 1": { + "dropdown 1": { + value: 1, + }, + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result["group 1"]).toHaveBeenAnsweredCorrectly(); + }); + + it("can score incorrect widget in group", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "group 1": { + type: "group", + options: { + content: "[[☃ dropdown 1]]", + widgets: { + "dropdown 1": getTestDropdownWidget(), + }, + images: {}, + }, + }, + }; + const widgetIds: Array = ["group 1"]; + const userInputMap: UserInputMap = { + "group 1": { + "dropdown 1": { + value: 2, + }, + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result["group 1"]).toHaveBeenAnsweredIncorrectly(); + }); + + it("can handle a correct modern Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getExpressionWidget(), + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "2+2", + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result["expression 1"]).toHaveBeenAnsweredCorrectly(); + }); + + it("can handle a correct legacy Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getLegacyExpressionWidget() as any, + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "2+2", + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result["expression 1"]).toHaveBeenAnsweredCorrectly(); + }); + + it("can handle an incorrect modern Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getExpressionWidget(), + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "2+42", + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result["expression 1"]).toHaveBeenAnsweredIncorrectly(); + }); + + it("can handle an incorrect legacy Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getLegacyExpressionWidget() as any, + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "2+42", + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result["expression 1"]).toHaveBeenAnsweredIncorrectly(); + }); +}); diff --git a/packages/perseus-score/src/score.ts b/packages/perseus-score/src/score.ts new file mode 100644 index 0000000000..e7de7e5ca0 --- /dev/null +++ b/packages/perseus-score/src/score.ts @@ -0,0 +1,184 @@ +// TODO: combine scorePerseusItem with scoreWidgetsFunctional + +import { + Errors, + getUpgradedWidgetOptions, + getWidgetIdsFromContent, + PerseusError, +} from "@khanacademy/perseus-core"; + +import type {PerseusScore, UserInputMap} from "./validation.types"; +import type { + PerseusRenderer, + PerseusWidgetsMap, +} from "@khanacademy/perseus-core"; + +const noScore: PerseusScore = { + type: "points", + earned: 0, + total: 0, + message: null, +}; + +/** + * If a widget says that it is empty once it is graded. + * Trying to encapsulate references to the score format. + */ +export function scoreIsEmpty(score: PerseusScore): boolean { + // HACK(benkomalo): ugh. this isn't great; the Perseus score objects + // overload the type "invalid" for what should probably be three + // distinct cases: + // - truly empty or not fully filled out + // - invalid or malformed inputs + // - "almost correct" like inputs where the widget wants to give + // feedback (e.g. a fraction needs to be reduced, or `pi` should + // be used instead of 3.14) + // + // Unfortunately the coercion happens all over the place, as these + // Perseus style score objects are created *everywhere* (basically + // in every widget), so it's hard to change now. We assume that + // anything with a "message" is not truly empty, and one of the + // latter two cases for now. + return ( + score.type === "invalid" && + (!score.message || score.message.length === 0) + ); +} + +/** + * Combine two score objects. + * + * Given two score objects for two different widgets, combine them so that + * if one is wrong, the total score is wrong, etc. + */ +export function combineScores( + scoreA: PerseusScore, + scoreB: PerseusScore, +): PerseusScore { + let message; + + if (scoreA.type === "points" && scoreB.type === "points") { + if ( + scoreA.message && + scoreB.message && + scoreA.message !== scoreB.message + ) { + // TODO(alpert): Figure out how to combine messages usefully + message = null; + } else { + message = scoreA.message || scoreB.message; + } + + return { + type: "points", + earned: scoreA.earned + scoreB.earned, + total: scoreA.total + scoreB.total, + message: message, + }; + } + if (scoreA.type === "points" && scoreB.type === "invalid") { + return scoreB; + } + if (scoreA.type === "invalid" && scoreB.type === "points") { + return scoreA; + } + if (scoreA.type === "invalid" && scoreB.type === "invalid") { + if ( + scoreA.message && + scoreB.message && + scoreA.message !== scoreB.message + ) { + // TODO(alpert): Figure out how to combine messages usefully + message = null; + } else { + message = scoreA.message || scoreB.message; + } + + return { + type: "invalid", + message: message, + }; + } + + /** + * The above checks cover all combinations of score type, so if we get here + * then something is amiss with our inputs. + */ + throw new PerseusError( + "PerseusScore with unknown type encountered", + Errors.InvalidInput, + { + metadata: { + scoreA: JSON.stringify(scoreA), + scoreB: JSON.stringify(scoreB), + }, + }, + ); +} + +export function flattenScores(widgetScoreMap: { + [widgetId: string]: PerseusScore; +}): PerseusScore { + return Object.values(widgetScoreMap).reduce(combineScores, noScore); +} + +// once scorePerseusItem is the only one calling scoreWidgetsFunctional +export function scorePerseusItem( + perseusRenderData: PerseusRenderer, + userInputMap: UserInputMap, + locale: string, +): PerseusScore { + // There seems to be a chance that PerseusRenderer.widgets might include + // widget data for widgets that are not in PerseusRenderer.content, + // so this checks that the widgets are being used before scoring them + const usedWidgetIds = getWidgetIdsFromContent(perseusRenderData.content); + const scores = scoreWidgetsFunctional( + perseusRenderData.widgets, + usedWidgetIds, + userInputMap, + locale, + ); + return flattenScores(scores); +} + +export function scoreWidgetsFunctional( + widgets: PerseusWidgetsMap, + // This is a port of old code, I'm not sure why + // we need widgetIds vs the keys of the widgets object + widgetIds: ReadonlyArray, + userInputMap: UserInputMap, + locale: string, +): {[widgetId: string]: PerseusScore} { + const upgradedWidgets = getUpgradedWidgetOptions(widgets); + + const gradedWidgetIds = widgetIds.filter((id) => { + const props = upgradedWidgets[id]; + const widgetIsGraded: boolean = props?.graded == null || props.graded; + const widgetIsStatic = !!props?.static; + // Ungraded widgets or widgets set to static shouldn't be graded. + return widgetIsGraded && !widgetIsStatic; + }); + + const widgetScores: Record = {}; + gradedWidgetIds.forEach((id) => { + const widget = upgradedWidgets[id]; + if (!widget) { + return; + } + + const userInput = userInputMap[id]; + const validator = getWidgetValidator(widget.type); + const scorer = getWidgetScorer(widget.type); + + // 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, locale) ?? + scorer?.(userInput, widget.options, locale); + if (score != null) { + widgetScores[id] = score; + } + }); + + return widgetScores; +} diff --git a/packages/perseus-score/src/validate.test.ts b/packages/perseus-score/src/validate.test.ts new file mode 100644 index 0000000000..f18821c768 --- /dev/null +++ b/packages/perseus-score/src/validate.test.ts @@ -0,0 +1,286 @@ +import { + getExpressionWidget, + getLegacyExpressionWidget, + getTestDropdownWidget, +} from "../util/test-helpers"; + +import {emptyWidgetsFunctional} from "./validate"; + +import type {UserInputMap} from "./validation.types"; +import type {PerseusWidgetsMap} from "@khanacademy/perseus-core"; + +describe("emptyWidgetsFunctional", () => { + it("returns an empty array if there are no widgets", () => { + // Arrange / Act + const result = emptyWidgetsFunctional({}, [], {}, "en"); + + // Assert + expect(result).toEqual([]); + }); + + it("properly identifies empty widgets", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1"]; + const userInputMap: UserInputMap = { + "dropdown 1": { + value: 0, + }, + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result).toEqual(["dropdown 1"]); + }); + + it("does not return widget IDs that are not empty", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1"]; + const userInputMap: UserInputMap = { + "dropdown 1": { + value: 1, + }, + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result).toEqual([]); + }); + + it("does not check for empty widgets whose IDs aren't provided", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + }; + const widgetIds: Array = []; + const userInputMap: UserInputMap = { + "dropdown 1": { + value: 0, + }, + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result).toEqual([]); + }); + + it("can properly split empty and non-empty widgets", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + "dropdown 2": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1", "dropdown 2"]; + const userInputMap: UserInputMap = { + "dropdown 1": { + value: 0, + }, + "dropdown 2": { + value: 1, + }, + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result).toEqual(["dropdown 1"]); + }); + + it("properly identifies groups with empty widgets", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "group 1": { + type: "group", + options: { + content: "[[☃ dropdown 1]]", + widgets: { + "dropdown 1": getTestDropdownWidget(), + }, + images: {}, + }, + }, + }; + const widgetIds: Array = ["group 1"]; + const userInputMap: UserInputMap = { + "group 1": { + "dropdown 1": { + value: 0, + }, + }, + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result).toEqual(["group 1"]); + }); + + it("does not return group ID when its widgets are non-empty", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "group 1": { + type: "group", + options: { + content: "[[☃ dropdown 1]]", + widgets: { + "dropdown 1": getTestDropdownWidget(), + }, + images: {}, + }, + }, + }; + const widgetIds: Array = ["group 1"]; + const userInputMap: UserInputMap = { + "group 1": { + "dropdown 1": { + value: 1, + }, + }, + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result).toEqual([]); + }); + + it("handles an empty modern Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getExpressionWidget(), + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "", + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result).toEqual(["expression 1"]); + }); + + it("upgrades an empty legacy Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getLegacyExpressionWidget() as any, + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "", + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + + "en", + ); + + // Assert + expect(result).toEqual(["expression 1"]); + }); + + it("handles a non-empty modern Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getExpressionWidget(), + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "2+2", + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + "en", + ); + + // Assert + expect(result).toEqual([]); + }); + + it("upgrades a non-empty legacy Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getLegacyExpressionWidget() as any, + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "2+2", + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + "en", + ); + + // Assert + expect(result).toEqual([]); + }); +}); diff --git a/packages/perseus-score/src/validate.ts b/packages/perseus-score/src/validate.ts new file mode 100644 index 0000000000..d954fffec2 --- /dev/null +++ b/packages/perseus-score/src/validate.ts @@ -0,0 +1,34 @@ +import {scoreIsEmpty} from "./score"; + +import type {UserInputMap, ValidationDataMap} from "./validation.types"; + +/** + * 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: ValidationDataMap, + // This is a port of old code, I'm not sure why + // we need widgetIds vs the keys of the widgets object + widgetIds: ReadonlyArray, + userInputMap: UserInputMap, + locale: string, +): ReadonlyArray { + return widgetIds.filter((id) => { + const widget = widgets[id]; + if (!widget || widget.static === true) { + // Static widgets shouldn't count as empty + return false; + } + + const validator = getWidgetValidator(widget.type); + const userInput = userInputMap[id]; + const validationData = widget.options; + const score = validator?.(userInput, validationData, locale); + + if (score) { + return scoreIsEmpty(score); + } + }); +} diff --git a/packages/perseus-score/util/test-helpers.ts b/packages/perseus-score/util/test-helpers.ts new file mode 100644 index 0000000000..48bcf63ba4 --- /dev/null +++ b/packages/perseus-score/util/test-helpers.ts @@ -0,0 +1,55 @@ +import type {DropdownWidget, ExpressionWidget} from "@khanacademy/perseus-core"; + +export function getTestDropdownWidget(): DropdownWidget { + return { + type: "dropdown", + options: { + choices: [ + { + content: "Test choice 1", + correct: true, + }, + { + content: "Test choice 2", + correct: false, + }, + ], + placeholder: "Test placeholder", + static: false, + }, + }; +} + +export function getExpressionWidget(): ExpressionWidget { + return { + type: "expression", + version: {major: 1, minor: 0}, + options: { + times: false, + buttonSets: ["basic"], + functions: [], + answerForms: [ + { + form: false, + simplify: false, + value: "2+2", + considered: "correct", + }, + ], + }, + }; +} + +export function getLegacyExpressionWidget() { + return { + type: "expression", + options: { + times: false, + buttonSets: ["basic"], + functions: [], + form: false, + simplify: false, + value: "2+2", + }, + }; +} diff --git a/packages/perseus/src/components/svg-image.tsx b/packages/perseus/src/components/svg-image.tsx index c0195f8e38..039e7ddaec 100644 --- a/packages/perseus/src/components/svg-image.tsx +++ b/packages/perseus/src/components/svg-image.tsx @@ -18,8 +18,8 @@ import ImageLoader from "./image-loader"; import type {ImageProps} from "./image-loader"; import type {Coord} from "../interactive2/types"; -import type {Alignment, Dimensions} from "../types"; -import type {Size} from "@khanacademy/perseus-core"; +import type {Dimensions} from "../types"; +import type {Alignment, Size} from "@khanacademy/perseus-core"; // Minimum image width to make an image appear as zoomable. const ZOOMABLE_THRESHOLD = 700; diff --git a/packages/perseus/src/index.ts b/packages/perseus/src/index.ts index 864e277717..330f172bed 100644 --- a/packages/perseus/src/index.ts +++ b/packages/perseus/src/index.ts @@ -18,7 +18,6 @@ export {default as ServerItemRenderer} from "./server-item-renderer"; export {default as HintsRenderer} from "./hints-renderer"; export {default as HintRenderer} from "./hint-renderer"; export {default as Renderer} from "./renderer"; -export {scorePerseusItem} from "./renderer-util"; /** * Widgets @@ -183,7 +182,6 @@ export {default as WIDGET_PROP_DENYLIST} from "./mixins/widget-prop-denylist"; export type {ILogger, LogErrorOptions} from "./logging/log"; export type {ServerItemRenderer as ServerItemRendererComponent} from "./server-item-renderer"; export type { - Alignment, APIOptions, APIOptionsWithDefaults, ChangeHandler, diff --git a/packages/perseus/src/renderability.ts b/packages/perseus/src/renderability.ts index b115b21c45..29b27edc2a 100644 --- a/packages/perseus/src/renderability.ts +++ b/packages/perseus/src/renderability.ts @@ -8,11 +8,14 @@ * group or sequence widgets. */ -import {Errors, PerseusError} from "@khanacademy/perseus-core"; +import { + Errors, + PerseusError, + upgradeWidgetInfoToLatestVersion, +} from "@khanacademy/perseus-core"; import _ from "underscore"; import {traverse} from "./traversal"; -import * as Widgets from "./widgets"; import type {PerseusWidget} from "@khanacademy/perseus-core"; @@ -52,8 +55,7 @@ const isRawWidgetInfoRenderableBy = function ( // NOTE: This doesn't modify the widget info if the widget info // is at a later version than is supported. - const upgradedWidgetInfo = - Widgets.upgradeWidgetInfoToLatestVersion(widgetInfo); + const upgradedWidgetInfo = upgradeWidgetInfoToLatestVersion(widgetInfo); return isUpgradedWidgetInfoRenderableBy( upgradedWidgetInfo, rendererContentVersion[upgradedWidgetInfo.type], diff --git a/packages/perseus/src/renderer-util.test.ts b/packages/perseus/src/renderer-util.test.ts index 56a73059af..01b8a9d031 100644 --- a/packages/perseus/src/renderer-util.test.ts +++ b/packages/perseus/src/renderer-util.test.ts @@ -1,3 +1,4 @@ +import {scorePerseusItem} from "@khanacademy/perseus-score"; import {act, screen} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; @@ -8,78 +9,12 @@ import { import {question1} from "./__testdata__/renderer.testdata"; import * as Dependencies from "./dependencies"; -import { - emptyWidgetsFunctional, - scorePerseusItem, - scoreWidgetsFunctional, -} from "./renderer-util"; -import {mockStrings} from "./strings"; import {registerAllWidgetsForTesting} from "./util/register-all-widgets-for-testing"; import {renderQuestion} from "./widgets/__testutils__/renderQuestion"; import DropdownWidgetExport from "./widgets/dropdown"; -import type { - DropdownWidget, - ExpressionWidget, - PerseusWidgetsMap, -} from "@khanacademy/perseus-core"; -import type {UserInputMap} from "@khanacademy/perseus-score"; import type {UserEvent} from "@testing-library/user-event"; -function getTestDropdownWidget(): DropdownWidget { - return { - type: "dropdown", - options: { - choices: [ - { - content: "Test choice 1", - correct: true, - }, - { - content: "Test choice 2", - correct: false, - }, - ], - placeholder: "Test placeholder", - static: false, - }, - }; -} - -function getExpressionWidget(): ExpressionWidget { - return { - type: "expression", - version: {major: 1, minor: 0}, - options: { - times: false, - buttonSets: ["basic"], - functions: [], - answerForms: [ - { - form: false, - simplify: false, - value: "2+2", - considered: "correct", - }, - ], - }, - }; -} - -function getLegacyExpressionWidget() { - return { - type: "expression", - options: { - times: false, - buttonSets: ["basic"], - functions: [], - form: false, - simplify: false, - value: "2+2", - }, - }; -} - describe("renderer utils", () => { beforeAll(() => { registerAllWidgetsForTesting(); @@ -102,641 +37,6 @@ describe("renderer utils", () => { ) as jest.Mock; }); - describe("emptyWidgetsFunctional", () => { - it("returns an empty array if there are no widgets", () => { - // Arrange / Act - const result = emptyWidgetsFunctional( - {}, - [], - {}, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual([]); - }); - - it("properly identifies empty widgets", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 0, - }, - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual(["dropdown 1"]); - }); - - it("does not return widget IDs that are not empty", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 1, - }, - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual([]); - }); - - it("does not check for empty widgets whose IDs aren't provided", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - }; - const widgetIds: Array = []; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 0, - }, - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual([]); - }); - - it("can properly split empty and non-empty widgets", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - "dropdown 2": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1", "dropdown 2"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 0, - }, - "dropdown 2": { - value: 1, - }, - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual(["dropdown 1"]); - }); - - it("properly identifies groups with empty widgets", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "group 1": { - type: "group", - options: { - content: "[[☃ dropdown 1]]", - widgets: { - "dropdown 1": getTestDropdownWidget(), - }, - images: {}, - }, - }, - }; - const widgetIds: Array = ["group 1"]; - const userInputMap: UserInputMap = { - "group 1": { - "dropdown 1": { - value: 0, - }, - }, - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual(["group 1"]); - }); - - it("does not return group ID when its widgets are non-empty", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "group 1": { - type: "group", - options: { - content: "[[☃ dropdown 1]]", - widgets: { - "dropdown 1": getTestDropdownWidget(), - }, - images: {}, - }, - }, - }; - const widgetIds: Array = ["group 1"]; - const userInputMap: UserInputMap = { - "group 1": { - "dropdown 1": { - value: 1, - }, - }, - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual([]); - }); - - it("handles an empty modern Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getExpressionWidget(), - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "", - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual(["expression 1"]); - }); - - it("upgrades an empty legacy Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getLegacyExpressionWidget() as any, - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "", - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual(["expression 1"]); - }); - - it("handles a non-empty modern Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getExpressionWidget(), - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "2+2", - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual([]); - }); - - it("upgrades a non-empty legacy Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getLegacyExpressionWidget() as any, - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "2+2", - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual([]); - }); - }); - - describe("scoreWidgetsFunctional", () => { - it("returns an empty object when there's no widgets", () => { - // Arrange / Act - const result = scoreWidgetsFunctional( - {}, - [], - {}, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual({}); - }); - - it("returns invalid if widget is unanswered", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 0, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["dropdown 1"]).toHaveInvalidInput(); - }); - - it("can determine if a widget was answered correctly", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 1, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["dropdown 1"]).toHaveBeenAnsweredCorrectly(); - }); - - it("can determine if a widget was answered incorrectly", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 2, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["dropdown 1"]).toHaveBeenAnsweredIncorrectly(); - }); - - it("can handle multiple widgets", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - "dropdown 2": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1", "dropdown 2"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 1, - }, - "dropdown 2": { - value: 2, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["dropdown 1"]).toHaveBeenAnsweredCorrectly(); - expect(result["dropdown 2"]).toHaveBeenAnsweredIncorrectly(); - }); - - it("skips widgets not in widgetIds", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - "dropdown 2": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 1, - }, - "dropdown 2": { - value: 2, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["dropdown 1"]).toHaveBeenAnsweredCorrectly(); - expect(result["dropdown 2"]).toBe(undefined); - }); - - it("returns invalid if a widget in a group is unanswered", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "group 1": { - type: "group", - options: { - content: "[[☃ dropdown 1]]", - widgets: { - "dropdown 1": getTestDropdownWidget(), - }, - images: {}, - }, - }, - }; - const widgetIds: Array = ["group 1"]; - const userInputMap: UserInputMap = { - "group 1": { - "dropdown 1": { - value: 0, - }, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["group 1"]).toHaveInvalidInput(); - }); - - it("can score correct widget in group", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "group 1": { - type: "group", - options: { - content: "[[☃ dropdown 1]]", - widgets: { - "dropdown 1": getTestDropdownWidget(), - }, - images: {}, - }, - }, - }; - const widgetIds: Array = ["group 1"]; - const userInputMap: UserInputMap = { - "group 1": { - "dropdown 1": { - value: 1, - }, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["group 1"]).toHaveBeenAnsweredCorrectly(); - }); - - it("can score incorrect widget in group", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "group 1": { - type: "group", - options: { - content: "[[☃ dropdown 1]]", - widgets: { - "dropdown 1": getTestDropdownWidget(), - }, - images: {}, - }, - }, - }; - const widgetIds: Array = ["group 1"]; - const userInputMap: UserInputMap = { - "group 1": { - "dropdown 1": { - value: 2, - }, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["group 1"]).toHaveBeenAnsweredIncorrectly(); - }); - - it("can handle a correct modern Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getExpressionWidget(), - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "2+2", - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["expression 1"]).toHaveBeenAnsweredCorrectly(); - }); - - it("can handle a correct legacy Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getLegacyExpressionWidget() as any, - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "2+2", - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["expression 1"]).toHaveBeenAnsweredCorrectly(); - }); - - it("can handle an incorrect modern Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getExpressionWidget(), - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "2+42", - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["expression 1"]).toHaveBeenAnsweredIncorrectly(); - }); - - it("can handle an incorrect legacy Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getLegacyExpressionWidget() as any, - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "2+42", - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["expression 1"]).toHaveBeenAnsweredIncorrectly(); - }); - }); - describe("scorePerseusItem", () => { let userEvent: UserEvent; beforeEach(() => { @@ -773,7 +73,7 @@ describe("renderer utils", () => { images: {}, }, {}, - mockStrings, + "en", ); @@ -811,7 +111,7 @@ describe("renderer utils", () => { images: {}, }, {"dropdown 1": {value: 0}}, - mockStrings, + "en", ); @@ -840,7 +140,7 @@ describe("renderer utils", () => { }, }, {"dropdown 1": {value: 1}}, - mockStrings, + "en", ); @@ -865,7 +165,7 @@ describe("renderer utils", () => { images: {}, }, {"dropdown 1": {value: 2}}, - mockStrings, + "en", ); @@ -891,12 +191,7 @@ describe("renderer utils", () => { // Act const userInput = renderer.getUserInputMap(); - const score = scorePerseusItem( - question1, - userInput, - mockStrings, - "en", - ); + const score = scorePerseusItem(question1, userInput, "en"); // Assert expect(score).toHaveBeenAnsweredCorrectly(); diff --git a/packages/perseus/src/renderer-util.ts b/packages/perseus/src/renderer-util.ts index f0aba3c102..ca753f4df9 100644 --- a/packages/perseus/src/renderer-util.ts +++ b/packages/perseus/src/renderer-util.ts @@ -1,143 +1 @@ -import {getWidgetIdsFromContent, mapObject} from "@khanacademy/perseus-core"; - -import {scoreIsEmpty, flattenScores} from "./util/scoring"; -import { - getWidgetScorer, - getWidgetValidator, - upgradeWidgetInfoToLatestVersion, -} from "./widgets"; - -import type {PerseusStrings} from "./strings"; -import type { - PerseusRenderer, - PerseusWidgetsMap, -} from "@khanacademy/perseus-core"; -import type { - UserInputMap, - PerseusScore, - ValidationDataMap, -} from "@khanacademy/perseus-score"; - -export function getUpgradedWidgetOptions( - oldWidgetOptions: PerseusWidgetsMap, -): PerseusWidgetsMap { - return mapObject(oldWidgetOptions, (widgetInfo, widgetId) => { - if (!widgetInfo.type || !widgetInfo.alignment) { - const newValues: Record = {}; - - if (!widgetInfo.type) { - // TODO: why does widget have no type? - // We don't want to derive type from widget ID - // see: LEMS-1845 - newValues.type = widgetId.split(" ")[0]; - } - - if (!widgetInfo.alignment) { - newValues.alignment = "default"; - } - - widgetInfo = {...widgetInfo, ...newValues}; - } - // 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: ValidationDataMap, - // This is a port of old code, I'm not sure why - // we need widgetIds vs the keys of the widgets object - widgetIds: ReadonlyArray, - userInputMap: UserInputMap, - strings: PerseusStrings, - locale: string, -): ReadonlyArray { - return widgetIds.filter((id) => { - const widget = widgets[id]; - if (!widget || widget.static === true) { - // Static widgets shouldn't count as empty - return false; - } - - 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); - } - }); -} - -// TODO: combine scorePerseusItem with scoreWidgetsFunctional -// once scorePerseusItem is the only one calling scoreWidgetsFunctional -export function scorePerseusItem( - perseusRenderData: PerseusRenderer, - userInputMap: UserInputMap, - // TODO(LEMS-2461,LEMS-2391): these probably - // need to be removed before we move this to the server - strings: PerseusStrings, - locale: string, -): PerseusScore { - // There seems to be a chance that PerseusRenderer.widgets might include - // widget data for widgets that are not in PerseusRenderer.content, - // so this checks that the widgets are being used before scoring them - const usedWidgetIds = getWidgetIdsFromContent(perseusRenderData.content); - const scores = scoreWidgetsFunctional( - perseusRenderData.widgets, - usedWidgetIds, - userInputMap, - strings, - locale, - ); - return flattenScores(scores); -} - -export function scoreWidgetsFunctional( - widgets: PerseusWidgetsMap, - // This is a port of old code, I'm not sure why - // we need widgetIds vs the keys of the widgets object - widgetIds: ReadonlyArray, - userInputMap: UserInputMap, - strings: PerseusStrings, - locale: string, -): {[widgetId: string]: PerseusScore} { - const upgradedWidgets = getUpgradedWidgetOptions(widgets); - - const gradedWidgetIds = widgetIds.filter((id) => { - const props = upgradedWidgets[id]; - const widgetIsGraded: boolean = props?.graded == null || props.graded; - const widgetIsStatic = !!props?.static; - // Ungraded widgets or widgets set to static shouldn't be graded. - return widgetIsGraded && !widgetIsStatic; - }); - - const widgetScores: Record = {}; - gradedWidgetIds.forEach((id) => { - const widget = upgradedWidgets[id]; - if (!widget) { - return; - } - - const userInput = userInputMap[id]; - const validator = getWidgetValidator(widget.type); - const scorer = getWidgetScorer(widget.type); - - // 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; - } - }); - - return widgetScores; -} +// STOPSHIP delete this file diff --git a/packages/perseus/src/renderer.tsx b/packages/perseus/src/renderer.tsx index 545917b1a6..c9051b61a3 100644 --- a/packages/perseus/src/renderer.tsx +++ b/packages/perseus/src/renderer.tsx @@ -1,7 +1,20 @@ /* eslint-disable @khanacademy/ts-no-error-suppressions */ /* eslint-disable react/no-unsafe */ -import {Errors, PerseusError, mapObject} from "@khanacademy/perseus-core"; +import { + Errors, + PerseusError, + getUpgradedWidgetOptions, + mapObject, +} from "@khanacademy/perseus-core"; import * as PerseusLinter from "@khanacademy/perseus-linter"; +import { + emptyWidgetsFunctional, + flattenScores, + scoreWidgetsFunctional, + type PerseusScore, + type UserInputArray, + type UserInputMap, +} from "@khanacademy/perseus-score"; import {entries} from "@khanacademy/wonder-stuff-core"; import classNames from "classnames"; import $ from "jquery"; @@ -24,14 +37,8 @@ import {Log} from "./logging/log"; import {ClassNames as ApiClassNames, ApiOptions} from "./perseus-api"; import PerseusMarkdown from "./perseus-markdown"; import QuestionParagraph from "./question-paragraph"; -import { - emptyWidgetsFunctional, - getUpgradedWidgetOptions, - scoreWidgetsFunctional, -} from "./renderer-util"; import TranslationLinter from "./translation-linter"; import Util from "./util"; -import {flattenScores} from "./util/scoring"; import preprocessTex from "./util/tex-preprocess"; import WidgetContainer from "./widget-container"; import * as Widgets from "./widgets"; @@ -60,11 +67,6 @@ import type { ShowSolutions, } from "@khanacademy/perseus-core"; import type {LinterContextProps} from "@khanacademy/perseus-linter"; -import type { - PerseusScore, - UserInputArray, - UserInputMap, -} from "@khanacademy/perseus-score"; import "./styles/perseus-renderer.less"; @@ -1585,7 +1587,6 @@ class Renderer this.state.widgetInfo, this.widgetIds, this.getUserInputMap(), - this.props.strings, this.context.locale, ); } @@ -1737,7 +1738,6 @@ class Renderer this.state.widgetInfo, this.widgetIds, this.getUserInputMap(), - this.props.strings, this.context.locale, ); const combinedScore = flattenScores(scores); diff --git a/packages/perseus/src/traversal.ts b/packages/perseus/src/traversal.ts index 3c42f4b896..bac2e637c1 100644 --- a/packages/perseus/src/traversal.ts +++ b/packages/perseus/src/traversal.ts @@ -13,7 +13,10 @@ * more confident in the interface provided first. */ -import {mapObject} from "@khanacademy/perseus-core"; +import { + mapObject, + upgradeWidgetInfoToLatestVersion, +} from "@khanacademy/perseus-core"; import _ from "underscore"; import * as Widgets from "./widgets"; @@ -29,8 +32,7 @@ const deepCallbackFor = function ( // This doesn't modify the widget info if the widget info // is at a later version than is supported, which is important // for our latestVersion test below. - const upgradedWidgetInfo = - Widgets.upgradeWidgetInfoToLatestVersion(widgetInfo); + const upgradedWidgetInfo = upgradeWidgetInfoToLatestVersion(widgetInfo); const latestVersion = Widgets.getVersion(upgradedWidgetInfo.type); // Only traverse our children if we can understand this version diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index 174d756969..f27291ed49 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -15,6 +15,7 @@ import type { getOrdererPublicWidgetOptions, getCategorizerPublicWidgetOptions, getExpressionPublicWidgetOptions, + Alignment, } from "@khanacademy/perseus-core"; import type {LinterContextProps} from "@khanacademy/perseus-linter"; import type { @@ -494,15 +495,6 @@ type TrackingSequenceExtraArguments = { visible: number; }; -export type Alignment = - | "default" - | "block" - | "inline-block" - | "inline" - | "float-left" - | "float-right" - | "full-width"; - type WidgetOptions = any; /** diff --git a/packages/perseus/src/util/scoring.test.ts b/packages/perseus/src/util/scoring.test.ts index efddbdbd6c..876d29a9ba 100644 --- a/packages/perseus/src/util/scoring.test.ts +++ b/packages/perseus/src/util/scoring.test.ts @@ -1,4 +1,4 @@ -import {flattenScores, isCorrect} from "./scoring"; +import {isCorrect} from "./scoring"; describe("isCorrect", () => { it("is true given a score with all points earned", () => { @@ -16,118 +16,3 @@ describe("isCorrect", () => { expect(isCorrect(score)).toBe(false); }); }); - -describe("flattenScores", () => { - it("defaults to an empty score", () => { - const result = flattenScores({}); - - expect(result).toHaveBeenAnsweredCorrectly({shouldHavePoints: false}); - expect(result).toEqual({ - type: "points", - total: 0, - earned: 0, - message: null, - }); - }); - - it("defaults to single score if there is only one", () => { - const result = flattenScores({ - "radio 1": { - type: "points", - total: 1, - earned: 1, - message: null, - }, - }); - - expect(result).toHaveBeenAnsweredCorrectly(); - expect(result).toEqual({ - type: "points", - total: 1, - earned: 1, - message: null, - }); - }); - - it("returns an invalid score if any are invalid", () => { - const result = flattenScores({ - "radio 1": { - type: "points", - total: 1, - earned: 1, - message: null, - }, - "radio 2": { - type: "invalid", - message: null, - }, - }); - - expect(result).toHaveInvalidInput(); - expect(result).toEqual({ - type: "invalid", - message: null, - }); - }); - - it("tallies scores if multiple widgets have points", () => { - const result = flattenScores({ - "radio 1": { - type: "points", - total: 1, - earned: 1, - message: null, - }, - "radio 2": { - type: "points", - total: 1, - earned: 1, - message: null, - }, - "radio 3": { - type: "points", - total: 1, - earned: 1, - message: null, - }, - }); - - expect(result).toHaveBeenAnsweredCorrectly(); - expect(result).toEqual({ - type: "points", - total: 3, - earned: 3, - message: null, - }); - }); - - it("doesn't count incorrect widgets", () => { - const result = flattenScores({ - "radio 1": { - type: "points", - total: 1, - earned: 1, - message: null, - }, - "radio 2": { - type: "points", - total: 1, - earned: 1, - message: null, - }, - "radio 3": { - type: "points", - total: 1, - earned: 0, - message: null, - }, - }); - - expect(result).toEqual({ - type: "points", - total: 3, - earned: 2, - message: null, - }); - }); -}); diff --git a/packages/perseus/src/util/scoring.ts b/packages/perseus/src/util/scoring.ts index 2c55b4c2d0..a0395aa101 100644 --- a/packages/perseus/src/util/scoring.ts +++ b/packages/perseus/src/util/scoring.ts @@ -3,115 +3,6 @@ import {Errors, PerseusError} from "@khanacademy/perseus-core"; import type {KEScore} from "@khanacademy/perseus-core"; import type {PerseusScore, UserInputArray} from "@khanacademy/perseus-score"; -const noScore: PerseusScore = { - type: "points", - earned: 0, - total: 0, - message: null, -}; - -/** - * If a widget says that it is empty once it is graded. - * Trying to encapsulate references to the score format. - */ -export function scoreIsEmpty(score: PerseusScore): boolean { - // HACK(benkomalo): ugh. this isn't great; the Perseus score objects - // overload the type "invalid" for what should probably be three - // distinct cases: - // - truly empty or not fully filled out - // - invalid or malformed inputs - // - "almost correct" like inputs where the widget wants to give - // feedback (e.g. a fraction needs to be reduced, or `pi` should - // be used instead of 3.14) - // - // Unfortunately the coercion happens all over the place, as these - // Perseus style score objects are created *everywhere* (basically - // in every widget), so it's hard to change now. We assume that - // anything with a "message" is not truly empty, and one of the - // latter two cases for now. - return ( - score.type === "invalid" && - (!score.message || score.message.length === 0) - ); -} - -/** - * Combine two score objects. - * - * Given two score objects for two different widgets, combine them so that - * if one is wrong, the total score is wrong, etc. - */ -export function combineScores( - scoreA: PerseusScore, - scoreB: PerseusScore, -): PerseusScore { - let message; - - if (scoreA.type === "points" && scoreB.type === "points") { - if ( - scoreA.message && - scoreB.message && - scoreA.message !== scoreB.message - ) { - // TODO(alpert): Figure out how to combine messages usefully - message = null; - } else { - message = scoreA.message || scoreB.message; - } - - return { - type: "points", - earned: scoreA.earned + scoreB.earned, - total: scoreA.total + scoreB.total, - message: message, - }; - } - if (scoreA.type === "points" && scoreB.type === "invalid") { - return scoreB; - } - if (scoreA.type === "invalid" && scoreB.type === "points") { - return scoreA; - } - if (scoreA.type === "invalid" && scoreB.type === "invalid") { - if ( - scoreA.message && - scoreB.message && - scoreA.message !== scoreB.message - ) { - // TODO(alpert): Figure out how to combine messages usefully - message = null; - } else { - message = scoreA.message || scoreB.message; - } - - return { - type: "invalid", - message: message, - }; - } - - /** - * The above checks cover all combinations of score type, so if we get here - * then something is amiss with our inputs. - */ - throw new PerseusError( - "PerseusScore with unknown type encountered", - Errors.InvalidInput, - { - metadata: { - scoreA: JSON.stringify(scoreA), - scoreB: JSON.stringify(scoreB), - }, - }, - ); -} - -export function flattenScores(widgetScoreMap: { - [widgetId: string]: PerseusScore; -}): PerseusScore { - return Object.values(widgetScoreMap).reduce(combineScores, noScore); -} - export function isCorrect(score: PerseusScore): boolean { return score.type === "points" && score.earned >= score.total; } diff --git a/packages/perseus/src/util/test-utils.ts b/packages/perseus/src/util/test-utils.ts index 342ff61176..39aabb146e 100644 --- a/packages/perseus/src/util/test-utils.ts +++ b/packages/perseus/src/util/test-utils.ts @@ -1,5 +1,8 @@ -import {scorePerseusItem} from "../renderer-util"; -import {mockStrings} from "../strings"; +import { + scorePerseusItem, + type PerseusScore, + type UserInputMap, +} from "@khanacademy/perseus-score"; import type { CategorizerWidget, @@ -10,7 +13,6 @@ import type { PerseusRenderer, RadioWidget, } from "@khanacademy/perseus-core"; -import type {PerseusScore, UserInputMap} from "@khanacademy/perseus-score"; export const genericPerseusItemData: PerseusItem = { question: { @@ -44,7 +46,7 @@ export function scorePerseusItemTesting( perseusRenderData: PerseusRenderer, userInputMap: UserInputMap, ): PerseusScore { - return scorePerseusItem(perseusRenderData, userInputMap, mockStrings, "en"); + return scorePerseusItem(perseusRenderData, userInputMap, "en"); } /** diff --git a/packages/perseus/src/widgets.ts b/packages/perseus/src/widgets.ts index 4308b0f501..20a0f4857b 100644 --- a/packages/perseus/src/widgets.ts +++ b/packages/perseus/src/widgets.ts @@ -5,7 +5,6 @@ import {Log} from "./logging/log"; import type {PerseusStrings} from "./strings"; import type { - Alignment, Tracking, WidgetExports, WidgetTransform, @@ -13,13 +12,14 @@ import type { WidgetValidatorFunction, PublicWidgetOptionsFunction, } from "./types"; -import type {PerseusWidget, Version} from "@khanacademy/perseus-core"; +import type { + Alignment, + PerseusWidget, + Version, +} from "@khanacademy/perseus-core"; import type * as React from "react"; const DEFAULT_ALIGNMENT = "block"; -// NOTE(kevinb): "default" is not one in `validAlignments`. -const DEFAULT_SUPPORTED_ALIGNMENTS = ["default"]; -const DEFAULT_STATIC = false; const DEFAULT_TRACKING = ""; const DEFAULT_LINTABLE = false; @@ -206,131 +206,6 @@ export const getAllWidgetTypes = (): ReadonlyArray => { return _.keys(widgets); }; -export const upgradeWidgetInfoToLatestVersion = ( - oldWidgetInfo: PerseusWidget, -): PerseusWidget => { - const type = oldWidgetInfo.type; - // NOTE(jeremy): This looks like it could be replaced by fixing types so - // that `type` is non-optional. But we're seeing this in Sentry today so I - // suspect we have legacy data (potentially unpublished) and we should - // figure that out before depending solely on types. - if (!_.isString(type)) { - throw new PerseusError( - "widget type must be a string, but was: " + type, - Errors.Internal, - ); - } - const widgetExports = widgets[type]; - - if (widgetExports == null) { - // If we have a widget that isn't registered, we can't upgrade it - // TODO(aria): Figure out what the best thing to do here would be - return oldWidgetInfo; - } - - // Unversioned widgets (pre-July 2014) are all implicitly 0.0 - const initialVersion = oldWidgetInfo.version || {major: 0, minor: 0}; - const latestVersion = widgetExports.version || {major: 0, minor: 0}; - - // If the widget version is later than what we understand (major - // version is higher than latest, or major versions are equal and minor - // version is higher than latest), don't perform any upgrades. - if ( - initialVersion.major > latestVersion.major || - (initialVersion.major === latestVersion.major && - initialVersion.minor > latestVersion.minor) - ) { - return oldWidgetInfo; - } - - // We do a clone here so that it's safe to mutate the input parameter - // in propUpgrades functions (which I will probably accidentally do at - // some point, and we would like to not break when that happens). - let newEditorProps = _.clone(oldWidgetInfo.options) || {}; - - const upgradePropsMap = widgetExports.propUpgrades || {}; - - // Empty props usually mean a newly created widget by the editor, - // and are always considerered up-to-date. - // Mostly, we'd rather not run upgrade functions on props that are - // not complete. - if (_.keys(newEditorProps).length !== 0) { - // We loop through all the versions after the current version of - // the loaded widget, up to and including the latest version of the - // loaded widget, and run the upgrade function to bring our loaded - // widget's props up to that version. - // There is a little subtlety here in that we call - // upgradePropsMap[1] to upgrade *to* version 1, - // (not from version 1). - for ( - let nextVersion = initialVersion.major + 1; - nextVersion <= latestVersion.major; - nextVersion++ - ) { - if (upgradePropsMap[String(nextVersion)]) { - newEditorProps = - upgradePropsMap[String(nextVersion)](newEditorProps); - } else { - // This is a Log.error because it is unlikely to be hit in - // local testing, and a Log.error is slightly less scary in - // prod than a `throw new Error` - Log.error( - "No upgrade found for widget. Cannot render.", - Errors.Internal, - { - loggedMetadata: { - type, - fromMajorVersion: nextVersion - 1, - toMajorVersion: nextVersion, - }, - }, - ); - // But try to keep going anyways (yolo!) - // (Throwing an error here would just break the page - // silently anyways, so that doesn't seem much better - // than a halfhearted attempt to continue, however - // shallow...) - } - } - } - - // Minor version upgrades (eg. new optional props) don't have - // transform functions. Instead, we fill in the new props with their - // defaults. - const defaultProps = type in editors ? editors[type].defaultProps : {}; - newEditorProps = { - ...defaultProps, - ...newEditorProps, - }; - - let alignment = oldWidgetInfo.alignment; - - // Widgets that support multiple alignments will "lock in" the - // alignment to the alignment that would be listed first in the - // select box. If the widget only supports one alignment, the - // alignment value will likely just end up as "default". - if (alignment == null || alignment === "default") { - alignment = getSupportedAlignments(type)[0]; - } - - let widgetStatic = oldWidgetInfo.static; - - if (widgetStatic == null) { - widgetStatic = DEFAULT_STATIC; - } - - return _.extend({}, oldWidgetInfo, { - // maintain other info, like type - // After upgrading we guarantee that the version is up-to-date - version: latestVersion, - // Default graded to true (so null/undefined becomes true): - graded: oldWidgetInfo.graded != null ? oldWidgetInfo.graded : true, - alignment: alignment, - static: widgetStatic, - options: newEditorProps, - }); -}; - export const getRendererPropsForWidgetInfo = ( widgetInfo: PerseusWidget, strings: PerseusStrings, @@ -387,30 +262,6 @@ export const traverseChildWidgets = ( return widgetInfo; }; -/** - * Handling for the optional alignments for widgets - * See widget-container.jsx for details on how alignments are implemented. - */ - -/** - * Returns the list of supported alignments for the given (string) widget - * type. This is used primarily at editing time to display the choices - * for the user. - * - * Supported alignments are given as an array of strings in the exports of - * a widget's module. - */ -export const getSupportedAlignments = ( - type: string, -): ReadonlyArray => { - const widgetExport = widgets[type]; - // @ts-expect-error - TS2322 - Type 'string[] | readonly Alignment[]' is not assignable to type 'readonly Alignment[]'. - return ( - (widgetExport && widgetExport.supportedAlignments) || - DEFAULT_SUPPORTED_ALIGNMENTS - ); -}; - /** * For the given (string) widget type, determine the default alignment for * the widget. This is used at rendering time to go from "default" alignment diff --git a/packages/perseus/src/widgets/categorizer/categorizer.test.ts b/packages/perseus/src/widgets/categorizer/categorizer.test.ts index d2dcdeb02a..fe8dfdde8a 100644 --- a/packages/perseus/src/widgets/categorizer/categorizer.test.ts +++ b/packages/perseus/src/widgets/categorizer/categorizer.test.ts @@ -1,10 +1,9 @@ +import {scorePerseusItem} from "@khanacademy/perseus-score"; import {screen} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; import {testDependencies} from "../../../../../testing/test-dependencies"; import * as Dependencies from "../../dependencies"; -import {scorePerseusItem} from "../../renderer-util"; -import {mockStrings} from "../../strings"; import {scorePerseusItemTesting} from "../../util/test-utils"; import {renderQuestion} from "../__testutils__/renderQuestion"; @@ -63,7 +62,6 @@ describe("categorizer widget", () => { const score = scorePerseusItem( question1, renderer.getUserInputMap(), - mockStrings, "en", ); @@ -87,7 +85,6 @@ describe("categorizer widget", () => { const score = scorePerseusItem( question1, renderer.getUserInputMap(), - mockStrings, "en", ); diff --git a/packages/perseus/src/widgets/expression/expression.test.tsx b/packages/perseus/src/widgets/expression/expression.test.tsx index 75902c1d1a..2af60dc728 100644 --- a/packages/perseus/src/widgets/expression/expression.test.tsx +++ b/packages/perseus/src/widgets/expression/expression.test.tsx @@ -1,5 +1,6 @@ import {it, describe, beforeEach} from "@jest/globals"; import {KeypadType} from "@khanacademy/math-input"; +import {scorePerseusItem} from "@khanacademy/perseus-score"; import {act, screen, waitFor} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; @@ -8,8 +9,6 @@ import { testDependenciesV2, } from "../../../../../testing/test-dependencies"; import * as Dependencies from "../../dependencies"; -import {scorePerseusItem} from "../../renderer-util"; -import {mockStrings} from "../../strings"; import {scorePerseusItemTesting} from "../../util/test-utils"; import {renderQuestion} from "../__testutils__/renderQuestion"; @@ -469,7 +468,6 @@ describe("Expression Widget", function () { const score = scorePerseusItem( expressionItem2.question, renderer.getUserInputMap(), - mockStrings, "en", ); @@ -494,7 +492,6 @@ describe("Expression Widget", function () { const score = scorePerseusItem( expressionItem2.question, renderer.getUserInputMap(), - mockStrings, "en", ); diff --git a/packages/perseus/src/widgets/group/group.test.tsx b/packages/perseus/src/widgets/group/group.test.tsx index 7d7a7c5b01..0316210dd1 100644 --- a/packages/perseus/src/widgets/group/group.test.tsx +++ b/packages/perseus/src/widgets/group/group.test.tsx @@ -1,3 +1,4 @@ +import {scorePerseusItem} from "@khanacademy/perseus-score"; import {RenderStateRoot} from "@khanacademy/wonder-blocks-core"; // eslint-disable-next-line testing-library/no-manual-cleanup import {act, cleanup, render, screen, waitFor} from "@testing-library/react"; @@ -8,7 +9,6 @@ import * as Perseus from "@khanacademy/perseus"; import {testDependencies} from "../../../../../testing/test-dependencies"; import * as Dependencies from "../../dependencies"; -import {scorePerseusItem} from "../../renderer-util"; import {mockStrings} from "../../strings"; import {traverse} from "../../traversal"; import {renderQuestion} from "../__testutils__/renderQuestion"; @@ -424,7 +424,7 @@ describe("group widget", () => { ); const guess = renderer.getUserInputMap(); - const score = scorePerseusItem(question1, guess, mockStrings, "en"); + const score = scorePerseusItem(question1, guess, "en"); const guessAndScore = [renderer.getUserInput(), score]; // Assert diff --git a/packages/perseus/src/widgets/group/score-group.ts b/packages/perseus/src/widgets/group/score-group.ts index 2ddaf02c61..815bee021a 100644 --- a/packages/perseus/src/widgets/group/score-group.ts +++ b/packages/perseus/src/widgets/group/score-group.ts @@ -1,11 +1,9 @@ -import {scoreWidgetsFunctional} from "../../renderer-util"; -import {flattenScores} from "../../util/scoring"; - -import type {PerseusStrings} from "../../strings"; -import type { - PerseusGroupRubric, - PerseusGroupUserInput, - PerseusScore, +import { + flattenScores, + scoreWidgetsFunctional, + type PerseusGroupRubric, + type PerseusGroupUserInput, + type PerseusScore, } from "@khanacademy/perseus-score"; // The `group` widget is basically a widget hosting a full Perseus system in @@ -13,14 +11,12 @@ import type { function scoreGroup( userInput: PerseusGroupUserInput, rubric: PerseusGroupRubric, - strings: PerseusStrings, locale: string, ): PerseusScore { const scores = scoreWidgetsFunctional( rubric.widgets, Object.keys(rubric.widgets), userInput, - strings, locale, ); diff --git a/packages/perseus/src/widgets/group/validate-group.ts b/packages/perseus/src/widgets/group/validate-group.ts index 7562950d49..0ce0161305 100644 --- a/packages/perseus/src/widgets/group/validate-group.ts +++ b/packages/perseus/src/widgets/group/validate-group.ts @@ -1,23 +1,19 @@ -import {emptyWidgetsFunctional} from "../../renderer-util"; - -import type {PerseusStrings} from "../../strings"; -import type { - PerseusGroupUserInput, - PerseusGroupValidationData, - ValidationResult, +import { + emptyWidgetsFunctional, + type PerseusGroupUserInput, + type PerseusGroupValidationData, + type 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, ); diff --git a/packages/perseus/src/widgets/passage/__tests__/passage.test.tsx b/packages/perseus/src/widgets/passage/__tests__/passage.test.tsx index f34a76d644..99beea5008 100644 --- a/packages/perseus/src/widgets/passage/__tests__/passage.test.tsx +++ b/packages/perseus/src/widgets/passage/__tests__/passage.test.tsx @@ -1,12 +1,11 @@ import {it, describe, beforeEach} from "@jest/globals"; +import {scorePerseusItem} from "@khanacademy/perseus-score"; import {act, render, screen} from "@testing-library/react"; import React from "react"; import {testDependencies} from "../../../../../../testing/test-dependencies"; import * as Dependencies from "../../../dependencies"; import {ApiOptions} from "../../../perseus-api"; -import {scorePerseusItem} from "../../../renderer-util"; -import {mockStrings} from "../../../strings"; import {renderQuestion} from "../../__testutils__/renderQuestion"; import PassageWidgetExport, {LineHeightMeasurer} from "../passage"; @@ -138,7 +137,6 @@ describe("passage widget", () => { const score = scorePerseusItem( question2, renderer.getUserInputMap(), - mockStrings, "en", ); diff --git a/testing/renderer-with-debug-ui.tsx b/testing/renderer-with-debug-ui.tsx index 7db7fb17ef..34649e543a 100644 --- a/testing/renderer-with-debug-ui.tsx +++ b/testing/renderer-with-debug-ui.tsx @@ -9,9 +9,9 @@ import deviceMobile from "@phosphor-icons/core/regular/device-mobile.svg"; import * as React from "react"; import ReactJson from "react-json-view"; +import {scorePerseusItem} from "@khanacademy/perseus-score"; + import {Renderer, usePerseusI18n} from "../packages/perseus/src/index"; -import {scorePerseusItem} from "../packages/perseus/src/renderer-util"; -import {mockStrings} from "../packages/perseus/src/strings"; import {registerAllWidgetsForTesting} from "../packages/perseus/src/util/register-all-widgets-for-testing"; import SideBySide from "./side-by-side"; @@ -86,7 +86,6 @@ export const RendererWithDebugUI = ({ const score = scorePerseusItem( question, ref.current.getUserInputMap(), - mockStrings, "en", ); setState([guess, score]); diff --git a/testing/server-item-renderer-with-debug-ui.tsx b/testing/server-item-renderer-with-debug-ui.tsx index 82cdbac18a..ab1d489fce 100644 --- a/testing/server-item-renderer-with-debug-ui.tsx +++ b/testing/server-item-renderer-with-debug-ui.tsx @@ -3,8 +3,9 @@ import {View} from "@khanacademy/wonder-blocks-core"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import * as React from "react"; +import {scorePerseusItem} from "@khanacademy/perseus-score"; + import * as Perseus from "../packages/perseus/src/index"; -import {mockStrings} from "../packages/perseus/src/strings"; import {keScoreFromPerseusScore} from "../packages/perseus/src/util/scoring"; import KEScoreUI from "./ke-score-ui"; @@ -37,12 +38,7 @@ export const ServerItemRendererWithDebugUI = ({ } const userInput = renderer.getUserInput(); - const score = Perseus.scorePerseusItem( - item.question, - userInput, - mockStrings, - "en", - ); + const score = scorePerseusItem(item.question, userInput, "en"); // Continue to include an empty guess for the now defunct answer area. // TODO(alex): Check whether we rely on the format here for From b25fb6c17934f534ea0cc8781a64809c3d1f1464 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 10:32:49 -0600 Subject: [PATCH 02/25] [LEMS-2737/move-scoring-logic] WIP: scoring registry --- packages/perseus-score/src/score.ts | 2 + packages/perseus-score/src/validate.ts | 1 + .../perseus-score/src/validation.types.ts | 16 ++++ .../src/widgets/widget-registry.ts | 88 +++++++++++++++++++ packages/perseus/src/types.ts | 19 ---- packages/perseus/src/widgets.ts | 12 --- 6 files changed, 107 insertions(+), 31 deletions(-) create mode 100644 packages/perseus-score/src/widgets/widget-registry.ts diff --git a/packages/perseus-score/src/score.ts b/packages/perseus-score/src/score.ts index e7de7e5ca0..e27e6d46d3 100644 --- a/packages/perseus-score/src/score.ts +++ b/packages/perseus-score/src/score.ts @@ -7,6 +7,8 @@ import { PerseusError, } from "@khanacademy/perseus-core"; +import {getWidgetScorer, getWidgetValidator} from "./widgets/widget-registry"; + import type {PerseusScore, UserInputMap} from "./validation.types"; import type { PerseusRenderer, diff --git a/packages/perseus-score/src/validate.ts b/packages/perseus-score/src/validate.ts index d954fffec2..2595d818c9 100644 --- a/packages/perseus-score/src/validate.ts +++ b/packages/perseus-score/src/validate.ts @@ -1,4 +1,5 @@ import {scoreIsEmpty} from "./score"; +import {getWidgetValidator} from "./widgets/widget-registry"; import type {UserInputMap, ValidationDataMap} from "./validation.types"; diff --git a/packages/perseus-score/src/validation.types.ts b/packages/perseus-score/src/validation.types.ts index 27bbec84f4..7b4ec071d7 100644 --- a/packages/perseus-score/src/validation.types.ts +++ b/packages/perseus-score/src/validation.types.ts @@ -46,6 +46,22 @@ import type { Relationship, } from "@khanacademy/perseus-core"; +export type WidgetValidatorFunction = ( + userInput: UserInput, + validationData: ValidationData, + locale: string, +) => ValidationResult; + +export type WidgetScorerFunction = ( + // The user data needed to score + userInput: UserInput, + // The scoring criteria to score against + rubric: Rubric, + // Locale, for math evaluation + // (1,000.00 === 1.000,00 in some countries) + locale?: string, +) => PerseusScore; + export type PerseusScore = | { type: "invalid"; diff --git a/packages/perseus-score/src/widgets/widget-registry.ts b/packages/perseus-score/src/widgets/widget-registry.ts new file mode 100644 index 0000000000..9a044a3be2 --- /dev/null +++ b/packages/perseus-score/src/widgets/widget-registry.ts @@ -0,0 +1,88 @@ +import scoreCategorizer from "./categorizer/score-categorizer"; +import validateCategorizer from "./categorizer/validate-categorizer"; +import scoreCSProgram from "./cs-program/score-cs-program"; +import scoreDropdown from "./dropdown/score-dropdown"; +import validateDropdown from "./dropdown/validate-dropdown"; +import scoreExpression from "./expression/score-expression"; +import validateExpression from "./expression/validate-expression"; +import scoreGrapher from "./grapher/score-grapher"; +import scoreIframe from "./iframe/score-iframe"; +import scoreInputNumber from "./input-number/score-input-number"; +import scoreInteractiveGraph from "./interactive-graph/score-interactive-graph"; +import scoreLabelImage from "./label-image/score-label-image"; +import validateLabelImage from "./label-image/validate-label-image"; +import scoreMatcher from "./matcher/score-matcher"; +import scoreMatrix from "./matrix/score-matrix"; +import validateMatrix from "./matrix/validate-matrix"; +import scoreNumberLine from "./number-line/score-number-line"; +import validateNumberLine from "./number-line/validate-number-line"; +import scoreNumericInput from "./numeric-input/score-numeric-input"; +import scoreOrderer from "./orderer/score-orderer"; +import validateOrderer from "./orderer/validate-orderer"; +import scorePlotter from "./plotter/score-plotter"; +import validatePlotter from "./plotter/validate-plotter"; +import scoreRadio from "./radio/score-radio"; +import validateRadio from "./radio/validate-radio"; +import scoreSorter from "./sorter/score-sorter"; +import validateSorter from "./sorter/validate-sorter"; +import scoreTable from "./table/score-table"; +import validateTable from "./table/validate-table"; + +import type { + WidgetScorerFunction, + WidgetValidatorFunction, +} from "../validation.types"; + +const widgets = {}; + +function registerWidget( + type: string, + scorer: WidgetScorerFunction, + validator?: WidgetValidatorFunction, +) { + widgets[type] = { + scorer, + validator, + }; +} + +export const getWidgetValidator = ( + name: string, +): WidgetValidatorFunction | null => { + return widgets[name]?.validator ?? null; +}; + +export const getWidgetScorer = (name: string): WidgetScorerFunction | null => { + return widgets[name]?.scorer ?? null; +}; + +registerWidget( + "categorizer", + scoreCategorizer as any, + validateCategorizer as any, +); +registerWidget("cs-program", scoreCSProgram as any); +registerWidget("dropdown", scoreDropdown as any, validateDropdown as any); +registerWidget("expression", scoreExpression as any, validateExpression as any); +registerWidget("grapher", scoreGrapher as any); +registerWidget("iframe", scoreIframe as any); +registerWidget("input-number", scoreInputNumber as any); +registerWidget("interactive-graph", scoreInteractiveGraph as any); +registerWidget( + "label-image", + scoreLabelImage as any, + validateLabelImage as any, +); +registerWidget("matcher", scoreMatcher as any); +registerWidget("matrix", scoreMatrix as any, validateMatrix as any); +registerWidget( + "number-line", + scoreNumberLine as any, + validateNumberLine as any, +); +registerWidget("numeric-input", scoreNumericInput as any); +registerWidget("orderer", scoreOrderer as any, validateOrderer as any); +registerWidget("plotter", scorePlotter as any, validatePlotter as any); +registerWidget("radio", scoreRadio as any, validateRadio as any); +registerWidget("sorter", scoreSorter as any, validateSorter as any); +registerWidget("table", scoreTable as any, validateTable as any); diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index f27291ed49..58c297baba 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -510,25 +510,6 @@ 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, - // Strings, for error messages in invalid widgets - string?: PerseusStrings, - // Locale, for math evaluation - // (1,000.00 === 1.000,00 in some countries) - locale?: string, -) => PerseusScore; - /** * A union type of all the functions that provide public widget options. */ diff --git a/packages/perseus/src/widgets.ts b/packages/perseus/src/widgets.ts index 20a0f4857b..3816a70aab 100644 --- a/packages/perseus/src/widgets.ts +++ b/packages/perseus/src/widgets.ts @@ -8,8 +8,6 @@ import type { Tracking, WidgetExports, WidgetTransform, - WidgetScorerFunction, - WidgetValidatorFunction, PublicWidgetOptionsFunction, } from "./types"; import type { @@ -138,16 +136,6 @@ 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; -}; - export const getPublicWidgetOptionsFunction = ( name: string, ): PublicWidgetOptionsFunction => { From d2387fc980b918535b8ceef4617adf4e83997f55 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 10:47:44 -0600 Subject: [PATCH 03/25] [LEMS-2737/move-scoring-logic] WIP: handle noop scorer --- .../src/util}/score-noop.test.ts | 0 .../src/util}/score-noop.ts | 2 +- .../perseus-score/src/widgets/widget-registry.ts | 15 +++++++++++++++ packages/perseus/src/types.ts | 16 ---------------- .../src/widgets/categorizer/categorizer.tsx | 10 ---------- .../src/widgets/cs-program/cs-program.tsx | 4 ---- .../src/widgets/definition/definition.tsx | 3 --- .../deprecated-standin/deprecated-standin.tsx | 3 --- .../perseus/src/widgets/dropdown/dropdown.tsx | 7 ------- .../src/widgets/explanation/explanation.test.ts | 10 ---------- .../src/widgets/explanation/explanation.tsx | 3 --- .../src/widgets/expression/expression.tsx | 7 ------- packages/perseus/src/widgets/grapher/grapher.tsx | 4 ---- packages/perseus/src/widgets/iframe/iframe.tsx | 4 ---- packages/perseus/src/widgets/image/image.tsx | 3 --- .../src/widgets/input-number/input-number.tsx | 8 +------- .../src/widgets/interaction/interaction.tsx | 3 --- .../perseus/src/widgets/interactive-graph.tsx | 4 ---- .../src/widgets/label-image/label-image.tsx | 8 +------- packages/perseus/src/widgets/matcher/matcher.tsx | 4 ---- packages/perseus/src/widgets/matrix/matrix.tsx | 7 ------- .../perseus/src/widgets/measurer/measurer.tsx | 3 --- .../perseus/src/widgets/molecule/molecule.tsx | 2 -- .../src/widgets/number-line/number-line.tsx | 7 ------- .../src/widgets/numeric-input/numeric-input.tsx | 4 ---- packages/perseus/src/widgets/orderer/orderer.tsx | 7 ------- .../passage-ref-target/passage-ref-target.tsx | 3 --- .../src/widgets/passage-ref/passage-ref.tsx | 3 --- packages/perseus/src/widgets/passage/passage.tsx | 2 -- packages/perseus/src/widgets/plotter/plotter.tsx | 7 ------- packages/perseus/src/widgets/radio/radio.ts | 7 ------- packages/perseus/src/widgets/sorter/sorter.tsx | 7 ------- packages/perseus/src/widgets/table/table.tsx | 7 ------- packages/perseus/src/widgets/video/video.tsx | 3 --- 34 files changed, 18 insertions(+), 169 deletions(-) rename packages/{perseus/src/widgets/__shared__ => perseus-score/src/util}/score-noop.test.ts (100%) rename packages/{perseus/src/widgets/__shared__ => perseus-score/src/util}/score-noop.ts (88%) diff --git a/packages/perseus/src/widgets/__shared__/score-noop.test.ts b/packages/perseus-score/src/util/score-noop.test.ts similarity index 100% rename from packages/perseus/src/widgets/__shared__/score-noop.test.ts rename to packages/perseus-score/src/util/score-noop.test.ts diff --git a/packages/perseus/src/widgets/__shared__/score-noop.ts b/packages/perseus-score/src/util/score-noop.ts similarity index 88% rename from packages/perseus/src/widgets/__shared__/score-noop.ts rename to packages/perseus-score/src/util/score-noop.ts index 6896b95418..b3ce06d203 100644 --- a/packages/perseus/src/widgets/__shared__/score-noop.ts +++ b/packages/perseus-score/src/util/score-noop.ts @@ -1,4 +1,4 @@ -import type {PerseusScore} from "@khanacademy/perseus-score"; +import type {PerseusScore} from "../validation.types"; /** * Several widgets don't have "right"/"wrong" scoring logic, diff --git a/packages/perseus-score/src/widgets/widget-registry.ts b/packages/perseus-score/src/widgets/widget-registry.ts index 9a044a3be2..92cc53abf2 100644 --- a/packages/perseus-score/src/widgets/widget-registry.ts +++ b/packages/perseus-score/src/widgets/widget-registry.ts @@ -1,3 +1,5 @@ +import scoreNoop from "../util/score-noop"; + import scoreCategorizer from "./categorizer/score-categorizer"; import validateCategorizer from "./categorizer/validate-categorizer"; import scoreCSProgram from "./cs-program/score-cs-program"; @@ -86,3 +88,16 @@ registerWidget("plotter", scorePlotter as any, validatePlotter as any); registerWidget("radio", scoreRadio as any, validateRadio as any); registerWidget("sorter", scoreSorter as any, validateSorter as any); registerWidget("table", scoreTable as any, validateTable as any); + +registerWidget("deprecated-standin", () => scoreNoop(1) as any); +registerWidget("measurer", () => scoreNoop(1) as any); + +registerWidget("definition", scoreNoop as any); +registerWidget("explanation", scoreNoop as any); +registerWidget("image", scoreNoop as any); +registerWidget("interaction", scoreNoop as any); +registerWidget("molecule", scoreNoop as any); +registerWidget("passage", scoreNoop as any); +registerWidget("passage-ref", scoreNoop as any); +registerWidget("passage-ref-target", scoreNoop as any); +registerWidget("video", scoreNoop as any); diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index 58c297baba..6a16190a40 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -19,13 +19,10 @@ import type { } from "@khanacademy/perseus-core"; import type {LinterContextProps} from "@khanacademy/perseus-linter"; import type { - PerseusScore, Rubric, UserInput, UserInputArray, UserInputMap, - ValidationData, - ValidationResult, } from "@khanacademy/perseus-score"; import type {Result} from "@khanacademy/wonder-blocks-data"; import type * as React from "react"; @@ -560,19 +557,6 @@ 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. - */ - scorer?: WidgetScorerFunction; - /** * A function that provides a public version of the widget options that can * be shared with the client. diff --git a/packages/perseus/src/widgets/categorizer/categorizer.tsx b/packages/perseus/src/widgets/categorizer/categorizer.tsx index 32da3c1b8a..495d6927fe 100644 --- a/packages/perseus/src/widgets/categorizer/categorizer.tsx +++ b/packages/perseus/src/widgets/categorizer/categorizer.tsx @@ -4,10 +4,6 @@ import { getCategorizerPublicWidgetOptions, } from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; -import { - scoreCategorizer, - validateCategorizer, -} from "@khanacademy/perseus-score"; import {StyleSheet, css} from "aphrodite"; import classNames from "classnames"; import * as React from "react"; @@ -330,11 +326,5 @@ export default { ); }, isLintable: true, - // 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 e652ed6e5f..05918cba55 100644 --- a/packages/perseus/src/widgets/cs-program/cs-program.tsx +++ b/packages/perseus/src/widgets/cs-program/cs-program.tsx @@ -2,7 +2,6 @@ * This widget is for embedding Khan Academy CS programs. */ -import {scoreCSProgram} from "@khanacademy/perseus-score"; import {StyleSheet, css} from "aphrodite"; import $ from "jquery"; import * as React from "react"; @@ -201,7 +200,4 @@ export default { supportedAlignments: ["block", "full-width"], widget: CSProgram, hidden: true, - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusCSProgramUserInput'. - scorer: scoreCSProgram, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/definition/definition.tsx b/packages/perseus/src/widgets/definition/definition.tsx index 57e2fd394b..1790ac5dbd 100644 --- a/packages/perseus/src/widgets/definition/definition.tsx +++ b/packages/perseus/src/widgets/definition/definition.tsx @@ -7,7 +7,6 @@ import {PerseusI18nContext} from "../../components/i18n-context"; import {DefinitionConsumer} from "../../definition-context"; import Renderer from "../../renderer"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/definition/definition-ai-utils"; -import scoreNoop from "../__shared__/score-noop"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; import type {DefinitionPromptJSON} from "../../widget-ai-utils/definition/definition-ai-utils"; @@ -110,6 +109,4 @@ export default { defaultAlignment: "inline", widget: Definition, transform: (x: any) => x, - // TODO: things that aren't interactive shouldn't need scoring functions - scorer: () => scoreNoop(), } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/deprecated-standin/deprecated-standin.tsx b/packages/perseus/src/widgets/deprecated-standin/deprecated-standin.tsx index a9399c79b2..06fe54e309 100644 --- a/packages/perseus/src/widgets/deprecated-standin/deprecated-standin.tsx +++ b/packages/perseus/src/widgets/deprecated-standin/deprecated-standin.tsx @@ -2,7 +2,6 @@ import Banner from "@khanacademy/wonder-blocks-banner"; import React from "react"; import {PerseusI18nContext} from "../../components/i18n-context"; -import scoreNoop from "../__shared__/score-noop"; import type {Widget, WidgetExports} from "../../types"; @@ -41,6 +40,4 @@ export default { displayName: "Deprecated Standin", widget: DeprecatedStandin, hidden: true, - // TODO: things that aren't interactive shouldn't need scoring functions - scorer: () => scoreNoop(1), } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/dropdown/dropdown.tsx b/packages/perseus/src/widgets/dropdown/dropdown.tsx index 4ccb40a05e..35c8a3fcfb 100644 --- a/packages/perseus/src/widgets/dropdown/dropdown.tsx +++ b/packages/perseus/src/widgets/dropdown/dropdown.tsx @@ -1,4 +1,3 @@ -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"; @@ -170,10 +169,4 @@ export default { accessible: true, widget: Dropdown, transform: optionsTransform, - // 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/explanation/explanation.test.ts b/packages/perseus/src/widgets/explanation/explanation.test.ts index 43e37181ca..2abf990dd2 100644 --- a/packages/perseus/src/widgets/explanation/explanation.test.ts +++ b/packages/perseus/src/widgets/explanation/explanation.test.ts @@ -264,14 +264,4 @@ describe("Explanation", function () { expect(changeMock.mock.contexts[0]).toEqual(widget); expect(changeMock).toHaveBeenCalledWith("foo", "bar", callbackMock); }); - - describe("scorer", () => { - it("should always return 0 points", async () => { - const result = ExplanationWidgetExports?.scorer?.(); - - expect(result).toHaveBeenAnsweredCorrectly({ - shouldHavePoints: false, - }); - }); - }); }); diff --git a/packages/perseus/src/widgets/explanation/explanation.tsx b/packages/perseus/src/widgets/explanation/explanation.tsx index 48eb476e23..262cd21458 100644 --- a/packages/perseus/src/widgets/explanation/explanation.tsx +++ b/packages/perseus/src/widgets/explanation/explanation.tsx @@ -11,7 +11,6 @@ import {PerseusI18nContext} from "../../components/i18n-context"; import * as Changeable from "../../mixins/changeable"; import Renderer from "../../renderer"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/explanation/explanation-ai-utils"; -import scoreNoop from "../__shared__/score-noop"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; import type {ExplanationPromptJSON} from "../../widget-ai-utils/explanation/explanation-ai-utils"; @@ -227,6 +226,4 @@ export default { widget: Explanation, transform: _.identity, isLintable: true, - // TODO: things that aren't interactive shouldn't need scoring functions - scorer: () => scoreNoop(), } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/expression/expression.tsx b/packages/perseus/src/widgets/expression/expression.tsx index 91f1e24c5e..e3330e3aef 100644 --- a/packages/perseus/src/widgets/expression/expression.tsx +++ b/packages/perseus/src/widgets/expression/expression.tsx @@ -7,7 +7,6 @@ import { getExpressionPublicWidgetOptions, } from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; -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"; @@ -537,12 +536,6 @@ export default { // For use by the editor isLintable: true, - // 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, getPublicWidgetOptions: getExpressionPublicWidgetOptions, // TODO(LEMS-2656): remove TS suppression diff --git a/packages/perseus/src/widgets/grapher/grapher.tsx b/packages/perseus/src/widgets/grapher/grapher.tsx index 73c98f3e74..d42c83b430 100644 --- a/packages/perseus/src/widgets/grapher/grapher.tsx +++ b/packages/perseus/src/widgets/grapher/grapher.tsx @@ -4,7 +4,6 @@ import { point as kpoint, } from "@khanacademy/kmath"; import {GrapherUtil} from "@khanacademy/perseus-core"; -import {scoreGrapher} from "@khanacademy/perseus-score"; import * as React from "react"; import _ from "underscore"; @@ -659,7 +658,4 @@ export default { widget: Grapher, transform: propTransform, staticTransform: staticTransform, - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusGrapherUserInput'. - scorer: scoreGrapher, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/iframe/iframe.tsx b/packages/perseus/src/widgets/iframe/iframe.tsx index e28d557328..1fe142bb3b 100644 --- a/packages/perseus/src/widgets/iframe/iframe.tsx +++ b/packages/perseus/src/widgets/iframe/iframe.tsx @@ -7,7 +7,6 @@ * but could also be used for embedding viz's hosted elsewhere. */ -import {scoreIframe} from "@khanacademy/perseus-score"; import $ from "jquery"; import * as React from "react"; import _ from "underscore"; @@ -170,7 +169,4 @@ export default { widget: Iframe, // Let's not expose it to all content creators yet hidden: true, - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusIframeUserInput'. - scorer: scoreIframe, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/image/image.tsx b/packages/perseus/src/widgets/image/image.tsx index 941cf3af42..c73e56b61f 100644 --- a/packages/perseus/src/widgets/image/image.tsx +++ b/packages/perseus/src/widgets/image/image.tsx @@ -9,7 +9,6 @@ import SvgImage from "../../components/svg-image"; import * as Changeable from "../../mixins/changeable"; import Renderer from "../../renderer"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/image/image-ai-utils"; -import scoreNoop from "../__shared__/score-noop"; import type {ChangeFn, WidgetExports, WidgetProps, Widget} from "../../types"; import type {ImagePromptJSON} from "../../widget-ai-utils/image/image-ai-utils"; @@ -263,6 +262,4 @@ export default { displayName: "Image", widget: ImageWidget, isLintable: true, - // TODO: things that aren't interactive shouldn't need scoring functions - scorer: () => scoreNoop(), } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/input-number/input-number.tsx b/packages/perseus/src/widgets/input-number/input-number.tsx index 24f2af568d..0d961f5898 100644 --- a/packages/perseus/src/widgets/input-number/input-number.tsx +++ b/packages/perseus/src/widgets/input-number/input-number.tsx @@ -1,8 +1,5 @@ import {linterContextDefault} from "@khanacademy/perseus-linter"; -import { - inputNumberAnswerTypes, - scoreInputNumber, -} from "@khanacademy/perseus-score"; +import {inputNumberAnswerTypes} from "@khanacademy/perseus-score"; import {spacing} from "@khanacademy/wonder-blocks-tokens"; import {StyleSheet} from "aphrodite"; import * as React from "react"; @@ -286,9 +283,6 @@ export default { widget: InputNumber, transform: propTransform, isLintable: true, - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusInputNumberUserInput'. - scorer: scoreInputNumber, getOneCorrectAnswerFromRubric(rubric: any): string | null | undefined { if (rubric.value == null) { diff --git a/packages/perseus/src/widgets/interaction/interaction.tsx b/packages/perseus/src/widgets/interaction/interaction.tsx index e7eaed7e09..fb5c4586ed 100644 --- a/packages/perseus/src/widgets/interaction/interaction.tsx +++ b/packages/perseus/src/widgets/interaction/interaction.tsx @@ -9,7 +9,6 @@ import Graphie from "../../components/graphie"; import * as Changeable from "../../mixins/changeable"; import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/interaction/interaction-ai-utils"; -import scoreNoop from "../__shared__/score-noop"; import type {Coord} from "../../interactive2/types"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; @@ -812,6 +811,4 @@ export default { widget: Interaction, transform: _.identity, hidden: true, - // TODO: things that aren't interactive shouldn't need scoring functions - scorer: () => scoreNoop(), } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/interactive-graph.tsx b/packages/perseus/src/widgets/interactive-graph.tsx index 91950c7b6a..720d10bce4 100644 --- a/packages/perseus/src/widgets/interactive-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graph.tsx @@ -10,7 +10,6 @@ import { Errors, PerseusError, } from "@khanacademy/perseus-core"; -import {scoreInteractiveGraph} from "@khanacademy/perseus-score"; import $ from "jquery"; import debounce from "lodash.debounce"; import * as React from "react"; @@ -2518,7 +2517,4 @@ export default { displayName: "Interactive graph (Assessments only)", widget: InteractiveGraph, staticTransform: staticTransform, - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusInteractiveGraphUserInput'. - scorer: scoreInteractiveGraph, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/label-image/label-image.tsx b/packages/perseus/src/widgets/label-image/label-image.tsx index 34a5db8eb9..796a0e496e 100644 --- a/packages/perseus/src/widgets/label-image/label-image.tsx +++ b/packages/perseus/src/widgets/label-image/label-image.tsx @@ -6,10 +6,7 @@ * knowledge by directly interacting with the image. */ -import { - scoreLabelImageMarker, - scoreLabelImage, -} from "@khanacademy/perseus-score"; +import {scoreLabelImageMarker} from "@khanacademy/perseus-score"; import Clickable from "@khanacademy/wonder-blocks-clickable"; import {View} from "@khanacademy/wonder-blocks-core"; import {StyleSheet, css} from "aphrodite"; @@ -758,7 +755,4 @@ export default { widget: LabelImageWithDependencies, accessible: true, isLintable: true, - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusLabelImageUserInput'. - scorer: scoreLabelImage, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/matcher/matcher.tsx b/packages/perseus/src/widgets/matcher/matcher.tsx index 93eda02fd6..1b5fe4b6a6 100644 --- a/packages/perseus/src/widgets/matcher/matcher.tsx +++ b/packages/perseus/src/widgets/matcher/matcher.tsx @@ -1,5 +1,4 @@ import {linterContextDefault} from "@khanacademy/perseus-linter"; -import {scoreMatcher} from "@khanacademy/perseus-score"; import {CircularSpinner} from "@khanacademy/wonder-blocks-progress-spinner"; import {StyleSheet, css} from "aphrodite"; import * as React from "react"; @@ -287,7 +286,4 @@ export default { displayName: "Matcher (two column)", widget: Matcher, isLintable: true, - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusMatcherUserInput'. - scorer: scoreMatcher, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/matrix/matrix.tsx b/packages/perseus/src/widgets/matrix/matrix.tsx index 6f89f50d07..cb19570d60 100644 --- a/packages/perseus/src/widgets/matrix/matrix.tsx +++ b/packages/perseus/src/widgets/matrix/matrix.tsx @@ -4,7 +4,6 @@ import { type PerseusMatrixWidgetOptions, } from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; -import {scoreMatrix, validateMatrix} from "@khanacademy/perseus-score"; import {StyleSheet} from "aphrodite"; import classNames from "classnames"; import * as React from "react"; @@ -573,10 +572,4 @@ export default { transform: propTransform, staticTransform: staticTransform, isLintable: true, - // 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/measurer/measurer.tsx b/packages/perseus/src/widgets/measurer/measurer.tsx index 10c430aecc..007d4cc45f 100644 --- a/packages/perseus/src/widgets/measurer/measurer.tsx +++ b/packages/perseus/src/widgets/measurer/measurer.tsx @@ -10,7 +10,6 @@ import _ from "underscore"; import SvgImage from "../../components/svg-image"; import GraphUtils from "../../util/graph-utils"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/measurer/measurer-ai-utils"; -import scoreNoop from "../__shared__/score-noop"; import type {Coord} from "../../interactive2/types"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; @@ -191,6 +190,4 @@ export default { widget: Measurer, version: measurerLogic.version, propUpgrades: measurerLogic.widgetOptionsUpgrades, - // TODO: things that aren't interactive shouldn't need scoring functions - scorer: () => scoreNoop(1), } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/molecule/molecule.tsx b/packages/perseus/src/widgets/molecule/molecule.tsx index f4e87ae43c..5e260f5525 100644 --- a/packages/perseus/src/widgets/molecule/molecule.tsx +++ b/packages/perseus/src/widgets/molecule/molecule.tsx @@ -2,7 +2,6 @@ import * as React from "react"; import {PerseusI18nContext} from "../../components/i18n-context"; -import scoreNoop from "../__shared__/score-noop"; import draw from "./molecule-drawing"; import MoleculeLayout from "./molecule-layout"; @@ -160,5 +159,4 @@ export default { displayName: "Molecule renderer", hidden: true, widget: MoleculeWidget, - scorer: () => scoreNoop(), } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/number-line/number-line.tsx b/packages/perseus/src/widgets/number-line/number-line.tsx index 97924ff29d..496e1b783b 100644 --- a/packages/perseus/src/widgets/number-line/number-line.tsx +++ b/packages/perseus/src/widgets/number-line/number-line.tsx @@ -1,5 +1,4 @@ import {number as knumber, KhanMath} from "@khanacademy/kmath"; -import {scoreNumberLine, validateNumberLine} from "@khanacademy/perseus-score"; import * as React from "react"; import ReactDOM from "react-dom"; import _ from "underscore"; @@ -802,10 +801,4 @@ export default { widget: NumberLine, transform: numberLineTransform, staticTransform: staticTransform, - // 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.tsx b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx index 583a317488..18decbeb15 100644 --- a/packages/perseus/src/widgets/numeric-input/numeric-input.tsx +++ b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx @@ -1,6 +1,5 @@ import {KhanMath} from "@khanacademy/kmath"; import {linterContextDefault} from "@khanacademy/perseus-linter"; -import {scoreNumericInput} from "@khanacademy/perseus-score"; import {StyleSheet} from "aphrodite"; import * as React from "react"; import _ from "underscore"; @@ -376,9 +375,6 @@ export default { widget: NumericInput, transform: propsTransform, isLintable: true, - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusNumericInputUserInput'. - scorer: scoreNumericInput, // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type 'Rubric' is not assignable to type 'PerseusNumericInputRubric' diff --git a/packages/perseus/src/widgets/orderer/orderer.tsx b/packages/perseus/src/widgets/orderer/orderer.tsx index 3eade7e3f7..8afad9834d 100644 --- a/packages/perseus/src/widgets/orderer/orderer.tsx +++ b/packages/perseus/src/widgets/orderer/orderer.tsx @@ -2,7 +2,6 @@ /* eslint-disable @babel/no-invalid-this, react/no-unsafe */ import {Errors, getOrdererPublicWidgetOptions} from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; -import {scoreOrderer, validateOrderer} from "@khanacademy/perseus-score"; import $ from "jquery"; import * as React from "react"; import ReactDOM from "react-dom"; @@ -781,11 +780,5 @@ export default { hidden: true, widget: Orderer, isLintable: true, - // 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, getPublicWidgetOptions: getOrdererPublicWidgetOptions, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/passage-ref-target/passage-ref-target.tsx b/packages/perseus/src/widgets/passage-ref-target/passage-ref-target.tsx index 5565aa5c6e..90d723b209 100644 --- a/packages/perseus/src/widgets/passage-ref-target/passage-ref-target.tsx +++ b/packages/perseus/src/widgets/passage-ref-target/passage-ref-target.tsx @@ -6,7 +6,6 @@ import _ from "underscore"; import {PerseusI18nContext} from "../../components/i18n-context"; import * as Changeable from "../../mixins/changeable"; import Renderer from "../../renderer"; -import scoreNoop from "../__shared__/score-noop"; import type {APIOptions, WidgetExports, Widget} from "../../types"; import type {PerseusPassageRefTargetWidgetOptions} from "@khanacademy/perseus-core"; @@ -68,6 +67,4 @@ export default { return _.pick(editorProps, "content"); }, isLintable: true, - // TODO: things that aren't interactive shouldn't need scoring functions - scorer: () => scoreNoop(), } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/passage-ref/passage-ref.tsx b/packages/perseus/src/widgets/passage-ref/passage-ref.tsx index c7409a929b..2a79477c30 100644 --- a/packages/perseus/src/widgets/passage-ref/passage-ref.tsx +++ b/packages/perseus/src/widgets/passage-ref/passage-ref.tsx @@ -9,7 +9,6 @@ import {PerseusI18nContext} from "../../components/i18n-context"; import * as Changeable from "../../mixins/changeable"; import PerseusMarkdown from "../../perseus-markdown"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/passage-ref/passage-ref-ai-utils"; -import scoreNoop from "../__shared__/score-noop"; import {isPassageWidget} from "../passage/utils"; import type {ChangeFn, Widget, WidgetExports, WidgetProps} from "../../types"; @@ -189,6 +188,4 @@ export default { summaryText: widgetOptions.summaryText, }), version: passageRefLogic.version, - // TODO: things that aren't interactive shouldn't need scoring functions - scorer: () => scoreNoop(), } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/passage/passage.tsx b/packages/perseus/src/widgets/passage/passage.tsx index 90f5106fcd..8914dedfa0 100644 --- a/packages/perseus/src/widgets/passage/passage.tsx +++ b/packages/perseus/src/widgets/passage/passage.tsx @@ -10,7 +10,6 @@ import {PerseusI18nContext} from "../../components/i18n-context"; import {getDependencies} from "../../dependencies"; import Renderer from "../../renderer"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/passage/passage-ai-utils"; -import scoreNoop from "../__shared__/score-noop"; import PassageMarkdown from "./passage-markdown"; import {isPassageWidget} from "./utils"; @@ -564,5 +563,4 @@ export default { ); }, isLintable: true, - scorer: () => scoreNoop(), } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/plotter/plotter.tsx b/packages/perseus/src/widgets/plotter/plotter.tsx index d486489a98..b883fd5530 100644 --- a/packages/perseus/src/widgets/plotter/plotter.tsx +++ b/packages/perseus/src/widgets/plotter/plotter.tsx @@ -1,6 +1,5 @@ /* eslint-disable react/no-unsafe */ import {KhanMath} from "@khanacademy/kmath"; -import {scorePlotter, validatePlotter} from "@khanacademy/perseus-score"; import $ from "jquery"; import * as React from "react"; import ReactDOM from "react-dom"; @@ -1178,10 +1177,4 @@ export default { hidden: true, widget: Plotter, staticTransform: staticTransform, - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type UserInput is not assignable to type PerseusPlotterUserInput - scorer: scorePlotter, - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type UserInput is not assignable to type PerseusPlotterUserInput - validator: validatePlotter, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/radio/radio.ts b/packages/perseus/src/widgets/radio/radio.ts index ceeb1ac593..c6a1df2f97 100644 --- a/packages/perseus/src/widgets/radio/radio.ts +++ b/packages/perseus/src/widgets/radio/radio.ts @@ -2,7 +2,6 @@ import { radioLogic, type PerseusRadioWidgetOptions, } from "@khanacademy/perseus-core"; -import {scoreRadio, validateRadio} from "@khanacademy/perseus-score"; import _ from "underscore"; import Util from "../../util"; @@ -138,10 +137,4 @@ export default { version: radioLogic.version, propUpgrades: radioLogic.widgetOptionsUpgrades, isLintable: true, - // 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 91945c7082..24f7b5bbfb 100644 --- a/packages/perseus/src/widgets/sorter/sorter.tsx +++ b/packages/perseus/src/widgets/sorter/sorter.tsx @@ -1,5 +1,4 @@ import {linterContextDefault} from "@khanacademy/perseus-linter"; -import {scoreSorter, validateSorter} from "@khanacademy/perseus-score"; import * as React from "react"; import Sortable from "../../components/sortable"; @@ -132,10 +131,4 @@ export default { displayName: "Sorter", widget: Sorter, isLintable: true, - // 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 32e4af19ac..1388d49287 100644 --- a/packages/perseus/src/widgets/table/table.tsx +++ b/packages/perseus/src/widgets/table/table.tsx @@ -1,5 +1,4 @@ import {linterContextDefault} from "@khanacademy/perseus-linter"; -import {scoreTable, validateTable} from "@khanacademy/perseus-score"; import * as React from "react"; import ReactDOM from "react-dom"; import _ from "underscore"; @@ -323,10 +322,4 @@ export default { transform: propTransform, hidden: true, isLintable: true, - // 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; diff --git a/packages/perseus/src/widgets/video/video.tsx b/packages/perseus/src/widgets/video/video.tsx index f36f8c13f5..7bdc269be3 100644 --- a/packages/perseus/src/widgets/video/video.tsx +++ b/packages/perseus/src/widgets/video/video.tsx @@ -12,7 +12,6 @@ import {getDependencies} from "../../dependencies"; import * as Changeable from "../../mixins/changeable"; import a11y from "../../util/a11y"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/video/video-ai-utils"; -import scoreNoop from "../__shared__/score-noop"; import VideoTranscriptLink from "./video-transcript-link"; @@ -122,6 +121,4 @@ export default { defaultAlignment: "block", supportedAlignments: ["block", "float-left", "float-right", "full-width"], widget: Video, - // TODO: things that aren't interactive shouldn't need scoring functions - scorer: () => scoreNoop(), } satisfies WidgetExports; From 3523c8f96d9ab6cb3e1391efeb6edf692ed53c5b Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 10:56:19 -0600 Subject: [PATCH 04/25] [LEMS-2737/move-scoring-logic] WIP: move group scorer --- .../src/widgets/group/score-group.ts | 17 +++++++++-------- .../src/widgets/group/validate-group.ts | 13 +++++++------ .../src/widgets/widget-registry.ts | 3 +++ packages/perseus/src/widgets/group/group.tsx | 9 --------- 4 files changed, 19 insertions(+), 23 deletions(-) rename packages/{perseus => perseus-score}/src/widgets/group/score-group.ts (72%) rename packages/{perseus => perseus-score}/src/widgets/group/validate-group.ts (71%) diff --git a/packages/perseus/src/widgets/group/score-group.ts b/packages/perseus-score/src/widgets/group/score-group.ts similarity index 72% rename from packages/perseus/src/widgets/group/score-group.ts rename to packages/perseus-score/src/widgets/group/score-group.ts index 815bee021a..67c7d14771 100644 --- a/packages/perseus/src/widgets/group/score-group.ts +++ b/packages/perseus-score/src/widgets/group/score-group.ts @@ -1,12 +1,13 @@ -import { - flattenScores, - scoreWidgetsFunctional, - type PerseusGroupRubric, - type PerseusGroupUserInput, - type PerseusScore, -} from "@khanacademy/perseus-score"; - // The `group` widget is basically a widget hosting a full Perseus system in + +import {flattenScores, scoreWidgetsFunctional} from "../../score"; + +import type { + PerseusGroupRubric, + PerseusGroupUserInput, + PerseusScore, +} from "../../validation.types"; + // it. As such, scoring a group means scoring all widgets it contains. function scoreGroup( userInput: PerseusGroupUserInput, diff --git a/packages/perseus/src/widgets/group/validate-group.ts b/packages/perseus-score/src/widgets/group/validate-group.ts similarity index 71% rename from packages/perseus/src/widgets/group/validate-group.ts rename to packages/perseus-score/src/widgets/group/validate-group.ts index 0ce0161305..9e9efd5c53 100644 --- a/packages/perseus/src/widgets/group/validate-group.ts +++ b/packages/perseus-score/src/widgets/group/validate-group.ts @@ -1,9 +1,10 @@ -import { - emptyWidgetsFunctional, - type PerseusGroupUserInput, - type PerseusGroupValidationData, - type ValidationResult, -} from "@khanacademy/perseus-score"; +import {emptyWidgetsFunctional} from "../../validate"; + +import type { + PerseusGroupUserInput, + PerseusGroupValidationData, + ValidationResult, +} from "../../validation.types"; function validateGroup( userInput: PerseusGroupUserInput, diff --git a/packages/perseus-score/src/widgets/widget-registry.ts b/packages/perseus-score/src/widgets/widget-registry.ts index 92cc53abf2..37510e1f76 100644 --- a/packages/perseus-score/src/widgets/widget-registry.ts +++ b/packages/perseus-score/src/widgets/widget-registry.ts @@ -8,6 +8,8 @@ import validateDropdown from "./dropdown/validate-dropdown"; import scoreExpression from "./expression/score-expression"; import validateExpression from "./expression/validate-expression"; import scoreGrapher from "./grapher/score-grapher"; +import scoreGroup from "./group/score-group"; +import validateGroup from "./group/validate-group"; import scoreIframe from "./iframe/score-iframe"; import scoreInputNumber from "./input-number/score-input-number"; import scoreInteractiveGraph from "./interactive-graph/score-interactive-graph"; @@ -67,6 +69,7 @@ registerWidget("cs-program", scoreCSProgram as any); registerWidget("dropdown", scoreDropdown as any, validateDropdown as any); registerWidget("expression", scoreExpression as any, validateExpression as any); registerWidget("grapher", scoreGrapher as any); +registerWidget("group", scoreGroup as any, validateGroup as any); registerWidget("iframe", scoreIframe as any); registerWidget("input-number", scoreInputNumber as any); registerWidget("interactive-graph", scoreInteractiveGraph as any); diff --git a/packages/perseus/src/widgets/group/group.tsx b/packages/perseus/src/widgets/group/group.tsx index 73becb9a95..93a7eee5d5 100644 --- a/packages/perseus/src/widgets/group/group.tsx +++ b/packages/perseus/src/widgets/group/group.tsx @@ -8,9 +8,6 @@ import {ApiOptions} from "../../perseus-api"; 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, ChangeFn, @@ -209,12 +206,6 @@ export default { displayName: "Group (SAT only)", widget: Group, traverseChildWidgets: traverseChildWidgets, - // TODO(LEMS-2656): remove TS suppression - // @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; From 8fc194e9261cf31999d8881f2ca923cddb328506 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 11:19:13 -0600 Subject: [PATCH 05/25] [LEMS-2737/move-scoring-logic] WIP: move alignments --- packages/perseus-core/src/widgets/cs-program/index.ts | 1 + packages/perseus-core/src/widgets/definition/index.ts | 1 + packages/perseus-core/src/widgets/dropdown/index.ts | 1 + packages/perseus-core/src/widgets/explanation/index.ts | 1 + packages/perseus-core/src/widgets/expression/index.ts | 1 + packages/perseus-core/src/widgets/image/index.ts | 2 ++ packages/perseus-core/src/widgets/input-number/index.ts | 1 + packages/perseus-core/src/widgets/logic-export.types.ts | 3 +++ packages/perseus-core/src/widgets/numeric-input/index.ts | 1 + .../perseus-core/src/widgets/passage-ref-target/index.ts | 1 + packages/perseus-core/src/widgets/passage-ref/index.ts | 1 + packages/perseus-core/src/widgets/video/index.ts | 2 ++ packages/perseus/src/types.ts | 2 -- packages/perseus/src/widgets/cs-program/cs-program.tsx | 1 - packages/perseus/src/widgets/definition/definition.tsx | 1 - packages/perseus/src/widgets/dropdown/dropdown.tsx | 1 - packages/perseus/src/widgets/explanation/explanation.tsx | 1 - packages/perseus/src/widgets/expression/expression.tsx | 1 - packages/perseus/src/widgets/image/image.tsx | 8 +------- .../perseus/src/widgets/input-number/input-number.tsx | 1 - .../perseus/src/widgets/numeric-input/numeric-input.tsx | 1 - .../src/widgets/passage-ref-target/passage-ref-target.tsx | 1 - packages/perseus/src/widgets/passage-ref/passage-ref.tsx | 1 - packages/perseus/src/widgets/video/video.tsx | 2 -- 24 files changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/perseus-core/src/widgets/cs-program/index.ts b/packages/perseus-core/src/widgets/cs-program/index.ts index b7679a5532..75db9700b9 100644 --- a/packages/perseus-core/src/widgets/cs-program/index.ts +++ b/packages/perseus-core/src/widgets/cs-program/index.ts @@ -25,6 +25,7 @@ const defaultWidgetOptions: CSProgramDefaultWidgetOptions = { const csProgramWidgetLogic: WidgetLogic = { name: "cs-program", defaultWidgetOptions, + supportedAlignments: ["block", "full-width"], }; export default csProgramWidgetLogic; diff --git a/packages/perseus-core/src/widgets/definition/index.ts b/packages/perseus-core/src/widgets/definition/index.ts index b021308858..0ae33a2eaf 100644 --- a/packages/perseus-core/src/widgets/definition/index.ts +++ b/packages/perseus-core/src/widgets/definition/index.ts @@ -14,6 +14,7 @@ const defaultWidgetOptions: DefinitionDefaultWidgetOptions = { const definitionWidgetLogic: WidgetLogic = { name: "definition", defaultWidgetOptions, + defaultAlignment: "inline", }; export default definitionWidgetLogic; diff --git a/packages/perseus-core/src/widgets/dropdown/index.ts b/packages/perseus-core/src/widgets/dropdown/index.ts index 720041a20e..5c2055b7d9 100644 --- a/packages/perseus-core/src/widgets/dropdown/index.ts +++ b/packages/perseus-core/src/widgets/dropdown/index.ts @@ -19,6 +19,7 @@ const defaultWidgetOptions: DropdownDefaultWidgetOptions = { const dropdownWidgetLogic: WidgetLogic = { name: "definition", defaultWidgetOptions, + defaultAlignment: "inline-block", }; export default dropdownWidgetLogic; diff --git a/packages/perseus-core/src/widgets/explanation/index.ts b/packages/perseus-core/src/widgets/explanation/index.ts index 6398694a4f..b7eef62aed 100644 --- a/packages/perseus-core/src/widgets/explanation/index.ts +++ b/packages/perseus-core/src/widgets/explanation/index.ts @@ -16,6 +16,7 @@ const defaultWidgetOptions: ExplanationDefaultWidgetOptions = { const explanationWidgetLogic: WidgetLogic = { name: "explanation", defaultWidgetOptions, + defaultAlignment: "inline", }; export default explanationWidgetLogic; diff --git a/packages/perseus-core/src/widgets/expression/index.ts b/packages/perseus-core/src/widgets/expression/index.ts index 126c9fce98..70c8cf78c4 100644 --- a/packages/perseus-core/src/widgets/expression/index.ts +++ b/packages/perseus-core/src/widgets/expression/index.ts @@ -13,6 +13,7 @@ const expressionWidgetLogic: WidgetLogic = { version: currentVersion, widgetOptionsUpgrades: widgetOptionsUpgrades, defaultWidgetOptions: defaultWidgetOptions, + defaultAlignment: "inline-block", }; export default expressionWidgetLogic; diff --git a/packages/perseus-core/src/widgets/image/index.ts b/packages/perseus-core/src/widgets/image/index.ts index 88ac76f84e..bcf43634ff 100644 --- a/packages/perseus-core/src/widgets/image/index.ts +++ b/packages/perseus-core/src/widgets/image/index.ts @@ -26,6 +26,8 @@ const defaultWidgetOptions: ImageDefaultWidgetOptions = { const imageWidgetLogic: WidgetLogic = { name: "image", defaultWidgetOptions, + supportedAlignments: ["block", "full-width"], + defaultAlignment: "block", }; export default imageWidgetLogic; diff --git a/packages/perseus-core/src/widgets/input-number/index.ts b/packages/perseus-core/src/widgets/input-number/index.ts index a493eca545..1e817ef501 100644 --- a/packages/perseus-core/src/widgets/input-number/index.ts +++ b/packages/perseus-core/src/widgets/input-number/index.ts @@ -25,6 +25,7 @@ const defaultWidgetOptions: InputNumberDefaultWidgetOptions = { const inputNumberWidgetLogic: WidgetLogic = { name: "input-number", defaultWidgetOptions, + defaultAlignment: "inline-block", }; export default inputNumberWidgetLogic; diff --git a/packages/perseus-core/src/widgets/logic-export.types.ts b/packages/perseus-core/src/widgets/logic-export.types.ts index aa1e7514aa..0a5bc5ef6c 100644 --- a/packages/perseus-core/src/widgets/logic-export.types.ts +++ b/packages/perseus-core/src/widgets/logic-export.types.ts @@ -1,4 +1,5 @@ import type {Version} from "../data-schema"; +import type {Alignment} from "../types"; export type WidgetOptionsUpgradeMap = { // OldProps => NewProps, @@ -10,4 +11,6 @@ export type WidgetLogic = { version?: Version; widgetOptionsUpgrades?: WidgetOptionsUpgradeMap; defaultWidgetOptions?: any; + supportedAlignments?: ReadonlyArray; + defaultAlignment?: Alignment; }; diff --git a/packages/perseus-core/src/widgets/numeric-input/index.ts b/packages/perseus-core/src/widgets/numeric-input/index.ts index ae973ef2a0..44b2d5bd7a 100644 --- a/packages/perseus-core/src/widgets/numeric-input/index.ts +++ b/packages/perseus-core/src/widgets/numeric-input/index.ts @@ -27,6 +27,7 @@ const defaultWidgetOptions: NumericInputDefaultWidgetOptions = { const numericInputWidgetLogic: WidgetLogic = { name: "numeric-input", defaultWidgetOptions, + defaultAlignment: "inline-block", }; export default numericInputWidgetLogic; diff --git a/packages/perseus-core/src/widgets/passage-ref-target/index.ts b/packages/perseus-core/src/widgets/passage-ref-target/index.ts index b204baf665..bcd9afa477 100644 --- a/packages/perseus-core/src/widgets/passage-ref-target/index.ts +++ b/packages/perseus-core/src/widgets/passage-ref-target/index.ts @@ -13,6 +13,7 @@ const defaultWidgetOptions: PassageRefTargetDefaultWidgetOptions = { const passageRefTargetWidgetLogic: WidgetLogic = { name: "passageRefTarget", defaultWidgetOptions, + defaultAlignment: "inline", }; export default passageRefTargetWidgetLogic; diff --git a/packages/perseus-core/src/widgets/passage-ref/index.ts b/packages/perseus-core/src/widgets/passage-ref/index.ts index 52d1b74a71..c9d88b87a5 100644 --- a/packages/perseus-core/src/widgets/passage-ref/index.ts +++ b/packages/perseus-core/src/widgets/passage-ref/index.ts @@ -8,6 +8,7 @@ const passageRefWidgetLogic: WidgetLogic = { name: "passageRef", version: currentVersion, defaultWidgetOptions: defaultWidgetOptions, + defaultAlignment: "inline", }; export default passageRefWidgetLogic; diff --git a/packages/perseus-core/src/widgets/video/index.ts b/packages/perseus-core/src/widgets/video/index.ts index 29b2406d97..394f894e7a 100644 --- a/packages/perseus-core/src/widgets/video/index.ts +++ b/packages/perseus-core/src/widgets/video/index.ts @@ -13,6 +13,8 @@ const defaultWidgetOptions: VideoDefaultWidgetOptions = { const videoWidgetLogic: WidgetLogic = { name: "video", defaultWidgetOptions, + supportedAlignments: ["block", "float-left", "float-right", "full-width"], + defaultAlignment: "block", }; export default videoWidgetLogic; diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index 6a16190a40..1abb88dc49 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -539,8 +539,6 @@ export type WidgetExports< * This key defaults to `{major: 0, minor: 0}` if not provided. */ version?: Version; - supportedAlignments?: ReadonlyArray; - defaultAlignment?: Alignment; getDefaultAlignment?: () => Alignment; isLintable?: boolean; tracking?: Tracking; diff --git a/packages/perseus/src/widgets/cs-program/cs-program.tsx b/packages/perseus/src/widgets/cs-program/cs-program.tsx index 05918cba55..cd1a27fbba 100644 --- a/packages/perseus/src/widgets/cs-program/cs-program.tsx +++ b/packages/perseus/src/widgets/cs-program/cs-program.tsx @@ -197,7 +197,6 @@ const styles = StyleSheet.create({ export default { name: "cs-program", displayName: "CS Program", - supportedAlignments: ["block", "full-width"], widget: CSProgram, hidden: true, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/definition/definition.tsx b/packages/perseus/src/widgets/definition/definition.tsx index 1790ac5dbd..a7a62f93cf 100644 --- a/packages/perseus/src/widgets/definition/definition.tsx +++ b/packages/perseus/src/widgets/definition/definition.tsx @@ -106,7 +106,6 @@ export default { name: "definition", displayName: "Definition", accessible: true, - defaultAlignment: "inline", widget: Definition, transform: (x: any) => x, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/dropdown/dropdown.tsx b/packages/perseus/src/widgets/dropdown/dropdown.tsx index 35c8a3fcfb..430d5d6d0b 100644 --- a/packages/perseus/src/widgets/dropdown/dropdown.tsx +++ b/packages/perseus/src/widgets/dropdown/dropdown.tsx @@ -165,7 +165,6 @@ const optionsTransform: (arg1: PerseusDropdownWidgetOptions) => RenderProps = ( export default { name: "dropdown", displayName: "Drop down", - defaultAlignment: "inline-block", accessible: true, widget: Dropdown, transform: optionsTransform, diff --git a/packages/perseus/src/widgets/explanation/explanation.tsx b/packages/perseus/src/widgets/explanation/explanation.tsx index 262cd21458..22d5e07969 100644 --- a/packages/perseus/src/widgets/explanation/explanation.tsx +++ b/packages/perseus/src/widgets/explanation/explanation.tsx @@ -222,7 +222,6 @@ export default { name: "explanation", displayName: "Explanation", accessible: true, - defaultAlignment: "inline", widget: Explanation, transform: _.identity, isLintable: true, diff --git a/packages/perseus/src/widgets/expression/expression.tsx b/packages/perseus/src/widgets/expression/expression.tsx index e3330e3aef..2ba32eaa39 100644 --- a/packages/perseus/src/widgets/expression/expression.tsx +++ b/packages/perseus/src/widgets/expression/expression.tsx @@ -510,7 +510,6 @@ export default { name: "expression", displayName: "Expression / Equation", accessible: true, - defaultAlignment: "inline-block", widget: ExpressionWithDependencies, transform: (widgetOptions: PerseusExpressionWidgetOptions): RenderProps => { const { diff --git a/packages/perseus/src/widgets/image/image.tsx b/packages/perseus/src/widgets/image/image.tsx index c73e56b61f..5082f3b13f 100644 --- a/packages/perseus/src/widgets/image/image.tsx +++ b/packages/perseus/src/widgets/image/image.tsx @@ -22,10 +22,6 @@ const defaultBackgroundImage = { height: 0, } as const; -const editorAlignments = ["block", "full-width"] as const; - -const DEFAULT_ALIGNMENT = "block"; - type RenderProps = PerseusImageWidgetOptions; // there is no transform as part of exports type ExternalProps = WidgetProps; @@ -59,7 +55,7 @@ class ImageWidget extends React.Component implements Widget { declare context: React.ContextType; static defaultProps: DefaultProps = { - alignment: DEFAULT_ALIGNMENT, + alignment: "block", title: "", range: [defaultRange, defaultRange], box: [defaultBoxSize, defaultBoxSize], @@ -257,8 +253,6 @@ export default { const bgImage = widgetOptions.backgroundImage; return !(bgImage && bgImage.url && !widgetOptions.alt); }, - defaultAlignment: DEFAULT_ALIGNMENT, - supportedAlignments: editorAlignments, displayName: "Image", widget: ImageWidget, isLintable: true, diff --git a/packages/perseus/src/widgets/input-number/input-number.tsx b/packages/perseus/src/widgets/input-number/input-number.tsx index 0d961f5898..0731fe8e38 100644 --- a/packages/perseus/src/widgets/input-number/input-number.tsx +++ b/packages/perseus/src/widgets/input-number/input-number.tsx @@ -278,7 +278,6 @@ const propTransform = ( export default { name: "input-number", displayName: "Input number (deprecated - use numeric input instead)", - defaultAlignment: "inline-block", hidden: true, widget: InputNumber, transform: propTransform, diff --git a/packages/perseus/src/widgets/numeric-input/numeric-input.tsx b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx index 18decbeb15..7578503c29 100644 --- a/packages/perseus/src/widgets/numeric-input/numeric-input.tsx +++ b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx @@ -370,7 +370,6 @@ const propsTransform = function ( export default { name: "numeric-input", displayName: "Numeric input", - defaultAlignment: "inline-block", accessible: true, widget: NumericInput, transform: propsTransform, diff --git a/packages/perseus/src/widgets/passage-ref-target/passage-ref-target.tsx b/packages/perseus/src/widgets/passage-ref-target/passage-ref-target.tsx index 90d723b209..bd7d4c1a94 100644 --- a/packages/perseus/src/widgets/passage-ref-target/passage-ref-target.tsx +++ b/packages/perseus/src/widgets/passage-ref-target/passage-ref-target.tsx @@ -60,7 +60,6 @@ class PassageRefTarget extends React.Component implements Widget { export default { name: "passage-ref-target", displayName: "PassageRefTarget", - defaultAlignment: "inline", widget: PassageRefTarget, hidden: true, transform: (editorProps: any): any => { diff --git a/packages/perseus/src/widgets/passage-ref/passage-ref.tsx b/packages/perseus/src/widgets/passage-ref/passage-ref.tsx index 2a79477c30..faad514ea3 100644 --- a/packages/perseus/src/widgets/passage-ref/passage-ref.tsx +++ b/packages/perseus/src/widgets/passage-ref/passage-ref.tsx @@ -180,7 +180,6 @@ export default { name: "passage-ref", displayName: "PassageRef (SAT only)", hidden: true, - defaultAlignment: "inline", widget: PassageRef, transform: (widgetOptions: PerseusPassageRefWidgetOptions) => ({ passageNumber: widgetOptions.passageNumber, diff --git a/packages/perseus/src/widgets/video/video.tsx b/packages/perseus/src/widgets/video/video.tsx index 7bdc269be3..c2c7d58c24 100644 --- a/packages/perseus/src/widgets/video/video.tsx +++ b/packages/perseus/src/widgets/video/video.tsx @@ -118,7 +118,5 @@ class Video extends React.Component implements Widget { export default { name: "video", displayName: "Video", - defaultAlignment: "block", - supportedAlignments: ["block", "float-left", "float-right", "full-width"], widget: Video, } satisfies WidgetExports; From 15c03e5bd5360f48bade13d4e359cb7406e35d21 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 11:47:02 -0600 Subject: [PATCH 06/25] [LEMS-2737/move-scoring-logic] WIP: Perseus Core registry --- .../src/widgets/graded-group-set/index.ts | 4 +- .../src/widgets/graded-group/index.ts | 4 +- .../perseus-core/src/widgets/group/index.ts | 4 +- .../src/widgets/phet-simulation/index.ts | 4 +- .../src/widgets/widget-registry.ts | 71 +++++++++++++++++++ packages/perseus/src/types.ts | 2 - packages/perseus/src/widgets.ts | 1 - 7 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 packages/perseus-core/src/widgets/widget-registry.ts diff --git a/packages/perseus-core/src/widgets/graded-group-set/index.ts b/packages/perseus-core/src/widgets/graded-group-set/index.ts index 6fa59fdc6d..bbe3f927c4 100644 --- a/packages/perseus-core/src/widgets/graded-group-set/index.ts +++ b/packages/perseus-core/src/widgets/graded-group-set/index.ts @@ -10,9 +10,9 @@ const defaultWidgetOptions: GradedGroupSetDefaultWidgetOptions = { gradedGroups: [], }; -const GradedGroupSetWidgetLogic: WidgetLogic = { +const gradedGroupSetWidgetLogic: WidgetLogic = { name: "graded-group-set", defaultWidgetOptions, }; -export default GradedGroupSetWidgetLogic; +export default gradedGroupSetWidgetLogic; diff --git a/packages/perseus-core/src/widgets/graded-group/index.ts b/packages/perseus-core/src/widgets/graded-group/index.ts index c1a88c93b9..585ba26502 100644 --- a/packages/perseus-core/src/widgets/graded-group/index.ts +++ b/packages/perseus-core/src/widgets/graded-group/index.ts @@ -14,9 +14,9 @@ const defaultWidgetOptions: GradedGroupDefaultWidgetOptions = { hint: null, }; -const GradedGroupWidgetLogic: WidgetLogic = { +const gradedGroupWidgetLogic: WidgetLogic = { name: "graded-group", defaultWidgetOptions, }; -export default GradedGroupWidgetLogic; +export default gradedGroupWidgetLogic; diff --git a/packages/perseus-core/src/widgets/group/index.ts b/packages/perseus-core/src/widgets/group/index.ts index ffd15b1a58..12ce86763f 100644 --- a/packages/perseus-core/src/widgets/group/index.ts +++ b/packages/perseus-core/src/widgets/group/index.ts @@ -15,9 +15,9 @@ const defaultWidgetOptions: GroupDefaultWidgetOptions = { metadata: undefined, }; -const GroupWidgetLogic: WidgetLogic = { +const groupWidgetLogic: WidgetLogic = { name: "group", defaultWidgetOptions, }; -export default GroupWidgetLogic; +export default groupWidgetLogic; diff --git a/packages/perseus-core/src/widgets/phet-simulation/index.ts b/packages/perseus-core/src/widgets/phet-simulation/index.ts index 4f7ff375ac..4e966a7e78 100644 --- a/packages/perseus-core/src/widgets/phet-simulation/index.ts +++ b/packages/perseus-core/src/widgets/phet-simulation/index.ts @@ -11,9 +11,9 @@ const defaultWidgetOptions: PhetSimulationDefaultWidgetOptions = { description: "", }; -const PhetSimulationWidgetLogic: WidgetLogic = { +const phetSimulationWidgetLogic: WidgetLogic = { name: "phet-simulation", defaultWidgetOptions, }; -export default PhetSimulationWidgetLogic; +export default phetSimulationWidgetLogic; diff --git a/packages/perseus-core/src/widgets/widget-registry.ts b/packages/perseus-core/src/widgets/widget-registry.ts new file mode 100644 index 0000000000..4fabc3ebed --- /dev/null +++ b/packages/perseus-core/src/widgets/widget-registry.ts @@ -0,0 +1,71 @@ +import categorizerWidgetLogic from "./categorizer"; +import csProgramWidgetLogic from "./cs-program"; +import definitionWidgetLogic from "./definition"; +import dropdownWidgetLogic from "./dropdown"; +import explanationWidgetLogic from "./explanation"; +import expressionWidgetLogic from "./expression"; +import gradedGroupWidgetLogic from "./graded-group"; +import gradedGroupSetWidgetLogic from "./graded-group-set"; +import groupWidgetLogic from "./group"; +import iframeWidgetLogic from "./iframe"; +import imageWidgetLogic from "./image"; +import inputNumberWidgetLogic from "./input-number"; +import interactionWidgetLogic from "./interaction"; +import interactiveGraphWidgetLogic from "./interactive-graph"; +import labelImageWidgetLogic from "./label-image"; +import matcherWidgetLogic from "./matcher"; +import matrixWidgetLogic from "./matrix"; +import measurerWidgetLogic from "./measurer"; +import numberLineWidgetLogic from "./number-line"; +import numericInputWidgetLogic from "./numeric-input"; +import ordererWidgetLogic from "./orderer"; +import passageWidgetLogic from "./passage"; +import passageRefWidgetLogic from "./passage-ref"; +import passageRefTargetWidgetLogic from "./passage-ref-target"; +import phetSimulationWidgetLogic from "./phet-simulation"; +import plotterWidgetLogic from "./plotter"; +import pythonProgramWidgetLogic from "./python-program"; +import radioWidgetLogic from "./radio"; +import sorterWidgetLogic from "./sorter"; +import tableWidgetLogic from "./table"; +import videoWidgetLogic from "./video"; + +import type {WidgetLogic} from "./logic-export.types"; + +const widgets = {}; + +function registerWidget(type: string, logic: WidgetLogic) { + widgets[type] = logic; +} + +registerWidget("categorizer", categorizerWidgetLogic); +registerWidget("cs-program", csProgramWidgetLogic); +registerWidget("definition", definitionWidgetLogic); +registerWidget("dropdown", dropdownWidgetLogic); +registerWidget("explanation", explanationWidgetLogic); +registerWidget("expression", expressionWidgetLogic); +registerWidget("graded-group", gradedGroupWidgetLogic); +registerWidget("graded-group-set", gradedGroupSetWidgetLogic); +registerWidget("group", groupWidgetLogic); +registerWidget("iframe", iframeWidgetLogic); +registerWidget("image", imageWidgetLogic); +registerWidget("input-number", inputNumberWidgetLogic); +registerWidget("interaction", interactionWidgetLogic); +registerWidget("interactive-graph", interactiveGraphWidgetLogic); +registerWidget("label-image", labelImageWidgetLogic); +registerWidget("matcher", matcherWidgetLogic); +registerWidget("matrix", matrixWidgetLogic); +registerWidget("measurer", measurerWidgetLogic); +registerWidget("number-line", numberLineWidgetLogic); +registerWidget("numeric-input", numericInputWidgetLogic); +registerWidget("orderer", ordererWidgetLogic); +registerWidget("passage", passageWidgetLogic); +registerWidget("passage-ref", passageRefWidgetLogic); +registerWidget("passage-ref-target", passageRefTargetWidgetLogic); +registerWidget("phet-simulation", phetSimulationWidgetLogic); +registerWidget("plotter", plotterWidgetLogic); +registerWidget("python-program", pythonProgramWidgetLogic); +registerWidget("radio", radioWidgetLogic); +registerWidget("sorter", sorterWidgetLogic); +registerWidget("table", tableWidgetLogic); +registerWidget("video", videoWidgetLogic); diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index 1abb88dc49..cc9b706926 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -15,7 +15,6 @@ import type { getOrdererPublicWidgetOptions, getCategorizerPublicWidgetOptions, getExpressionPublicWidgetOptions, - Alignment, } from "@khanacademy/perseus-core"; import type {LinterContextProps} from "@khanacademy/perseus-linter"; import type { @@ -539,7 +538,6 @@ export type WidgetExports< * This key defaults to `{major: 0, minor: 0}` if not provided. */ version?: Version; - getDefaultAlignment?: () => Alignment; isLintable?: boolean; tracking?: Tracking; diff --git a/packages/perseus/src/widgets.ts b/packages/perseus/src/widgets.ts index 3816a70aab..c4bbe44a7d 100644 --- a/packages/perseus/src/widgets.ts +++ b/packages/perseus/src/widgets.ts @@ -261,7 +261,6 @@ export const traverseChildWidgets = ( */ export const getDefaultAlignment = (type: string): Alignment => { const widgetExports = widgets[type]; - let alignment; if (!widgetExports) { return DEFAULT_ALIGNMENT; } From 39efebe29eee52a93a164fdc66b60ab8dfd6f286 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 11:58:26 -0600 Subject: [PATCH 07/25] [LEMS-2737/move-scoring-logic] WIP: getSupportedAlignments --- packages/perseus-core/src/index.ts | 2 + packages/perseus-core/src/widgets/upgrade.ts | 35 +-------------- .../src/widgets/widget-registry.ts | 43 +++++++++++++++++++ .../src/components/widget-editor.tsx | 8 +++- .../src/diffs/renderer-diff.tsx | 5 +-- packages/perseus-editor/src/editor.tsx | 4 +- packages/perseus/src/widget-container.tsx | 7 ++- packages/perseus/src/widgets.ts | 24 ----------- 8 files changed, 62 insertions(+), 66 deletions(-) diff --git a/packages/perseus-core/src/index.ts b/packages/perseus-core/src/index.ts index 3b74899dbf..018b5e1211 100644 --- a/packages/perseus-core/src/index.ts +++ b/packages/perseus-core/src/index.ts @@ -118,6 +118,8 @@ export { export type * from "./widgets/logic-export.types"; +export * as WidgetLogic from "./widgets/widget-registry"; + export {default as getOrdererPublicWidgetOptions} from "./widgets/orderer/orderer-util"; export {default as getCategorizerPublicWidgetOptions} from "./widgets/categorizer/categorizer-util"; export {default as getExpressionPublicWidgetOptions} from "./widgets/expression/expression-util"; diff --git a/packages/perseus-core/src/widgets/upgrade.ts b/packages/perseus-core/src/widgets/upgrade.ts index faba3c1194..47026d8584 100644 --- a/packages/perseus-core/src/widgets/upgrade.ts +++ b/packages/perseus-core/src/widgets/upgrade.ts @@ -4,43 +4,12 @@ import {Errors} from "../error/errors"; import {PerseusError} from "../error/perseus-error"; import {mapObject} from "../utils/objective_"; +import {getSupportedAlignments} from "./widget-registry"; + import type {PerseusWidget, PerseusWidgetsMap} from "../data-schema"; -import type {Alignment} from "../types"; const DEFAULT_STATIC = false; -// START: STOPSHIP / HACK -// do we really need this?? -const widgetSupportedAlignments = { - "cs-program": ["block", "full-width"], - image: ["block", "full-width"], - video: ["block", "float-left", "float-right", "full-width"], -}; - -// NOTE(kevinb): "default" is not one in `validAlignments`. -const DEFAULT_SUPPORTED_ALIGNMENTS = ["default"]; - -/** - * Handling for the optional alignments for widgets - * See widget-container.jsx for details on how alignments are implemented. - */ - -/** - * Returns the list of supported alignments for the given (string) widget - * type. This is used primarily at editing time to display the choices - * for the user. - * - * Supported alignments are given as an array of strings in the exports of - * a widget's module. - */ -export const getSupportedAlignments = ( - type: string, -): ReadonlyArray => { - const supportedAlignments = widgetSupportedAlignments[type]; - return supportedAlignments || DEFAULT_SUPPORTED_ALIGNMENTS; -}; -// END: STOPSHIP / HACK - export const upgradeWidgetInfoToLatestVersion = ( oldWidgetInfo: PerseusWidget, ): PerseusWidget => { diff --git a/packages/perseus-core/src/widgets/widget-registry.ts b/packages/perseus-core/src/widgets/widget-registry.ts index 4fabc3ebed..6dc79746cd 100644 --- a/packages/perseus-core/src/widgets/widget-registry.ts +++ b/packages/perseus-core/src/widgets/widget-registry.ts @@ -31,6 +31,7 @@ import tableWidgetLogic from "./table"; import videoWidgetLogic from "./video"; import type {WidgetLogic} from "./logic-export.types"; +import type {Alignment} from "../types"; const widgets = {}; @@ -38,6 +39,48 @@ function registerWidget(type: string, logic: WidgetLogic) { widgets[type] = logic; } +/** + * Handling for the optional alignments for widgets + * See widget-container.jsx for details on how alignments are implemented. + */ + +/** + * Returns the list of supported alignments for the given (string) widget + * type. This is used primarily at editing time to display the choices + * for the user. + * + * Supported alignments are given as an array of strings in the exports of + * a widget's module. + */ + +// NOTE(kevinb): "default" is not one in `validAlignments`. +const DEFAULT_SUPPORTED_ALIGNMENTS = ["default"]; +export const getSupportedAlignments = ( + type: string, +): ReadonlyArray => { + const widgetLogic = widgets[type]; + return widgetLogic?.supportedAlignments || DEFAULT_SUPPORTED_ALIGNMENTS; +}; + +/** + * For the given (string) widget type, determine the default alignment for + * the widget. This is used at rendering time to go from "default" alignment + * to the actual alignment displayed on the screen. + * + * The default alignment is given either as a string (called + * `defaultAlignment`) or a function (called `getDefaultAlignment`) on + * the exports of a widget's module. + */ +const DEFAULT_ALIGNMENT = "block"; +export const getDefaultAlignment = (type: string): Alignment => { + const widgetLogic = widgets[type]; + if (!widgetLogic) { + return DEFAULT_ALIGNMENT; + } + + return widgetLogic.defaultAlignment || DEFAULT_ALIGNMENT; +}; + registerWidget("categorizer", categorizerWidgetLogic); registerWidget("cs-program", csProgramWidgetLogic); registerWidget("definition", definitionWidgetLogic); diff --git a/packages/perseus-editor/src/components/widget-editor.tsx b/packages/perseus-editor/src/components/widget-editor.tsx index 40c57b213d..d90fc915d9 100644 --- a/packages/perseus-editor/src/components/widget-editor.tsx +++ b/packages/perseus-editor/src/components/widget-editor.tsx @@ -6,6 +6,11 @@ import { iconChevronDown, iconTrash, } from "@khanacademy/perseus"; +import { + WidgetLogic, + type Alignment, + type PerseusWidget, +} from "@khanacademy/perseus-core"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import Switch from "@khanacademy/wonder-blocks-switch"; import {spacing} from "@khanacademy/wonder-blocks-tokens"; @@ -19,7 +24,6 @@ import SectionControlButton from "./section-control-button"; import type Editor from "../editor"; import type {APIOptions} from "@khanacademy/perseus"; -import type {Alignment, PerseusWidget} from "@khanacademy/perseus-core"; const {InlineIcon} = components; @@ -152,7 +156,7 @@ class WidgetEditor extends React.Component< const Ed = Widgets.getEditor(widgetInfo.type); let supportedAlignments: ReadonlyArray; if (this.props.apiOptions.showAlignmentOptions) { - supportedAlignments = Widgets.getSupportedAlignments( + supportedAlignments = WidgetLogic.getSupportedAlignments( widgetInfo.type, ); } else { diff --git a/packages/perseus-editor/src/diffs/renderer-diff.tsx b/packages/perseus-editor/src/diffs/renderer-diff.tsx index ab4a0bffde..0eb758369d 100644 --- a/packages/perseus-editor/src/diffs/renderer-diff.tsx +++ b/packages/perseus-editor/src/diffs/renderer-diff.tsx @@ -2,14 +2,13 @@ import {Widgets} from "@khanacademy/perseus"; /** * A side by side diff view for Perseus renderers. */ +import {WidgetLogic, type PerseusRenderer} from "@khanacademy/perseus-core"; import * as React from "react"; import _ from "underscore"; import TextDiff from "./text-diff"; import WidgetDiff from "./widget-diff"; -import type {PerseusRenderer} from "@khanacademy/perseus-core"; - // In diffs, only show the widgetInfo props that can change const filterWidgetInfo = function (widgetInfo, showAlignmentOptions: boolean) { const {alignment, graded, options, type} = widgetInfo || {}; @@ -19,7 +18,7 @@ const filterWidgetInfo = function (widgetInfo, showAlignmentOptions: boolean) { // Show alignment options iff multiple valid ones exist for this widget if ( showAlignmentOptions && - Widgets.getSupportedAlignments(type).length > 1 + WidgetLogic.getSupportedAlignments(type).length > 1 ) { // @ts-expect-error - TS2339 - Property 'alignment' does not exist on type '{ readonly options: any; }'. filteredWidgetInfo.alignment = alignment; diff --git a/packages/perseus-editor/src/editor.tsx b/packages/perseus-editor/src/editor.tsx index 99a357b3c3..c5e42d1cab 100644 --- a/packages/perseus-editor/src/editor.tsx +++ b/packages/perseus-editor/src/editor.tsx @@ -6,7 +6,7 @@ import { Util, Widgets, } from "@khanacademy/perseus"; -import {Errors, PerseusError} from "@khanacademy/perseus-core"; +import {Errors, PerseusError, WidgetLogic} from "@khanacademy/perseus-core"; import $ from "jquery"; // eslint-disable-next-line import/no-unresolved, import/no-extraneous-dependencies import katex from "katex"; @@ -673,7 +673,7 @@ class Editor extends React.Component { const widgetContent = widgetPlaceholder.replace("{id}", id); // Add newlines before block-display widgets like graphs - const isBlock = Widgets.getDefaultAlignment(widgetType) === "block"; + const isBlock = WidgetLogic.getDefaultAlignment(widgetType) === "block"; const prelude = oldContent.slice(0, cursorRange[0]); const postlude = oldContent.slice(cursorRange[1]); diff --git a/packages/perseus/src/widget-container.tsx b/packages/perseus/src/widget-container.tsx index 2e5a6d18f9..409264c8b5 100644 --- a/packages/perseus/src/widget-container.tsx +++ b/packages/perseus/src/widget-container.tsx @@ -1,4 +1,8 @@ /* eslint-disable react/no-unsafe */ +import { + WidgetLogic, + type PerseusWidgetOptions, +} from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; import classNames from "classnames"; import * as React from "react"; @@ -11,7 +15,6 @@ import {containerSizeClass, getClassFromWidth} from "./util/sizing-utils"; import * as Widgets from "./widgets"; import type {WidgetProps} from "./types"; -import type {PerseusWidgetOptions} from "@khanacademy/perseus-core"; import type {LinterContextProps} from "@khanacademy/perseus-linter"; type Props = { @@ -117,7 +120,7 @@ class WidgetContainer extends React.Component { let alignment = this.state.widgetProps.alignment; if (alignment === "default") { - alignment = Widgets.getDefaultAlignment(type); + alignment = WidgetLogic.getDefaultAlignment(type); } className += " widget-" + alignment; diff --git a/packages/perseus/src/widgets.ts b/packages/perseus/src/widgets.ts index c4bbe44a7d..9bcb791e08 100644 --- a/packages/perseus/src/widgets.ts +++ b/packages/perseus/src/widgets.ts @@ -17,7 +17,6 @@ import type { } from "@khanacademy/perseus-core"; import type * as React from "react"; -const DEFAULT_ALIGNMENT = "block"; const DEFAULT_TRACKING = ""; const DEFAULT_LINTABLE = false; @@ -250,29 +249,6 @@ export const traverseChildWidgets = ( return widgetInfo; }; -/** - * For the given (string) widget type, determine the default alignment for - * the widget. This is used at rendering time to go from "default" alignment - * to the actual alignment displayed on the screen. - * - * The default alignment is given either as a string (called - * `defaultAlignment`) or a function (called `getDefaultAlignment`) on - * the exports of a widget's module. - */ -export const getDefaultAlignment = (type: string): Alignment => { - const widgetExports = widgets[type]; - if (!widgetExports) { - return DEFAULT_ALIGNMENT; - } - - if (widgetExports.getDefaultAlignment) { - alignment = widgetExports.getDefaultAlignment(); - } else { - alignment = widgetExports.defaultAlignment; - } - return alignment || DEFAULT_ALIGNMENT; -}; - const validAlignments: ReadonlyArray = [ "block", "inline-block", From 0934f34963de4288c0ec2d86ee2713293f5cd4a3 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 12:10:12 -0600 Subject: [PATCH 08/25] [LEMS-2737/move-scoring-logic] WIP: add registry helpers --- packages/perseus-core/src/widgets/upgrade.ts | 33 ++++++----- .../src/widgets/widget-registry.ts | 20 +++++++ packages/perseus/src/widgets.ts | 58 +------------------ 3 files changed, 40 insertions(+), 71 deletions(-) diff --git a/packages/perseus-core/src/widgets/upgrade.ts b/packages/perseus-core/src/widgets/upgrade.ts index 47026d8584..09120600f0 100644 --- a/packages/perseus-core/src/widgets/upgrade.ts +++ b/packages/perseus-core/src/widgets/upgrade.ts @@ -4,7 +4,13 @@ import {Errors} from "../error/errors"; import {PerseusError} from "../error/perseus-error"; import {mapObject} from "../utils/objective_"; -import {getSupportedAlignments} from "./widget-registry"; +import { + getCurrentVersion, + getDefaultWidgetOptions, + getSupportedAlignments, + getWidgetOptionsUpgrades, + isWidgetRegistered, +} from "./widget-registry"; import type {PerseusWidget, PerseusWidgetsMap} from "../data-schema"; @@ -24,9 +30,8 @@ export const upgradeWidgetInfoToLatestVersion = ( Errors.Internal, ); } - const widgetExports = widgets[type]; - if (widgetExports == null) { + if (!isWidgetRegistered(type)) { // If we have a widget that isn't registered, we can't upgrade it // TODO(aria): Figure out what the best thing to do here would be return oldWidgetInfo; @@ -34,7 +39,7 @@ export const upgradeWidgetInfoToLatestVersion = ( // Unversioned widgets (pre-July 2014) are all implicitly 0.0 const initialVersion = oldWidgetInfo.version || {major: 0, minor: 0}; - const latestVersion = widgetExports.version || {major: 0, minor: 0}; + const latestVersion = getCurrentVersion(type); // If the widget version is later than what we understand (major // version is higher than latest, or major versions are equal and minor @@ -50,15 +55,15 @@ export const upgradeWidgetInfoToLatestVersion = ( // We do a clone here so that it's safe to mutate the input parameter // in propUpgrades functions (which I will probably accidentally do at // some point, and we would like to not break when that happens). - let newEditorProps = _.clone(oldWidgetInfo.options) || {}; + let newEditorOptions = _.clone(oldWidgetInfo.options) || {}; - const upgradePropsMap = widgetExports.propUpgrades || {}; + const upgradePropsMap = getWidgetOptionsUpgrades(type); // Empty props usually mean a newly created widget by the editor, // and are always considerered up-to-date. // Mostly, we'd rather not run upgrade functions on props that are // not complete. - if (_.keys(newEditorProps).length !== 0) { + if (_.keys(newEditorOptions).length !== 0) { // We loop through all the versions after the current version of // the loaded widget, up to and including the latest version of the // loaded widget, and run the upgrade function to bring our loaded @@ -72,8 +77,8 @@ export const upgradeWidgetInfoToLatestVersion = ( nextVersion++ ) { if (upgradePropsMap[String(nextVersion)]) { - newEditorProps = - upgradePropsMap[String(nextVersion)](newEditorProps); + newEditorOptions = + upgradePropsMap[String(nextVersion)](newEditorOptions); } else { // This is a Log.error because it is unlikely to be hit in // local testing, and a Log.error is slightly less scary in @@ -101,10 +106,10 @@ export const upgradeWidgetInfoToLatestVersion = ( // Minor version upgrades (eg. new optional props) don't have // transform functions. Instead, we fill in the new props with their // defaults. - const defaultProps = type in editors ? editors[type].defaultProps : {}; - newEditorProps = { - ...defaultProps, - ...newEditorProps, + const defaultOptions = getDefaultWidgetOptions(type); + newEditorOptions = { + ...defaultOptions, + ...newEditorOptions, }; let alignment = oldWidgetInfo.alignment; @@ -131,7 +136,7 @@ export const upgradeWidgetInfoToLatestVersion = ( graded: oldWidgetInfo.graded != null ? oldWidgetInfo.graded : true, alignment: alignment, static: widgetStatic, - options: newEditorProps, + options: newEditorOptions, }); }; diff --git a/packages/perseus-core/src/widgets/widget-registry.ts b/packages/perseus-core/src/widgets/widget-registry.ts index 6dc79746cd..416636a93c 100644 --- a/packages/perseus-core/src/widgets/widget-registry.ts +++ b/packages/perseus-core/src/widgets/widget-registry.ts @@ -39,6 +39,26 @@ function registerWidget(type: string, logic: WidgetLogic) { widgets[type] = logic; } +export function isWidgetRegistered(type: string) { + const widgetLogic = widgets[type]; + return !!widgetLogic; +} + +export function getCurrentVersion(type: string) { + const widgetLogic = widgets[type]; + return widgetLogic?.version || {major: 0, minor: 0}; +} + +export function getWidgetOptionsUpgrades(type: string) { + const widgetLogic = widgets[type]; + return widgetLogic?.widgetOptionsUpgrades || {}; +} + +export function getDefaultWidgetOptions(type: string) { + const widgetLogic = widgets[type]; + return widgetLogic?.defaultWidgetOptions || {}; +} + /** * Handling for the optional alignments for widgets * See widget-container.jsx for details on how alignments are implemented. diff --git a/packages/perseus/src/widgets.ts b/packages/perseus/src/widgets.ts index 9bcb791e08..36a98c473b 100644 --- a/packages/perseus/src/widgets.ts +++ b/packages/perseus/src/widgets.ts @@ -10,11 +10,7 @@ import type { WidgetTransform, PublicWidgetOptionsFunction, } from "./types"; -import type { - Alignment, - PerseusWidget, - Version, -} from "@khanacademy/perseus-core"; +import type {PerseusWidget, Version} from "@khanacademy/perseus-core"; import type * as React from "react"; const DEFAULT_TRACKING = ""; @@ -39,8 +35,6 @@ export const registerWidgets = (widgets: ReadonlyArray) => { widgets.forEach((widget) => { registerWidget(widget.name, widget); }); - - validateAlignments(); }; /** @@ -249,56 +243,6 @@ export const traverseChildWidgets = ( return widgetInfo; }; -const validAlignments: ReadonlyArray = [ - "block", - "inline-block", - "inline", - "float-left", - "float-right", - "full-width", -]; - -/** - * Used at startup to fail fast if an alignment given by a widget is - * invalid. - */ -// TODO(alex): Change this to run as a testcase (vs. being run at runtime) -// TODO(LP-10707): I think this can be completely removed because our TypeScript types -// enforce this! -export const validateAlignments = () => { - _.each(widgets, function (widgetInfo) { - if ( - widgetInfo.defaultAlignment && - !_.contains(validAlignments, widgetInfo.defaultAlignment) - ) { - throw new PerseusError( - "Widget '" + - widgetInfo.displayName + - "' has an invalid defaultAlignment value: " + - widgetInfo.defaultAlignment, - Errors.InvalidInput, - ); - } - - if (widgetInfo.supportedAlignments) { - const unknownAlignments = _.difference( - widgetInfo.supportedAlignments, - validAlignments, - ); - - if (unknownAlignments.length) { - throw new PerseusError( - "Widget '" + - widgetInfo.displayName + - "' has an invalid value for supportedAlignments: " + - unknownAlignments.join(" "), - Errors.InvalidInput, - ); - } - } - }); -}; - /** * Handling for static mode for widgets that support it. */ From f764d86be9e6d87ccfbfadb1f446c70c207fdbc8 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 12:27:44 -0600 Subject: [PATCH 09/25] [LEMS-2737/move-scoring-logic] WIP: mock widget --- packages/perseus-score/src/index.ts | 5 +++++ .../mock-widget/mock-widget-validation.types.ts | 7 +++++++ .../widgets/mock-widget}/score-mock-widget.ts | 8 +++----- .../mock-widget}/validate-mock-widget.test.ts | 2 +- .../mock-widget}/validate-mock-widget.ts | 4 ++-- .../perseus/src/__tests__/renderer-api.test.tsx | 4 +--- .../perseus/src/__tests__/renderer.test.tsx | 2 -- .../src/__tests__/server-item-renderer.test.tsx | 2 -- .../mock-widget/mock-widget.test.ts | 2 -- .../mock-widget/prompt-utils.test.ts | 2 +- .../widget-ai-utils/mock-widget/prompt-utils.ts | 2 +- .../widgets/mock-widgets/mock-widget-types.ts | 8 -------- .../src/widgets/mock-widgets/mock-widget.tsx | 17 ++++------------- 13 files changed, 25 insertions(+), 40 deletions(-) create mode 100644 packages/perseus-score/src/widgets/mock-widget/mock-widget-validation.types.ts rename packages/{perseus/src/widgets/mock-widgets => perseus-score/src/widgets/mock-widget}/score-mock-widget.ts (77%) rename packages/{perseus/src/widgets/mock-widgets => perseus-score/src/widgets/mock-widget}/validate-mock-widget.test.ts (87%) rename packages/{perseus/src/widgets/mock-widgets => perseus-score/src/widgets/mock-widget}/validate-mock-widget.ts (68%) diff --git a/packages/perseus-score/src/index.ts b/packages/perseus-score/src/index.ts index 22034b8222..1596df43db 100644 --- a/packages/perseus-score/src/index.ts +++ b/packages/perseus-score/src/index.ts @@ -39,3 +39,8 @@ export { export {scorePerseusItem, scoreWidgetsFunctional, flattenScores} from "./score"; export {emptyWidgetsFunctional} from "./validate"; + +export type { + PerseusMockWidgetRubric, + PerseusMockWidgetUserInput, +} from "./widgets/mock-widget/mock-widget-validation.types"; diff --git a/packages/perseus-score/src/widgets/mock-widget/mock-widget-validation.types.ts b/packages/perseus-score/src/widgets/mock-widget/mock-widget-validation.types.ts new file mode 100644 index 0000000000..72e56604cf --- /dev/null +++ b/packages/perseus-score/src/widgets/mock-widget/mock-widget-validation.types.ts @@ -0,0 +1,7 @@ +export type PerseusMockWidgetRubric = { + value: string; +}; + +export type PerseusMockWidgetUserInput = { + currentValue: string; +}; diff --git a/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts b/packages/perseus-score/src/widgets/mock-widget/score-mock-widget.ts similarity index 77% rename from packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts rename to packages/perseus-score/src/widgets/mock-widget/score-mock-widget.ts index e735878a51..e7a256179e 100644 --- a/packages/perseus/src/widgets/mock-widgets/score-mock-widget.ts +++ b/packages/perseus-score/src/widgets/mock-widget/score-mock-widget.ts @@ -1,16 +1,14 @@ import validateMockWidget from "./validate-mock-widget"; import type { - PerseusMockWidgetUserInput, PerseusMockWidgetRubric, -} from "./mock-widget-types"; -import type {PerseusStrings} from "../../strings"; -import type {PerseusScore} from "@khanacademy/perseus-score"; + PerseusMockWidgetUserInput, +} from "./mock-widget-validation.types"; +import type {PerseusScore} from "../../validation.types"; function scoreMockWidget( userInput: PerseusMockWidgetUserInput, rubric: PerseusMockWidgetRubric, - strings: PerseusStrings, ): PerseusScore { const validationResult = validateMockWidget(userInput); if (validationResult != null) { diff --git a/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.test.ts b/packages/perseus-score/src/widgets/mock-widget/validate-mock-widget.test.ts similarity index 87% rename from packages/perseus/src/widgets/mock-widgets/validate-mock-widget.test.ts rename to packages/perseus-score/src/widgets/mock-widget/validate-mock-widget.test.ts index 614d0263e2..e43956d086 100644 --- a/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.test.ts +++ b/packages/perseus-score/src/widgets/mock-widget/validate-mock-widget.test.ts @@ -1,6 +1,6 @@ import validateMockWidget from "./validate-mock-widget"; -import type {PerseusMockWidgetUserInput} from "./mock-widget-types"; +import type {PerseusMockWidgetUserInput} from "./mock-widget-validation.types"; describe("mock-widget", () => { it("should be invalid if no value provided", () => { diff --git a/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts b/packages/perseus-score/src/widgets/mock-widget/validate-mock-widget.ts similarity index 68% rename from packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts rename to packages/perseus-score/src/widgets/mock-widget/validate-mock-widget.ts index bf588946af..a5b6ff9fc8 100644 --- a/packages/perseus/src/widgets/mock-widgets/validate-mock-widget.ts +++ b/packages/perseus-score/src/widgets/mock-widget/validate-mock-widget.ts @@ -1,5 +1,5 @@ -import type {PerseusMockWidgetUserInput} from "./mock-widget-types"; -import type {ValidationResult} from "@khanacademy/perseus-score"; +import type {PerseusMockWidgetUserInput} from "./mock-widget-validation.types"; +import type {ValidationResult} from "../../validation.types"; function validateMockWidget( userInput: PerseusMockWidgetUserInput, diff --git a/packages/perseus/src/__tests__/renderer-api.test.tsx b/packages/perseus/src/__tests__/renderer-api.test.tsx index a26f3381d4..085fd38789 100644 --- a/packages/perseus/src/__tests__/renderer-api.test.tsx +++ b/packages/perseus/src/__tests__/renderer-api.test.tsx @@ -21,7 +21,7 @@ import mockWidget1Item from "./test-items/mock-widget-1-item"; import mockWidget2Item from "./test-items/mock-widget-2-item"; import tableItem from "./test-items/table-item"; -import type {PerseusMockWidgetUserInput} from "../widgets/mock-widgets/mock-widget-types"; +import type {PerseusMockWidgetUserInput} from "@khanacademy/perseus-score"; import type {UserEvent} from "@testing-library/user-event"; const itemWidget = mockWidget1Item; @@ -37,8 +37,6 @@ describe("Perseus API", function () { jest.spyOn(Dependencies, "getDependencies").mockReturnValue( testDependencies, ); - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: MockWidget is not assignable to type WidgetExports registerWidget("mock-widget", MockWidget); }); diff --git a/packages/perseus/src/__tests__/renderer.test.tsx b/packages/perseus/src/__tests__/renderer.test.tsx index aaa5603377..aeaeebf801 100644 --- a/packages/perseus/src/__tests__/renderer.test.tsx +++ b/packages/perseus/src/__tests__/renderer.test.tsx @@ -45,8 +45,6 @@ jest.mock("../translation-linter", () => { describe("renderer", () => { beforeAll(() => { - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: MockWidget is not assignable to type WidgetExports registerWidget("mock-widget", MockWidgetExport); }); diff --git a/packages/perseus/src/__tests__/server-item-renderer.test.tsx b/packages/perseus/src/__tests__/server-item-renderer.test.tsx index e58e854bad..061d69cb55 100644 --- a/packages/perseus/src/__tests__/server-item-renderer.test.tsx +++ b/packages/perseus/src/__tests__/server-item-renderer.test.tsx @@ -66,8 +66,6 @@ const renderQuestion = ( describe("server item renderer", () => { beforeAll(() => { - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: MockWidget is not assignable to type WidgetExports registerWidget("mock-widget", MockWidget); }); 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 6e7dd045bb..785d9130ae 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 @@ -33,8 +33,6 @@ const question: PerseusRenderer = { describe("mock-widget", () => { let userEvent: UserEvent; beforeEach(() => { - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: MockWidget is not assignable to type WidgetExports registerWidget("mock-widget", MockWidgetExport); userEvent = userEventLib.setup({ 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 08081be58d..f9236e652e 100644 --- a/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.test.ts +++ b/packages/perseus/src/widget-ai-utils/mock-widget/prompt-utils.test.ts @@ -1,6 +1,6 @@ import {getPromptJSON} from "./prompt-utils"; -import type {PerseusMockWidgetUserInput} from "../../widgets/mock-widgets/mock-widget-types"; +import type {PerseusMockWidgetUserInput} from "@khanacademy/perseus-score"; 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 241bfcc9d3..4773ff7ef6 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 "../../widgets/mock-widgets/mock-widget-types"; +import type {PerseusMockWidgetUserInput} from "@khanacademy/perseus-score"; import type React from "react"; export type MockWidgetPromptJSON = { diff --git a/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts b/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts index e6a8aa2416..5c3359e4c9 100644 --- a/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts +++ b/packages/perseus/src/widgets/mock-widgets/mock-widget-types.ts @@ -7,14 +7,6 @@ export type MockWidgetOptions = { 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 diff --git a/packages/perseus/src/widgets/mock-widgets/mock-widget.tsx b/packages/perseus/src/widgets/mock-widgets/mock-widget.tsx index 327b93ab23..9c89a7e268 100644 --- a/packages/perseus/src/widgets/mock-widgets/mock-widget.tsx +++ b/packages/perseus/src/widgets/mock-widgets/mock-widget.tsx @@ -5,16 +5,13 @@ import * as React from "react"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/mock-widget/prompt-utils"; -import scoreMockWidget from "./score-mock-widget"; -import validateMockWidget from "./validate-mock-widget"; - +import type {MockWidgetOptions} from "./mock-widget-types"; +import type {WidgetProps, Widget, FocusPath, WidgetExports} from "../../types"; +import type {MockWidgetPromptJSON} from "../../widget-ai-utils/mock-widget/prompt-utils"; import type { - MockWidgetOptions, PerseusMockWidgetRubric, PerseusMockWidgetUserInput, -} from "./mock-widget-types"; -import type {WidgetProps, Widget, FocusPath, WidgetExports} from "../../types"; -import type {MockWidgetPromptJSON} from "../../widget-ai-utils/mock-widget/prompt-utils"; +} from "@khanacademy/perseus-score"; type ExternalProps = WidgetProps; @@ -130,10 +127,4 @@ export default { displayName: "Mock Widget", widget: MockWidgetComponent, isLintable: true, - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'UserInput' is not assignable to type 'MockWidget'. - scorer: scoreMockWidget, - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusMockWidgetUserInput'. - validator: validateMockWidget, } satisfies WidgetExports; From 53480131120e2b0deb25bafe55f29d04d18ebcf3 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 13:54:18 -0600 Subject: [PATCH 10/25] [LEMS-2737/move-scoring-logic] WIP: lint/tsc happy, tests sad --- packages/perseus-core/src/widgets/upgrade.ts | 23 +- packages/perseus-score/src/score.test.ts | 147 ++++++++++- .../src/widgets/dropdown/score-dropdown.ts | 6 - packages/perseus/src/renderer-util.test.ts | 240 +++--------------- 4 files changed, 197 insertions(+), 219 deletions(-) diff --git a/packages/perseus-core/src/widgets/upgrade.ts b/packages/perseus-core/src/widgets/upgrade.ts index 09120600f0..6229187c6f 100644 --- a/packages/perseus-core/src/widgets/upgrade.ts +++ b/packages/perseus-core/src/widgets/upgrade.ts @@ -83,17 +83,18 @@ export const upgradeWidgetInfoToLatestVersion = ( // This is a Log.error because it is unlikely to be hit in // local testing, and a Log.error is slightly less scary in // prod than a `throw new Error` - Log.error( - "No upgrade found for widget. Cannot render.", - Errors.Internal, - { - loggedMetadata: { - type, - fromMajorVersion: nextVersion - 1, - toMajorVersion: nextVersion, - }, - }, - ); + // STOPSHIP, figure this out + // Log.error( + // "No upgrade found for widget. Cannot render.", + // Errors.Internal, + // { + // loggedMetadata: { + // type, + // fromMajorVersion: nextVersion - 1, + // toMajorVersion: nextVersion, + // }, + // }, + // ); // But try to keep going anyways (yolo!) // (Throwing an error here would just break the page // silently anyways, so that doesn't seem much better diff --git a/packages/perseus-score/src/score.test.ts b/packages/perseus-score/src/score.test.ts index 596af2838f..bf8f81dfc2 100644 --- a/packages/perseus-score/src/score.test.ts +++ b/packages/perseus-score/src/score.test.ts @@ -4,10 +4,13 @@ import { getTestDropdownWidget, } from "../util/test-helpers"; -import {flattenScores, scoreWidgetsFunctional} from "./score"; +import {flattenScores, scorePerseusItem, scoreWidgetsFunctional} from "./score"; import type {UserInputMap} from "./validation.types"; -import type {PerseusWidgetsMap} from "@khanacademy/perseus-core"; +import type { + DropdownWidget, + PerseusWidgetsMap, +} from "@khanacademy/perseus-core"; describe("flattenScores", () => { it("defaults to an empty score", () => { @@ -468,3 +471,143 @@ describe("scoreWidgetsFunctional", () => { expect(result["expression 1"]).toHaveBeenAnsweredIncorrectly(); }); }); + +function generateDropdown(): DropdownWidget { + return { + type: "dropdown", + alignment: "default", + static: false, + graded: true, + options: { + static: false, + ariaLabel: "Test ARIA label", + visibleLabel: "Test visible label", + placeholder: "Answer me", + choices: [ + { + content: "Incorrect", + correct: false, + }, + { + content: "Correct", + correct: true, + }, + ], + }, + version: { + major: 0, + minor: 0, + }, + }; +} + +describe("scorePerseusItem", () => { + it("should return empty if any validator returns empty", () => { + // Act + const score = scorePerseusItem( + { + content: "[[☃ dropdown 1]] [[☃ dropdown 2]]", + widgets: { + "dropdown 1": generateDropdown(), + "dropdown 2": generateDropdown(), + }, + images: {}, + }, + { + "dropdown 1": {value: 0}, + "dropdown 2": {value: 1}, + }, + "en", + ); + + // Assert + expect(score).toEqual({type: "invalid", message: null}); + }); + + it("should score item if all validators return null", () => { + // Arrange + + // Act + const score = scorePerseusItem( + { + content: "[[☃ dropdown 1]] [[☃ dropdown 2]]", + widgets: { + "dropdown 1": generateDropdown(), + "dropdown 2": generateDropdown(), + }, + images: {}, + }, + { + "dropdown 1": {value: 2}, + "dropdown 2": {value: 2}, + }, + "en", + ); + + // Assert + expect(score).toEqual({ + type: "points", + total: 2, + earned: 2, + message: null, + }); + }); + + it("should return correct, with no points earned, if widget is static", () => { + const json = { + content: "[[☃ dropdown 1]]", + widgets: { + "dropdown 1": generateDropdown(), + }, + images: {}, + }; + json.widgets["dropdown 1"].static = true; + const score = scorePerseusItem(json, {"dropdown 1": {value: 2}}, "en"); + + expect(score).toHaveBeenAnsweredCorrectly({ + shouldHavePoints: false, + }); + }); + + it("should ignore widgets that aren't referenced in content", () => { + const score = scorePerseusItem( + { + content: "[[☃ dropdown 1]]", + widgets: { + "dropdown 1": generateDropdown(), + "dropdown 2": generateDropdown(), + }, + images: {}, + }, + {"dropdown 1": {value: 2}}, + + "en", + ); + + expect(score).toHaveBeenAnsweredCorrectly({ + shouldHavePoints: true, + }); + }); + + // it("should return score from contained Renderer", async () => { + // // Arrange + // const {renderer} = renderQuestion(question1); + + // // 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, userInput, "en"); + + // // 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..4585c7950a 100644 --- a/packages/perseus-score/src/widgets/dropdown/score-dropdown.ts +++ b/packages/perseus-score/src/widgets/dropdown/score-dropdown.ts @@ -1,5 +1,3 @@ -import validateDropdown from "./validate-dropdown"; - import type { PerseusDropdownRubric, PerseusDropdownUserInput, @@ -10,10 +8,6 @@ function scoreDropdown( userInput: PerseusDropdownUserInput, rubric: PerseusDropdownRubric, ): PerseusScore { - const validationError = validateDropdown(userInput); - if (validationError) { - return validationError; - } const correct = rubric.choices[userInput.value - 1].correct; return { type: "points", diff --git a/packages/perseus/src/renderer-util.test.ts b/packages/perseus/src/renderer-util.test.ts index 01b8a9d031..ac1811f739 100644 --- a/packages/perseus/src/renderer-util.test.ts +++ b/packages/perseus/src/renderer-util.test.ts @@ -1,200 +1,40 @@ -import {scorePerseusItem} from "@khanacademy/perseus-score"; -import {act, screen} from "@testing-library/react"; -import {userEvent as userEventLib} from "@testing-library/user-event"; - -import { - testDependencies, - testDependenciesV2, -} from "../../../testing/test-dependencies"; - -import {question1} from "./__testdata__/renderer.testdata"; -import * as Dependencies from "./dependencies"; -import {registerAllWidgetsForTesting} from "./util/register-all-widgets-for-testing"; -import {renderQuestion} from "./widgets/__testutils__/renderQuestion"; -import DropdownWidgetExport from "./widgets/dropdown"; - -import type {UserEvent} from "@testing-library/user-event"; - -describe("renderer utils", () => { - beforeAll(() => { - registerAllWidgetsForTesting(); - }); - - beforeEach(() => { - jest.spyOn(testDependenciesV2.analytics, "onAnalyticsEvent"); - jest.spyOn(Dependencies, "getDependencies").mockReturnValue( - testDependencies, - ); - jest.spyOn(Dependencies, "useDependencies").mockReturnValue( - testDependenciesV2, - ); - // Mocked for loading graphie in svg-image - global.fetch = jest.fn(() => - Promise.resolve({ - text: () => "", - ok: true, - }), - ) as jest.Mock; - }); - - describe("scorePerseusItem", () => { - let userEvent: UserEvent; - beforeEach(() => { - userEvent = userEventLib.setup({ - advanceTimers: jest.advanceTimersByTime, - }); - }); - - 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: {}, - }, - {}, - - "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}}, - - "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}}, - - "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}}, - - "en", - ); - - expect(validatorSpy).toHaveBeenCalledTimes(1); - expect(score).toHaveBeenAnsweredCorrectly({ - shouldHavePoints: true, - }); - }); - - it("should return score from contained Renderer", async () => { - // Arrange - const {renderer} = renderQuestion(question1); - - // 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, userInput, "en"); - - // Assert - expect(score).toHaveBeenAnsweredCorrectly(); - }); - }); -}); +// STOPSHIP delete this file +// import {scorePerseusItem} from "@khanacademy/perseus-score"; +// import {act, screen} from "@testing-library/react"; +// import {userEvent as userEventLib} from "@testing-library/user-event"; + +// import { +// testDependencies, +// testDependenciesV2, +// } from "../../../testing/test-dependencies"; + +// import {question1} from "./__testdata__/renderer.testdata"; +// import * as Dependencies from "./dependencies"; +// import {registerAllWidgetsForTesting} from "./util/register-all-widgets-for-testing"; +// import {renderQuestion} from "./widgets/__testutils__/renderQuestion"; +// import DropdownWidgetExport from "./widgets/dropdown"; + +// import type {UserEvent} from "@testing-library/user-event"; + +// describe("renderer utils", () => { +// beforeAll(() => { +// registerAllWidgetsForTesting(); +// }); + +// beforeEach(() => { +// jest.spyOn(testDependenciesV2.analytics, "onAnalyticsEvent"); +// jest.spyOn(Dependencies, "getDependencies").mockReturnValue( +// testDependencies, +// ); +// jest.spyOn(Dependencies, "useDependencies").mockReturnValue( +// testDependenciesV2, +// ); +// // Mocked for loading graphie in svg-image +// global.fetch = jest.fn(() => +// Promise.resolve({ +// text: () => "", +// ok: true, +// }), +// ) as jest.Mock; +// }); +// }); From 14450db0d7d2b6d1f94483b12f67e349e62abd6c Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 14:55:12 -0600 Subject: [PATCH 11/25] [LEMS-2737/move-scoring-logic] WIP: fix some tests --- packages/perseus-editor/src/components/widget-editor.tsx | 4 ++-- packages/perseus-score/src/widgets/widget-registry.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/perseus-editor/src/components/widget-editor.tsx b/packages/perseus-editor/src/components/widget-editor.tsx index d90fc915d9..bef4a60b94 100644 --- a/packages/perseus-editor/src/components/widget-editor.tsx +++ b/packages/perseus-editor/src/components/widget-editor.tsx @@ -7,6 +7,7 @@ import { iconTrash, } from "@khanacademy/perseus"; import { + upgradeWidgetInfoToLatestVersion, WidgetLogic, type Alignment, type PerseusWidget, @@ -49,8 +50,7 @@ const _upgradeWidgetInfo = (props: WidgetEditorProps): PerseusWidget => { // We can't call serialize here because this.refs.widget // doesn't exist before this component is mounted. const filteredProps = _.omit(props, WIDGET_PROP_DENYLIST); - // @ts-expect-error TS(2345) Type '"categorizer" | undefined' is not assignable to type '"deprecated-standin"'. - return Widgets.upgradeWidgetInfoToLatestVersion(filteredProps); + return upgradeWidgetInfoToLatestVersion(filteredProps as any); }; // This component handles upgading widget editor props via prop diff --git a/packages/perseus-score/src/widgets/widget-registry.ts b/packages/perseus-score/src/widgets/widget-registry.ts index 37510e1f76..32bc172c6e 100644 --- a/packages/perseus-score/src/widgets/widget-registry.ts +++ b/packages/perseus-score/src/widgets/widget-registry.ts @@ -18,6 +18,7 @@ import validateLabelImage from "./label-image/validate-label-image"; import scoreMatcher from "./matcher/score-matcher"; import scoreMatrix from "./matrix/score-matrix"; import validateMatrix from "./matrix/validate-matrix"; +import scoreMockWidget from "./mock-widget/score-mock-widget"; import scoreNumberLine from "./number-line/score-number-line"; import validateNumberLine from "./number-line/validate-number-line"; import scoreNumericInput from "./numeric-input/score-numeric-input"; @@ -80,6 +81,7 @@ registerWidget( ); registerWidget("matcher", scoreMatcher as any); registerWidget("matrix", scoreMatrix as any, validateMatrix as any); +registerWidget("mock-widget", scoreMockWidget as any, scoreMockWidget as any); registerWidget( "number-line", scoreNumberLine as any, From 38ea7b6326cd2f1852217dcb9936edec632b8757 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 15:16:10 -0600 Subject: [PATCH 12/25] [LEMS-2737/move-scoring-logic] fix tests --- packages/perseus/src/renderer-util.test.ts | 40 ------------------- packages/perseus/src/renderer-util.ts | 1 - .../perseus/src/widgets/group/group.test.tsx | 8 ++-- 3 files changed, 4 insertions(+), 45 deletions(-) delete mode 100644 packages/perseus/src/renderer-util.test.ts delete mode 100644 packages/perseus/src/renderer-util.ts diff --git a/packages/perseus/src/renderer-util.test.ts b/packages/perseus/src/renderer-util.test.ts deleted file mode 100644 index ac1811f739..0000000000 --- a/packages/perseus/src/renderer-util.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -// STOPSHIP delete this file -// import {scorePerseusItem} from "@khanacademy/perseus-score"; -// import {act, screen} from "@testing-library/react"; -// import {userEvent as userEventLib} from "@testing-library/user-event"; - -// import { -// testDependencies, -// testDependenciesV2, -// } from "../../../testing/test-dependencies"; - -// import {question1} from "./__testdata__/renderer.testdata"; -// import * as Dependencies from "./dependencies"; -// import {registerAllWidgetsForTesting} from "./util/register-all-widgets-for-testing"; -// import {renderQuestion} from "./widgets/__testutils__/renderQuestion"; -// import DropdownWidgetExport from "./widgets/dropdown"; - -// import type {UserEvent} from "@testing-library/user-event"; - -// describe("renderer utils", () => { -// beforeAll(() => { -// registerAllWidgetsForTesting(); -// }); - -// beforeEach(() => { -// jest.spyOn(testDependenciesV2.analytics, "onAnalyticsEvent"); -// jest.spyOn(Dependencies, "getDependencies").mockReturnValue( -// testDependencies, -// ); -// jest.spyOn(Dependencies, "useDependencies").mockReturnValue( -// testDependenciesV2, -// ); -// // Mocked for loading graphie in svg-image -// global.fetch = jest.fn(() => -// Promise.resolve({ -// text: () => "", -// ok: true, -// }), -// ) as jest.Mock; -// }); -// }); diff --git a/packages/perseus/src/renderer-util.ts b/packages/perseus/src/renderer-util.ts deleted file mode 100644 index ca753f4df9..0000000000 --- a/packages/perseus/src/renderer-util.ts +++ /dev/null @@ -1 +0,0 @@ -// STOPSHIP delete this file diff --git a/packages/perseus/src/widgets/group/group.test.tsx b/packages/perseus/src/widgets/group/group.test.tsx index 0316210dd1..007104ee45 100644 --- a/packages/perseus/src/widgets/group/group.test.tsx +++ b/packages/perseus/src/widgets/group/group.test.tsx @@ -249,8 +249,8 @@ describe("group widget", () => { "originalIndex": 4, }, ], - "countChoices": undefined, - "deselectEnabled": undefined, + "countChoices": false, + "deselectEnabled": false, "hasNoneOfTheAbove": false, "multipleSelect": false, "numCorrect": 1, @@ -336,8 +336,8 @@ describe("group widget", () => { "originalIndex": 4, }, ], - "countChoices": undefined, - "deselectEnabled": undefined, + "countChoices": false, + "deselectEnabled": false, "hasNoneOfTheAbove": false, "multipleSelect": false, "numCorrect": 1, From de0e4ae0456cdffbd418586977ce913095159613 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 15:21:21 -0600 Subject: [PATCH 13/25] [LEMS-2737/move-scoring-logic] convert Log to Error --- packages/perseus-core/src/widgets/upgrade.ts | 31 +++++++------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/packages/perseus-core/src/widgets/upgrade.ts b/packages/perseus-core/src/widgets/upgrade.ts index 6229187c6f..9c63a6067e 100644 --- a/packages/perseus-core/src/widgets/upgrade.ts +++ b/packages/perseus-core/src/widgets/upgrade.ts @@ -80,26 +80,17 @@ export const upgradeWidgetInfoToLatestVersion = ( newEditorOptions = upgradePropsMap[String(nextVersion)](newEditorOptions); } else { - // This is a Log.error because it is unlikely to be hit in - // local testing, and a Log.error is slightly less scary in - // prod than a `throw new Error` - // STOPSHIP, figure this out - // Log.error( - // "No upgrade found for widget. Cannot render.", - // Errors.Internal, - // { - // loggedMetadata: { - // type, - // fromMajorVersion: nextVersion - 1, - // toMajorVersion: nextVersion, - // }, - // }, - // ); - // But try to keep going anyways (yolo!) - // (Throwing an error here would just break the page - // silently anyways, so that doesn't seem much better - // than a halfhearted attempt to continue, however - // shallow...) + throw new PerseusError( + "No upgrade found for widget. Cannot render.", + Errors.Internal, + { + metadata: { + type, + fromMajorVersion: nextVersion - 1, + toMajorVersion: nextVersion, + }, + }, + ); } } } From e894520b973d74ba4c78146b200e88ba13587322 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 15:53:39 -0600 Subject: [PATCH 14/25] [LEMS-2737/move-scoring-logic] remove validation checks in scorers --- packages/perseus-core/src/widgets/upgrade.ts | 2 +- .../src/components/widget-editor.tsx | 3 +- .../src/diffs/renderer-diff.tsx | 7 +- packages/perseus-score/src/score.test.ts | 22 ---- packages/perseus-score/src/validate.test.ts | 8 -- .../widgets/categorizer/score-categorizer.ts | 7 -- .../expression/score-expression.test.ts | 33 ------ .../widgets/expression/score-expression.ts | 7 -- .../widgets/label-image/score-label-image.ts | 7 -- .../src/widgets/matrix/score-matrix.test.ts | 109 ------------------ .../src/widgets/matrix/score-matrix.ts | 7 -- .../widgets/number-line/score-number-line.ts | 7 -- .../src/widgets/orderer/score-orderer.test.ts | 43 ------- .../src/widgets/orderer/score-orderer.ts | 7 -- .../src/widgets/plotter/score-plotter.ts | 6 - .../src/widgets/radio/score-radio.ts | 7 -- .../src/widgets/sorter/score-sorter.test.ts | 47 -------- .../src/widgets/sorter/score-sorter.ts | 7 -- 18 files changed, 7 insertions(+), 329 deletions(-) diff --git a/packages/perseus-core/src/widgets/upgrade.ts b/packages/perseus-core/src/widgets/upgrade.ts index 9c63a6067e..39c665dd02 100644 --- a/packages/perseus-core/src/widgets/upgrade.ts +++ b/packages/perseus-core/src/widgets/upgrade.ts @@ -152,7 +152,7 @@ export function getUpgradedWidgetOptions( widgetInfo = {...widgetInfo, ...newValues}; } - // TODO(LEMS-2656): remove TS suppression + return upgradeWidgetInfoToLatestVersion(widgetInfo) as any; }); } diff --git a/packages/perseus-editor/src/components/widget-editor.tsx b/packages/perseus-editor/src/components/widget-editor.tsx index bef4a60b94..d384a7a209 100644 --- a/packages/perseus-editor/src/components/widget-editor.tsx +++ b/packages/perseus-editor/src/components/widget-editor.tsx @@ -9,8 +9,6 @@ import { import { upgradeWidgetInfoToLatestVersion, WidgetLogic, - type Alignment, - type PerseusWidget, } from "@khanacademy/perseus-core"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import Switch from "@khanacademy/wonder-blocks-switch"; @@ -25,6 +23,7 @@ import SectionControlButton from "./section-control-button"; import type Editor from "../editor"; import type {APIOptions} from "@khanacademy/perseus"; +import type {Alignment, PerseusWidget} from "@khanacademy/perseus-core"; const {InlineIcon} = components; diff --git a/packages/perseus-editor/src/diffs/renderer-diff.tsx b/packages/perseus-editor/src/diffs/renderer-diff.tsx index 0eb758369d..f556f398e3 100644 --- a/packages/perseus-editor/src/diffs/renderer-diff.tsx +++ b/packages/perseus-editor/src/diffs/renderer-diff.tsx @@ -1,14 +1,17 @@ -import {Widgets} from "@khanacademy/perseus"; /** * A side by side diff view for Perseus renderers. */ -import {WidgetLogic, type PerseusRenderer} from "@khanacademy/perseus-core"; + +import {Widgets} from "@khanacademy/perseus"; +import {WidgetLogic} from "@khanacademy/perseus-core"; import * as React from "react"; import _ from "underscore"; import TextDiff from "./text-diff"; import WidgetDiff from "./widget-diff"; +import type {PerseusRenderer} from "@khanacademy/perseus-core"; + // In diffs, only show the widgetInfo props that can change const filterWidgetInfo = function (widgetInfo, showAlignmentOptions: boolean) { const {alignment, graded, options, type} = widgetInfo || {}; diff --git a/packages/perseus-score/src/score.test.ts b/packages/perseus-score/src/score.test.ts index bf8f81dfc2..bdcd8e244f 100644 --- a/packages/perseus-score/src/score.test.ts +++ b/packages/perseus-score/src/score.test.ts @@ -588,26 +588,4 @@ describe("scorePerseusItem", () => { shouldHavePoints: true, }); }); - - // it("should return score from contained Renderer", async () => { - // // Arrange - // const {renderer} = renderQuestion(question1); - - // // 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, userInput, "en"); - - // // Assert - // expect(score).toHaveBeenAnsweredCorrectly(); - // }); }); diff --git a/packages/perseus-score/src/validate.test.ts b/packages/perseus-score/src/validate.test.ts index f18821c768..3f9d7e5e1d 100644 --- a/packages/perseus-score/src/validate.test.ts +++ b/packages/perseus-score/src/validate.test.ts @@ -35,7 +35,6 @@ describe("emptyWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -60,7 +59,6 @@ describe("emptyWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -85,7 +83,6 @@ describe("emptyWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -114,7 +111,6 @@ describe("emptyWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -150,7 +146,6 @@ describe("emptyWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -186,7 +181,6 @@ describe("emptyWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -209,7 +203,6 @@ describe("emptyWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -232,7 +225,6 @@ describe("emptyWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); diff --git a/packages/perseus-score/src/widgets/categorizer/score-categorizer.ts b/packages/perseus-score/src/widgets/categorizer/score-categorizer.ts index b5262d3dcb..c621fe6b98 100644 --- a/packages/perseus-score/src/widgets/categorizer/score-categorizer.ts +++ b/packages/perseus-score/src/widgets/categorizer/score-categorizer.ts @@ -1,5 +1,3 @@ -import validateCategorizer from "./validate-categorizer"; - import type { PerseusCategorizerRubric, PerseusCategorizerUserInput, @@ -10,11 +8,6 @@ function scoreCategorizer( userInput: PerseusCategorizerUserInput, rubric: PerseusCategorizerRubric, ): PerseusScore { - const validationError = validateCategorizer(userInput, rubric); - if (validationError) { - return validationError; - } - let allCorrect = true; rubric.values.forEach((value, i) => { if (userInput.values[i] !== value) { 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 8f1ea595b7..f2d910eee8 100644 --- a/packages/perseus-score/src/widgets/expression/score-expression.test.ts +++ b/packages/perseus-score/src/widgets/expression/score-expression.test.ts @@ -1,40 +1,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"; describe("scoreExpression", () => { - it("should be correctly answerable if validation passes", function () { - // Arrange - const mockValidator = jest - .spyOn(ExpressionValidator, "default") - .mockReturnValue(null); - const rubric: PerseusExpressionRubric = expressionItem3Options; - - // Act - const score = scoreExpression("z+1", rubric, "en"); - - // Assert - expect(mockValidator).toHaveBeenCalledWith("z+1"); - expect(score).toHaveBeenAnsweredCorrectly(); - }); - - it("should return 'empty' result if validation fails", function () { - // Arrange - const mockValidator = jest - .spyOn(ExpressionValidator, "default") - .mockReturnValue({type: "invalid", message: null}); - const rubric: PerseusExpressionRubric = expressionItem3Options; - - // Act - const score = scoreExpression("z+1", rubric, "en"); - - // Assert - expect(mockValidator).toHaveBeenCalledWith("z+1"); - expect(score).toHaveInvalidInput(); - }); - it("should handle defined ungraded answer case with no error callback", function () { const err = scoreExpression("x+1", expressionItem3Options, "en"); expect(err).toHaveInvalidInput(); diff --git a/packages/perseus-score/src/widgets/expression/score-expression.ts b/packages/perseus-score/src/widgets/expression/score-expression.ts index 88c4ad4970..73481252e9 100644 --- a/packages/perseus-score/src/widgets/expression/score-expression.ts +++ b/packages/perseus-score/src/widgets/expression/score-expression.ts @@ -8,8 +8,6 @@ import _ from "underscore"; import KhanAnswerTypes from "../../util/answer-types"; -import validateExpression from "./validate-expression"; - import type {Score} from "../../util/answer-types"; import type { PerseusExpressionRubric, @@ -41,11 +39,6 @@ function scoreExpression( rubric: PerseusExpressionRubric, locale: string, ): PerseusScore { - const validationError = validateExpression(userInput); - if (validationError) { - return validationError; - } - const options = _.clone(rubric); _.extend(options, { decimal_separator: getDecimalSeparator(locale), 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 20944ce3e0..59d53d4a0c 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,5 +1,3 @@ -import validateLabelImage from "./validate-label-image"; - import type { PerseusLabelImageUserInput, PerseusLabelImageRubric, @@ -46,11 +44,6 @@ function scoreLabelImage( userInput: PerseusLabelImageUserInput, rubric: PerseusLabelImageRubric, ): PerseusScore { - const validationError = validateLabelImage(userInput); - if (validationError) { - return validationError; - } - let numCorrect = 0; for (let i = 0; i < userInput.markers.length; i++) { 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 a4874e1bb9..d71f2e98df 100644 --- a/packages/perseus-score/src/widgets/matrix/score-matrix.test.ts +++ b/packages/perseus-score/src/widgets/matrix/score-matrix.test.ts @@ -1,5 +1,4 @@ import scoreMatrix from "./score-matrix"; -import * as MatrixValidator from "./validate-matrix"; import type { PerseusMatrixRubric, @@ -7,58 +6,6 @@ import type { } from "../../validation.types"; describe("scoreMatrix", () => { - it("should be correctly answerable if validation passes", function () { - // Arrange - const mockValidator = jest - .spyOn(MatrixValidator, "default") - .mockReturnValue(null); - - const rubric: PerseusMatrixRubric = { - answers: [ - [0, 1, 2], - [3, 4, 5], - [6, 7, 8], - ], - }; - - const userInput: PerseusMatrixUserInput = { - answers: rubric.answers, - }; - - // Act - const score = scoreMatrix(userInput, rubric); - - // Assert - expect(mockValidator).toHaveBeenCalledWith(userInput); - expect(score).toHaveBeenAnsweredCorrectly(); - }); - - it("should return 'empty' result if validation fails", function () { - // Arrange - const mockValidator = jest - .spyOn(MatrixValidator, "default") - .mockReturnValue({type: "invalid", message: null}); - - const rubric: PerseusMatrixRubric = { - answers: [ - [0, 1, 2], - [3, 4, 5], - [6, 7, 8], - ], - }; - - const userInput: PerseusMatrixUserInput = { - answers: rubric.answers, - }; - - // Act - const score = scoreMatrix(userInput, rubric); - - // Assert - expect(mockValidator).toHaveBeenCalledWith(userInput); - expect(score).toHaveInvalidInput(); - }); - it("can be answered correctly", () => { // Arrange const rubric: PerseusMatrixRubric = { @@ -105,62 +52,6 @@ describe("scoreMatrix", () => { expect(result).toHaveBeenAnsweredIncorrectly(); }); - it("is invalid when there's an empty cell: null", () => { - // Arrange - const rubric: PerseusMatrixRubric = { - answers: [ - [0, 1, 2], - [3, 4, 5], - [6, 7, 8], - ], - }; - - const userInput: PerseusMatrixUserInput = { - answers: [ - // TODO: this is either legacy logic or an incorrect type, - // but this is what the scoring function is checking for - // @ts-expect-error - TS(2322) - Type 'null' is not assignable to type 'number'. - [0, 0, null], - [0, 0, 0], - [0, 0, 0], - ], - }; - - // Act - const result = scoreMatrix(userInput, rubric); - - // Assert - expect(result).toHaveInvalidInput(); - }); - - it("is invalid when there's an empty cell: empty string", () => { - // Arrange - const rubric: PerseusMatrixRubric = { - answers: [ - [0, 1, 2], - [3, 4, 5], - [6, 7, 8], - ], - }; - - const userInput: PerseusMatrixUserInput = { - answers: [ - // TODO: this is either legacy logic or an incorrect type, - // but this is what the scoring function is checking for - // @ts-expect-error - TS(2322) - Type 'null' is not assignable to type 'number'. - [0, 0, ""], - [0, 0, 0], - [0, 0, 0], - ], - }; - - // Act - const result = scoreMatrix(userInput, rubric); - - // Assert - expect(result).toHaveInvalidInput(); - }); - it("is considered incorrect when the size is wrong", () => { // Arrange const rubric: PerseusMatrixRubric = { diff --git a/packages/perseus-score/src/widgets/matrix/score-matrix.ts b/packages/perseus-score/src/widgets/matrix/score-matrix.ts index aaa3d2ef86..c8ffb0082e 100644 --- a/packages/perseus-score/src/widgets/matrix/score-matrix.ts +++ b/packages/perseus-score/src/widgets/matrix/score-matrix.ts @@ -3,8 +3,6 @@ import _ from "underscore"; import KhanAnswerTypes from "../../util/answer-types"; -import validateMatrix from "./validate-matrix"; - import type { PerseusMatrixRubric, PerseusMatrixUserInput, @@ -15,11 +13,6 @@ function scoreMatrix( userInput: PerseusMatrixUserInput, rubric: PerseusMatrixRubric, ): PerseusScore { - const validationError = validateMatrix(userInput); - if (validationError != null) { - return validationError; - } - const solution = rubric.answers; const supplied = userInput.answers; const solutionSize = getMatrixSize(solution); diff --git a/packages/perseus-score/src/widgets/number-line/score-number-line.ts b/packages/perseus-score/src/widgets/number-line/score-number-line.ts index 3f0417f41f..976a48c684 100644 --- a/packages/perseus-score/src/widgets/number-line/score-number-line.ts +++ b/packages/perseus-score/src/widgets/number-line/score-number-line.ts @@ -1,7 +1,5 @@ import {number as knumber} from "@khanacademy/kmath"; -import validateNumberLine from "./validate-number-line"; - import type { PerseusNumberLineRubric, PerseusNumberLineUserInput, @@ -12,11 +10,6 @@ function scoreNumberLine( userInput: PerseusNumberLineUserInput, rubric: PerseusNumberLineRubric, ): PerseusScore { - const validationError = validateNumberLine(userInput); - if (validationError) { - return validationError; - } - const range = rubric.range; const start = rubric.initialX != null ? rubric.initialX : range[0]; const startRel = rubric.isInequality ? "ge" : "eq"; 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..6ddce1e847 100644 --- a/packages/perseus-score/src/widgets/orderer/score-orderer.test.ts +++ b/packages/perseus-score/src/widgets/orderer/score-orderer.test.ts @@ -1,5 +1,4 @@ import scoreOrderer from "./score-orderer"; -import * as OrdererValidator from "./validate-orderer"; import type { PerseusOrdererRubric, @@ -69,46 +68,4 @@ describe("scoreOrderer", () => { // Assert expect(result).toHaveBeenAnsweredIncorrectly(); }); - - it("should be correctly answerable if validation passes", () => { - // Arrange - const mockValidator = jest - .spyOn(OrdererValidator, "default") - .mockReturnValue(null); - - const rubric: PerseusOrdererRubric = generateOrdererRubric(); - - const userInput: PerseusOrdererUserInput = { - current: rubric.correctOptions.map((e) => e.content), - }; - // Act - const result = scoreOrderer(userInput, rubric); - - // Assert - expect(mockValidator).toHaveBeenCalledWith(userInput); - expect(result).toHaveBeenAnsweredCorrectly(); - }); - - it("should return an invalid response if validation fails", () => { - // Arrange - const mockValidator = jest - .spyOn(OrdererValidator, "default") - .mockReturnValue({ - type: "invalid", - message: null, - }); - - const rubric: PerseusOrdererRubric = generateOrdererRubric(); - - const userInput: PerseusOrdererUserInput = { - current: [], - }; - - // Act - const result = scoreOrderer(userInput, rubric); - - // Assert - expect(mockValidator).toHaveBeenCalledWith(userInput); - expect(result).toHaveInvalidInput(); - }); }); diff --git a/packages/perseus-score/src/widgets/orderer/score-orderer.ts b/packages/perseus-score/src/widgets/orderer/score-orderer.ts index 4d8ae4d533..d69818acd3 100644 --- a/packages/perseus-score/src/widgets/orderer/score-orderer.ts +++ b/packages/perseus-score/src/widgets/orderer/score-orderer.ts @@ -1,7 +1,5 @@ import _ from "underscore"; -import validateOrderer from "./validate-orderer"; - import type { PerseusOrdererRubric, PerseusOrdererUserInput, @@ -12,11 +10,6 @@ function scoreOrderer( userInput: PerseusOrdererUserInput, rubric: PerseusOrdererRubric, ): PerseusScore { - const validationError = validateOrderer(userInput); - if (validationError) { - return validationError; - } - const correct = _.isEqual( userInput.current, rubric.correctOptions.map((option) => option.content), diff --git a/packages/perseus-score/src/widgets/plotter/score-plotter.ts b/packages/perseus-score/src/widgets/plotter/score-plotter.ts index e25c239223..54197bfbc1 100644 --- a/packages/perseus-score/src/widgets/plotter/score-plotter.ts +++ b/packages/perseus-score/src/widgets/plotter/score-plotter.ts @@ -1,8 +1,6 @@ import {approximateDeepEqual} from "@khanacademy/perseus-core"; import _ from "underscore"; -import validatePlotter from "./validate-plotter"; - import type { PerseusPlotterUserInput, PerseusPlotterRubric, @@ -13,10 +11,6 @@ function scorePlotter( userInput: PerseusPlotterUserInput, rubric: PerseusPlotterRubric, ): PerseusScore { - const validationError = validatePlotter(userInput, rubric); - if (validationError) { - return validationError; - } return { type: "points", earned: approximateDeepEqual(userInput, rubric.correct) ? 1 : 0, diff --git a/packages/perseus-score/src/widgets/radio/score-radio.ts b/packages/perseus-score/src/widgets/radio/score-radio.ts index eb04198d67..266bfb23ef 100644 --- a/packages/perseus-score/src/widgets/radio/score-radio.ts +++ b/packages/perseus-score/src/widgets/radio/score-radio.ts @@ -1,7 +1,5 @@ import ErrorCodes from "../../error-codes"; -import validateRadio from "./validate-radio"; - import type { PerseusRadioRubric, PerseusRadioUserInput, @@ -12,11 +10,6 @@ function scoreRadio( userInput: PerseusRadioUserInput, rubric: PerseusRadioRubric, ): PerseusScore { - const validationError = validateRadio(userInput); - if (validationError) { - return validationError; - } - const numSelected = userInput.choicesSelected.reduce((sum, selected) => { return sum + (selected ? 1 : 0); }, 0); 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 8420df08e0..f54c902458 100644 --- a/packages/perseus-score/src/widgets/sorter/score-sorter.test.ts +++ b/packages/perseus-score/src/widgets/sorter/score-sorter.test.ts @@ -1,5 +1,4 @@ import scoreSorter from "./score-sorter"; -import * as SorterValidator from "./validate-sorter"; import type { PerseusSorterRubric, @@ -40,50 +39,4 @@ describe("scoreSorter", () => { // Assert expect(result).toHaveBeenAnsweredIncorrectly(); }); - - it("should abort if validator returns invalid", () => { - // Arrange - // Mock validator saying input is invalid - const mockValidate = jest - .spyOn(SorterValidator, "default") - .mockReturnValue({type: "invalid", message: null}); - - const userInput: PerseusSorterUserInput = { - options: ["$15$ grams", "$55$ grams", "$0.005$ kilograms"], - changed: false, - }; - const rubric: PerseusSorterRubric = { - correct: ["$0.005$ kilograms", "$15$ grams", "$55$ grams"], - }; - - // Act - const result = scoreSorter(userInput, rubric); - - // Assert - expect(mockValidate).toHaveBeenCalledWith(userInput); - expect(result).toHaveInvalidInput(); - }); - - it("should score if validator passes", () => { - // Arrange - // Mock validator saying "all good" - const mockValidate = jest - .spyOn(SorterValidator, "default") - .mockReturnValue(null); - - const userInput: PerseusSorterUserInput = { - options: ["$0.005$ kilograms", "$15$ grams", "$55$ grams"], - changed: true, - }; - const rubric: PerseusSorterRubric = { - correct: ["$0.005$ kilograms", "$15$ grams", "$55$ grams"], - }; - - // Act - const result = scoreSorter(userInput, rubric); - - // Assert - expect(mockValidate).toHaveBeenCalledWith(userInput); - expect(result).toHaveBeenAnsweredCorrectly(); - }); }); diff --git a/packages/perseus-score/src/widgets/sorter/score-sorter.ts b/packages/perseus-score/src/widgets/sorter/score-sorter.ts index e316587a09..52f5d49b20 100644 --- a/packages/perseus-score/src/widgets/sorter/score-sorter.ts +++ b/packages/perseus-score/src/widgets/sorter/score-sorter.ts @@ -1,8 +1,6 @@ import {approximateDeepEqual} from "@khanacademy/perseus-core"; import _ from "underscore"; -import validateSorter from "./validate-sorter"; - import type { PerseusSorterRubric, PerseusSorterUserInput, @@ -13,11 +11,6 @@ function scoreSorter( userInput: PerseusSorterUserInput, rubric: PerseusSorterRubric, ): PerseusScore { - const validationError = validateSorter(userInput); - if (validationError) { - return validationError; - } - const correct = approximateDeepEqual(userInput.options, rubric.correct); return { type: "points", From 7d73db295ceee168d42f2f71389f46457b73b8e9 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 15:58:25 -0600 Subject: [PATCH 15/25] [LEMS-2737/move-scoring-logic] fix test util file placement --- packages/perseus-score/src/score.test.ts | 5 ++--- packages/perseus-score/{ => src}/util/test-helpers.ts | 0 packages/perseus-score/src/validate.test.ts | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) rename packages/perseus-score/{ => src}/util/test-helpers.ts (100%) diff --git a/packages/perseus-score/src/score.test.ts b/packages/perseus-score/src/score.test.ts index bdcd8e244f..b8b58b1937 100644 --- a/packages/perseus-score/src/score.test.ts +++ b/packages/perseus-score/src/score.test.ts @@ -1,10 +1,9 @@ +import {flattenScores, scorePerseusItem, scoreWidgetsFunctional} from "./score"; import { getExpressionWidget, getLegacyExpressionWidget, getTestDropdownWidget, -} from "../util/test-helpers"; - -import {flattenScores, scorePerseusItem, scoreWidgetsFunctional} from "./score"; +} from "./util/test-helpers"; import type {UserInputMap} from "./validation.types"; import type { diff --git a/packages/perseus-score/util/test-helpers.ts b/packages/perseus-score/src/util/test-helpers.ts similarity index 100% rename from packages/perseus-score/util/test-helpers.ts rename to packages/perseus-score/src/util/test-helpers.ts diff --git a/packages/perseus-score/src/validate.test.ts b/packages/perseus-score/src/validate.test.ts index 3f9d7e5e1d..1b93b3a439 100644 --- a/packages/perseus-score/src/validate.test.ts +++ b/packages/perseus-score/src/validate.test.ts @@ -2,8 +2,7 @@ import { getExpressionWidget, getLegacyExpressionWidget, getTestDropdownWidget, -} from "../util/test-helpers"; - +} from "./util/test-helpers"; import {emptyWidgetsFunctional} from "./validate"; import type {UserInputMap} from "./validation.types"; From f231ad4bb080b2c43222476fcba68c4b9dc3d014 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Tue, 28 Jan 2025 16:33:31 -0600 Subject: [PATCH 16/25] [LEMS-2737/move-scoring-logic] docs(changeset): Move scorePerseusItem logic to PerseusScore --- .changeset/wise-owls-wait.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/wise-owls-wait.md diff --git a/.changeset/wise-owls-wait.md b/.changeset/wise-owls-wait.md new file mode 100644 index 0000000000..2ab9994d01 --- /dev/null +++ b/.changeset/wise-owls-wait.md @@ -0,0 +1,9 @@ +--- +"@khanacademy/perseus": major +"@khanacademy/perseus-core": minor +"@khanacademy/perseus-score": minor +"@khanacademy/perseus-dev-ui": patch +"@khanacademy/perseus-editor": patch +--- + +Move scorePerseusItem logic to PerseusScore From 4216e8a09b2efcb7084d9990a06739e51448e1da Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Wed, 29 Jan 2025 09:56:36 -0600 Subject: [PATCH 17/25] [LEMS-2737/move-scoring-logic] more cleanup --- dev/flipbook.tsx | 2 +- dev/package.json | 1 - packages/perseus-score/src/score.test.ts | 13 ------------- packages/perseus-score/src/score.ts | 3 +-- 4 files changed, 2 insertions(+), 17 deletions(-) diff --git a/dev/flipbook.tsx b/dev/flipbook.tsx index fada467749..fd12155f04 100644 --- a/dev/flipbook.tsx +++ b/dev/flipbook.tsx @@ -1,5 +1,4 @@ /* eslint monorepo/no-internal-import: "off", monorepo/no-relative-import: "off", import/no-relative-packages: "off" */ -import {scorePerseusItem} from "@khanacademy/perseus-score"; import Banner from "@khanacademy/wonder-blocks-banner"; import Button from "@khanacademy/wonder-blocks-button"; import {View} from "@khanacademy/wonder-blocks-core"; @@ -22,6 +21,7 @@ import {SvgImage} from "../packages/perseus/src/components"; import {mockStrings} from "../packages/perseus/src/strings"; import {isCorrect} from "../packages/perseus/src/util/scoring"; import {trueForAllMafsSupportedGraphTypes} from "../packages/perseus/src/widgets/interactive-graphs/mafs-supported-graph-types"; +import {scorePerseusItem} from "../packages/perseus-score/src"; import {EditableControlledInput} from "./editable-controlled-input"; import { diff --git a/dev/package.json b/dev/package.json index 259ccb08ac..065d083070 100644 --- a/dev/package.json +++ b/dev/package.json @@ -19,7 +19,6 @@ "@khanacademy/math-input": "^22.2.2", "@khanacademy/perseus-core": "3.3.0", "@khanacademy/perseus-linter": "^1.2.14", - "@khanacademy/perseus-score": "^2.0.0", "@khanacademy/pure-markdown": "^0.3.23", "@khanacademy/simple-markdown": "^0.13.16", "@khanacademy/wonder-blocks-banner": "4.0.5", diff --git a/packages/perseus-score/src/score.test.ts b/packages/perseus-score/src/score.test.ts index b8b58b1937..9b1315e050 100644 --- a/packages/perseus-score/src/score.test.ts +++ b/packages/perseus-score/src/score.test.ts @@ -152,7 +152,6 @@ describe("scoreWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -177,7 +176,6 @@ describe("scoreWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -202,7 +200,6 @@ describe("scoreWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -231,7 +228,6 @@ describe("scoreWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -261,7 +257,6 @@ describe("scoreWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -298,7 +293,6 @@ describe("scoreWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -334,7 +328,6 @@ describe("scoreWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -370,7 +363,6 @@ describe("scoreWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -393,7 +385,6 @@ describe("scoreWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -416,7 +407,6 @@ describe("scoreWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -439,7 +429,6 @@ describe("scoreWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -462,7 +451,6 @@ describe("scoreWidgetsFunctional", () => { widgets, widgetIds, userInputMap, - "en", ); @@ -579,7 +567,6 @@ describe("scorePerseusItem", () => { images: {}, }, {"dropdown 1": {value: 2}}, - "en", ); diff --git a/packages/perseus-score/src/score.ts b/packages/perseus-score/src/score.ts index e27e6d46d3..581934ce59 100644 --- a/packages/perseus-score/src/score.ts +++ b/packages/perseus-score/src/score.ts @@ -1,5 +1,3 @@ -// TODO: combine scorePerseusItem with scoreWidgetsFunctional - import { Errors, getUpgradedWidgetOptions, @@ -143,6 +141,7 @@ export function scorePerseusItem( return flattenScores(scores); } +// TODO: combine scorePerseusItem with scoreWidgetsFunctional export function scoreWidgetsFunctional( widgets: PerseusWidgetsMap, // This is a port of old code, I'm not sure why From 83080a4f275ccaf73f0eb9bfaf7cee407f80675e Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Thu, 30 Jan 2025 09:32:01 -0600 Subject: [PATCH 18/25] [LEMS-2737/move-scoring-logic] add grapher to core registry --- packages/perseus-core/src/widgets/widget-registry.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/perseus-core/src/widgets/widget-registry.ts b/packages/perseus-core/src/widgets/widget-registry.ts index 416636a93c..7909216af4 100644 --- a/packages/perseus-core/src/widgets/widget-registry.ts +++ b/packages/perseus-core/src/widgets/widget-registry.ts @@ -6,6 +6,7 @@ import explanationWidgetLogic from "./explanation"; import expressionWidgetLogic from "./expression"; import gradedGroupWidgetLogic from "./graded-group"; import gradedGroupSetWidgetLogic from "./graded-group-set"; +import grapherWidgetLogic from "./grapher"; import groupWidgetLogic from "./group"; import iframeWidgetLogic from "./iframe"; import imageWidgetLogic from "./image"; @@ -109,6 +110,7 @@ registerWidget("explanation", explanationWidgetLogic); registerWidget("expression", expressionWidgetLogic); registerWidget("graded-group", gradedGroupWidgetLogic); registerWidget("graded-group-set", gradedGroupSetWidgetLogic); +registerWidget("grapher", grapherWidgetLogic); registerWidget("group", groupWidgetLogic); registerWidget("iframe", iframeWidgetLogic); registerWidget("image", imageWidgetLogic); From 34055a0581f20a152f41aa5cda4d414b020d61fd Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Thu, 30 Jan 2025 13:36:07 -0600 Subject: [PATCH 19/25] [LEMS-2737/move-scoring-logic] rename WidgetLogic to CoreWidgetRegistry --- packages/perseus-core/src/index.ts | 2 +- .../{widget-registry.ts => core-widget-registry.ts} | 0 packages/perseus-core/src/widgets/upgrade.ts | 2 +- packages/perseus-editor/src/components/widget-editor.tsx | 4 ++-- packages/perseus-editor/src/diffs/renderer-diff.tsx | 4 ++-- packages/perseus-editor/src/editor.tsx | 9 +++++++-- packages/perseus/src/widget-container.tsx | 4 ++-- 7 files changed, 15 insertions(+), 10 deletions(-) rename packages/perseus-core/src/widgets/{widget-registry.ts => core-widget-registry.ts} (100%) diff --git a/packages/perseus-core/src/index.ts b/packages/perseus-core/src/index.ts index b828546340..226c67a66b 100644 --- a/packages/perseus-core/src/index.ts +++ b/packages/perseus-core/src/index.ts @@ -120,7 +120,7 @@ export { export type * from "./widgets/logic-export.types"; -export * as WidgetLogic from "./widgets/widget-registry"; +export * as CoreWidgetRegistry from "./widgets/core-widget-registry"; export {default as getOrdererPublicWidgetOptions} from "./widgets/orderer/orderer-util"; export {default as getCategorizerPublicWidgetOptions} from "./widgets/categorizer/categorizer-util"; diff --git a/packages/perseus-core/src/widgets/widget-registry.ts b/packages/perseus-core/src/widgets/core-widget-registry.ts similarity index 100% rename from packages/perseus-core/src/widgets/widget-registry.ts rename to packages/perseus-core/src/widgets/core-widget-registry.ts diff --git a/packages/perseus-core/src/widgets/upgrade.ts b/packages/perseus-core/src/widgets/upgrade.ts index 39c665dd02..3143c049dd 100644 --- a/packages/perseus-core/src/widgets/upgrade.ts +++ b/packages/perseus-core/src/widgets/upgrade.ts @@ -10,7 +10,7 @@ import { getSupportedAlignments, getWidgetOptionsUpgrades, isWidgetRegistered, -} from "./widget-registry"; +} from "./core-widget-registry"; import type {PerseusWidget, PerseusWidgetsMap} from "../data-schema"; diff --git a/packages/perseus-editor/src/components/widget-editor.tsx b/packages/perseus-editor/src/components/widget-editor.tsx index d384a7a209..e64eba299f 100644 --- a/packages/perseus-editor/src/components/widget-editor.tsx +++ b/packages/perseus-editor/src/components/widget-editor.tsx @@ -7,8 +7,8 @@ import { iconTrash, } from "@khanacademy/perseus"; import { + CoreWidgetRegistry, upgradeWidgetInfoToLatestVersion, - WidgetLogic, } from "@khanacademy/perseus-core"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import Switch from "@khanacademy/wonder-blocks-switch"; @@ -155,7 +155,7 @@ class WidgetEditor extends React.Component< const Ed = Widgets.getEditor(widgetInfo.type); let supportedAlignments: ReadonlyArray; if (this.props.apiOptions.showAlignmentOptions) { - supportedAlignments = WidgetLogic.getSupportedAlignments( + supportedAlignments = CoreWidgetRegistry.getSupportedAlignments( widgetInfo.type, ); } else { diff --git a/packages/perseus-editor/src/diffs/renderer-diff.tsx b/packages/perseus-editor/src/diffs/renderer-diff.tsx index f556f398e3..879cd818e3 100644 --- a/packages/perseus-editor/src/diffs/renderer-diff.tsx +++ b/packages/perseus-editor/src/diffs/renderer-diff.tsx @@ -3,7 +3,7 @@ */ import {Widgets} from "@khanacademy/perseus"; -import {WidgetLogic} from "@khanacademy/perseus-core"; +import {CoreWidgetRegistry} from "@khanacademy/perseus-core"; import * as React from "react"; import _ from "underscore"; @@ -21,7 +21,7 @@ const filterWidgetInfo = function (widgetInfo, showAlignmentOptions: boolean) { // Show alignment options iff multiple valid ones exist for this widget if ( showAlignmentOptions && - WidgetLogic.getSupportedAlignments(type).length > 1 + CoreWidgetRegistry.getSupportedAlignments(type).length > 1 ) { // @ts-expect-error - TS2339 - Property 'alignment' does not exist on type '{ readonly options: any; }'. filteredWidgetInfo.alignment = alignment; diff --git a/packages/perseus-editor/src/editor.tsx b/packages/perseus-editor/src/editor.tsx index c5e42d1cab..b211b8396b 100644 --- a/packages/perseus-editor/src/editor.tsx +++ b/packages/perseus-editor/src/editor.tsx @@ -6,7 +6,11 @@ import { Util, Widgets, } from "@khanacademy/perseus"; -import {Errors, PerseusError, WidgetLogic} from "@khanacademy/perseus-core"; +import { + CoreWidgetRegistry, + Errors, + PerseusError, +} from "@khanacademy/perseus-core"; import $ from "jquery"; // eslint-disable-next-line import/no-unresolved, import/no-extraneous-dependencies import katex from "katex"; @@ -673,7 +677,8 @@ class Editor extends React.Component { const widgetContent = widgetPlaceholder.replace("{id}", id); // Add newlines before block-display widgets like graphs - const isBlock = WidgetLogic.getDefaultAlignment(widgetType) === "block"; + const isBlock = + CoreWidgetRegistry.getDefaultAlignment(widgetType) === "block"; const prelude = oldContent.slice(0, cursorRange[0]); const postlude = oldContent.slice(cursorRange[1]); diff --git a/packages/perseus/src/widget-container.tsx b/packages/perseus/src/widget-container.tsx index 409264c8b5..f7a4f83453 100644 --- a/packages/perseus/src/widget-container.tsx +++ b/packages/perseus/src/widget-container.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/no-unsafe */ import { - WidgetLogic, + CoreWidgetRegistry, type PerseusWidgetOptions, } from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; @@ -120,7 +120,7 @@ class WidgetContainer extends React.Component { let alignment = this.state.widgetProps.alignment; if (alignment === "default") { - alignment = WidgetLogic.getDefaultAlignment(type); + alignment = CoreWidgetRegistry.getDefaultAlignment(type); } className += " widget-" + alignment; From 19a5d8403c3156ae8de338363bba4c9ba094dba1 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Thu, 30 Jan 2025 13:38:09 -0600 Subject: [PATCH 20/25] [LEMS-2737/move-scoring-logic] add oldWidgetInfo to error metadata --- packages/perseus-core/src/widgets/upgrade.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/perseus-core/src/widgets/upgrade.ts b/packages/perseus-core/src/widgets/upgrade.ts index 3143c049dd..16f12df49c 100644 --- a/packages/perseus-core/src/widgets/upgrade.ts +++ b/packages/perseus-core/src/widgets/upgrade.ts @@ -88,6 +88,7 @@ export const upgradeWidgetInfoToLatestVersion = ( type, fromMajorVersion: nextVersion - 1, toMajorVersion: nextVersion, + oldWidgetInfo: JSON.stringify(oldWidgetInfo), }, }, ); From d9a6699934da10a2581a7b8deb00135b5328f6b2 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Thu, 30 Jan 2025 13:43:40 -0600 Subject: [PATCH 21/25] [LEMS-2737/move-scoring-logic] secure getSupportedAlignments a little --- .../perseus-core/src/widgets/core-widget-registry.ts | 7 +++++-- packages/perseus-core/src/widgets/upgrade.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/perseus-core/src/widgets/core-widget-registry.ts b/packages/perseus-core/src/widgets/core-widget-registry.ts index 7909216af4..a45c4e2a27 100644 --- a/packages/perseus-core/src/widgets/core-widget-registry.ts +++ b/packages/perseus-core/src/widgets/core-widget-registry.ts @@ -75,12 +75,15 @@ export function getDefaultWidgetOptions(type: string) { */ // NOTE(kevinb): "default" is not one in `validAlignments`. -const DEFAULT_SUPPORTED_ALIGNMENTS = ["default"]; +const DEFAULT_SUPPORTED_ALIGNMENTS: ReadonlyArray = ["default"]; export const getSupportedAlignments = ( type: string, ): ReadonlyArray => { const widgetLogic = widgets[type]; - return widgetLogic?.supportedAlignments || DEFAULT_SUPPORTED_ALIGNMENTS; + if (!widgetLogic?.supportedAlignments?.[0]) { + return DEFAULT_SUPPORTED_ALIGNMENTS; + } + return widgetLogic?.supportedAlignments; }; /** diff --git a/packages/perseus-core/src/widgets/upgrade.ts b/packages/perseus-core/src/widgets/upgrade.ts index 16f12df49c..eea825077f 100644 --- a/packages/perseus-core/src/widgets/upgrade.ts +++ b/packages/perseus-core/src/widgets/upgrade.ts @@ -112,7 +112,14 @@ export const upgradeWidgetInfoToLatestVersion = ( // select box. If the widget only supports one alignment, the // alignment value will likely just end up as "default". if (alignment == null || alignment === "default") { - alignment = getSupportedAlignments(type)[0]; + alignment = getSupportedAlignments(type)?.[0]; + if (!alignment) { + throw new PerseusError( + "No default alignment found when upgrading widget", + Errors.Internal, + {metadata: {widgetType: type}}, + ); + } } let widgetStatic = oldWidgetInfo.static; From 488f6633c650ae027c6098c273aff460e6701b61 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Thu, 30 Jan 2025 13:45:23 -0600 Subject: [PATCH 22/25] [LEMS-2737/move-scoring-logic] remove underscore extends --- packages/perseus-core/src/widgets/upgrade.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/perseus-core/src/widgets/upgrade.ts b/packages/perseus-core/src/widgets/upgrade.ts index eea825077f..87155b9be5 100644 --- a/packages/perseus-core/src/widgets/upgrade.ts +++ b/packages/perseus-core/src/widgets/upgrade.ts @@ -128,7 +128,8 @@ export const upgradeWidgetInfoToLatestVersion = ( widgetStatic = DEFAULT_STATIC; } - return _.extend({}, oldWidgetInfo, { + return { + ...oldWidgetInfo, // maintain other info, like type // After upgrading we guarantee that the version is up-to-date version: latestVersion, @@ -137,7 +138,7 @@ export const upgradeWidgetInfoToLatestVersion = ( alignment: alignment, static: widgetStatic, options: newEditorOptions, - }); + } as any; }; export function getUpgradedWidgetOptions( From a916f5931e2ebe072fc905d4b5383cd479634d29 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Thu, 30 Jan 2025 13:47:47 -0600 Subject: [PATCH 23/25] [LEMS-2737/move-scoring-logic] cleanup getSupportedAlignments more --- packages/perseus-core/src/widgets/core-widget-registry.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/perseus-core/src/widgets/core-widget-registry.ts b/packages/perseus-core/src/widgets/core-widget-registry.ts index a45c4e2a27..b4cb17e517 100644 --- a/packages/perseus-core/src/widgets/core-widget-registry.ts +++ b/packages/perseus-core/src/widgets/core-widget-registry.ts @@ -73,15 +73,13 @@ export function getDefaultWidgetOptions(type: string) { * Supported alignments are given as an array of strings in the exports of * a widget's module. */ - -// NOTE(kevinb): "default" is not one in `validAlignments`. -const DEFAULT_SUPPORTED_ALIGNMENTS: ReadonlyArray = ["default"]; export const getSupportedAlignments = ( type: string, ): ReadonlyArray => { const widgetLogic = widgets[type]; if (!widgetLogic?.supportedAlignments?.[0]) { - return DEFAULT_SUPPORTED_ALIGNMENTS; + // default alignments + return ["default"]; } return widgetLogic?.supportedAlignments; }; From 852109c73aeffac6011c0732b12646ae47d3902c Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Thu, 30 Jan 2025 13:48:55 -0600 Subject: [PATCH 24/25] [LEMS-2737/move-scoring-logic] cleanup getDefaultAlignment --- packages/perseus-core/src/widgets/core-widget-registry.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/perseus-core/src/widgets/core-widget-registry.ts b/packages/perseus-core/src/widgets/core-widget-registry.ts index b4cb17e517..45c7699c2c 100644 --- a/packages/perseus-core/src/widgets/core-widget-registry.ts +++ b/packages/perseus-core/src/widgets/core-widget-registry.ts @@ -93,14 +93,12 @@ export const getSupportedAlignments = ( * `defaultAlignment`) or a function (called `getDefaultAlignment`) on * the exports of a widget's module. */ -const DEFAULT_ALIGNMENT = "block"; export const getDefaultAlignment = (type: string): Alignment => { const widgetLogic = widgets[type]; - if (!widgetLogic) { - return DEFAULT_ALIGNMENT; + if (!widgetLogic?.defaultAlignment) { + return "block"; } - - return widgetLogic.defaultAlignment || DEFAULT_ALIGNMENT; + return widgetLogic.defaultAlignment; }; registerWidget("categorizer", categorizerWidgetLogic); From de42db3ecdf02e76a466a949fe0db4d1d02e957f Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Mon, 3 Feb 2025 13:16:48 -0600 Subject: [PATCH 25/25] [LEMS-2737/move-scoring-logic] restore external API --- packages/perseus-score/src/index.ts | 2 ++ packages/perseus-score/src/widgets/widget-registry.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/perseus-score/src/index.ts b/packages/perseus-score/src/index.ts index 1596df43db..b8bc0e8602 100644 --- a/packages/perseus-score/src/index.ts +++ b/packages/perseus-score/src/index.ts @@ -44,3 +44,5 @@ export type { PerseusMockWidgetRubric, PerseusMockWidgetUserInput, } from "./widgets/mock-widget/mock-widget-validation.types"; + +export * from "./widgets/widget-registry"; diff --git a/packages/perseus-score/src/widgets/widget-registry.ts b/packages/perseus-score/src/widgets/widget-registry.ts index 32bc172c6e..871c5cdb3a 100644 --- a/packages/perseus-score/src/widgets/widget-registry.ts +++ b/packages/perseus-score/src/widgets/widget-registry.ts @@ -40,7 +40,7 @@ import type { const widgets = {}; -function registerWidget( +export function registerWidget( type: string, scorer: WidgetScorerFunction, validator?: WidgetValidatorFunction,