diff --git a/.changeset/two-tomatoes-tap.md b/.changeset/two-tomatoes-tap.md new file mode 100644 index 00000000000..fe4646a5404 --- /dev/null +++ b/.changeset/two-tomatoes-tap.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": patch +"@khanacademy/perseus-editor": patch +--- + +Add expression widget warnings to new save warning utility. Not ready for use yet. diff --git a/packages/perseus-core/src/utils/get-save-warnings-for-item.ts b/packages/perseus-core/src/utils/get-save-warnings-for-item.ts index 8eeab60777b..122a8a7a9eb 100644 --- a/packages/perseus-core/src/utils/get-save-warnings-for-item.ts +++ b/packages/perseus-core/src/utils/get-save-warnings-for-item.ts @@ -4,8 +4,8 @@ import {getSaveWarningsFnForWidget} from "../widgets/core-widget-registry"; import type {PerseusItem} from "../data-schema"; /* TODO(LEMS-3643): The following widgets have getSaveWarnings implemented -within their editors. - - Expression +within their editors. Update this list as they are migrated. + - Expression (already done) - Free Response - Graded Group - Graded Group Set diff --git a/packages/perseus-core/src/widgets/expression/expression-util.test.ts b/packages/perseus-core/src/widgets/expression/expression-util.test.ts index 956cf7472c6..0d1844985d8 100644 --- a/packages/perseus-core/src/widgets/expression/expression-util.test.ts +++ b/packages/perseus-core/src/widgets/expression/expression-util.test.ts @@ -1,6 +1,28 @@ -import getExpressionPublicWidgetOptions from "./expression-util"; +import {registerCoreWidgets} from "../core-widget-registry"; -import type {PerseusExpressionWidgetOptions} from "../../data-schema"; +import getExpressionPublicWidgetOptions, { + getSaveWarningsForExpressionWidget, +} from "./expression-util"; + +import type { + ExpressionWidget, + PerseusExpressionWidgetOptions, +} from "../../data-schema"; + +function getExpressionWidgetWithOptions( + options: Partial, +): ExpressionWidget { + return { + type: "expression", + options: { + answerForms: [], + buttonSets: ["basic"], + functions: [], + times: false, + ...options, + }, + }; +} describe("getExpressionPublicWidgetOptions", () => { it("should return the correct public options without any answer data", () => { @@ -38,3 +60,128 @@ describe("getExpressionPublicWidgetOptions", () => { }); }); }); + +describe("getSaveWarningsForExpressionWidget", () => { + beforeAll(() => { + registerCoreWidgets(); + }); + + it("returns a warning when no answers are specified", () => { + // Arrange + const widget = getExpressionWidgetWithOptions({ + answerForms: [], + }); + + // Act + const warnings = getSaveWarningsForExpressionWidget(widget); + + // Assert + expect(warnings).toEqual(["No answers specified"]); + }); + + it("returns a warning when no correct answer is specified", () => { + // Arrange + const widget = getExpressionWidgetWithOptions({ + answerForms: [ + { + value: "1", + form: false, + simplify: false, + considered: "wrong", + }, + ], + }); + + // Act + const warnings = getSaveWarningsForExpressionWidget(widget); + + // Assert + expect(warnings).toEqual(["No correct answer specified"]); + }); + + it("returns a warning when an answer is empty", () => { + // Arrange + const widget = getExpressionWidgetWithOptions({ + answerForms: [ + { + value: "a", + form: false, + simplify: false, + considered: "correct", + }, + { + value: "", + form: false, + simplify: false, + considered: "correct", + }, + ], + }); + + // Act + const warnings = getSaveWarningsForExpressionWidget(widget); + + // Assert + expect(warnings).toEqual(["Answer 2 is empty"]); + }); + + it("returns a warning when value could not be parsed", () => { + // Arrange + const widget = getExpressionWidgetWithOptions({ + answerForms: [ + { + value: "2.4.r", + form: false, + simplify: false, + considered: "correct", + }, + ], + }); + // Act + const warnings = getSaveWarningsForExpressionWidget(widget); + + // Assert + expect(warnings).toEqual(["Couldn't parse 2.4.r"]); + }); + + it("returns a warning if value is not simplified but is required to be", () => { + // Arrange + const widget = getExpressionWidgetWithOptions({ + answerForms: [ + { + value: "2/1", + form: false, + simplify: true, + considered: "correct", + }, + ], + }); + // Act + const warnings = getSaveWarningsForExpressionWidget(widget); + + // Assert + expect(warnings).toEqual([ + "2/1 isn't simplified, but is required to be", + ]); + }); + + it("returns an empty array when no warnings are detected", () => { + // Arrange + const widget = getExpressionWidgetWithOptions({ + answerForms: [ + { + value: "2", + form: false, + simplify: false, + considered: "correct", + }, + ], + }); + + // Act + const warnings = getSaveWarningsForExpressionWidget(widget); + + // Assert + expect(warnings).toEqual([]); + }); +}); diff --git a/packages/perseus-core/src/widgets/expression/expression-util.ts b/packages/perseus-core/src/widgets/expression/expression-util.ts index b8cafb2c6fc..403065de1da 100644 --- a/packages/perseus-core/src/widgets/expression/expression-util.ts +++ b/packages/perseus-core/src/widgets/expression/expression-util.ts @@ -1,4 +1,9 @@ -import type {PerseusExpressionWidgetOptions} from "../../data-schema"; +import * as KAS from "@khanacademy/kas"; + +import type { + ExpressionWidget, + PerseusExpressionWidgetOptions, +} from "../../data-schema"; /** * For details on the individual options, see the @@ -32,4 +37,48 @@ function getExpressionPublicWidgetOptions( }; } +export function getSaveWarningsForExpressionWidget( + widget: ExpressionWidget, +): Array { + const issues: Array = []; + const {answerForms, functions} = widget.options; + + if (answerForms.length === 0) { + issues.push("No answers specified"); + } else { + const hasCorrect = answerForms.some((form) => { + return form.considered === "correct"; + }); + if (!hasCorrect) { + issues.push("No correct answer specified"); + } + + answerForms.forEach((form, ix) => { + if (form.value === "") { + issues.push(`Answer ${ix + 1} is empty`); + } else { + // note we're not using icu for content creators + const expression = KAS.parse(form.value, { + functions: functions, + }); + if (!expression.parsed) { + issues.push(`Couldn't parse ${form.value}`); + } else if (form.simplify && !expression.expr.isSimplified()) { + issues.push( + `${form.value} isn't simplified, but is required to be`, + ); + } + } + }); + + // The following TODO is transferred over from the expression editor: + // TODO(joel) - warn about: + // - unreachable answers (how??) + // - specific answers following unspecific answers + // - incorrect answers as the final form + } + + return issues; +} + export default getExpressionPublicWidgetOptions; diff --git a/packages/perseus-core/src/widgets/expression/index.ts b/packages/perseus-core/src/widgets/expression/index.ts index c361d2893f3..d01b8116070 100644 --- a/packages/perseus-core/src/widgets/expression/index.ts +++ b/packages/perseus-core/src/widgets/expression/index.ts @@ -1,4 +1,6 @@ -import getExpressionPublicWidgetOptions from "./expression-util"; +import getExpressionPublicWidgetOptions, { + getSaveWarningsForExpressionWidget, +} from "./expression-util"; import type {PerseusExpressionWidgetOptions} from "../../data-schema"; import type {WidgetLogic} from "../logic-export.types"; @@ -23,6 +25,7 @@ const expressionWidgetLogic: WidgetLogic = { defaultWidgetOptions: defaultWidgetOptions, defaultAlignment: "inline-block", getPublicWidgetOptions: getExpressionPublicWidgetOptions, + getSaveWarnings: getSaveWarningsForExpressionWidget, accessible: true, }; diff --git a/packages/perseus-core/src/widgets/logic-export.types.ts b/packages/perseus-core/src/widgets/logic-export.types.ts index 2e9cba14a88..23f03888379 100644 --- a/packages/perseus-core/src/widgets/logic-export.types.ts +++ b/packages/perseus-core/src/widgets/logic-export.types.ts @@ -3,6 +3,7 @@ import {type getSaveWarningsForRadioWidget} from "./radio/radio-util"; import type getCategorizerPublicWidgetOptions from "./categorizer/categorizer-util"; import type getCSProgramPublicWidgetOptions from "./cs-program/cs-program-util"; import type getDropdownPublicWidgetOptions from "./dropdown/dropdown-util"; +import type {getSaveWarningsForExpressionWidget} from "./expression/expression-util"; import type getExpressionPublicWidgetOptions from "./expression/expression-util"; import type getFreeResponsePublicWidgetOptions from "./free-response/free-response-util"; import type getGrapherPublicWidgetOptions from "./grapher/grapher-util"; @@ -56,7 +57,9 @@ export type PublicWidgetOptionsFunction = | typeof getSorterPublicWidgetOptions | typeof getTablePublicWidgetOptions; -export type SaveWarningsFunction = typeof getSaveWarningsForRadioWidget; +export type SaveWarningsFunction = + | typeof getSaveWarningsForRadioWidget + | typeof getSaveWarningsForExpressionWidget; export type WidgetLogic = { name: string; diff --git a/packages/perseus-editor/src/widgets/expression-editor.tsx b/packages/perseus-editor/src/widgets/expression-editor.tsx index 23b8a3358d4..ca3b314a244 100644 --- a/packages/perseus-editor/src/widgets/expression-editor.tsx +++ b/packages/perseus-editor/src/widgets/expression-editor.tsx @@ -95,6 +95,8 @@ class ExpressionEditor extends React.Component { }; } + // TODO(LEMS-3643): Remove this function once the save warnings util is + // complete and in use. getSaveWarnings: () => any = () => { const issues: Array = [];