Skip to content

Commit

Permalink
[LEMS-2849] Answerless Radio (#2233)
Browse files Browse the repository at this point in the history
## Summary:
So this focuses on `numCorrect` which originally derived from the answers but now is its own widget option. I'm sure there's an argument that this also shouldn't be sent to the FE, but it's conditionally used to render help text: `Select 2 answers`. I don't know, I don't think it's that big of a deal to have it but I also made it optional so we can remove it from questions that don't need it: `needsNumCorrect = options.multipleSelect === true && options.countChoices === true`.

The important thing is that we can render, answer, and score a Radio widget that's been stripped of answers. As far as I can tell, this supports all behavior pre-scoring.

Issue: LEMS-2849

## Test plan:
Radio should continue to be renderable, answerable, and scorable.

Author: handeyeco

Reviewers: handeyeco, benchristel, jeremywiebe, Myranae

Required Reviewers:

Approved By: benchristel, jeremywiebe

Checks: ✅ 8 checks were successful

Pull Request URL: #2233
  • Loading branch information
handeyeco authored Mar 5, 2025
1 parent e02cc41 commit a0aee41
Show file tree
Hide file tree
Showing 23 changed files with 895 additions and 250 deletions.
7 changes: 7 additions & 0 deletions .changeset/mean-schools-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@khanacademy/perseus": major
"@khanacademy/perseus-core": major
"@khanacademy/perseus-editor": patch
---

RadioWidget v2 in support of answerless Radio
3 changes: 3 additions & 0 deletions packages/perseus-core/src/data-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1351,6 +1351,9 @@ export type PerseusRadioWidgetOptions = {
// If multipleSelect is enabled, Specify the number expected to be correct.
// NOTE: perseus_data.go says this is required even though it isn't necessary.
countChoices?: boolean;
// How many of the choices are correct, which is conditionally used to tell
// learners ahead of time how many options they'll need.
numCorrect?: number;
// Randomize the order of the options or keep them as defined
// NOTE: perseus_data.go says this is required even though it isn't necessary.
randomize?: boolean;
Expand Down
2 changes: 2 additions & 0 deletions packages/perseus-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export {default as pythonProgramLogic} from "./widgets/python-program";
export type {PythonProgramDefaultWidgetOptions} from "./widgets/python-program";
export {default as radioLogic} from "./widgets/radio";
export type {RadioDefaultWidgetOptions} from "./widgets/radio";
export {usesNumCorrect} from "./widgets/radio/radio-util";
export {default as sorterLogic} from "./widgets/sorter";
export type {SorterDefaultWidgetOptions} from "./widgets/sorter";
export {default as tableLogic} from "./widgets/table";
Expand Down Expand Up @@ -139,6 +140,7 @@ export type {DropdownPublicWidgetOptions} from "./widgets/dropdown/dropdown-util
export {default as getNumericInputPublicWidgetOptions} from "./widgets/numeric-input/numeric-input-util";
export {default as getNumberLinePublicWidgetOptions} from "./widgets/number-line/number-line-util";
export {default as getRadioPublicWidgetOptions} from "./widgets/radio/radio-util";
export {deriveNumCorrect} from "./widgets/radio/radio-upgrade";
export {default as getTablePublicWidgetOptions} from "./widgets/table/table-util";
export {default as getIFramePublicWidgetOptions} from "./widgets/iframe/iframe-util";
export {default as getMatrixPublicWidgetOptions} from "./widgets/matrix/matrix-util";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {parse} from "../parse";
import {failure, success} from "../result";

import {parseRadioWidget} from "./radio-widget";

describe("parseRadioWidget", () => {
it("migrates v1 options to v2", () => {
const widget = {
type: "radio",
graded: true,
options: {
choices: [
{
content: "Correct 1",
correct: true,
},
{
content: "Correct 2",
correct: true,
},
{
content: "Incorrect",
correct: false,
},
],
},
version: {
major: 1,
minor: 0,
},
};

expect(parse(widget, parseRadioWidget)).toEqual(
success({
type: "radio",
graded: true,
options: {
choices: [
{
content: "Correct 1",
correct: true,
},
{
content: "Correct 2",
correct: true,
},
{
content: "Incorrect",
correct: false,
},
],
numCorrect: 2,
},
version: {
major: 2,
minor: 0,
},
}),
);
});

it("rejects a widget with unrecognized version", () => {
const widget = {
type: "radio",
version: {
major: -1,
minor: 0,
},
graded: true,
options: {},
};

expect(parse(widget, parseRadioWidget)).toEqual(
failure(
expect.stringContaining(
"At (root) -- expected widget options with a known version number",
),
),
);
});
});
Original file line number Diff line number Diff line change
@@ -1,21 +1,62 @@
import {deriveNumCorrect} from "../../widgets/radio/radio-upgrade";
import {
any,
array,
boolean,
constant,
number,
object,
optional,
string,
} from "../general-purpose-parsers";
import {defaulted} from "../general-purpose-parsers/defaulted";

import {parseWidget} from "./widget";
import {versionedWidgetOptions} from "./versioned-widget-options";
import {parseWidgetWithVersion} from "./widget";
import {parseWidgetsMap} from "./widgets-map";

import type {RadioWidget} from "../../data-schema";
import type {Parser} from "../parser-types";
import type {ParsedValue, Parser} from "../parser-types";

export const parseRadioWidget: Parser<RadioWidget> = parseWidget(
const version2 = optional(object({major: constant(2), minor: number}));
const parseRadioWidgetV2: Parser<RadioWidget> = parseWidgetWithVersion(
version2,
constant("radio"),
object({
numCorrect: optional(number),
choices: array(
object({
content: defaulted(string, () => ""),
clue: optional(string),
correct: optional(boolean),
isNoneOfTheAbove: optional(boolean),
// deprecated
// There is an import cycle between radio-widget.ts and
// widgets-map.ts. The anonymous function below ensures that we
// don't refer to parseWidgetsMap before it's defined.
widgets: optional((rawVal, ctx) =>
parseWidgetsMap(rawVal, ctx),
),
}),
),
hasNoneOfTheAbove: optional(boolean),
countChoices: optional(boolean),
randomize: optional(boolean),
multipleSelect: optional(boolean),
deselectEnabled: optional(boolean),
// deprecated
onePerLine: optional(boolean),
// deprecated
displayCount: optional(any),
// v0 props
// `noneOfTheAbove` is still in use (but only set to `false`).
noneOfTheAbove: optional(constant(false)),
}),
);

const version1 = optional(object({major: constant(1), minor: number}));
const parseRadioWidgetV1: Parser<RadioWidget> = parseWidgetWithVersion(
version1,
constant("radio"),
object({
choices: array(
Expand Down Expand Up @@ -48,3 +89,74 @@ export const parseRadioWidget: Parser<RadioWidget> = parseWidget(
noneOfTheAbove: optional(constant(false)),
}),
);

function migrateV1ToV2(
widget: ParsedValue<typeof parseRadioWidgetV1>,
): RadioWidget {
const {options} = widget;
return {
...widget,
version: {major: 2, minor: 0},
options: {
...options,
numCorrect: deriveNumCorrect(options),
},
};
}

const version0 = optional(object({major: constant(0), minor: number}));
const parseRadioWidgetV0: Parser<RadioWidget> = parseWidgetWithVersion(
version0,
constant("radio"),
object({
choices: array(
object({
content: defaulted(string, () => ""),
clue: optional(string),
correct: optional(boolean),
isNoneOfTheAbove: optional(boolean),
// deprecated
// There is an import cycle between radio-widget.ts and
// widgets-map.ts. The anonymous function below ensures that we
// don't refer to parseWidgetsMap before it's defined.
widgets: optional((rawVal, ctx) =>
parseWidgetsMap(rawVal, ctx),
),
}),
),
hasNoneOfTheAbove: optional(boolean),
countChoices: optional(boolean),
randomize: optional(boolean),
multipleSelect: optional(boolean),
deselectEnabled: optional(boolean),
// deprecated
onePerLine: optional(boolean),
// deprecated
displayCount: optional(any),
// v0 props
// `noneOfTheAbove` is still in use (but only set to `false`).
noneOfTheAbove: optional(constant(false)),
}),
);

function migrateV0ToV1(
widget: ParsedValue<typeof parseRadioWidgetV1>,
): RadioWidget {
const {options} = widget;
const {noneOfTheAbove: _, ...rest} = options;
return {
...widget,
version: {major: 1, minor: 0},
options: {
...rest,
hasNoneOfTheAbove: false,
},
};
}

export const parseRadioWidget: Parser<RadioWidget> = versionedWidgetOptions(
2,
parseRadioWidgetV2,
)
.withMigrationFrom(1, parseRadioWidgetV1, migrateV1ToV2)
.withMigrationFrom(0, parseRadioWidgetV0, migrateV0ToV1).parser;
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,19 @@ describe("parseWidgetsMap", () => {
};

const result = parse(widgetsMap, parseWidgetsMap);
expect(result).toEqual(success(widgetsMap));
expect(result).toEqual(
success({
"radio 0": {
type: "radio",
version: {major: 2, minor: 0},
options: {
choices: [],
hasNoneOfTheAbove: false,
numCorrect: 0,
},
},
}),
);
});

it("rejects a widget ID with no number", () => {
Expand Down Expand Up @@ -672,9 +684,10 @@ describe("parseWidgetsMap", () => {
const widgetsMap: unknown = {
"radio 1": {
type: "radio",
version: {major: 0, minor: 0},
version: {major: 2, minor: 0},
options: {
choices: [],
numCorrect: 0,
noneOfTheAbove: false,
},
},
Expand Down
Loading

0 comments on commit a0aee41

Please sign in to comment.