@khanacademy/perseus 11.5.0 → 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.
@@ -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(useInput: UserInput, rubric: Rubric): PerseusScore;
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.5.0",
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": "^13.0.0",
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",
@@ -57,6 +57,7 @@
57
57
  "jquery": "^2.1.1",
58
58
  "katex": "^0.11.1",
59
59
  "lodash.debounce": "^4.0.8",
60
+ "mathquill": "git+https://git@github.com/Khan/mathquill.git#32d9f351aaa68537170b3120a52e99b8def3a2c3",
60
61
  "perseus-build-settings": "^0.2.0",
61
62
  "prop-types": "^15.6.1",
62
63
  "react": "16.14.0",
@@ -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
- <ItemRenderer
75
- ref={(node) => (renderer = node)}
76
- apiOptions={apiOptions}
77
- item={question}
78
- problemNum={0}
79
- reviewMode={false}
80
- savedState=""
81
- controlPeripherals={false}
82
- dependencies={testDependenciesV2}
83
- {...optionalProps}
84
- />
85
- {/* The ItemRenderer _requires_ two divs: a work area and hints
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
- <div id="workarea" />
88
- <div id="hintsarea" />
89
+ <div id="workarea" />
90
+ <div id="hintsarea" />
89
91
 
90
- <Peripherals />
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
- expect(screen.getByLabelText("7")).toBeVisible();
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", () => {
@@ -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),
@@ -1128,7 +1128,7 @@ exports[`multi-item renderer should snapshot: initial render 1`] = `
1128
1128
  class=""
1129
1129
  >
1130
1130
  <div
1131
- class="perseus-hint-renderer last-rendered"
1131
+ class="hint perseus-hint-renderer last-rendered"
1132
1132
  tabindex="-1"
1133
1133
  >
1134
1134
  <span
@@ -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 should be strictly matched
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;
@@ -144,7 +144,6 @@
144
144
  }
145
145
 
146
146
  .paragraph ul:not(.perseus-widget-radio) {
147
- .legacy-typography;
148
147
  padding-left: 35px;
149
148
  list-style-type: disc;
150
149
  }
@@ -1,4 +1,8 @@
1
- import {KeypadContext, MobileKeypad} from "@khanacademy/math-input";
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
- <KeypadContext.Provider
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
- </KeypadContext.Provider>
40
+ </StatefulKeypadContextProvider>
55
41
  );
56
42
  };
57
43
 
@@ -0,0 +1,333 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ KeypadContext,
4
+ StatefulKeypadContextProvider,
5
+ MobileKeypad,
6
+ } from "@khanacademy/math-input";
7
+ import {RenderStateRoot} from "@khanacademy/wonder-blocks-core";
8
+ import {
9
+ fireEvent,
10
+ render,
11
+ screen,
12
+ waitFor,
13
+ within,
14
+ } from "@testing-library/react";
15
+ import userEvent from "@testing-library/user-event";
16
+ import MathQuill from "mathquill";
17
+ import * as React from "react";
18
+
19
+ import {
20
+ testDependencies,
21
+ testDependenciesV2,
22
+ } from "../../../../../testing/test-dependencies";
23
+ import * as Dependencies from "../../dependencies";
24
+ import WrappedServerItemRenderer from "../../server-item-renderer";
25
+ import {registerWidget} from "../../widgets";
26
+ import {expressionItem2} from "../__testdata__/expression.testdata";
27
+ import ExpressionExport from "../expression";
28
+
29
+ const MQ = MathQuill.getInterface(2);
30
+
31
+ function RendererWithContext({item}) {
32
+ return (
33
+ <KeypadContext.Consumer>
34
+ {({keypadElement}) => {
35
+ return (
36
+ <WrappedServerItemRenderer
37
+ apiOptions={{
38
+ isMobile: true,
39
+ customKeypad: true,
40
+ useV2Keypad: true,
41
+ }}
42
+ item={item}
43
+ problemNum={0}
44
+ reviewMode={false}
45
+ dependencies={testDependenciesV2}
46
+ keypadElement={keypadElement}
47
+ />
48
+ );
49
+ }}
50
+ </KeypadContext.Consumer>
51
+ );
52
+ }
53
+
54
+ function KeypadWithContext() {
55
+ return (
56
+ <KeypadContext.Consumer>
57
+ {({setKeypadElement}) => {
58
+ return (
59
+ <MobileKeypad
60
+ onElementMounted={setKeypadElement}
61
+ onDismiss={() => {}}
62
+ onAnalyticsEvent={async () => {}}
63
+ useV2Keypad
64
+ />
65
+ );
66
+ }}
67
+ </KeypadContext.Consumer>
68
+ );
69
+ }
70
+
71
+ function ConnectedRenderer({item = expressionItem2}) {
72
+ return (
73
+ <RenderStateRoot>
74
+ <StatefulKeypadContextProvider>
75
+ <RendererWithContext item={item} />
76
+ <KeypadWithContext />
77
+ </StatefulKeypadContextProvider>
78
+ </RenderStateRoot>
79
+ );
80
+ }
81
+
82
+ describe("expression mobile", () => {
83
+ beforeAll(() => {
84
+ registerWidget("expression", ExpressionExport);
85
+ });
86
+
87
+ beforeEach(() => {
88
+ jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
89
+ testDependencies,
90
+ );
91
+ });
92
+
93
+ afterEach(() => {
94
+ // The Renderer uses a timer to wait for widgets to complete rendering.
95
+ // If we don't spin the timers here, then the timer fires in the test
96
+ // _after_ and breaks it because we do setState() in the callback,
97
+ // and by that point the component has been unmounted.
98
+ jest.runOnlyPendingTimers();
99
+ });
100
+
101
+ it("renders input", () => {
102
+ render(<ConnectedRenderer />);
103
+
104
+ expect(
105
+ screen.getByLabelText(
106
+ "Math input box Tap with one or two fingers to open keyboard",
107
+ ),
108
+ ).toBeInTheDocument();
109
+ });
110
+
111
+ it("shows the keypad after input interaction", async () => {
112
+ render(<ConnectedRenderer />);
113
+
114
+ const input = screen.getByLabelText(
115
+ "Math input box Tap with one or two fingers to open keyboard",
116
+ );
117
+
118
+ fireEvent.touchStart(input);
119
+
120
+ // waiting because `visibility` is animated
121
+ await waitFor(() =>
122
+ expect(screen.getByRole("button", {name: "1"})).toBeVisible(),
123
+ );
124
+ });
125
+
126
+ it("is possible to use the keypad", async () => {
127
+ render(<ConnectedRenderer />);
128
+
129
+ const input = screen.getByLabelText(
130
+ "Math input box Tap with one or two fingers to open keyboard",
131
+ );
132
+
133
+ fireEvent.touchStart(input);
134
+
135
+ // waiting because `visibility` is animated
136
+ await waitFor(() =>
137
+ expect(screen.getByRole("button", {name: "1"})).toBeVisible(),
138
+ );
139
+
140
+ userEvent.click(screen.getByRole("button", {name: "1"}));
141
+
142
+ // MathQuill is problematic,
143
+ // this is the only way I know how to test the "input"
144
+ const mathquillInput =
145
+ // eslint-disable-next-line testing-library/no-node-access
146
+ document.getElementsByClassName("mq-root-block")[0] as HTMLElement;
147
+ const span1 = within(mathquillInput).getByText("1");
148
+
149
+ expect(span1).toBeVisible();
150
+ });
151
+
152
+ it("is possible to type many numbers", async () => {
153
+ render(<ConnectedRenderer />);
154
+
155
+ const input = screen.getByLabelText(
156
+ "Math input box Tap with one or two fingers to open keyboard",
157
+ );
158
+
159
+ fireEvent.touchStart(input);
160
+
161
+ // waiting because `visibility` is animated
162
+ await waitFor(() =>
163
+ expect(screen.getByRole("button", {name: "1"})).toBeVisible(),
164
+ );
165
+
166
+ const testNumbers = [8, 6, 7, 5, 3, 0, 9];
167
+ testNumbers.forEach((num) => {
168
+ userEvent.click(screen.getByRole("button", {name: `${num}`}));
169
+ });
170
+
171
+ // MathQuill is problematic,
172
+ // this is the only way I know how to test the "input"
173
+ const mathquillInput =
174
+ // eslint-disable-next-line testing-library/no-node-access
175
+ document.getElementsByClassName("mq-root-block")[0] as HTMLElement;
176
+
177
+ testNumbers.forEach((num) => {
178
+ const span = within(mathquillInput).getByText(`${num}`);
179
+
180
+ expect(span).toBeVisible();
181
+ });
182
+ });
183
+
184
+ it("can handle symbols", async () => {
185
+ render(<ConnectedRenderer />);
186
+
187
+ const input = screen.getByLabelText(
188
+ "Math input box Tap with one or two fingers to open keyboard",
189
+ );
190
+
191
+ fireEvent.touchStart(input);
192
+
193
+ // waiting because `visibility` is animated
194
+ await waitFor(() =>
195
+ expect(screen.getByRole("button", {name: "1"})).toBeVisible(),
196
+ );
197
+
198
+ userEvent.click(screen.getByRole("button", {name: "4"}));
199
+ userEvent.click(screen.getByRole("button", {name: "2"}));
200
+ userEvent.click(screen.getByRole("button", {name: "Percent"}));
201
+
202
+ // MathQuill is problematic,
203
+ // this is the only way I know how to test the "input"
204
+ const mathquillInput =
205
+ // eslint-disable-next-line testing-library/no-node-access
206
+ document.getElementsByClassName("mq-root-block")[0] as HTMLElement;
207
+ const span4 = within(mathquillInput).getByText("4");
208
+ const span2 = within(mathquillInput).getByText("2");
209
+ const spanPercent = within(mathquillInput).getByText("%");
210
+
211
+ expect(span4).toBeVisible();
212
+ expect(span2).toBeVisible();
213
+ expect(spanPercent).toBeVisible();
214
+ });
215
+
216
+ it("can do arithmetic", async () => {
217
+ render(<ConnectedRenderer />);
218
+
219
+ const input = screen.getByLabelText(
220
+ "Math input box Tap with one or two fingers to open keyboard",
221
+ );
222
+
223
+ fireEvent.touchStart(input);
224
+
225
+ // waiting because `visibility` is animated
226
+ await waitFor(() =>
227
+ expect(screen.getByRole("button", {name: "1"})).toBeVisible(),
228
+ );
229
+
230
+ userEvent.click(screen.getByRole("button", {name: "4"}));
231
+ userEvent.click(screen.getByRole("button", {name: "Plus"}));
232
+ userEvent.click(screen.getByRole("button", {name: "2"}));
233
+
234
+ // MathQuill is problematic,
235
+ // this is the only way I know how to test the "input"
236
+ const mathquillInput =
237
+ // eslint-disable-next-line testing-library/no-node-access
238
+ document.getElementsByClassName("mq-root-block")[0] as HTMLElement;
239
+ const span4 = within(mathquillInput).getByText("4");
240
+ const spanPlus = within(mathquillInput).getByText("+");
241
+ const span2 = within(mathquillInput).getByText("2");
242
+
243
+ expect(span4).toBeVisible();
244
+ expect(spanPlus).toBeVisible();
245
+ expect(span2).toBeVisible();
246
+ });
247
+
248
+ it("can navigate to operators page", async () => {
249
+ render(<ConnectedRenderer />);
250
+
251
+ const input = screen.getByLabelText(
252
+ "Math input box Tap with one or two fingers to open keyboard",
253
+ );
254
+
255
+ fireEvent.touchStart(input);
256
+
257
+ // waiting because `visibility` is animated
258
+ await waitFor(() =>
259
+ expect(screen.getByRole("button", {name: "1"})).toBeVisible(),
260
+ );
261
+
262
+ userEvent.click(screen.getByRole("tab", {name: "Operators"}));
263
+ expect(screen.getByRole("button", {name: "Equals sign"})).toBeVisible();
264
+ });
265
+
266
+ it("can navigate to geometry page", async () => {
267
+ render(<ConnectedRenderer />);
268
+
269
+ const input = screen.getByLabelText(
270
+ "Math input box Tap with one or two fingers to open keyboard",
271
+ );
272
+
273
+ fireEvent.touchStart(input);
274
+
275
+ // waiting because `visibility` is animated
276
+ await waitFor(() =>
277
+ expect(screen.getByRole("button", {name: "1"})).toBeVisible(),
278
+ );
279
+
280
+ userEvent.click(screen.getByRole("tab", {name: "Geometry"}));
281
+ expect(screen.getByRole("button", {name: "Sine"})).toBeVisible();
282
+ });
283
+
284
+ it("can navigate to extras page", async () => {
285
+ render(<ConnectedRenderer />);
286
+
287
+ const input = screen.getByLabelText(
288
+ "Math input box Tap with one or two fingers to open keyboard",
289
+ );
290
+
291
+ fireEvent.touchStart(input);
292
+
293
+ // waiting because `visibility` is animated
294
+ await waitFor(() =>
295
+ expect(screen.getByRole("button", {name: "1"})).toBeVisible(),
296
+ );
297
+
298
+ userEvent.click(screen.getByRole("tab", {name: "Extras"}));
299
+ expect(screen.getByRole("button", {name: "x"})).toBeVisible();
300
+ });
301
+
302
+ it("can write a full equation", async () => {
303
+ render(<ConnectedRenderer />);
304
+
305
+ const input = screen.getByLabelText(
306
+ "Math input box Tap with one or two fingers to open keyboard",
307
+ );
308
+
309
+ fireEvent.touchStart(input);
310
+
311
+ // waiting because `visibility` is animated
312
+ await waitFor(() =>
313
+ expect(screen.getByRole("button", {name: "1"})).toBeVisible(),
314
+ );
315
+
316
+ userEvent.click(screen.getByRole("tab", {name: "Extras"}));
317
+ userEvent.click(screen.getByRole("button", {name: "x"}));
318
+ userEvent.click(screen.getByRole("tab", {name: "Operators"}));
319
+ userEvent.click(screen.getByRole("button", {name: "Equals sign"}));
320
+ userEvent.click(screen.getByRole("tab", {name: "Geometry"}));
321
+ userEvent.click(screen.getByRole("button", {name: "Sine"}));
322
+ userEvent.click(screen.getByRole("tab", {name: "Numbers"}));
323
+ userEvent.click(screen.getByRole("button", {name: "9"}));
324
+
325
+ // MathQuill is problematic,
326
+ // this is how to get the value of the input directly from MQ
327
+ const mathquillInstance =
328
+ // eslint-disable-next-line testing-library/no-node-access
329
+ MQ(document.getElementsByClassName("mq-editable-field")[0]);
330
+
331
+ expect(mathquillInstance.latex()).toBe("x=\\sin\\left(9\\right)");
332
+ });
333
+ });
@@ -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: [