Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SSS: Hook emptyWidgets() up to validation functions #2000

Merged
merged 7 commits into from
Dec 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-adults-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": minor
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I think this is right. Some of the TS is a little "big-brain" for me to understand and it looks like you might still be tidying up a little bit, but I think it's mostly good to go.

My concern/observation: this does change the behavior of emptyWidgets. Before we only enabled the "Check" button after a deep validation check (including the validation that requires the answers). Now we'll enable the "Check" button after a shallow validation check (only checking if the answer is scorable).

This is inevitable and baked into the project. It just occurs to me that we haven't really shared this with content and support advocates. I think it'll hardly be noticeable, but what do I know.

---

Change empty widgets check in Renderer to depend only on data available (and not on scoring data)
24 changes: 24 additions & 0 deletions packages/perseus/src/__testdata__/renderer.testdata.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import type {
DropdownWidget,
ExpressionWidget,
ImageWidget,
NumericInputWidget,
PerseusRenderer,
} from "../perseus-types";
import type {RenderProps} from "../widgets/radio";

export const expressionWidget: ExpressionWidget = {
type: "expression",
options: {
answerForms: [
{
considered: "correct",
form: true,
simplify: true,
value: "1.0",
},
],
buttonSets: ["basic"],
functions: [],
times: true,
},
};

export const dropdownWidget: DropdownWidget = {
type: "dropdown",
alignment: "default",
Expand Down Expand Up @@ -96,6 +114,12 @@ export const question2: PerseusRenderer = {
widgets: {"numeric-input 1": numericInputWidget},
};

export const question3: PerseusRenderer = {
content: "Enter $1.0$ in the input field: [[\u2603 expression 1]]\n\n\n\n",
images: {},
widgets: {"expression 1": expressionWidget},
};

export const definitionItem: PerseusRenderer = {
content:
"Mock widgets ==> [[\u2603 definition 1]] [[\u2603 definition 2]] [[\u2603 definition 3]]",
Expand Down
31 changes: 17 additions & 14 deletions packages/perseus/src/__tests__/renderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
definitionItem,
mockedRandomItem,
mockedShuffledRadioProps,
question3,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes in this file were needed because:

  • the input-number widget is deprecated
  • the numeric-input widget does not have any validation it can do that does not require scoring data, so it can't be used for empty widget tests.

} from "../__testdata__/renderer.testdata";
import * as Dependencies from "../dependencies";
import {registerWidget} from "../widgets";
Expand Down Expand Up @@ -1605,35 +1606,36 @@ describe("renderer", () => {
it("should return all empty widgets", async () => {
// Arrange
const {renderer} = renderQuestion({
...question2,
...question3,
content:
"Input 1: [[☃ numeric-input 1]]\n\n" +
"Input 2: [[☃ numeric-input 2]]",
"Input 1: [[☃ expression 1]]\n\n" +
"Input 2: [[☃ expression 2]]",
widgets: {
...question2.widgets,
"numeric-input 2": question2.widgets["numeric-input 1"],
...question3.widgets,
"expression 2": question3.widgets["expression 1"],
},
});
await userEvent.type(screen.getAllByRole("textbox")[0], "150");
act(() => jest.runOnlyPendingTimers());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: What makes these lines necessary? (there's another one at L1683)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because we are now using the expression widget. I couldn't figure out why the userEvent.type() call above wasn't working. In other places of Perseus where we test expression we also do this. I think the reason is that it takes a render cycle for the typing to show up in the expression widget. expression is based on MathInput which uses MathQuill under the hood. Probably related to that.


// Act
const emptyWidgets = renderer.emptyWidgets();

// Assert
expect(emptyWidgets).toStrictEqual(["numeric-input 2"]);
expect(emptyWidgets).toStrictEqual(["expression 2"]);
});

it("should not return static widgets even if empty", () => {
// Arrange
const {renderer} = renderQuestion({
...question2,
...question3,
content:
"Input 1: [[☃ numeric-input 1]]\n\n" +
"Input 2: [[☃ numeric-input 2]]",
"Input 1: [[☃ expression 1]]\n\n" +
"Input 2: [[☃ expression 2]]",
widgets: {
...question2.widgets,
"numeric-input 2": {
...question2.widgets["numeric-input 1"],
...question3.widgets,
"expression 2": {
...question3.widgets["expression 1"],
static: true,
},
},
Expand All @@ -1643,7 +1645,7 @@ describe("renderer", () => {
const emptyWidgets = renderer.emptyWidgets();

// Assert
expect(emptyWidgets).toStrictEqual(["numeric-input 1"]);
expect(emptyWidgets).toStrictEqual(["expression 1"]);
});

it("should return widget ID for group with empty widget", () => {
Expand All @@ -1663,7 +1665,7 @@ describe("renderer", () => {
JSON.stringify(simpleGroupQuestion),
);
simpleGroupQuestionCopy.widgets["group 1"].options.widgets[
"numeric-input 1"
"expression 1"
].static = true;
const {renderer} = renderQuestion(simpleGroupQuestionCopy);

Expand All @@ -1678,6 +1680,7 @@ describe("renderer", () => {
// Arrange
const {renderer} = renderQuestion(simpleGroupQuestion);
await userEvent.type(screen.getByRole("textbox"), "99");
act(() => jest.runOnlyPendingTimers());

// Act
const emptyWidgets = renderer.emptyWidgets();
Expand Down
36 changes: 22 additions & 14 deletions packages/perseus/src/renderer-util.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import {mapObject} from "./interactive2/objective_";
import {scoreIsEmpty, flattenScores} from "./util/scoring";
import {getWidgetIdsFromContent} from "./widget-type-utils";
import {getWidgetScorer, upgradeWidgetInfoToLatestVersion} from "./widgets";
import {
getWidgetScorer,
getWidgetValidator,
upgradeWidgetInfoToLatestVersion,
} from "./widgets";

import type {PerseusRenderer, PerseusWidgetsMap} from "./perseus-types";
import type {PerseusStrings} from "./strings";
import type {PerseusScore} from "./types";
import type {UserInput, UserInputMap} from "./validation.types";
import type {
UserInput,
UserInputMap,
ValidationDataMap,
} from "./validation.types";

export function getUpgradedWidgetOptions(
oldWidgetOptions: PerseusWidgetsMap,
Expand All @@ -33,31 +41,31 @@ export function getUpgradedWidgetOptions(
});
}

/**
* Checks the given user input to see if any answerable widgets have not been
* "filled in" (ie. if they're empty). Another way to think about this
* function is that its a check to see if we can score the provided input.
*/
Comment on lines +44 to +48
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Praise: Thanks for this! 🤩

export function emptyWidgetsFunctional(
widgets: PerseusWidgetsMap,
widgets: ValidationDataMap,
// This is a port of old code, I'm not sure why
// we need widgetIds vs the keys of the widgets object
widgetIds: Array<string>,
userInputMap: UserInputMap,
strings: PerseusStrings,
locale: string,
): ReadonlyArray<string> {
const upgradedWidgets = getUpgradedWidgetOptions(widgets);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: What makes this line no longer necessary? I looked through the PR, but not sure what's related

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not needed because this empty widgets call can (should?) only ever be called in a situation where the widget options have already been upgraded. When we are on the client, this data will be widget options from the Renderer which have already been upgraded. When we are scoring on the server, it will be scoring data that is already upgraded. Basically, I don't think this function should be exported outside of Perseus.


return widgetIds.filter((id) => {
const widget = upgradedWidgets[id];
if (!widget || widget.static) {
const widget = widgets[id];
if (!widget || widget.static === true) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This use of static here means that both our frontend data and scoring data will need to have the static field kept. Just observing this. I'm working through this in an upcoming type PR

// Static widgets shouldn't count as empty
return false;
}

const scorer = getWidgetScorer(widget.type);
const score = scorer?.(
userInputMap[id] as UserInput,
widget.options,
strings,
locale,
);
const validator = getWidgetValidator(widget.type);
const userInput = userInputMap[id];
const validationData = widget.options;
const score = validator?.(userInput, validationData, strings, locale);

if (score) {
return scoreIsEmpty(score);
Expand Down
16 changes: 16 additions & 0 deletions packages/perseus/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
UserInput,
UserInputArray,
UserInputMap,
ValidationData,
} from "./validation.types";
import type {WidgetPromptJSON} from "./widget-ai-utils/prompt-types";
import type {KeypadAPI} from "@khanacademy/math-input";
Expand Down Expand Up @@ -578,6 +579,13 @@ export type WidgetTransform = (

export type ValidationResult = Extract<PerseusScore, {type: "invalid"}> | null;

export type WidgetValidatorFunction = (
userInput: UserInput,
validationData: ValidationData,
strings: PerseusStrings,
locale: string,
) => ValidationResult;

export type WidgetScorerFunction = (
// The user data needed to score
userInput: UserInput,
Expand Down Expand Up @@ -632,6 +640,14 @@ export type WidgetExports<
*/
staticTransform?: WidgetTransform; // this is a function of some sort,

/**
* Validates the learner's guess to check if it's sufficient for scoring.
* Typically, this is basically an "emptiness" check, but for some widgets
* such as `interactive-graph` it is a check that the learner has made any
* edits (ie. the widget is not in it's origin state).
*/
validator?: WidgetValidatorFunction;

/**
* A function that scores user input (the guess) for the widget.
*/
Expand Down
66 changes: 65 additions & 1 deletion packages/perseus/src/validation.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export type PerseusExpressionRubric = {
export type PerseusExpressionUserInput = string;

export type PerseusGroupRubric = PerseusGroupWidgetOptions;
export type PerseusGroupValidationData = {widgets: ValidationDataMap};
export type PerseusGroupUserInput = UserInputMap;

export type PerseusGradedGroupRubric = PerseusGradedGroupWidgetOptions;
Expand Down Expand Up @@ -264,6 +265,7 @@ export type UserInput =
| PerseusDropdownUserInput
| PerseusExpressionUserInput
| PerseusGrapherUserInput
| PerseusGroupUserInput
| PerseusIFrameUserInput
| PerseusInputNumberUserInput
| PerseusInteractiveGraphUserInput
Expand All @@ -278,11 +280,73 @@ export type UserInput =
| PerseusSorterUserInput
| PerseusTableUserInput;

export type UserInputMap = {[widgetId: string]: UserInput | UserInputMap};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wasn't this here because some of the utility functions were recursive and might have nesting going on? Is that not the case anymore? May not have understood how it was used originally, so just checking :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only widget that did that was the group. I added a proper type for the group's user input (which is export type PerseusGroupUserInput = UserInputMap;). So it makes this type a bit more accurate and the code that uses the UserInput easier to work with. I have a future PR almost ready where this type will be further improved.

export type UserInputMap = {[widgetId: string]: UserInput};

/**
* deprecated prefer using UserInputMap
*/
export type UserInputArray = ReadonlyArray<
UserInputArray | UserInput | null | undefined
>;
export interface ValidationDataTypes {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uses the same construct as the new PerseusWidgetTypes does. However, the fact that not all widgets have validation data concerns me because it means that this interface won't have an entry for all widgets. 🤔 I'm not sure how to tackle that yet.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the potential issues with the interface not having an entry for all widgets?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Today I'm not totally sure yet. At least, it would give us an inaccurate view of what the different types of validation data we support (we might have a type defined, but not in this "master list"). Ideally in future PRs we can derive some extra checks to ensure these interface type registries stay accurate. 🤞

categorizer: PerseusCategorizerValidationData;
// "cs-program": PerseusCSProgramValidationData;
// definition: PerseusDefinitionValidationData;
// dropdown: PerseusDropdownRubric;
// explanation: PerseusExplanationValidationData;
// expression: PerseusExpressionValidationData;
// grapher: PerseusGrapherValidationData;
// "graded-group-set": PerseusGradedGroupSetValidationData;
// "graded-group": PerseusGradedGroupValidationData;
group: PerseusGroupValidationData;
// iframe: PerseusIFrameValidationData;
// image: PerseusImageValidationData;
// "input-number": PerseusInputNumberValidationData;
// interaction: PerseusInteractionValidationData;
// "interactive-graph": PerseusInteractiveGraphValidationData;
// "label-image": PerseusLabelImageValidationData;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing you're going to do something with this commented code right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, just not sure what.

#2000 (comment)

// matcher: PerseusMatcherValidationData;
// matrix: PerseusMatrixValidationData;
// measurer: PerseusMeasurerValidationData;
// "molecule-renderer": PerseusMoleculeRendererValidationData;
// "number-line": PerseusNumberLineValidationData;
// "numeric-input": PerseusNumericInputValidationData;
// orderer: PerseusOrdererValidationData;
// "passage-ref-target": PerseusRefTargetValidationData;
// "passage-ref": PerseusPassageRefValidationData;
// passage: PerseusPassageValidationData;
// "phet-simulation": PerseusPhetSimulationValidationData;
// "python-program": PerseusPythonProgramValidationData;
plotter: PerseusPlotterValidationData;
// radio: PerseusRadioValidationData;
// sorter: PerseusSorterValidationData;
// table: PerseusTableValidationData;
// video: PerseusVideoValidationData;

// Deprecated widgets
// sequence: PerseusAutoCorrectValidationData;
}

/**
* A map of validation data, keyed by `widgetId`. This data is used to check if
* a question is answerable. This data represents the minimal intersection of
* data that's available in the client (widget options) and server (scoring
* data) and is represented by a group of types known as "validation data".
*
* NOTE: The value in this map is intentionally a subset of WidgetOptions<T>.
* By using the same shape (minus any unneeded data), we are able to pass a
* `PerseusWidgetsMap` or ` into any function that accepts a
* `ValidationDataMap` without any mutation of data.
*/
export type ValidationDataMap = {
[Property in keyof ValidationDataTypes as `${Property} ${number}`]: {
type: Property;
static?: boolean;
options: ValidationDataTypes[Property];
};
};

/**
* A union type of all the different widget validation data types that exist.
*/
export type ValidationData = ValidationDataTypes[keyof ValidationDataTypes];
7 changes: 7 additions & 0 deletions packages/perseus/src/widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
WidgetExports,
WidgetTransform,
WidgetScorerFunction,
WidgetValidatorFunction,
} from "./types";
import type * as React from "react";

Expand Down Expand Up @@ -137,6 +138,12 @@ export const getWidgetExport = (name: string): WidgetExports | null => {
return widgets[name] ?? null;
};

export const getWidgetValidator = (
name: string,
): WidgetValidatorFunction | null => {
return widgets[name]?.validator ?? null;
};

export const getWidgetScorer = (name: string): WidgetScorerFunction | null => {
return widgets[name]?.scorer ?? null;
};
Expand Down
4 changes: 4 additions & 0 deletions packages/perseus/src/widgets/categorizer/categorizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import Util from "../../util";
import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/categorizer/categorizer-ai-utils";

import scoreCategorizer from "./score-categorizer";
import validateCategorizer from "./validate-categorizer";

import type {PerseusCategorizerWidgetOptions} from "../../perseus-types";
import type {Widget, WidgetExports, WidgetProps} from "../../types";
Expand Down Expand Up @@ -328,4 +329,7 @@ export default {
// TODO(LEMS-2656): remove TS suppression
// @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusCSProgramUserInput'.
scorer: scoreCategorizer,
// TODO(LEMS-2656): remove TS suppression
// @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusCSProgramUserInput'.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same issue as with the scorer entry. Each of our validation functions accepts the specific user input and validation data type for the widget, but our function definition for it on WidgetExports takes a type union of any widget's user input and validation data type.

The solution, I think, is to introduce more generic types on WidgetExports but that gets out of hand quickly each time I try. I'm going to leave this here for now and come back to it after this PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be a fun "wind down" task next week. Might not. idk, I'm fine with error suppression for now.

validator: validateCategorizer,
} satisfies WidgetExports<typeof Categorizer>;
4 changes: 4 additions & 0 deletions packages/perseus/src/widgets/dropdown/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {ApiOptions} from "../../perseus-api";
import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/dropdown/dropdown-ai-utils";

import scoreDropdown from "./score-dropdown";
import validateDropdown from "./validate-dropdown";

import type {PerseusDropdownWidgetOptions} from "../../perseus-types";
import type {Widget, WidgetExports, WidgetProps} from "../../types";
Expand Down Expand Up @@ -162,4 +163,7 @@ export default {
// TODO(LEMS-2656): remove TS suppression
// @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusDropdownUserInput'.
scorer: scoreDropdown,
// TODO(LEMS-2656): remove TS suppression
// @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusDropdownUserInput'.
validator: validateDropdown,
} satisfies WidgetExports<typeof Dropdown>;
4 changes: 4 additions & 0 deletions packages/perseus/src/widgets/expression/expression.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/expression/

import getDecimalSeparator from "./get-decimal-separator";
import scoreExpression from "./score-expression";
import validateExpression from "./validate-expression";

import type {DependenciesContext} from "../../dependencies";
import type {PerseusExpressionWidgetOptions} from "../../perseus-types";
Expand Down Expand Up @@ -558,6 +559,9 @@ export default {
// TODO(LEMS-2656): remove TS suppression
// @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusExpressionUserInput'.
scorer: scoreExpression,
// TODO(LEMS-2656): remove TS suppression
// @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusExpressionUserInput'.
validator: validateExpression,

// TODO(LEMS-2656): remove TS suppression
// @ts-expect-error: Type 'Rubric' is not assignable to type 'PerseusExpressionRubric'.
Expand Down
Loading
Loading