@khanacademy/perseus 11.5.1 → 11.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/es/index.css +6 -6
- package/dist/es/index.js +17 -5
- package/dist/es/index.js.map +1 -1
- package/dist/index.css +6 -6
- package/dist/index.js +17 -5
- package/dist/index.js.map +1 -1
- package/dist/widgets/numeric-input.d.ts +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/server-item-renderer.test.tsx.snap +3 -3
- package/src/__tests__/item-renderer.test.tsx +24 -18
- package/src/hint-renderer.tsx +1 -0
- package/src/multi-items/__tests__/__snapshots__/multi-renderer.test.tsx.snap +1 -1
- package/src/perseus-types.ts +2 -1
- package/src/styles/khan-exercise.css +6 -4
- package/src/styles/perseus-renderer.less +0 -1
- package/src/widgets/__stories__/test-keypad-context-wrapper.tsx +7 -21
- package/src/widgets/__tests__/expression-mobile.test.tsx +7 -20
- package/src/widgets/__tests__/numeric-input.test.ts +35 -0
- package/src/widgets/numeric-input.tsx +22 -10
- package/tsconfig-build.tsbuildinfo +1 -1
|
@@ -36,7 +36,7 @@ export declare class NumericInput extends React.Component<Props, State> {
|
|
|
36
36
|
static defaultProps: DefaultProps;
|
|
37
37
|
static getUserInputFromProps(props: Props): UserInput;
|
|
38
38
|
static getOneCorrectAnswerFromRubric(rubric: Rubric): string | null | undefined;
|
|
39
|
-
static validate(
|
|
39
|
+
static validate(userInput: UserInput, rubric: Rubric): PerseusScore;
|
|
40
40
|
state: State;
|
|
41
41
|
examples: () => ReadonlyArray<string>;
|
|
42
42
|
shouldShowExamples: () => boolean;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "Core Perseus API (includes renderers and widgets)",
|
|
4
4
|
"author": "Khan Academy",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"version": "11.
|
|
6
|
+
"version": "11.6.0",
|
|
7
7
|
"publishConfig": {
|
|
8
8
|
"access": "public"
|
|
9
9
|
},
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@khanacademy/kas": "^0.3.1",
|
|
26
26
|
"@khanacademy/kmath": "^0.1.2",
|
|
27
|
-
"@khanacademy/math-input": "^
|
|
27
|
+
"@khanacademy/math-input": "^14.0.0",
|
|
28
28
|
"@khanacademy/perseus-core": "1.1.0",
|
|
29
29
|
"@khanacademy/perseus-linter": "^0.3.4",
|
|
30
30
|
"@khanacademy/pure-markdown": "^0.2.6",
|
|
@@ -189,7 +189,7 @@ exports[`server item renderer should snapshot: initial render 1`] = `
|
|
|
189
189
|
class=""
|
|
190
190
|
>
|
|
191
191
|
<div
|
|
192
|
-
class="perseus-hint-renderer"
|
|
192
|
+
class="hint perseus-hint-renderer"
|
|
193
193
|
tabindex="-1"
|
|
194
194
|
>
|
|
195
195
|
<span
|
|
@@ -219,7 +219,7 @@ exports[`server item renderer should snapshot: initial render 1`] = `
|
|
|
219
219
|
</div>
|
|
220
220
|
</div>
|
|
221
221
|
<div
|
|
222
|
-
class="perseus-hint-renderer"
|
|
222
|
+
class="hint perseus-hint-renderer"
|
|
223
223
|
tabindex="-1"
|
|
224
224
|
>
|
|
225
225
|
<span
|
|
@@ -249,7 +249,7 @@ exports[`server item renderer should snapshot: initial render 1`] = `
|
|
|
249
249
|
</div>
|
|
250
250
|
</div>
|
|
251
251
|
<div
|
|
252
|
-
class="perseus-hint-renderer last-hint last-rendered"
|
|
252
|
+
class="hint perseus-hint-renderer last-hint last-rendered"
|
|
253
253
|
tabindex="-1"
|
|
254
254
|
>
|
|
255
255
|
<span
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import {StatefulKeypadContextProvider} from "@khanacademy/math-input";
|
|
1
2
|
import {RenderStateRoot} from "@khanacademy/wonder-blocks-core";
|
|
2
|
-
import {fireEvent, render, screen} from "@testing-library/react";
|
|
3
|
+
import {fireEvent, render, screen, waitFor} from "@testing-library/react";
|
|
3
4
|
import userEvent from "@testing-library/user-event";
|
|
4
5
|
import * as React from "react";
|
|
5
6
|
import "@testing-library/jest-dom"; // Imports custom matchers
|
|
@@ -71,23 +72,25 @@ export const renderQuestion = (
|
|
|
71
72
|
let renderer: ItemRenderer | null = null;
|
|
72
73
|
const {container} = render(
|
|
73
74
|
<RenderStateRoot>
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
75
|
+
<StatefulKeypadContextProvider>
|
|
76
|
+
<ItemRenderer
|
|
77
|
+
ref={(node) => (renderer = node)}
|
|
78
|
+
apiOptions={apiOptions}
|
|
79
|
+
item={question}
|
|
80
|
+
problemNum={0}
|
|
81
|
+
reviewMode={false}
|
|
82
|
+
savedState=""
|
|
83
|
+
controlPeripherals={false}
|
|
84
|
+
dependencies={testDependenciesV2}
|
|
85
|
+
{...optionalProps}
|
|
86
|
+
/>
|
|
87
|
+
{/* The ItemRenderer _requires_ two divs: a work area and hints
|
|
86
88
|
area. Without both of these, it fails to render anything! */}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
<div id="workarea" />
|
|
90
|
+
<div id="hintsarea" />
|
|
89
91
|
|
|
90
|
-
|
|
92
|
+
<Peripherals />
|
|
93
|
+
</StatefulKeypadContextProvider>
|
|
91
94
|
</RenderStateRoot>,
|
|
92
95
|
);
|
|
93
96
|
if (!renderer) {
|
|
@@ -309,18 +312,21 @@ describe("item renderer", () => {
|
|
|
309
312
|
);
|
|
310
313
|
});
|
|
311
314
|
|
|
312
|
-
it("should activate the keypad when widget with input is focused", () => {
|
|
315
|
+
it("should activate the keypad when widget with input is focused", async () => {
|
|
313
316
|
// Arrange
|
|
314
317
|
const {renderer} = renderQuestion(itemWithInput, {
|
|
315
318
|
isMobile: true,
|
|
316
319
|
customKeypad: true,
|
|
320
|
+
useV2Keypad: true,
|
|
317
321
|
});
|
|
318
322
|
|
|
319
323
|
// Act
|
|
320
324
|
renderer.focus();
|
|
321
325
|
|
|
322
326
|
// Assert
|
|
323
|
-
|
|
327
|
+
await waitFor(() => {
|
|
328
|
+
expect(screen.getByLabelText("7")).toBeVisible();
|
|
329
|
+
});
|
|
324
330
|
});
|
|
325
331
|
|
|
326
332
|
it("should provide current and previous focus paths on focus change to, and away from, a single widget", () => {
|
package/src/hint-renderer.tsx
CHANGED
|
@@ -62,6 +62,7 @@ class HintRenderer extends React.Component<Props> {
|
|
|
62
62
|
const {isMobile} = apiOptions;
|
|
63
63
|
|
|
64
64
|
const classNames = classnames(
|
|
65
|
+
"hint",
|
|
65
66
|
!isMobile && "perseus-hint-renderer",
|
|
66
67
|
isMobile && css(styles.newHint),
|
|
67
68
|
isMobile && lastRendered && css(styles.lastRenderedNewHint),
|
package/src/perseus-types.ts
CHANGED
|
@@ -762,7 +762,8 @@ export type PerseusNumericInputAnswer = {
|
|
|
762
762
|
// The forms available for this answer. Options: "integer, ""decimal", "proper", "improper", "mixed", or "pi"
|
|
763
763
|
// NOTE: perseus_data.go says this is required even though it isn't necessary.
|
|
764
764
|
answerForms?: ReadonlyArray<MathFormat>;
|
|
765
|
-
// Whether the answerForms
|
|
765
|
+
// Whether we should check the answer strictly against the the configured answerForms (strict = true)
|
|
766
|
+
// or include the set of default answerForms (strict = false).
|
|
766
767
|
strict: boolean;
|
|
767
768
|
// A range of error +/- the value
|
|
768
769
|
// NOTE: perseus_data.go says this is non-nullable even though we handle null values.
|
|
@@ -10,6 +10,12 @@ var {
|
|
|
10
10
|
font-style: normal;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
.hint.last-hint {
|
|
14
|
+
.paragraph {
|
|
15
|
+
font-weight: bold;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
.hint_blue {
|
|
14
20
|
color: #6495ed;
|
|
15
21
|
}
|
|
@@ -32,10 +38,6 @@ var {
|
|
|
32
38
|
color: #9d38bd;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
|
-
.last-hint {
|
|
36
|
-
font-weight: bold;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
41
|
div.subhint {
|
|
40
42
|
border: 1px solid #aaaaaa;
|
|
41
43
|
background: #f9f9f9;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
KeypadContext,
|
|
3
|
+
StatefulKeypadContextProvider,
|
|
4
|
+
MobileKeypad,
|
|
5
|
+
} from "@khanacademy/math-input";
|
|
2
6
|
import {View} from "@khanacademy/wonder-blocks-core";
|
|
3
7
|
import {action} from "@storybook/addon-actions";
|
|
4
8
|
import {StyleSheet} from "aphrodite";
|
|
@@ -29,29 +33,11 @@ type Props = {
|
|
|
29
33
|
};
|
|
30
34
|
|
|
31
35
|
const TestKeypadContextWrapper = (props: Props): React.ReactElement => {
|
|
32
|
-
const [keypadElement, setKeypadElement] = React.useState(null);
|
|
33
|
-
const [renderer, setRenderer] = React.useState(null);
|
|
34
|
-
const [scrollableElement, setScrollableElement] = React.useState(
|
|
35
|
-
document.body,
|
|
36
|
-
);
|
|
37
|
-
|
|
38
36
|
return (
|
|
39
|
-
<
|
|
40
|
-
value={{
|
|
41
|
-
// @ts-expect-error - TS2322 - Type 'Dispatch<SetStateAction<HTMLElement>>' is not assignable to type '(scrollableElement?: HTMLElement | null | undefined) => void'.
|
|
42
|
-
setKeypadElement,
|
|
43
|
-
keypadElement,
|
|
44
|
-
// @ts-expect-error - TS2322 - Type 'Dispatch<SetStateAction<HTMLElement>>' is not assignable to type '(scrollableElement?: HTMLElement | null | undefined) => void'.
|
|
45
|
-
setRenderer,
|
|
46
|
-
renderer,
|
|
47
|
-
// @ts-expect-error - TS2322 - Type 'Dispatch<SetStateAction<HTMLElement>>' is not assignable to type '(scrollableElement?: HTMLElement | null | undefined) => void'.
|
|
48
|
-
setScrollableElement,
|
|
49
|
-
scrollableElement,
|
|
50
|
-
}}
|
|
51
|
-
>
|
|
37
|
+
<StatefulKeypadContextProvider>
|
|
52
38
|
{props.children}
|
|
53
39
|
<Footer />
|
|
54
|
-
</
|
|
40
|
+
</StatefulKeypadContextProvider>
|
|
55
41
|
);
|
|
56
42
|
};
|
|
57
43
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import "@testing-library/jest-dom";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
KeypadContext,
|
|
4
|
+
StatefulKeypadContextProvider,
|
|
5
|
+
MobileKeypad,
|
|
6
|
+
} from "@khanacademy/math-input";
|
|
3
7
|
import {RenderStateRoot} from "@khanacademy/wonder-blocks-core";
|
|
4
8
|
import {
|
|
5
9
|
fireEvent,
|
|
@@ -22,8 +26,6 @@ import {registerWidget} from "../../widgets";
|
|
|
22
26
|
import {expressionItem2} from "../__testdata__/expression.testdata";
|
|
23
27
|
import ExpressionExport from "../expression";
|
|
24
28
|
|
|
25
|
-
import type {KeypadAPI} from "@khanacademy/math-input";
|
|
26
|
-
|
|
27
29
|
const MQ = MathQuill.getInterface(2);
|
|
28
30
|
|
|
29
31
|
function RendererWithContext({item}) {
|
|
@@ -67,27 +69,12 @@ function KeypadWithContext() {
|
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
function ConnectedRenderer({item = expressionItem2}) {
|
|
70
|
-
const [keypadElement, setKeypadElement] =
|
|
71
|
-
React.useState<KeypadAPI | null>();
|
|
72
|
-
const [renderer, setRenderer] = React.useState<any>(null);
|
|
73
|
-
const [scrollableElement, setScrollableElement] =
|
|
74
|
-
React.useState<HTMLElement | null>();
|
|
75
|
-
|
|
76
72
|
return (
|
|
77
73
|
<RenderStateRoot>
|
|
78
|
-
<
|
|
79
|
-
value={{
|
|
80
|
-
setKeypadElement,
|
|
81
|
-
keypadElement,
|
|
82
|
-
setRenderer,
|
|
83
|
-
renderer,
|
|
84
|
-
setScrollableElement,
|
|
85
|
-
scrollableElement,
|
|
86
|
-
}}
|
|
87
|
-
>
|
|
74
|
+
<StatefulKeypadContextProvider>
|
|
88
75
|
<RendererWithContext item={item} />
|
|
89
76
|
<KeypadWithContext />
|
|
90
|
-
</
|
|
77
|
+
</StatefulKeypadContextProvider>
|
|
91
78
|
</RenderStateRoot>
|
|
92
79
|
);
|
|
93
80
|
}
|
|
@@ -250,6 +250,41 @@ describe("static function validate", () => {
|
|
|
250
250
|
expect(score.message?.includes("pi")).toBeFalsy();
|
|
251
251
|
});
|
|
252
252
|
|
|
253
|
+
it("still validates against pi if provided in answerForms", () => {
|
|
254
|
+
const rubric: Rubric = {
|
|
255
|
+
answers: [
|
|
256
|
+
{
|
|
257
|
+
maxError: null,
|
|
258
|
+
message: "",
|
|
259
|
+
simplify: "required",
|
|
260
|
+
status: "correct",
|
|
261
|
+
strict: false,
|
|
262
|
+
value: 311.01767270538954,
|
|
263
|
+
answerForms: ["pi"],
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
labelText: "",
|
|
267
|
+
size: "normal",
|
|
268
|
+
static: false,
|
|
269
|
+
coefficient: false,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const userInput = {
|
|
273
|
+
currentValue: "99 pi",
|
|
274
|
+
} as const;
|
|
275
|
+
|
|
276
|
+
const score = NumericInput.validate(userInput, rubric);
|
|
277
|
+
|
|
278
|
+
expect(score).toMatchInlineSnapshot(`
|
|
279
|
+
{
|
|
280
|
+
"earned": 1,
|
|
281
|
+
"message": null,
|
|
282
|
+
"total": 1,
|
|
283
|
+
"type": "points",
|
|
284
|
+
}
|
|
285
|
+
`);
|
|
286
|
+
});
|
|
287
|
+
|
|
253
288
|
it("with a strict answer", () => {
|
|
254
289
|
const rubric: Rubric = {
|
|
255
290
|
answers: [
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
PerseusNumericInputAnswer,
|
|
16
16
|
PerseusNumericInputWidgetOptions,
|
|
17
17
|
PerseusNumericInputAnswerForm,
|
|
18
|
+
MathFormat,
|
|
18
19
|
} from "../perseus-types";
|
|
19
20
|
import type {
|
|
20
21
|
FocusPath,
|
|
@@ -25,7 +26,11 @@ import type {
|
|
|
25
26
|
|
|
26
27
|
const ParseTex = TexWrangler.parseTex;
|
|
27
28
|
|
|
28
|
-
const answerFormButtons
|
|
29
|
+
const answerFormButtons: ReadonlyArray<{
|
|
30
|
+
title: string;
|
|
31
|
+
value: MathFormat;
|
|
32
|
+
content: string;
|
|
33
|
+
}> = [
|
|
29
34
|
{title: "Integers", value: "integer", content: "6"},
|
|
30
35
|
{title: "Decimals", value: "decimal", content: "0.75"},
|
|
31
36
|
{title: "Proper fractions", value: "proper", content: "\u2157"},
|
|
@@ -149,8 +154,8 @@ export class NumericInput extends React.Component<Props, State> {
|
|
|
149
154
|
return answerStrings[0];
|
|
150
155
|
}
|
|
151
156
|
|
|
152
|
-
static validate(
|
|
153
|
-
const
|
|
157
|
+
static validate(userInput: UserInput, rubric: Rubric): PerseusScore {
|
|
158
|
+
const defaultAnswerForms = answerFormButtons
|
|
154
159
|
.map((e) => e["value"])
|
|
155
160
|
// Don't default to validating the answer as a pi answer
|
|
156
161
|
// if answerForm isn't set on the answer
|
|
@@ -159,6 +164,18 @@ export class NumericInput extends React.Component<Props, State> {
|
|
|
159
164
|
|
|
160
165
|
const createValidator = (answer: PerseusNumericInputAnswer) => {
|
|
161
166
|
const stringAnswer = `${answer.value}`;
|
|
167
|
+
|
|
168
|
+
// Always validate against the provided answer forms (pi, decimal, etc.)
|
|
169
|
+
const validatorForms = [...(answer.answerForms ?? [])];
|
|
170
|
+
|
|
171
|
+
// When an answer is set to strict, we validate using ONLY
|
|
172
|
+
// the provided answerForms. If strict is false, or if there
|
|
173
|
+
// were no provided answer forms, we will include all
|
|
174
|
+
// of the default answer forms in our validator.
|
|
175
|
+
if (!answer.strict || validatorForms.length === 0) {
|
|
176
|
+
validatorForms.push(...defaultAnswerForms);
|
|
177
|
+
}
|
|
178
|
+
|
|
162
179
|
return KhanAnswerTypes.number.createValidatorFunctional(
|
|
163
180
|
stringAnswer,
|
|
164
181
|
{
|
|
@@ -169,19 +186,14 @@ export class NumericInput extends React.Component<Props, State> {
|
|
|
169
186
|
: "optional",
|
|
170
187
|
inexact: true, // TODO(merlob) backfill / delete
|
|
171
188
|
maxError: answer.maxError,
|
|
172
|
-
forms:
|
|
173
|
-
answer.strict &&
|
|
174
|
-
answer.answerForms &&
|
|
175
|
-
answer.answerForms.length !== 0
|
|
176
|
-
? answer.answerForms
|
|
177
|
-
: allAnswerForms,
|
|
189
|
+
forms: validatorForms,
|
|
178
190
|
},
|
|
179
191
|
);
|
|
180
192
|
};
|
|
181
193
|
|
|
182
194
|
// We may have received TeX; try to parse it before grading.
|
|
183
195
|
// If `currentValue` is not TeX, this should be a no-op.
|
|
184
|
-
const currentValue = ParseTex(
|
|
196
|
+
const currentValue = ParseTex(userInput.currentValue);
|
|
185
197
|
const correctAnswers = rubric.answers.filter(
|
|
186
198
|
(answer) => answer.status === "correct",
|
|
187
199
|
);
|