diff --git a/.changeset/gentle-ties-drum.md b/.changeset/gentle-ties-drum.md new file mode 100644 index 0000000000..4aa405345e --- /dev/null +++ b/.changeset/gentle-ties-drum.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +Updating how svg-image loads data diff --git a/packages/perseus/src/__tests__/renderer.test.tsx b/packages/perseus/src/__tests__/renderer.test.tsx index fccdbddebb..8db770a186 100644 --- a/packages/perseus/src/__tests__/renderer.test.tsx +++ b/packages/perseus/src/__tests__/renderer.test.tsx @@ -63,6 +63,14 @@ describe("renderer", () => { jest.spyOn(Dependencies, "getDependencies").mockReturnValue( testDependencies, ); + + // Mocked for loading graphie in svg-image + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => "", + ok: true, + }), + ) as jest.Mock; }); afterEach(() => { diff --git a/packages/perseus/src/components/__tests__/__snapshots__/svg-image.test.tsx.snap b/packages/perseus/src/components/__tests__/__snapshots__/svg-image.test.tsx.snap new file mode 100644 index 0000000000..448e7ebb6e --- /dev/null +++ b/packages/perseus/src/components/__tests__/__snapshots__/svg-image.test.tsx.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SvgImage should load and render a localized graphie svg 1`] = ` +
+
+ svg image +
+
+`; + +exports[`SvgImage should load and render a normal graphie svg 1`] = ` +
+
+ svg image +
+
+`; + +exports[`SvgImage should load and render a png 1`] = ` +
+ png image +
+`; + +exports[`SvgImage should render a spinner initially 1`] = ` +
+ +
+ + + +
+
+
+`; diff --git a/packages/perseus/src/components/__tests__/svg-image.test.tsx b/packages/perseus/src/components/__tests__/svg-image.test.tsx new file mode 100644 index 0000000000..e3ee0c66ef --- /dev/null +++ b/packages/perseus/src/components/__tests__/svg-image.test.tsx @@ -0,0 +1,122 @@ +import {act, render} from "@testing-library/react"; +import * as React from "react"; + +import {testDependencies} from "../../../../../testing/test-dependencies"; +import * as Dependencies from "../../dependencies"; +import {typicalCase} from "../../util/graphie-utils.testdata"; +import SvgImage from "../svg-image"; + +describe("SvgImage", () => { + const images: Array> = []; + let originalImage; + + beforeEach(() => { + jest.clearAllMocks(); + originalImage = window.Image; + // Mock HTML Image so we can trigger onLoad callbacks and see full + // image rendering. + // @ts-expect-error - TS2322 - Type 'Mock, [], any>' is not assignable to type 'new (width?: number | undefined, height?: number | undefined) => HTMLImageElement'. + window.Image = jest.fn(() => { + const img: Record = {}; + images.push(img); + return img; + }); + + global.fetch = jest.fn((url) => { + return Promise.resolve({ + text: () => Promise.resolve(typicalCase.jsonpString), + ok: true, + }); + }) as jest.Mock; + }); + + afterEach(() => { + window.Image = originalImage; + }); + + // Tells the image loader 1, or all, of our images loaded + const markImagesAsLoaded = (imageIndex?: number) => { + if (imageIndex != null) { + const img = images[imageIndex]; + if (img?.onload) { + act(() => img.onload()); + } + } else { + images.forEach((i) => { + if (i?.onload) { + act(() => i.onload()); + } + }); + } + }; + it("should render a spinner initially", () => { + // Arrange + jest.spyOn(Dependencies, "getDependencies").mockReturnValue( + testDependencies, + ); + + // Act + const {container} = render( + , + ); + + // Assert + expect( + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + container.querySelector("div[class*=spinnerContainer]"), + ).toBeVisible(); + expect(container).toMatchSnapshot(); + }); + + it("should load and render a png", () => { + // Arrange + jest.spyOn(Dependencies, "getDependencies").mockReturnValue( + testDependencies, + ); + + // Act + const {container} = render( + , + ); + + markImagesAsLoaded(); // Tell the ImageLoader that our images are loaded + + // Assert + expect(container).toMatchSnapshot(); + }); + + it("should load and render a normal graphie svg", async () => { + // Arrange + jest.spyOn(Dependencies, "getDependencies").mockReturnValue( + testDependencies, + ); + + // Act + const {container} = render( + , + ); + + markImagesAsLoaded(); // Tell the ImageLoader that our images are loaded + + // Assert + expect(container).toMatchSnapshot(); + }); + + it("should load and render a localized graphie svg", async () => { + // Arrange + jest.spyOn(Dependencies, "getDependencies").mockReturnValue({ + ...testDependencies, + kaLocale: "es", + }); + + // Act + const {container} = render( + , + ); + + markImagesAsLoaded(); // Tell the ImageLoader that our images are loaded + + // Assert + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/perseus/src/components/svg-image.tsx b/packages/perseus/src/components/svg-image.tsx index fc62b434ce..3a63f7ad2a 100644 --- a/packages/perseus/src/components/svg-image.tsx +++ b/packages/perseus/src/components/svg-image.tsx @@ -8,8 +8,8 @@ import * as React from "react"; import _ from "underscore"; import {getDependencies} from "../dependencies"; -import {Log} from "../logging/log"; import Util from "../util"; +import {loadGraphie} from "../util/graphie-utils"; import * as Zoom from "../zoom"; import FixedToResponsive from "./fixed-to-responsive"; @@ -24,101 +24,12 @@ import type {Alignment, Dimensions} from "../types"; // Minimum image width to make an image appear as zoomable. const ZOOMABLE_THRESHOLD = 700; -// The global cache of label data. Its format is: -// { -// hash (e.g. "c21435944d2cf0c8f39d9059cb35836aa701d04a"): { -// loaded: a boolean of whether the data has been loaded or not -// dataCallbacks: a list of callbacks to call with the data when the data -// is loaded -// data: the other data for this hash -// }, -// ... -// } -const labelDataCache: Record = {}; - -// Write our own JSONP handler because all the other ones don't do things we -// need. -const doJSONP = function (url: string, options) { - options = { - callbackName: "callback", - success: $.noop, - error: $.noop, - ...options, - }; - - // Create the script - const script = document.createElement("script"); - script.setAttribute("async", ""); - script.setAttribute("src", url); - - // A cleanup function to run when we're done. - function cleanup() { - document.head?.removeChild(script); - delete window[options.callbackName]; - } - - // Add the global callback. - // @ts-expect-error - TS2740 - Type '() => void' is missing the following properties from type 'Window': clientInformation, closed, customElements, devicePixelRatio, and 206 more. - window[options.callbackName] = function (...args) { - cleanup(); - options.success.apply(null, args); - }; - - // Add the error handler. - script.addEventListener("error", function (...args) { - cleanup(); - options.error.apply(null, args); - }); - - // Insert the script to start the download. - document.head?.appendChild(script); -}; - -// For offline exercises in the mobile app, we download the graphie data -// (svgs and localized data files) and serve them from the local file -// system (with file://). We replace urls that start with `web+graphie` -// in the perseus json with this `file+graphie` prefix to indicate that -// they should have the `file://` protocol instead of `https://`. -const svgLocalLabelsRegex = /^file\+graphie:/; -const hashRegex = /\/([^/]+)$/; - function isImageProbablyPhotograph(imageUrl) { // TODO(david): Do an inventory to refine this heuristic. For example, what // % of .png images are illustrations? return /\.(jpg|jpeg)$/i.test(imageUrl); } -function getLocale() { - const {JIPT, kaLocale} = getDependencies(); - return JIPT.useJIPT ? "en-pt" : kaLocale; -} - -function shouldUseLocalizedData() { - return getLocale() !== "en"; -} - -// A regex to split at the last / of a URL, separating the base part from the -// hash. This is used to create the localized label data URLs. -const splitHashRegex = /\/(?=[^/]+$)/; - -function getLocalizedDataUrl(url: string) { - // For local (cached) graphie images, they are already localized. - if (svgLocalLabelsRegex.test(url)) { - return Util.getDataUrl(url); - } - const [base, hash] = Util.getBaseUrl(url).split(splitHashRegex); - return `${base}/${getLocale()}/${hash}-data.json`; -} - -// Get the hash from the url, which is just the filename -function getUrlHash(url: string) { - const match = url.match(hashRegex); - if (match == null) { - throw new PerseusError("not a valid URL", Errors.InvalidInput); - } - return match && match[1]; -} - function defaultPreloader(dimensions: Dimensions) { return ( { } loadResources() { - const hash = getUrlHash(this.props.src); - - // We can't make multiple jsonp calls to the same file because their - // callbacks will collide with each other. Instead, we cache the data - // and only make the jsonp calls once. - if (labelDataCache[hash]) { - if (labelDataCache[hash].loaded) { - const {data, localized} = labelDataCache[hash]; - this.onDataLoaded(data, localized); - } else { - labelDataCache[hash].dataCallbacks.push(this.onDataLoaded); - } - } else { - const cacheData = { - loaded: false, - dataCallbacks: [this.onDataLoaded], - data: null, - localized: shouldUseLocalizedData(), - } as const; - - labelDataCache[hash] = cacheData; - - const retrieveData = ( - url: string, - errorCallback: (x?: any, status?: any, error?: any) => void, - ) => { - doJSONP(url, { - callbackName: "svgData" + hash, - success: (data) => { - // @ts-expect-error - TS2540 - Cannot assign to 'data' because it is a read-only property. - cacheData.data = data; - // @ts-expect-error - TS2540 - Cannot assign to 'loaded' because it is a read-only property. - cacheData.loaded = true; - - _.each(cacheData.dataCallbacks, (callback) => { - // @ts-expect-error - TS2345 - Argument of type 'null' is not assignable to parameter of type '{ labels: readonly any[]; range: readonly any[]; }'. - callback(cacheData.data, cacheData.localized); - }); - }, - error: errorCallback, + loadGraphie(this.props.src, (data, localized) => { + if (this._isMounted && data.labels && data.range) { + const labelsRendered: LabelsRenderedMap = {}; + data.labels.forEach((label) => { + labelsRendered[label.content] = false; + }); + + this.setState({ + dataLoaded: true, + labelDataIsLocalized: localized, + labelsRendered, + labels: data.labels, + range: data.range, }); - }; - - if (shouldUseLocalizedData()) { - retrieveData( - getLocalizedDataUrl(this.props.src), - (x, status, error) => { - // @ts-expect-error - TS2540 - Cannot assign to 'localized' because it is a read-only property. - cacheData.localized = false; - - // If there is isn't any localized data, fall back to - // the original, unlocalized data - retrieveData( - Util.getDataUrl(this.props.src), - (x, status, error) => { - Log.error( - "Data load failed for svg-image", - Errors.Service, - { - cause: error, - loggedMetadata: { - dataUrl: Util.getDataUrl( - this.props.src, - ), - status, - }, - }, - ); - }, - ); - }, - ); - } else { - retrieveData( - Util.getDataUrl(this.props.src), - (x, status, error) => { - Log.error( - "Data load failed for svg-image", - Errors.Service, - { - cause: error, - loggedMetadata: { - dataUrl: Util.getDataUrl(this.props.src), - status, - }, - }, - ); - }, - ); } - } + }); } - onDataLoaded: ( - data: { - labels: ReadonlyArray; - range: [Coord, Coord]; - }, - localized: boolean, - ) => void = ( - data: { - labels: ReadonlyArray; - range: [Coord, Coord]; - }, - localized: boolean, - ) => { - if (this._isMounted && data.labels && data.range) { - const labelsRendered: LabelsRenderedMap = data.labels.reduce< - Record - >( - (dict: LabelsRenderedMap, label) => ({ - ...dict, - [label.content]: false, - }), - {}, - ); - - this.setState({ - dataLoaded: true, - labelDataIsLocalized: localized, - labelsRendered, - labels: data.labels, - range: data.range, - }); - } - }; - sizeProvided(): boolean { return this.props.width != null && this.props.height != null; } diff --git a/packages/perseus/src/index.ts b/packages/perseus/src/index.ts index 9c071022e0..bbcf1a6059 100644 --- a/packages/perseus/src/index.ts +++ b/packages/perseus/src/index.ts @@ -156,6 +156,14 @@ export {getClockwiseAngle} from "./widgets/interactive-graphs/math"; export {makeSafeUrl} from "./widgets/phet-simulation"; +// These exports are to support shared functionality between Perseus and Graphie2000 +export {parseDataFromJSONP} from "./util/graphie-utils"; +export type { + GraphieData, + GraphieLabel, + GraphieRange, +} from "./util/graphie-utils"; + /** * Mixins */ diff --git a/packages/perseus/src/renderer-util.test.ts b/packages/perseus/src/renderer-util.test.ts index 4b32b8471e..18dd808d92 100644 --- a/packages/perseus/src/renderer-util.test.ts +++ b/packages/perseus/src/renderer-util.test.ts @@ -78,8 +78,7 @@ function getLegacyExpressionWidget() { }, }; } - -describe("emptyWidgetsFunctional", () => { +describe("renderer utils", () => { beforeAll(() => { registerAllWidgetsForTesting(); }); @@ -92,679 +91,691 @@ describe("emptyWidgetsFunctional", () => { jest.spyOn(Dependencies, "useDependencies").mockReturnValue( testDependenciesV2, ); + // Mocked for loading graphie in svg-image + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => "", + ok: true, + }), + ) as jest.Mock; }); - it("returns an empty array if there are no widgets", () => { - // Arrange / Act - const result = emptyWidgetsFunctional({}, [], {}, mockStrings, "en"); - - // Assert - expect(result).toEqual([]); - }); - - it("properly identifies empty widgets", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 0, - }, - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual(["dropdown 1"]); - }); - - it("does not return widget IDs that are not empty", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 1, - }, - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual([]); - }); - - it("does not check for empty widgets whose IDs aren't provided", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - }; - const widgetIds: Array = []; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 0, - }, - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual([]); - }); - - it("can properly split empty and non-empty widgets", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - "dropdown 2": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1", "dropdown 2"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 0, - }, - "dropdown 2": { - value: 1, - }, - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual(["dropdown 1"]); - }); + describe("emptyWidgetsFunctional", () => { + it("returns an empty array if there are no widgets", () => { + // Arrange / Act + const result = emptyWidgetsFunctional( + {}, + [], + {}, + mockStrings, + "en", + ); + + // Assert + expect(result).toEqual([]); + }); - it("properly identifies groups with empty widgets", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "group 1": { - type: "group", - options: { - content: "[[☃ dropdown 1]]", - widgets: { - "dropdown 1": getTestDropdownWidget(), - }, - images: {}, - }, - }, - }; - const widgetIds: Array = ["group 1"]; - const userInputMap: UserInputMap = { - "group 1": { + it("properly identifies empty widgets", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1"]; + const userInputMap: UserInputMap = { "dropdown 1": { value: 0, }, - }, - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual(["group 1"]); - }); + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result).toEqual(["dropdown 1"]); + }); - it("does not return group ID when its widgets are non-empty", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "group 1": { - type: "group", - options: { - content: "[[☃ dropdown 1]]", - widgets: { - "dropdown 1": getTestDropdownWidget(), - }, - images: {}, - }, - }, - }; - const widgetIds: Array = ["group 1"]; - const userInputMap: UserInputMap = { - "group 1": { + it("does not return widget IDs that are not empty", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1"]; + const userInputMap: UserInputMap = { "dropdown 1": { value: 1, }, - }, - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual([]); - }); - - it("handles an empty modern Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getExpressionWidget(), - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "", - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual(["expression 1"]); - }); - - it("upgrades an empty legacy Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getLegacyExpressionWidget() as any, - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "", - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual(["expression 1"]); - }); - - it("handles a non-empty modern Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getExpressionWidget(), - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "2+2", - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual([]); - }); - - it("upgrades a non-empty legacy Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getLegacyExpressionWidget() as any, - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "2+2", - }; - - // Act - const result = emptyWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual([]); - }); -}); - -describe("scoreWidgetsFunctional", () => { - beforeAll(() => { - registerAllWidgetsForTesting(); - }); - - beforeEach(() => { - jest.spyOn(testDependenciesV2.analytics, "onAnalyticsEvent"); - jest.spyOn(Dependencies, "getDependencies").mockReturnValue( - testDependencies, - ); - jest.spyOn(Dependencies, "useDependencies").mockReturnValue( - testDependenciesV2, - ); - }); - - it("returns an empty object when there's no widgets", () => { - // Arrange / Act - const result = scoreWidgetsFunctional({}, [], {}, mockStrings, "en"); - - // Assert - expect(result).toEqual({}); - }); + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result).toEqual([]); + }); - it("returns invalid if widget is unanswered", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 0, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); + it("does not check for empty widgets whose IDs aren't provided", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + }; + const widgetIds: Array = []; + const userInputMap: UserInputMap = { + "dropdown 1": { + value: 0, + }, + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result).toEqual([]); + }); - // Assert - expect(result["dropdown 1"]).toHaveInvalidInput(); - }); + it("can properly split empty and non-empty widgets", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + "dropdown 2": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1", "dropdown 2"]; + const userInputMap: UserInputMap = { + "dropdown 1": { + value: 0, + }, + "dropdown 2": { + value: 1, + }, + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result).toEqual(["dropdown 1"]); + }); - it("can determine if a widget was answered correctly", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 1, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); + it("properly identifies groups with empty widgets", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "group 1": { + type: "group", + options: { + content: "[[☃ dropdown 1]]", + widgets: { + "dropdown 1": getTestDropdownWidget(), + }, + images: {}, + }, + }, + }; + const widgetIds: Array = ["group 1"]; + const userInputMap: UserInputMap = { + "group 1": { + "dropdown 1": { + value: 0, + }, + }, + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result).toEqual(["group 1"]); + }); - // Assert - expect(result["dropdown 1"]).toHaveBeenAnsweredCorrectly(); - }); + it("does not return group ID when its widgets are non-empty", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "group 1": { + type: "group", + options: { + content: "[[☃ dropdown 1]]", + widgets: { + "dropdown 1": getTestDropdownWidget(), + }, + images: {}, + }, + }, + }; + const widgetIds: Array = ["group 1"]; + const userInputMap: UserInputMap = { + "group 1": { + "dropdown 1": { + value: 1, + }, + }, + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result).toEqual([]); + }); - it("can determine if a widget was answered incorrectly", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 2, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); + it("handles an empty modern Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getExpressionWidget(), + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "", + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result).toEqual(["expression 1"]); + }); - // Assert - expect(result["dropdown 1"]).toHaveBeenAnsweredIncorrectly(); - }); + it("upgrades an empty legacy Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getLegacyExpressionWidget() as any, + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "", + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result).toEqual(["expression 1"]); + }); - it("can handle multiple widgets", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - "dropdown 2": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1", "dropdown 2"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 1, - }, - "dropdown 2": { - value: 2, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); + it("handles a non-empty modern Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getExpressionWidget(), + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "2+2", + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result).toEqual([]); + }); - // Assert - expect(result["dropdown 1"]).toHaveBeenAnsweredCorrectly(); - expect(result["dropdown 2"]).toHaveBeenAnsweredIncorrectly(); + it("upgrades a non-empty legacy Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getLegacyExpressionWidget() as any, + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "2+2", + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result).toEqual([]); + }); }); - it("skips widgets not in widgetIds", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "dropdown 1": getTestDropdownWidget(), - "dropdown 2": getTestDropdownWidget(), - }; - const widgetIds: Array = ["dropdown 1"]; - const userInputMap: UserInputMap = { - "dropdown 1": { - value: 1, - }, - "dropdown 2": { - value: 2, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["dropdown 1"]).toHaveBeenAnsweredCorrectly(); - expect(result["dropdown 2"]).toBe(undefined); - }); + describe("scoreWidgetsFunctional", () => { + it("returns an empty object when there's no widgets", () => { + // Arrange / Act + const result = scoreWidgetsFunctional( + {}, + [], + {}, + mockStrings, + "en", + ); + + // Assert + expect(result).toEqual({}); + }); - it("returns invalid if a widget in a group is unanswered", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "group 1": { - type: "group", - options: { - content: "[[☃ dropdown 1]]", - widgets: { - "dropdown 1": getTestDropdownWidget(), - }, - images: {}, - }, - }, - }; - const widgetIds: Array = ["group 1"]; - const userInputMap: UserInputMap = { - "group 1": { + it("returns invalid if widget is unanswered", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1"]; + const userInputMap: UserInputMap = { "dropdown 1": { value: 0, }, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["group 1"]).toHaveInvalidInput(); - }); + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result["dropdown 1"]).toHaveInvalidInput(); + }); - it("can score correct widget in group", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "group 1": { - type: "group", - options: { - content: "[[☃ dropdown 1]]", - widgets: { - "dropdown 1": getTestDropdownWidget(), - }, - images: {}, - }, - }, - }; - const widgetIds: Array = ["group 1"]; - const userInputMap: UserInputMap = { - "group 1": { + it("can determine if a widget was answered correctly", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1"]; + const userInputMap: UserInputMap = { "dropdown 1": { value: 1, }, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); - - // Assert - expect(result["group 1"]).toHaveBeenAnsweredCorrectly(); - }); + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result["dropdown 1"]).toHaveBeenAnsweredCorrectly(); + }); - it("can score incorrect widget in group", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "group 1": { - type: "group", - options: { - content: "[[☃ dropdown 1]]", - widgets: { - "dropdown 1": getTestDropdownWidget(), - }, - images: {}, - }, - }, - }; - const widgetIds: Array = ["group 1"]; - const userInputMap: UserInputMap = { - "group 1": { + it("can determine if a widget was answered incorrectly", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1"]; + const userInputMap: UserInputMap = { "dropdown 1": { value: 2, }, - }, - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result["dropdown 1"]).toHaveBeenAnsweredIncorrectly(); + }); - // Assert - expect(result["group 1"]).toHaveBeenAnsweredIncorrectly(); - }); + it("can handle multiple widgets", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + "dropdown 2": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1", "dropdown 2"]; + const userInputMap: UserInputMap = { + "dropdown 1": { + value: 1, + }, + "dropdown 2": { + value: 2, + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result["dropdown 1"]).toHaveBeenAnsweredCorrectly(); + expect(result["dropdown 2"]).toHaveBeenAnsweredIncorrectly(); + }); - it("can handle a correct modern Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getExpressionWidget(), - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "2+2", - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); + it("skips widgets not in widgetIds", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "dropdown 1": getTestDropdownWidget(), + "dropdown 2": getTestDropdownWidget(), + }; + const widgetIds: Array = ["dropdown 1"]; + const userInputMap: UserInputMap = { + "dropdown 1": { + value: 1, + }, + "dropdown 2": { + value: 2, + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result["dropdown 1"]).toHaveBeenAnsweredCorrectly(); + expect(result["dropdown 2"]).toBe(undefined); + }); - // Assert - expect(result["expression 1"]).toHaveBeenAnsweredCorrectly(); - }); + it("returns invalid if a widget in a group is unanswered", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "group 1": { + type: "group", + options: { + content: "[[☃ dropdown 1]]", + widgets: { + "dropdown 1": getTestDropdownWidget(), + }, + images: {}, + }, + }, + }; + const widgetIds: Array = ["group 1"]; + const userInputMap: UserInputMap = { + "group 1": { + "dropdown 1": { + value: 0, + }, + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result["group 1"]).toHaveInvalidInput(); + }); - it("can handle a correct legacy Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getLegacyExpressionWidget() as any, - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "2+2", - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); + it("can score correct widget in group", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "group 1": { + type: "group", + options: { + content: "[[☃ dropdown 1]]", + widgets: { + "dropdown 1": getTestDropdownWidget(), + }, + images: {}, + }, + }, + }; + const widgetIds: Array = ["group 1"]; + const userInputMap: UserInputMap = { + "group 1": { + "dropdown 1": { + value: 1, + }, + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result["group 1"]).toHaveBeenAnsweredCorrectly(); + }); - // Assert - expect(result["expression 1"]).toHaveBeenAnsweredCorrectly(); - }); + it("can score incorrect widget in group", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "group 1": { + type: "group", + options: { + content: "[[☃ dropdown 1]]", + widgets: { + "dropdown 1": getTestDropdownWidget(), + }, + images: {}, + }, + }, + }; + const widgetIds: Array = ["group 1"]; + const userInputMap: UserInputMap = { + "group 1": { + "dropdown 1": { + value: 2, + }, + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result["group 1"]).toHaveBeenAnsweredIncorrectly(); + }); - it("can handle an incorrect modern Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getExpressionWidget(), - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "2+42", - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); + it("can handle a correct modern Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getExpressionWidget(), + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "2+2", + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result["expression 1"]).toHaveBeenAnsweredCorrectly(); + }); - // Assert - expect(result["expression 1"]).toHaveBeenAnsweredIncorrectly(); - }); + it("can handle a correct legacy Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getLegacyExpressionWidget() as any, + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "2+2", + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result["expression 1"]).toHaveBeenAnsweredCorrectly(); + }); - it("can handle an incorrect legacy Expression widget", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "expression 1": getLegacyExpressionWidget() as any, - }; - const widgetIds: Array = ["expression 1"]; - const userInputMap: UserInputMap = { - "expression 1": "2+42", - }; - - // Act - const result = scoreWidgetsFunctional( - widgets, - widgetIds, - userInputMap, - mockStrings, - "en", - ); + it("can handle an incorrect modern Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getExpressionWidget(), + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "2+42", + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result["expression 1"]).toHaveBeenAnsweredIncorrectly(); + }); - // Assert - expect(result["expression 1"]).toHaveBeenAnsweredIncorrectly(); + it("can handle an incorrect legacy Expression widget", () => { + // Arrange + const widgets: PerseusWidgetsMap = { + "expression 1": getLegacyExpressionWidget() as any, + }; + const widgetIds: Array = ["expression 1"]; + const userInputMap: UserInputMap = { + "expression 1": "2+42", + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result["expression 1"]).toHaveBeenAnsweredIncorrectly(); + }); }); -}); -describe("scorePerseusItem", () => { - let userEvent: UserEvent; - beforeEach(() => { - userEvent = userEventLib.setup({ - advanceTimers: jest.advanceTimersByTime, + describe("scorePerseusItem", () => { + let userEvent: UserEvent; + beforeEach(() => { + userEvent = userEventLib.setup({ + advanceTimers: jest.advanceTimersByTime, + }); }); - }); - it("should return score from contained Renderer", async () => { - // Arrange - const {renderer} = renderQuestion(question1); - // Answer all widgets correctly - await userEvent.click(screen.getAllByRole("radio")[4]); - // Note(jeremy): If we don't tab away from the radio button in this - // test, it seems like the userEvent typing doesn't land in the first - // text field. - await userEvent.tab(); - await userEvent.type( - screen.getByRole("textbox", {name: /nearest ten/}), - "230", - ); - await userEvent.type( - screen.getByRole("textbox", {name: /nearest hundred/}), - "200", - ); - const userInput = renderer.getUserInputMap(); - const score = scorePerseusItem(question1, userInput, mockStrings, "en"); - - // Assert - expect(score).toHaveBeenAnsweredCorrectly(); - expect(score).toEqual({ - earned: 3, - message: null, - total: 3, - type: "points", + it("should return score from contained Renderer", async () => { + // Arrange + const {renderer} = renderQuestion(question1); + // Answer all widgets correctly + await userEvent.click(screen.getAllByRole("radio")[4]); + // Note(jeremy): If we don't tab away from the radio button in this + // test, it seems like the userEvent typing doesn't land in the first + // text field. + await userEvent.tab(); + await userEvent.type( + screen.getByRole("textbox", {name: /nearest ten/}), + "230", + ); + await userEvent.type( + screen.getByRole("textbox", {name: /nearest hundred/}), + "200", + ); + const userInput = renderer.getUserInputMap(); + const score = scorePerseusItem( + question1, + userInput, + mockStrings, + "en", + ); + + // Assert + expect(score).toHaveBeenAnsweredCorrectly(); + expect(score).toEqual({ + earned: 3, + message: null, + total: 3, + type: "points", + }); }); }); }); diff --git a/packages/perseus/src/util/graphie-utils.test.ts b/packages/perseus/src/util/graphie-utils.test.ts new file mode 100644 index 0000000000..36fdb41919 --- /dev/null +++ b/packages/perseus/src/util/graphie-utils.test.ts @@ -0,0 +1,59 @@ +import {Dependencies} from ".."; +import {testDependencies} from "../../../../testing/test-dependencies"; + +import {parseDataFromJSONP, getLocalizedDataUrl} from "./graphie-utils"; +import {typicalCase, edgeCases} from "./graphie-utils.testdata"; + +describe("graphie utils", () => { + const errorCallback = jest.fn((error) => { + // Do nothing + }); + + it("should parse the modern graphie json format", () => { + // Act + const data = parseDataFromJSONP( + typicalCase.hash, + typicalCase.jsonpString, + errorCallback, + ); + + // Assert + expect(data).toEqual(typicalCase.expectedData); + }); + + it("should call the error callback when unable to parse JSONP", () => { + // Act + parseDataFromJSONP(typicalCase.hash, "invalid jsonp", errorCallback); + + // Assert + expect(errorCallback).toHaveBeenCalled(); + }); + + // also test the array of edge cases from the testdata file + edgeCases.forEach((edgeCase, i) => { + it(`should parse the edge case ${i}`, () => { + // Act + const data = parseDataFromJSONP( + edgeCase.hash, + edgeCase.jsonpString, + errorCallback, + ); + + // Assert + expect(data).toEqual(edgeCase.expectedData); + }); + }); + + it("should craft localized urls", () => { + // Arrange + jest.spyOn(Dependencies, "getDependencies").mockReturnValue({ + ...testDependencies, + kaLocale: "es", + }); + + // Act + const url = getLocalizedDataUrl(typicalCase.url); + // Assert + expect(url).toEqual(typicalCase.expectedLocalizedUrl); + }); +}); diff --git a/packages/perseus/src/util/graphie-utils.testdata.ts b/packages/perseus/src/util/graphie-utils.testdata.ts new file mode 100644 index 0000000000..a66bef3b37 --- /dev/null +++ b/packages/perseus/src/util/graphie-utils.testdata.ts @@ -0,0 +1,456 @@ +// A very simple test case following our modern jsonp format +// the url key is used for testing during the svg-image.test +export const typicalCase = { + hash: "ccefe63aa1bd05f1d11123f72790a49378d2e42b", + jsonpString: `svgDataccefe63aa1bd05f1d11123f72790a49378d2e42b({"range":null,"labels":[]});`, + expectedData: { + range: null, + labels: [], + }, + expectedLocalizedUrl: + "https://ka-perseus-graphie.s3.amazonaws.com/es/ccefe63aa1bd05f1d11123f72790a49378d2e42b-data.json", + url: "web+graphie://ka-perseus-graphie.s3.amazonaws.com/ccefe63aa1bd05f1d11123f72790a49378d2e42b", +}; +// These test cases were taken from a graphies-that-cant-unmarshal.txt report commented on LEMS-2524 +export const edgeCases = [ + { + hash: "e6d79d238e162da3fde14f0c19b106ce9bc6ad9b", + jsonpString: `svgDatae6d79d238e162da3fde14f0c19b106ce9bc6ad9b({"range":[[0,8.035714285714286],[0,8]]});`, + expectedData: { + range: [ + [0, 8.035714285714286], + [0, 8], + ], + }, + }, + { + hash: "e9b2325f3c269c297a45ee7a1f0c08f94524c02a", + jsonpString: `svgDatae9b2325f3c269c297a45ee7a1f0c08f94524c02a({"range":[[-1,10],[-2,8]]});`, + expectedData: { + range: [ + [-1, 10], + [-2, 8], + ], + }, + }, + { + hash: "ec517be364d82b35255739914788611cf5ea613b", + jsonpString: `svgDataec517be364d82b35255739914788611cf5ea613b({"range":[[-30,450],[-200,30]]});`, + expectedData: { + range: [ + [-30, 450], + [-200, 30], + ], + }, + }, + { + hash: "f4ffe7752049f27a5c2455110c0674b4719be71b", + jsonpString: `svgDataf4ffe7752049f27a5c2455110c0674b4719be71b({"range":[[-10,10],[-10,10]]});`, + expectedData: { + range: [ + [-10, 10], + [-10, 10], + ], + }, + }, + { + hash: "f66b79d3b313fe1355ef13900811dfe357e3ef2e", + jsonpString: `svgDataf66b79d3b313fe1355ef13900811dfe357e3ef2e({"range":[[-1,11],[-4,7]]});`, + expectedData: { + range: [ + [-1, 11], + [-4, 7], + ], + }, + }, + { + hash: "f81685c5f3fe2803c852fb02d82d056809c56756", + jsonpString: `svgDataf81685c5f3fe2803c852fb02d82d056809c56756({"range":[[-2.4,2.4],[-2.4,2.4]]});`, + expectedData: { + range: [ + [-2.4, 2.4], + [-2.4, 2.4], + ], + }, + }, + { + hash: "0bee5f48020fb411157295c08069ddac20567ad5", + jsonpString: `svgOtherData0bee5f48020fb411157295c08069ddac20567ad5({"range":[[0,8.035714285714286],[0,8]]});`, + expectedData: { + range: [ + [0, 8.035714285714286], + [0, 8], + ], + }, + }, + { + hash: "0c7064037164b2e3b1843314a35c08289200cc8b", + jsonpString: `svgOtherData0c7064037164b2e3b1843314a35c08289200cc8b({"range":[[-10,11.25],[-10,11.25]]});`, + expectedData: { + range: [ + [-10, 11.25], + [-10, 11.25], + ], + }, + }, + { + hash: "100d69992add52a7c33d4cfd7cfd0c958816a647", + jsonpString: `svgOtherData100d69992add52a7c33d4cfd7cfd0c958816a647({"range":[[0,6],[0,5]]});`, + expectedData: { + range: [ + [0, 6], + [0, 5], + ], + }, + }, + { + hash: "11a5934e79a80efad4b2507d6f10c7f38c1c59b7", + jsonpString: `svgOtherData11a5934e79a80efad4b2507d6f10c7f38c1c59b7({"range":[[-7.05,7.05],[-7.05,7.05]]});`, + expectedData: { + range: [ + [-7.05, 7.05], + [-7.05, 7.05], + ], + }, + }, + { + hash: "158dd1c6ff2979c1b4df4b99d80452c6b8a66b61", + jsonpString: `svgOtherData158dd1c6ff2979c1b4df4b99d80452c6b8a66b61({"range":[[-1,4],[-4,1]]});`, + expectedData: { + range: [ + [-1, 4], + [-4, 1], + ], + }, + }, + { + hash: "168938e468fc07899356eb24c28228ff2efa17cc", + jsonpString: `svgOtherData168938e468fc07899356eb24c28228ff2efa17cc({"range":[[0,4],[0,6.5]]});`, + expectedData: { + range: [ + [0, 4], + [0, 6.5], + ], + }, + }, + { + hash: "1f8dccef71c744a319d14c353056acb5a08b0ffd", + jsonpString: `svgOtherData1f8dccef71c744a319d14c353056acb5a08b0ffd({"range":[[-0.1,10.1],[1.4,2.6]]});`, + expectedData: { + range: [ + [-0.1, 10.1], + [1.4, 2.6], + ], + }, + }, + { + hash: "23ff310be4a4d0419cbfce217c1ec200dcf9b7cb", + jsonpString: `svgOtherData23ff310be4a4d0419cbfce217c1ec200dcf9b7cb({"range":[[-0.1,8.1],[-0.1,4.1]]});`, + expectedData: { + range: [ + [-0.1, 8.1], + [-0.1, 4.1], + ], + }, + }, + { + hash: "2f7378f0b9f63ce35391e957659d77049fad922e", + jsonpString: `svgOtherData2f7378f0b9f63ce35391e957659d77049fad922e({"range":[[0,6],[0,8]]});`, + expectedData: { + range: [ + [0, 6], + [0, 8], + ], + }, + }, + { + hash: "3037b142b2327d142892f823e4777897e421e722", + jsonpString: `{"range":[[-0.6513157894736842,3.5171052631578945],[-2.5,10.833333333333334]]}`, + expectedData: { + range: [ + [-0.6513157894736842, 3.5171052631578945], + [-2.5, 10.833333333333334], + ], + }, + }, + { + hash: "3266286570a06059eaa49a07f49b0dae63cf0cf0", + jsonpString: `svgOtherData3266286570a06059eaa49a07f49b0dae63cf0cf0({"range":[[-0.5,15.5],[-3.5,1.5]]});`, + expectedData: { + range: [ + [-0.5, 15.5], + [-3.5, 1.5], + ], + }, + }, + { + hash: "3404872c44d1d00e3e38771b8ad40b8228c82629", + jsonpString: `svgOtherData3404872c44d1d00e3e38771b8ad40b8228c82629({"range":[[-0.5,20.5],[-2.5,4.5]]});`, + expectedData: { + range: [ + [-0.5, 20.5], + [-2.5, 4.5], + ], + }, + }, + { + hash: "345fbf69f7eca6ce674ad0a431756cfa6efe81af", + jsonpString: `svgOtherData345fbf69f7eca6ce674ad0a431756cfa6efe81af({"range":[[-10,11.25],[-10,11.25]]});`, + expectedData: { + range: [ + [-10, 11.25], + [-10, 11.25], + ], + }, + }, + { + hash: "592497b6387209e77d376323da33df5dc5ce9622", + jsonpString: `svgOtherData592497b6387209e77d376323da33df5dc5ce9622({"range":[[-0.54,4.54],[-0.54,0.54]]});`, + expectedData: { + range: [ + [-0.54, 4.54], + [-0.54, 0.54], + ], + }, + }, + { + hash: "5c77fac14204132689a97b47b39e2f95c591b6d5", + jsonpString: `svgOtherData5c77fac14204132689a97b47b39e2f95c591b6d5({"range":[[-1,9],[-26,1]]});`, + expectedData: { + range: [ + [-1, 9], + [-26, 1], + ], + }, + }, + { + hash: "5e09a641003749c2e5bad2da604f96fced218347", + jsonpString: `{"range":[[-0.6513157894736842,3.5171052631578945],[-5,21.666666666666668]]}`, + expectedData: { + range: [ + [-0.6513157894736842, 3.5171052631578945], + [-5, 21.666666666666668], + ], + }, + }, + { + hash: "6e5f23adac003fc5e0f85593366d266df6c867f3", + jsonpString: `svgOtherData6e5f23adac003fc5e0f85593366d266df6c867f3({"range":[[-7.05,7.05],[-7.05,7.05]]});`, + expectedData: { + range: [ + [-7.05, 7.05], + [-7.05, 7.05], + ], + }, + }, + { + hash: "77565c2aa3dafb76e717e609c95afbe852b9c7bb", + jsonpString: `{"range":[[-0.54,4.54],[-0.54,0.54]]}`, + expectedData: { + range: [ + [-0.54, 4.54], + [-0.54, 0.54], + ], + }, + }, + { + hash: "7bb7da5c8c69b185da9aa17f359e0ecb39d8ff0f", + jsonpString: `svgOtherData7bb7da5c8c69b185da9aa17f359e0ecb39d8ff0f({"range":[[-4,4],[-4,4]]});`, + expectedData: { + range: [ + [-4, 4], + [-4, 4], + ], + }, + }, + { + hash: "7d75523b0c647d20747b67f77ea6ed3a98eaf470", + jsonpString: `svgOtherData7d75523b0c647d20747b67f77ea6ed3a98eaf470({"range":[[-0.54,4.54],[-0.54,1.54]]});`, + expectedData: { + range: [ + [-0.54, 4.54], + [-0.54, 1.54], + ], + }, + }, + { + hash: "873d919d2bc04b798dcbe0e56c83504aaa291b0e", + jsonpString: `svgOtherData873d919d2bc04b798dcbe0e56c83504aaa291b0e({"range":[[-7.05,7.05],[-7.05,7.05]]});`, + expectedData: { + range: [ + [-7.05, 7.05], + [-7.05, 7.05], + ], + }, + }, + { + hash: "891297fdfd43ca1c1904c92a96d578c0885f7403", + jsonpString: `svgOtherData891297fdfd43ca1c1904c92a96d578c0885f7403({"range":[[-10,11.25],[-10,11.25]]});`, + expectedData: { + range: [ + [-10, 11.25], + [-10, 11.25], + ], + }, + }, + { + hash: "89ca408ee52f2a2f40be6e4aa33ca57c4675bd5e", + jsonpString: `svgOtherData89ca408ee52f2a2f40be6e4aa33ca57c4675bd5e({"range":[[-10,10],[-10,10]]});`, + expectedData: { + range: [ + [-10, 10], + [-10, 10], + ], + }, + }, + { + hash: "8a7541628f10dc00a361345298f6088bf8242db0", + jsonpString: `svgOtherData8a7541628f10dc00a361345298f6088bf8242db0({"range":[[-30,450],[-200,30]]});`, + expectedData: { + range: [ + [-30, 450], + [-200, 30], + ], + }, + }, + { + hash: "8bf822c8357af9253a4596971fc31251a43cc546", + jsonpString: `svgOtherData8bf822c8357af9253a4596971fc31251a43cc546({"range":[[-3.141592653589793,8.54120502694725],[-3,3.375]]});`, + expectedData: { + range: [ + [-3.141592653589793, 8.54120502694725], + [-3, 3.375], + ], + }, + }, + { + hash: "8f653527b48530d8af67b6baa06eb252e012d3a3", + jsonpString: `{"range":[[-0.6513157894736842,3.5171052631578945],[-2.5,10.833333333333334]]}`, + expectedData: { + range: [ + [-0.6513157894736842, 3.5171052631578945], + [-2.5, 10.833333333333334], + ], + }, + }, + { + hash: "95dd4c96f5b00021de47f3ef0970bdfe42a1ab8f", + jsonpString: `{"range":[[-2,10],[-2,6]]}`, + expectedData: { + range: [ + [-2, 10], + [-2, 6], + ], + }, + }, + { + hash: "9fce14bf31bfa5fb0ed739190b46131c6ddc9650", + jsonpString: `svgOtherData9fce14bf31bfa5fb0ed739190b46131c6ddc9650({"range":[[-2,2],[-2,2]]});`, + expectedData: { + range: [ + [-2, 2], + [-2, 2], + ], + }, + }, + { + hash: "a26efe2b47085fdcd7553221e0fdbf5127ea4943", + jsonpString: `{"range":[[-0.54,0.54],[-0.54,0.54]]}`, + expectedData: { + range: [ + [-0.54, 0.54], + [-0.54, 0.54], + ], + }, + }, + { + hash: "a74a8cd51f03b5166a9d1744c8945f7194442fd6", + jsonpString: `{"range":[[-2.4,2.4],[-2.4,2.4]]}`, + expectedData: { + range: [ + [-2.4, 2.4], + [-2.4, 2.4], + ], + }, + }, + { + hash: "ac784c1452c7173721f137db5bfd1f0d7d7a25cf", + jsonpString: `svgOtherDataac784c1452c7173721f137db5bfd1f0d7d7a25cf({"range":[[-10,10],[-10,10]]});`, + expectedData: { + range: [ + [-10, 10], + [-10, 10], + ], + }, + }, + { + hash: "b1bc0c4ba3f62dcccd99ea8600cec1367cbfdff9", + jsonpString: `svgOtherDatab1bc0c4ba3f62dcccd99ea8600cec1367cbfdff9({"range":[[-10,11.25],[-10,11.25]]});`, + expectedData: { + range: [ + [-10, 11.25], + [-10, 11.25], + ], + }, + }, + { + hash: "b446726411c9d3f8188805e2cf7bff121bf0befa", + jsonpString: `svgOtherDatab446726411c9d3f8188805e2cf7bff121bf0befa({"range":[[0,1],[0,4]]});`, + expectedData: { + range: [ + [0, 1], + [0, 4], + ], + }, + }, + { + hash: "b7bdb61124fce0b28c4eaa731c90a5fbafba69a6", + jsonpString: `svgOtherDatab7bdb61124fce0b28c4eaa731c90a5fbafba69a6({"range":[[0,6],[0,5]]});`, + expectedData: { + range: [ + [0, 6], + [0, 5], + ], + }, + }, + { + hash: "c69ced35e04ff038d64a11b8969fafc3a725351a", + jsonpString: `svgOtherDatac69ced35e04ff038d64a11b8969fafc3a725351a({"range":[[0,18],[-1,11.5]]});`, + expectedData: { + range: [ + [0, 18], + [-1, 11.5], + ], + }, + }, + { + hash: "ce95d2d7a6a57d5d3c74fb66040eebb627e35245", + jsonpString: `svgOtherDatace95d2d7a6a57d5d3c74fb66040eebb627e35245({"range":[[0,11],[0,11]]});`, + expectedData: { + range: [ + [0, 11], + [0, 11], + ], + }, + }, + { + hash: "d0cabad9e056deca1c9d377a5476087a9fec967e", + jsonpString: `svgOtherDatad0cabad9e056deca1c9d377a5476087a9fec967e({"range":[[0,11.5],[0,9.5]]});`, + expectedData: { + range: [ + [0, 11.5], + [0, 9.5], + ], + }, + }, + { + hash: "e4c132d07947a480f383d566a77aea36a2fa9dde", + jsonpString: `svgOtherDatae4c132d07947a480f383d566a77aea36a2fa9dde({"range":[[-10,10],[-5,5]]});`, + expectedData: { + range: [ + [-10, 10], + [-5, 5], + ], + }, + }, +]; diff --git a/packages/perseus/src/util/graphie-utils.ts b/packages/perseus/src/util/graphie-utils.ts new file mode 100644 index 0000000000..055d2a22c2 --- /dev/null +++ b/packages/perseus/src/util/graphie-utils.ts @@ -0,0 +1,240 @@ +import {Errors, PerseusError} from "@khanacademy/perseus-core"; + +import {getDependencies} from "../dependencies"; +import {Log} from "../logging/log"; +import Util from "../util"; + +import type {Coord} from "../interactive2/types"; +import type {CSSProperties} from "aphrodite"; + +// For offline exercises in the mobile app, we download the graphie data +// (svgs and localized data files) and serve them from the local file +// system (with file://). We replace urls that start with `web+graphie` +// in the perseus json with this `file+graphie` prefix to indicate that +// they should have the `file://` protocol instead of `https://`. +const svgLocalLabelsRegex = /^file\+graphie:/; +const hashRegex = /\/([^/]+)$/; + +/** + * A Graphie axis range. + */ +export type GraphieRange = [number, number]; + +/** + * A Graphie label. + */ +export type GraphieLabel = { + // The text of the label. + content: string; + + // The direction of the label. A number indicates rotation angle. + alignment: + | number + | "center" + | "above" + | "above right" + | "right" + | "below right" + | "below" + | "below left" + | "left" + | "above left"; + + // The label position in the x, y Graphie axis range. + coordinates: GraphieRange; + + // Additional label styling. + style: CSSProperties; + + // Whether content is TeX. + typesetAsMath: boolean; +}; + +/** + * The Graphie data (labels, etc.) + */ +export type GraphieData = { + // The x, y axis range of the Graphie. + range: [GraphieRange, GraphieRange]; + + // The labels in the Graphie. + labels: Array; +}; + +function getLocale() { + const {JIPT, kaLocale} = getDependencies(); + return JIPT.useJIPT ? "en-pt" : kaLocale; +} + +function shouldUseLocalizedData() { + return getLocale() !== "en"; +} + +// A regex to split at the last / of a URL, separating the base part from the +// hash. This is used to create the localized label data URLs. +const splitHashRegex = /\/(?=[^/]+$)/; + +export function getLocalizedDataUrl(url: string) { + // For local (cached) graphie images, they are already localized. + if (svgLocalLabelsRegex.test(url)) { + return Util.getDataUrl(url); + } + const [base, hash] = Util.getBaseUrl(url).split(splitHashRegex); + return `${base}/${getLocale()}/${hash}-data.json`; +} + +// Get the hash from the url, which is just the filename +function getUrlHash(url: string) { + const match = url.match(hashRegex); + if (match == null) { + throw new PerseusError("not a valid URL", Errors.InvalidInput); + } + return match && match[1]; +} + +/** + * Parse JSONP for a web+graphie://... hash to extract Graphie data, or throw + * an error if invalid JSONP. + */ +export function parseDataFromJSONP( + graphieHash: string, + graphieJSONP: string, + errorCallback: (error?: any) => void, +): GraphieData | null { + // The JSONP is expected to be in the form of `svgDataHASH(...)` or `svgOtherDataHASH(...)` + const match = graphieJSONP.match( + new RegExp(`^(?:svgData|svgOtherData)${graphieHash}\\((.+)\\);$`), + ); + + // It is also possible that the JSONP is simply a JSON object, + // in which case we can parse it directly. + const jsonToParse = match ? match[1] : graphieJSONP; + + // Try to parse the JSONP, and if it fails, call the error callback + try { + return JSON.parse(jsonToParse); + } catch (error) { + errorCallback(error); + return null; + } +} + +type CacheEntry = { + labels: ReadonlyArray; + range: [Coord, Coord]; +}; + +// The global cache of label data. Its format is: +// { +// hash (e.g. "c21435944d2cf0c8f39d9059cb35836aa701d04a"): { +// loaded: a boolean of whether the data has been loaded or not +// dataCallbacks: a list of callbacks to call with the data when the data +// is loaded +// data: the other data for this hash +// }, +// ... +// } +const labelDataCache: Record< + string, + | { + loaded: false; + localized: boolean; + dataCallbacks: Array; + } + | { + loaded: true; + data: CacheEntry; + localized: boolean; + dataCallbacks: Array; + } +> = {}; + +export function loadGraphie( + url: string, + onDataLoaded: (data: CacheEntry, localized: boolean) => void, +) { + const hash = getUrlHash(url); + + // We can't make multiple jsonp calls to the same file because their + // callbacks will collide with each other. Instead, we cache the data + // and only make the jsonp calls once. + const entry = labelDataCache[hash]; + if (entry != null) { + if (entry.loaded) { + const {data, localized} = entry; + onDataLoaded(data, localized); + } else { + entry.dataCallbacks.push(onDataLoaded); + } + } else { + const cacheData = { + loaded: false as const, + dataCallbacks: [onDataLoaded], + localized: shouldUseLocalizedData(), + }; + + labelDataCache[hash] = cacheData; + + const retrieveData = async ( + url: string, + errorCallback: (x?: any, status?: any, error?: any) => void, + ) => { + const response = await fetch(url); + + if (!response?.ok) { + errorCallback(); + return; + } + + const jsonp = await response.text(); + + const data = parseDataFromJSONP(hash, jsonp, (error) => { + Log.error( + "Failed to parse JSONP for svg-image", + Errors.Service, + { + cause: error, + loggedMetadata: { + dataUrl: Util.getDataUrl(url), + jsonp, + }, + }, + ); + }); + + // If the data is null, then we failed to parse the JSONP + if (!data) { + return; + } + + const newCacheEntry = (labelDataCache[hash] = { + ...labelDataCache[hash], + loaded: true as const, + data, + }); + + newCacheEntry.dataCallbacks.forEach((callback) => { + callback(newCacheEntry.data, cacheData.localized); + }); + }; + + const dataLoadErrorHandler = (x, status, error) => { + Log.error("Data load failed for svg-image", Errors.Service, { + cause: error, + loggedMetadata: { + dataUrl: Util.getDataUrl(url), + status, + }, + }); + }; + + if (shouldUseLocalizedData()) { + cacheData.localized = false; + // If there is isn't any localized data, fall back to + // the original, unlocalized data + retrieveData(getLocalizedDataUrl(url), dataLoadErrorHandler); + } else { + retrieveData(Util.getDataUrl(url), dataLoadErrorHandler); + } + } +} diff --git a/packages/perseus/src/widget-ai-utils/graded-group-set/graded-group-set-ai-utils.test.ts b/packages/perseus/src/widget-ai-utils/graded-group-set/graded-group-set-ai-utils.test.ts index 56bddb3c41..4d589ada76 100644 --- a/packages/perseus/src/widget-ai-utils/graded-group-set/graded-group-set-ai-utils.test.ts +++ b/packages/perseus/src/widget-ai-utils/graded-group-set/graded-group-set-ai-utils.test.ts @@ -21,6 +21,14 @@ describe("GradedGroupSet AI utils", () => { jest.spyOn(Dependencies, "getDependencies").mockReturnValue( testDependencies, ); + + // Mocked for loading graphie in svg-image + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => "", + ok: true, + }), + ) as jest.Mock; }); it("it returns JSON with the expected format and fields", () => { diff --git a/packages/perseus/src/widget-ai-utils/group/group-ai-utils.test.ts b/packages/perseus/src/widget-ai-utils/group/group-ai-utils.test.ts index cccb08b76d..63d4227a19 100644 --- a/packages/perseus/src/widget-ai-utils/group/group-ai-utils.test.ts +++ b/packages/perseus/src/widget-ai-utils/group/group-ai-utils.test.ts @@ -6,6 +6,16 @@ import {question1} from "./group-ai-utils.testdata"; import type {RendererPromptJSON} from "../prompt-types"; describe("Group AI utils", () => { + beforeEach(() => { + // Mocked for loading graphie in svg-image + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => "", + ok: true, + }), + ) as jest.Mock; + }); + it("should return a GroupPromptJSON with default values when rendererJSON is undefined", () => { const result = getPromptJSON(undefined); diff --git a/packages/perseus/src/widget-ai-utils/image/image-ai-utils.test.ts b/packages/perseus/src/widget-ai-utils/image/image-ai-utils.test.ts index 15a2c9bbb8..2819f5a88c 100644 --- a/packages/perseus/src/widget-ai-utils/image/image-ai-utils.test.ts +++ b/packages/perseus/src/widget-ai-utils/image/image-ai-utils.test.ts @@ -40,6 +40,16 @@ const question = { } as const; describe("Image AI utils", () => { + beforeEach(() => { + // Mocked for loading graphie in svg-image + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => "", + ok: true, + }), + ) as jest.Mock; + }); + it("it returns JSON with the expected format and fields", () => { const renderProps: any = { alt: "An image of a textbook", diff --git a/packages/perseus/src/widget-ai-utils/interactive-graph/interactive-graph-ai-utils.test.ts b/packages/perseus/src/widget-ai-utils/interactive-graph/interactive-graph-ai-utils.test.ts index b4632503a1..c81f0bdce7 100644 --- a/packages/perseus/src/widget-ai-utils/interactive-graph/interactive-graph-ai-utils.test.ts +++ b/packages/perseus/src/widget-ai-utils/interactive-graph/interactive-graph-ai-utils.test.ts @@ -29,6 +29,14 @@ describe("InteractiveGraph AI utils", () => { userEvent = userEventLib.setup({ advanceTimers: jest.advanceTimersByTime, }); + + // Mocked for loading graphie in svg-image + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => "", + ok: true, + }), + ) as jest.Mock; }); it("should return JSON for an angle graph", () => { diff --git a/packages/perseus/src/widget-ai-utils/label-image/label-image-ai-utils.test.ts b/packages/perseus/src/widget-ai-utils/label-image/label-image-ai-utils.test.ts index df7ff58e19..05c8865078 100644 --- a/packages/perseus/src/widget-ai-utils/label-image/label-image-ai-utils.test.ts +++ b/packages/perseus/src/widget-ai-utils/label-image/label-image-ai-utils.test.ts @@ -76,6 +76,14 @@ describe("LabelImage AI utils", () => { userEvent = userEventLib.setup({ advanceTimers: jest.advanceTimersByTime, }); + + // Mocked for loading graphie in svg-image + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => "", + ok: true, + }), + ) as jest.Mock; }); it("it returns JSON with the expected format and fields", () => { diff --git a/packages/perseus/src/widgets/graded-group-set/graded-group-set.test.ts b/packages/perseus/src/widgets/graded-group-set/graded-group-set.test.ts index 65e429794b..9d4b0c6ba0 100644 --- a/packages/perseus/src/widgets/graded-group-set/graded-group-set.test.ts +++ b/packages/perseus/src/widgets/graded-group-set/graded-group-set.test.ts @@ -19,6 +19,14 @@ describe("graded group widget", () => { jest.spyOn(Dependencies, "getDependencies").mockReturnValue( testDependencies, ); + + // Mocked for loading graphie in svg-image + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => "", + ok: true, + }), + ) as jest.Mock; }); it("should snapshot", () => { diff --git a/packages/perseus/src/widgets/grapher/grapher.test.ts b/packages/perseus/src/widgets/grapher/grapher.test.ts index 424ade0a82..8aca5ed4c4 100644 --- a/packages/perseus/src/widgets/grapher/grapher.test.ts +++ b/packages/perseus/src/widgets/grapher/grapher.test.ts @@ -13,6 +13,14 @@ describe("grapher widget", () => { jest.spyOn(Dependencies, "getDependencies").mockReturnValue( testDependencies, ); + + // Mocked for loading graphie in svg-image + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => "", + ok: true, + }), + ) as jest.Mock; }); it("should snapshot linear graph question", async () => { diff --git a/packages/perseus/src/widgets/group/group.test.tsx b/packages/perseus/src/widgets/group/group.test.tsx index 7f090d3e46..7d7a7c5b01 100644 --- a/packages/perseus/src/widgets/group/group.test.tsx +++ b/packages/perseus/src/widgets/group/group.test.tsx @@ -27,6 +27,14 @@ describe("group widget", () => { jest.spyOn(Dependencies, "getDependencies").mockReturnValue( testDependencies, ); + + // Mocked for loading graphie in svg-image + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => "", + ok: true, + }), + ) as jest.Mock; }); it("should snapshot", () => { diff --git a/packages/perseus/src/widgets/image/image.test.ts b/packages/perseus/src/widgets/image/image.test.ts index 8f8b4234ea..11645a9de1 100644 --- a/packages/perseus/src/widgets/image/image.test.ts +++ b/packages/perseus/src/widgets/image/image.test.ts @@ -17,6 +17,14 @@ describe.each([true, false])("image widget - isMobile %b", (isMobile) => { jest.spyOn(Dependencies, "getDependencies").mockReturnValue( testDependencies, ); + + // Mocked for loading graphie in svg-image + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => "", + ok: true, + }), + ) as jest.Mock; }); it("should snapshot", () => { diff --git a/packages/perseus/src/widgets/interactive-graphs/__snapshots__/interactive-graph.test.tsx.snap b/packages/perseus/src/widgets/interactive-graphs/__snapshots__/interactive-graph.test.tsx.snap index bdcdde6579..b32598067b 100644 --- a/packages/perseus/src/widgets/interactive-graphs/__snapshots__/interactive-graph.test.tsx.snap +++ b/packages/perseus/src/widgets/interactive-graphs/__snapshots__/interactive-graph.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`interactive-graph widget A none-type graph renders predictably: first render 1`] = ` +exports[`Interactive Graph interactive-graph widget A none-type graph renders predictably: first render 1`] = `
`; -exports[`interactive-graph widget question Should render predictably: after interaction 1`] = ` +exports[`Interactive Graph interactive-graph widget question Should render predictably: after interaction 1`] = `
`; -exports[`interactive-graph widget question Should render predictably: after interaction 2`] = ` +exports[`Interactive Graph interactive-graph widget question Should render predictably: after interaction 2`] = `
`; -exports[`interactive-graph widget question Should render predictably: after interaction 3`] = ` +exports[`Interactive Graph interactive-graph widget question Should render predictably: after interaction 3`] = `
`; -exports[`interactive-graph widget question Should render predictably: after interaction 4`] = ` +exports[`Interactive Graph interactive-graph widget question Should render predictably: after interaction 4`] = `
`; -exports[`interactive-graph widget question Should render predictably: first render 1`] = ` +exports[`Interactive Graph interactive-graph widget question Should render predictably: first render 1`] = `
`; -exports[`interactive-graph widget question Should render predictably: first render 2`] = ` +exports[`Interactive Graph interactive-graph widget question Should render predictably: first render 2`] = `
`; -exports[`interactive-graph widget question Should render predictably: first render 3`] = ` +exports[`Interactive Graph interactive-graph widget question Should render predictably: first render 3`] = `
`; -exports[`interactive-graph widget question Should render predictably: first render 4`] = ` +exports[`Interactive Graph interactive-graph widget question Should render predictably: first render 4`] = `
{ const blankOptions: APIOptions = Object.freeze(ApiOptions.defaults); -describe("interactive-graph widget", function () { +describe("Interactive Graph", function () { + let userEvent: UserEvent; beforeEach(() => { + userEvent = userEventLib.setup({ + advanceTimers: jest.advanceTimersByTime, + }); jest.spyOn(Dependencies, "getDependencies").mockReturnValue( testDependencies, ); + + // Mocked for loading graphie in svg-image + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => Promise.resolve("{}"), + ok: true, + }), + ) as jest.Mock; }); - describe.each(questionsAndAnswers)( - "question", - ( - question: PerseusRenderer, - correct: ReadonlyArray, - incorrect: ReadonlyArray, - ) => { - it("Should accept the right answer", async () => { - // Arrange - const {renderer} = renderQuestion(question, blankOptions); + describe("interactive-graph widget", function () { + describe.each(questionsAndAnswers)( + "question", + ( + question: PerseusRenderer, + correct: ReadonlyArray, + incorrect: ReadonlyArray, + ) => { + it("Should accept the right answer", async () => { + // Arrange + const {renderer} = renderQuestion(question, blankOptions); + + // Act + // NOTE: This isn't acting on the UI as a user would, which is + // a weakness of these tests. Because this widget is designed for + // pointer-dragging, jsdom (which doesn't support getBoundingClientRect + // or other position-based node attributes) is insufficient to model + // drag & drop behavior. + // We'll want to use cypress tests or similar to ensure this widget + // works as expected. + act(() => + updateWidgetState( + renderer, + "interactive-graph 1", + (state) => (state.graph.coords = correct), + ), + ); + await waitForInitialGraphieRender(); + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); + + // Assert + expect(score).toHaveBeenAnsweredCorrectly(); + }); - // Act - // NOTE: This isn't acting on the UI as a user would, which is - // a weakness of these tests. Because this widget is designed for - // pointer-dragging, jsdom (which doesn't support getBoundingClientRect - // or other position-based node attributes) is insufficient to model - // drag & drop behavior. - // We'll want to use cypress tests or similar to ensure this widget - // works as expected. - act(() => - updateWidgetState( - renderer, - "interactive-graph 1", - (state) => (state.graph.coords = correct), - ), - ); - await waitForInitialGraphieRender(); - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); + it("Should render predictably", async () => { + // Arrange + const {renderer, container} = renderQuestion( + question, + blankOptions, + ); + expect(container).toMatchSnapshot("first render"); + + // Act + act(() => + updateWidgetState( + renderer, + "interactive-graph 1", + (state) => (state.graph.coords = correct), + ), + ); + await waitForInitialGraphieRender(); + + // Assert + expect(container).toMatchSnapshot("after interaction"); + }); - // Assert - expect(score).toHaveBeenAnsweredCorrectly(); - }); + it("should reject no interaction", async () => { + // Arrange + const {renderer} = renderQuestion(question, blankOptions); - it("Should render predictably", async () => { - // Arrange - const {renderer, container} = renderQuestion( - question, - blankOptions, - ); - expect(container).toMatchSnapshot("first render"); + // Act + await waitForInitialGraphieRender(); + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); - // Act - act(() => - updateWidgetState( - renderer, - "interactive-graph 1", - (state) => (state.graph.coords = correct), - ), - ); - await waitForInitialGraphieRender(); + // Assert + expect(score).toHaveInvalidInput(); + }); - // Assert - expect(container).toMatchSnapshot("after interaction"); + it("should reject an incorrect answer", async () => { + // Arrange + const {renderer} = renderQuestion(question, blankOptions); + + // Act + act(() => + updateWidgetState( + renderer, + "interactive-graph 1", + (state) => (state.graph.coords = incorrect), + ), + ); + await waitForInitialGraphieRender(); + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); + + // Assert + expect(score).toHaveBeenAnsweredIncorrectly(); + }); + }, + ); + + describe("A none-type graph", () => { + it("renders predictably", () => { + const question = interactiveGraphQuestionBuilder() + .withNoInteractiveFigure() + .build(); + const {container} = renderQuestion(question, blankOptions); + + expect(container).toMatchSnapshot("first render"); }); - it("should reject no interaction", async () => { - // Arrange + it("treats no interaction as a correct answer", async () => { + const question = interactiveGraphQuestionBuilder() + .withNoInteractiveFigure() + .build(); const {renderer} = renderQuestion(question, blankOptions); - - // Act - await waitForInitialGraphieRender(); const score = scorePerseusItemTesting( question, renderer.getUserInputMap(), ); - // Assert - expect(score).toHaveInvalidInput(); + expect(score).toHaveBeenAnsweredCorrectly({ + shouldHavePoints: false, + }); }); + }); + }); - it("should reject an incorrect answer", async () => { - // Arrange - const {renderer} = renderQuestion(question, blankOptions); + describe("a mafs graph", () => { + // Add types to this array as you test them + const apiOptions = { + flags: {mafs: trueForAllMafsSupportedGraphTypes}, + }; - // Act - act(() => - updateWidgetState( - renderer, - "interactive-graph 1", - (state) => (state.graph.coords = incorrect), - ), - ); - await waitForInitialGraphieRender(); - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); + const graphQuestionRenderers: { + [K in (typeof mafsSupportedGraphTypes)[number]]: PerseusRenderer; + } = { + angle: angleQuestion, + segment: segmentQuestion, + linear: linearQuestion, + "linear-system": linearSystemQuestion, + ray: rayQuestion, + polygon: polygonQuestion, + point: pointQuestion, + circle: circleQuestion, + quadratic: quadraticQuestion, + sinusoid: sinusoidQuestion, + "unlimited-point": pointQuestion, + "unlimited-polygon": polygonQuestion, + }; - // Assert - expect(score).toHaveBeenAnsweredIncorrectly(); - }); - }, - ); + const graphQuestionRenderersCorrect: { + [K in (typeof mafsSupportedGraphTypes)[number]]: PerseusRenderer; + } = { + angle: angleQuestionWithDefaultCorrect, + segment: segmentQuestionDefaultCorrect, + linear: linearQuestionWithDefaultCorrect, + "linear-system": linearSystemQuestionWithDefaultCorrect, + ray: rayQuestionWithDefaultCorrect, + polygon: polygonQuestionDefaultCorrect, + point: pointQuestionWithDefaultCorrect, + circle: circleQuestionWithDefaultCorrect, + quadratic: quadraticQuestionWithDefaultCorrect, + sinusoid: sinusoidQuestionWithDefaultCorrect, + "unlimited-point": pointQuestionWithDefaultCorrect, + "unlimited-polygon": polygonQuestionDefaultCorrect, + }; - describe("A none-type graph", () => { - it("renders predictably", () => { - const question = interactiveGraphQuestionBuilder() - .withNoInteractiveFigure() - .build(); - const {container} = renderQuestion(question, blankOptions); + describe.each(Object.entries(graphQuestionRenderers))( + "graph type %s", + (_type, question) => { + it("should render", () => { + renderQuestion(question, apiOptions); + }); - expect(container).toMatchSnapshot("first render"); - }); + it("should reject when has not been interacted with", () => { + // Arrange + const {renderer} = renderQuestion(question, apiOptions); - it("treats no interaction as a correct answer", async () => { - const question = interactiveGraphQuestionBuilder() - .withNoInteractiveFigure() - .build(); - const {renderer} = renderQuestion(question, blankOptions); - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); + // Act + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); - expect(score).toHaveBeenAnsweredCorrectly({ - shouldHavePoints: false, - }); - }); - }); -}); + // Assert + expect(score).toHaveInvalidInput(); + }); + }, + ); -describe("a mafs graph", () => { - let userEvent: UserEvent; - beforeEach(() => { - userEvent = userEventLib.setup({ - advanceTimers: jest.advanceTimersByTime, - }); - }); + describe.each(Object.entries(graphQuestionRenderersCorrect))( + "graph type %s: default correct", + (_type, question) => { + it("should render", () => { + renderQuestion(question, apiOptions); + }); + + // TODO(jeremy): This test is disabled because it fails + // sporadically (especially on slower/lower-end computers, like + // CI). Will work on a fix after the React 18 release. + it.skip("rejects incorrect answer", async () => { + // Arrange + const {renderer} = renderQuestion(question, apiOptions); + + await userEvent.tab(); + + // Act + await userEvent.keyboard("{arrowup}{arrowright}"); + + // Assert + await waitFor( + () => { + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); + expect(score).toHaveBeenAnsweredIncorrectly(); + }, + {timeout: 5000}, + ); + }); + + // TODO(jeremy): This test is disabled because it fails + // sporadically (especially on slower/lower-end computers, like + // CI). Will work on a fix after the React 18 release. + it.skip("accepts correct answer", async () => { + const {renderer} = renderQuestion(question, apiOptions); + + await userEvent.tab(); + + // Act + await userEvent.keyboard("{arrowup}{arrowdown}"); + + // Assert + await waitFor( + () => { + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); + expect(score).toHaveBeenAnsweredCorrectly(); + }, + {timeout: 5000}, + ); + }); + + it("is marked invalid when readOnly set to true", async () => { + const {renderer} = renderQuestion(question, { + ...apiOptions, + readOnly: true, + }); + + await userEvent.tab(); + + // Act + await userEvent.keyboard("{arrowup}{arrowdown}"); + + // Assert + await waitFor( + () => { + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); + expect(score).toHaveInvalidInput(); + }, + {timeout: 5000}, + ); + }); + }, + ); - // Add types to this array as you test them - const apiOptions = { - flags: {mafs: trueForAllMafsSupportedGraphTypes}, - }; - - const graphQuestionRenderers: { - [K in (typeof mafsSupportedGraphTypes)[number]]: PerseusRenderer; - } = { - angle: angleQuestion, - segment: segmentQuestion, - linear: linearQuestion, - "linear-system": linearSystemQuestion, - ray: rayQuestion, - polygon: polygonQuestion, - point: pointQuestion, - circle: circleQuestion, - quadratic: quadraticQuestion, - sinusoid: sinusoidQuestion, - "unlimited-point": pointQuestion, - "unlimited-polygon": polygonQuestion, - }; - - const graphQuestionRenderersCorrect: { - [K in (typeof mafsSupportedGraphTypes)[number]]: PerseusRenderer; - } = { - angle: angleQuestionWithDefaultCorrect, - segment: segmentQuestionDefaultCorrect, - linear: linearQuestionWithDefaultCorrect, - "linear-system": linearSystemQuestionWithDefaultCorrect, - ray: rayQuestionWithDefaultCorrect, - polygon: polygonQuestionDefaultCorrect, - point: pointQuestionWithDefaultCorrect, - circle: circleQuestionWithDefaultCorrect, - quadratic: quadraticQuestionWithDefaultCorrect, - sinusoid: sinusoidQuestionWithDefaultCorrect, - "unlimited-point": pointQuestionWithDefaultCorrect, - "unlimited-polygon": polygonQuestionDefaultCorrect, - }; - - describe.each(Object.entries(graphQuestionRenderers))( - "graph type %s", - (_type, question) => { - it("should render", () => { - renderQuestion(question, apiOptions); - }); - - it("should reject when has not been interacted with", () => { + describe("locked layer", () => { + it("should render locked points", async () => { // Arrange - const {renderer} = renderQuestion(question, apiOptions); + const {container} = renderQuestion( + segmentWithLockedPointsQuestion, + apiOptions, + ); - // Act - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const points = container.querySelectorAll( + // Filter out the interactive points' circles + "circle:not([class*='movable-point'])", ); - // Assert - expect(score).toHaveInvalidInput(); - }); - }, - ); + // Act - describe.each(Object.entries(graphQuestionRenderersCorrect))( - "graph type %s: default correct", - (_type, question) => { - it("should render", () => { - renderQuestion(question, apiOptions); + // Assert + expect(points).toHaveLength(2); }); - // TODO(jeremy): This test is disabled because it fails - // sporadically (especially on slower/lower-end computers, like - // CI). Will work on a fix after the React 18 release. - it.skip("rejects incorrect answer", async () => { + it("should render locked points with styles", async () => { // Arrange - const {renderer} = renderQuestion(question, apiOptions); - - await userEvent.tab(); + const {container} = renderQuestion( + segmentWithLockedPointsQuestion, + apiOptions, + ); // Act - await userEvent.keyboard("{arrowup}{arrowright}"); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const points = container.querySelectorAll( + "circle:not([class*='movable-point'])", + ); // Assert - await waitFor( - () => { - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); - expect(score).toHaveBeenAnsweredIncorrectly(); - }, - {timeout: 5000}, - ); + expect(points[0]).toHaveStyle({ + fill: lockedFigureColors.grayH, + stroke: lockedFigureColors.grayH, + }); + expect(points[1]).toHaveStyle({ + fill: wbColor.white, + stroke: lockedFigureColors.grayH, + }); }); + }); + }); - // TODO(jeremy): This test is disabled because it fails - // sporadically (especially on slower/lower-end computers, like - // CI). Will work on a fix after the React 18 release. - it.skip("accepts correct answer", async () => { - const {renderer} = renderQuestion(question, apiOptions); + describe("tabbing forward on a Mafs segment graph", () => { + it("focuses the first endpoint of a segment first", async () => { + const {container} = renderQuestion(segmentQuestion, { + flags: {mafs: {segment: true}}, + }); - await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); - // Act - await userEvent.keyboard("{arrowup}{arrowdown}"); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const movablePoints = container.querySelectorAll( + "[data-testid=movable-point__focusable-handle]", + ); + expect(movablePoints[0]).toHaveFocus(); + }); - // Assert - await waitFor( - () => { - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); - expect(score).toHaveBeenAnsweredCorrectly(); - }, - {timeout: 5000}, - ); + it("focuses the whole segment third", async () => { + const {container} = renderQuestion(segmentQuestion, { + flags: {mafs: {segment: true}}, }); - it("is marked invalid when readOnly set to true", async () => { - const {renderer} = renderQuestion(question, { - ...apiOptions, - readOnly: true, - }); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); - await userEvent.tab(); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const movableLine = container.querySelector( + "[data-testid=movable-line]", + ); + expect(movableLine).toHaveFocus(); + }); - // Act - await userEvent.keyboard("{arrowup}{arrowdown}"); + it("focuses the second point third", async () => { + const {container} = renderQuestion(segmentQuestion, { + flags: {mafs: {segment: true}}, + }); - // Assert - await waitFor( - () => { - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); - expect(score).toHaveInvalidInput(); - }, - {timeout: 5000}, - ); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const movablePoints = container.querySelectorAll( + "[data-testid=movable-point__focusable-handle]", + ); + expect(movablePoints[1]).toHaveFocus(); + }); + }); + + describe("tabbing backward on a Mafs segment graph", () => { + it("moves focus from the last point to the whole segment", async () => { + const {container} = renderQuestion(segmentQuestion, { + flags: {mafs: {segment: true}}, + }); + + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab({shift: true}); + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const movableLine = container.querySelector( + "[data-testid=movable-line]", + ); + expect(movableLine).toHaveFocus(); + }); + + it("moves focus from the whole segment to the first point", async () => { + const {container} = renderQuestion(segmentQuestion, { + flags: {mafs: {segment: true}}, }); - }, - ); + + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab({shift: true}); + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const movablePoints = container.querySelectorAll( + "[data-testid=movable-point__focusable-handle]", + ); + expect(movablePoints[0]).toHaveFocus(); + }); + }); describe("locked layer", () => { + const apiOptions = {flags: {mafs: {segment: true}}}; it("should render locked points", async () => { // Arrange const {container} = renderQuestion( @@ -377,7 +517,7 @@ describe("a mafs graph", () => { expect(points).toHaveLength(2); }); - it("should render locked points with styles", async () => { + it("should render locked points with styles when color is not specified", async () => { // Arrange const {container} = renderQuestion( segmentWithLockedPointsQuestion, @@ -400,1128 +540,1007 @@ describe("a mafs graph", () => { stroke: lockedFigureColors.grayH, }); }); - }); -}); - -describe("tabbing forward on a Mafs segment graph", () => { - let userEvent: UserEvent; - beforeEach(() => { - userEvent = userEventLib.setup({ - advanceTimers: jest.advanceTimersByTime, - }); - }); - - it("focuses the first endpoint of a segment first", async () => { - const {container} = renderQuestion(segmentQuestion, { - flags: {mafs: {segment: true}}, - }); - await userEvent.tab(); - await userEvent.tab(); + it("should render locked points with styles when color is specified", async () => { + // Arrange + const {container} = renderQuestion( + segmentWithLockedPointsWithColorQuestion, + { + flags: { + mafs: { + segment: true, + }, + }, + }, + ); - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const movablePoints = container.querySelectorAll( - "[data-testid=movable-point__focusable-handle]", - ); - expect(movablePoints[0]).toHaveFocus(); - }); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const points = container.querySelectorAll( + "circle:not([class*='movable-point'])", + ); - it("focuses the whole segment third", async () => { - const {container} = renderQuestion(segmentQuestion, { - flags: {mafs: {segment: true}}, + // Assert + expect(points[0]).toHaveStyle({ + fill: lockedFigureColors.green, + stroke: lockedFigureColors.green, + }); + expect(points[1]).toHaveStyle({ + fill: lockedFigureColors.green, + stroke: lockedFigureColors.green, + }); }); - await userEvent.tab(); - await userEvent.tab(); - await userEvent.tab(); + it("should render locked point with aria label when one is provided", () => { + // Arrange + const lockedPointWithAriaLabelQuestion = + interactiveGraphQuestionBuilder() + .addLockedPointAt(0, 0, { + ariaLabel: "Point A", + }) + .build(); + const {container} = renderQuestion( + lockedPointWithAriaLabelQuestion, + { + flags: { + mafs: { + segment: true, + "locked-figures-aria": true, + }, + }, + }, + ); - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const movableLine = container.querySelector( - "[data-testid=movable-line]", - ); - expect(movableLine).toHaveFocus(); - }); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-point"); - it("focuses the second point third", async () => { - const {container} = renderQuestion(segmentQuestion, { - flags: {mafs: {segment: true}}, + // Assert + expect(point).toHaveAttribute("aria-label", "Point A"); }); - await userEvent.tab(); - await userEvent.tab(); - await userEvent.tab(); - await userEvent.tab(); - - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const movablePoints = container.querySelectorAll( - "[data-testid=movable-point__focusable-handle]", - ); - expect(movablePoints[1]).toHaveFocus(); - }); -}); + it("should render locked points without aria label by default", () => { + // Arrange + const simpleLockedPointQuestion = interactiveGraphQuestionBuilder() + .addLockedPointAt(0, 0) + .build(); + const {container} = renderQuestion(simpleLockedPointQuestion, { + flags: { + mafs: { + segment: true, + }, + }, + }); -describe("tabbing backward on a Mafs segment graph", () => { - let userEvent: UserEvent; - beforeEach(() => { - userEvent = userEventLib.setup({ - advanceTimers: jest.advanceTimersByTime, - }); - }); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-point"); - it("moves focus from the last point to the whole segment", async () => { - const {container} = renderQuestion(segmentQuestion, { - flags: {mafs: {segment: true}}, + // Assert + expect(point).not.toHaveAttribute("aria-label"); }); - await userEvent.tab(); - await userEvent.tab(); - await userEvent.tab(); - await userEvent.tab(); - await userEvent.tab({shift: true}); + it("should render locked lines", () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedLineQuestion, { + flags: { + mafs: { + segment: true, + }, + }, + }); - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const movableLine = container.querySelector( - "[data-testid=movable-line]", - ); - expect(movableLine).toHaveFocus(); - }); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const lines = container.querySelectorAll(".locked-line"); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const rays = container.querySelectorAll(".locked-ray"); - it("moves focus from the whole segment to the first point", async () => { - const {container} = renderQuestion(segmentQuestion, { - flags: {mafs: {segment: true}}, + // Assert + expect(lines).toHaveLength(2); + expect(rays).toHaveLength(1); }); - await userEvent.tab(); - await userEvent.tab(); - await userEvent.tab(); - await userEvent.tab({shift: true}); - - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const movablePoints = container.querySelectorAll( - "[data-testid=movable-point__focusable-handle]", - ); - expect(movablePoints[0]).toHaveFocus(); - }); -}); - -describe("locked layer", () => { - const apiOptions = {flags: {mafs: {segment: true}}}; - it("should render locked points", async () => { - // Arrange - const {container} = renderQuestion( - segmentWithLockedPointsQuestion, - apiOptions, - ); - - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const points = container.querySelectorAll( - // Filter out the interactive points' circles - "circle:not([class*='movable-point'])", - ); - - // Act - - // Assert - expect(points).toHaveLength(2); - }); - - it("should render locked points with styles when color is not specified", async () => { - // Arrange - const {container} = renderQuestion( - segmentWithLockedPointsQuestion, - apiOptions, - ); + it("should render locked lines with styles", () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedLineQuestion, { + flags: { + mafs: { + segment: true, + }, + }, + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const points = container.querySelectorAll( - "circle:not([class*='movable-point'])", - ); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const lines = container.querySelectorAll(".locked-line line"); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const ray = container.querySelector(".locked-ray g"); - // Assert - expect(points[0]).toHaveStyle({ - fill: lockedFigureColors.grayH, - stroke: lockedFigureColors.grayH, - }); - expect(points[1]).toHaveStyle({ - fill: wbColor.white, - stroke: lockedFigureColors.grayH, + // Assert + expect(lines).toHaveLength(2); + expect(lines[0]).toHaveStyle({stroke: lockedFigureColors.green}); + expect(lines[1]).toHaveStyle({stroke: lockedFigureColors.grayH}); + expect(ray).toHaveStyle({stroke: lockedFigureColors.pink}); }); - }); - it("should render locked points with styles when color is specified", async () => { - // Arrange - const {container} = renderQuestion( - segmentWithLockedPointsWithColorQuestion, - { + it("should render locked lines with shown points", async () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedLineQuestion, { flags: { mafs: { segment: true, }, }, - }, - ); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const points = container.querySelectorAll( - "circle:not([class*='movable-point'])", - ); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const linePoints = container.querySelectorAll( + ".locked-line circle", + ); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const rayPoints = container.querySelectorAll(".locked-ray circle"); - // Assert - expect(points[0]).toHaveStyle({ - fill: lockedFigureColors.green, - stroke: lockedFigureColors.green, - }); - expect(points[1]).toHaveStyle({ - fill: lockedFigureColors.green, - stroke: lockedFigureColors.green, + // Assert + expect(linePoints).toHaveLength(4); + // Two points for each line + expect(linePoints[0]).toHaveStyle({ + fill: lockedFigureColors.green, + stroke: lockedFigureColors.green, + }); + expect(linePoints[1]).toHaveStyle({ + fill: wbColor.white, + stroke: lockedFigureColors.green, + }); + expect(linePoints[2]).toHaveStyle({ + fill: wbColor.white, + stroke: lockedFigureColors.grayH, + }); + expect(linePoints[3]).toHaveStyle({ + fill: lockedFigureColors.grayH, + stroke: lockedFigureColors.grayH, + }); + expect(rayPoints[0]).toHaveStyle({ + fill: wbColor.white, + stroke: lockedFigureColors.pink, + }); }); - }); - it("should render locked point with aria label when one is provided", () => { - // Arrange - const lockedPointWithAriaLabelQuestion = - interactiveGraphQuestionBuilder() - .addLockedPointAt(0, 0, { - ariaLabel: "Point A", - }) - .build(); - const {container} = renderQuestion(lockedPointWithAriaLabelQuestion, { - flags: { - mafs: { - segment: true, - "locked-figures-aria": true, + it("should render locked line with aria label when one is provided", () => { + // Arrange + const lockedLineWithAriaLabelQuestion = + interactiveGraphQuestionBuilder() + .addLockedLine([0, 0], [2, 2], { + ariaLabel: "Line A", + }) + .build(); + const {container} = renderQuestion( + lockedLineWithAriaLabelQuestion, + { + flags: { + mafs: { + segment: true, + "locked-figures-aria": true, + }, + }, }, - }, - }); + ); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-point"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-line"); - // Assert - expect(point).toHaveAttribute("aria-label", "Point A"); - }); + // Assert + expect(point).toHaveAttribute("aria-label", "Line A"); + }); - it("should render locked points without aria label by default", () => { - // Arrange - const simpleLockedPointQuestion = interactiveGraphQuestionBuilder() - .addLockedPointAt(0, 0) - .build(); - const {container} = renderQuestion(simpleLockedPointQuestion, { - flags: { - mafs: { - segment: true, + it("should render locked line without aria label by default", () => { + // Arrange + const simpleLockedLinequestion = interactiveGraphQuestionBuilder() + .addLockedLine([0, 0], [2, 2]) + .build(); + const {container} = renderQuestion(simpleLockedLinequestion, { + flags: { + mafs: { + segment: true, + }, }, - }, - }); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-point"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-line"); - // Assert - expect(point).not.toHaveAttribute("aria-label"); - }); + // Assert + expect(point).not.toHaveAttribute("aria-label"); + }); - it("should render locked lines", () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedLineQuestion, { - flags: { - mafs: { - segment: true, + it("should render locked vectors", async () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedVectors, { + flags: { + mafs: { + segment: true, + }, }, - }, - }); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const lines = container.querySelectorAll(".locked-line"); - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const rays = container.querySelectorAll(".locked-ray"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const vectors = container.querySelectorAll(".locked-vector"); - // Assert - expect(lines).toHaveLength(2); - expect(rays).toHaveLength(1); - }); + // Assert + expect(vectors).toHaveLength(2); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + let vector = vectors[0].children[0]; + expect(vector).toHaveStyle({ + "stroke-width": "2", + stroke: lockedFigureColors["grayH"], + }); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + let arrowheads = vector.querySelectorAll( + ".interactive-graph-arrowhead", + ); + expect(arrowheads).toHaveLength(1); + // Arrowhead should be at the end (tip) of the vector, and rotated + expect(arrowheads[0]).toHaveAttribute( + "transform", + "translate(40 -40) rotate(-45)", + ); - it("should render locked lines with styles", () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedLineQuestion, { - flags: { - mafs: { - segment: true, - }, - }, + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + vector = vectors[1].children[0]; + expect(vector).toHaveStyle({ + "stroke-width": "2", + stroke: lockedFigureColors["green"], + }); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + arrowheads = vector.querySelectorAll( + ".interactive-graph-arrowhead", + ); + expect(arrowheads).toHaveLength(1); + expect(arrowheads[0]).toHaveAttribute( + "transform", + "translate(-40 -80) rotate(-153.43494882292202)", + ); }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const lines = container.querySelectorAll(".locked-line line"); - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const ray = container.querySelector(".locked-ray g"); - - // Assert - expect(lines).toHaveLength(2); - expect(lines[0]).toHaveStyle({stroke: lockedFigureColors.green}); - expect(lines[1]).toHaveStyle({stroke: lockedFigureColors.grayH}); - expect(ray).toHaveStyle({stroke: lockedFigureColors.pink}); - }); - - it("should render locked lines with shown points", async () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedLineQuestion, { - flags: { - mafs: { - segment: true, + it("should render locked vector with aria label when one is provided", () => { + // Arrange + const lockedVectorWithAriaLabelQuestion = + interactiveGraphQuestionBuilder() + .addLockedVector([0, 0], [2, 2], { + ariaLabel: "Vector A", + }) + .build(); + const {container} = renderQuestion( + lockedVectorWithAriaLabelQuestion, + { + flags: { + mafs: { + segment: true, + "locked-figures-aria": true, + }, + }, }, - }, - }); + ); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const linePoints = container.querySelectorAll(".locked-line circle"); - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const rayPoints = container.querySelectorAll(".locked-ray circle"); - - // Assert - expect(linePoints).toHaveLength(4); - // Two points for each line - expect(linePoints[0]).toHaveStyle({ - fill: lockedFigureColors.green, - stroke: lockedFigureColors.green, - }); - expect(linePoints[1]).toHaveStyle({ - fill: wbColor.white, - stroke: lockedFigureColors.green, - }); - expect(linePoints[2]).toHaveStyle({ - fill: wbColor.white, - stroke: lockedFigureColors.grayH, - }); - expect(linePoints[3]).toHaveStyle({ - fill: lockedFigureColors.grayH, - stroke: lockedFigureColors.grayH, - }); - expect(rayPoints[0]).toHaveStyle({ - fill: wbColor.white, - stroke: lockedFigureColors.pink, + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-vector"); + + // Assert + expect(point).toHaveAttribute("aria-label", "Vector A"); }); - }); - it("should render locked line with aria label when one is provided", () => { - // Arrange - const lockedLineWithAriaLabelQuestion = - interactiveGraphQuestionBuilder() - .addLockedLine([0, 0], [2, 2], { - ariaLabel: "Line A", - }) + it("should render locked vector without aria label by default", () => { + // Arrange + const simpleLockedVectorquestion = interactiveGraphQuestionBuilder() + .addLockedVector([0, 0], [2, 2]) .build(); - const {container} = renderQuestion(lockedLineWithAriaLabelQuestion, { - flags: { - mafs: { - segment: true, - "locked-figures-aria": true, + const {container} = renderQuestion(simpleLockedVectorquestion, { + flags: { + mafs: { + segment: true, + }, }, - }, - }); - - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-line"); + }); - // Assert - expect(point).toHaveAttribute("aria-label", "Line A"); - }); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-vector"); - it("should render locked line without aria label by default", () => { - // Arrange - const simpleLockedLinequestion = interactiveGraphQuestionBuilder() - .addLockedLine([0, 0], [2, 2]) - .build(); - const {container} = renderQuestion(simpleLockedLinequestion, { - flags: { - mafs: { - segment: true, - }, - }, + // Assert + expect(point).not.toHaveAttribute("aria-label"); }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-line"); - - // Assert - expect(point).not.toHaveAttribute("aria-label"); - }); - - it("should render locked vectors", async () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedVectors, { - flags: { - mafs: { - segment: true, + it("should render locked ellipses", async () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedEllipses, { + flags: { + mafs: { + segment: true, + }, }, - }, - }); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const vectors = container.querySelectorAll(".locked-vector"); - - // Assert - expect(vectors).toHaveLength(2); - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - let vector = vectors[0].children[0]; - expect(vector).toHaveStyle({ - "stroke-width": "2", - stroke: lockedFigureColors["grayH"], - }); - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - let arrowheads = vector.querySelectorAll( - ".interactive-graph-arrowhead", - ); - expect(arrowheads).toHaveLength(1); - // Arrowhead should be at the end (tip) of the vector, and rotated - expect(arrowheads[0]).toHaveAttribute( - "transform", - "translate(40 -40) rotate(-45)", - ); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const circles = container.querySelectorAll("ellipse"); - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - vector = vectors[1].children[0]; - expect(vector).toHaveStyle({ - "stroke-width": "2", - stroke: lockedFigureColors["green"], + // Assert + expect(circles).toHaveLength(3); + expect(circles[0]).toHaveStyle({ + "fill-opacity": "0", + stroke: lockedFigureColors["grayH"], + }); + expect(circles[1]).toHaveStyle({ + "fill-opacity": "1", + stroke: lockedFigureColors["green"], + }); + expect(circles[2]).toHaveStyle({ + "fill-opacity": "0.4", + stroke: lockedFigureColors["green"], + }); }); - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - arrowheads = vector.querySelectorAll(".interactive-graph-arrowhead"); - expect(arrowheads).toHaveLength(1); - expect(arrowheads[0]).toHaveAttribute( - "transform", - "translate(-40 -80) rotate(-153.43494882292202)", - ); - }); - it("should render locked vector with aria label when one is provided", () => { - // Arrange - const lockedVectorWithAriaLabelQuestion = - interactiveGraphQuestionBuilder() - .addLockedVector([0, 0], [2, 2], { - ariaLabel: "Vector A", - }) - .build(); - const {container} = renderQuestion(lockedVectorWithAriaLabelQuestion, { - flags: { - mafs: { - segment: true, - "locked-figures-aria": true, + it("should render locked ellipses with white fill", async () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedEllipseWhite, { + flags: { + mafs: { + segment: true, + }, }, - }, - }); - - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-vector"); + }); - // Assert - expect(point).toHaveAttribute("aria-label", "Vector A"); - }); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const circles = container.querySelectorAll("ellipse"); + const whiteCircle = circles[0]; + const translucentCircle = circles[1]; - it("should render locked vector without aria label by default", () => { - // Arrange - const simpleLockedVectorquestion = interactiveGraphQuestionBuilder() - .addLockedVector([0, 0], [2, 2]) - .build(); - const {container} = renderQuestion(simpleLockedVectorquestion, { - flags: { - mafs: { - segment: true, - }, - }, + // Assert + expect(whiteCircle).toHaveStyle({ + "fill-opacity": 1, + fill: wbColor.white, + stroke: lockedFigureColors["green"], + }); + expect(translucentCircle).toHaveStyle({ + "fill-opacity": "0.4", + fill: lockedFigureColors["pink"], + stroke: lockedFigureColors["pink"], + }); }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-vector"); - - // Assert - expect(point).not.toHaveAttribute("aria-label"); - }); - - it("should render locked ellipses", async () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedEllipses, { - flags: { - mafs: { - segment: true, + it("should render locked ellipse with aria label when one is provided", () => { + // Arrange + const lockedEllipseWithAriaLabelQuestion = + interactiveGraphQuestionBuilder() + .addLockedEllipse([0, 0], [2, 2], { + ariaLabel: "Ellipse A", + }) + .build(); + const {container} = renderQuestion( + lockedEllipseWithAriaLabelQuestion, + { + flags: { + mafs: { + segment: true, + "locked-figures-aria": true, + }, + }, }, - }, - }); + ); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const circles = container.querySelectorAll("ellipse"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-ellipse"); - // Assert - expect(circles).toHaveLength(3); - expect(circles[0]).toHaveStyle({ - "fill-opacity": "0", - stroke: lockedFigureColors["grayH"], - }); - expect(circles[1]).toHaveStyle({ - "fill-opacity": "1", - stroke: lockedFigureColors["green"], - }); - expect(circles[2]).toHaveStyle({ - "fill-opacity": "0.4", - stroke: lockedFigureColors["green"], + // Assert + expect(point).toHaveAttribute("aria-label", "Ellipse A"); }); - }); - it("should render locked ellipses with white fill", async () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedEllipseWhite, { - flags: { - mafs: { - segment: true, + it("should render locked ellipse without aria label by default", () => { + // Arrange + const simpleLockedEllipsequestion = + interactiveGraphQuestionBuilder() + .addLockedEllipse([0, 0], [2, 2]) + .build(); + const {container} = renderQuestion(simpleLockedEllipsequestion, { + flags: { + mafs: { + segment: true, + }, }, - }, - }); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const circles = container.querySelectorAll("ellipse"); - const whiteCircle = circles[0]; - const translucentCircle = circles[1]; - - // Assert - expect(whiteCircle).toHaveStyle({ - "fill-opacity": 1, - fill: wbColor.white, - stroke: lockedFigureColors["green"], - }); - expect(translucentCircle).toHaveStyle({ - "fill-opacity": "0.4", - fill: lockedFigureColors["pink"], - stroke: lockedFigureColors["pink"], - }); - }); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-ellipse"); - it("should render locked ellipse with aria label when one is provided", () => { - // Arrange - const lockedEllipseWithAriaLabelQuestion = - interactiveGraphQuestionBuilder() - .addLockedEllipse([0, 0], [2, 2], { - ariaLabel: "Ellipse A", - }) - .build(); - const {container} = renderQuestion(lockedEllipseWithAriaLabelQuestion, { - flags: { - mafs: { - segment: true, - "locked-figures-aria": true, - }, - }, + // Assert + expect(point).not.toHaveAttribute("aria-label"); }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-ellipse"); - - // Assert - expect(point).toHaveAttribute("aria-label", "Ellipse A"); - }); - - it("should render locked ellipse without aria label by default", () => { - // Arrange - const simpleLockedEllipsequestion = interactiveGraphQuestionBuilder() - .addLockedEllipse([0, 0], [2, 2]) - .build(); - const {container} = renderQuestion(simpleLockedEllipsequestion, { - flags: { - mafs: { - segment: true, + it("should render locked polygons with style", async () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedPolygons, { + flags: { + mafs: { + segment: true, + }, }, - }, - }); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-ellipse"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const polygons = container.querySelectorAll( + ".locked-polygon polygon", + ); - // Assert - expect(point).not.toHaveAttribute("aria-label"); - }); + // Assert + expect(polygons).toHaveLength(3); + expect(polygons[0]).toHaveStyle({ + "fill-opacity": "0", + stroke: lockedFigureColors["grayH"], + }); + expect(polygons[1]).toHaveStyle({ + "fill-opacity": "0.4", + stroke: lockedFigureColors["green"], + }); + expect(polygons[2]).toHaveStyle({ + "fill-opacity": "1", + stroke: lockedFigureColors["purple"], + }); + }); - it("should render locked polygons with style", async () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedPolygons, { - flags: { - mafs: { - segment: true, + it("should render locked polygons with white fill", async () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedPolygonWhite, { + flags: { + mafs: { + segment: true, + }, }, - }, - }); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const polygons = container.querySelectorAll(".locked-polygon polygon"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const polygons = container.querySelectorAll( + ".locked-polygon polygon", + ); + const whitePolygon = polygons[0]; + const translucentPolygon = polygons[1]; - // Assert - expect(polygons).toHaveLength(3); - expect(polygons[0]).toHaveStyle({ - "fill-opacity": "0", - stroke: lockedFigureColors["grayH"], - }); - expect(polygons[1]).toHaveStyle({ - "fill-opacity": "0.4", - stroke: lockedFigureColors["green"], - }); - expect(polygons[2]).toHaveStyle({ - "fill-opacity": "1", - stroke: lockedFigureColors["purple"], + // Assert + expect(whitePolygon).toHaveStyle({ + "fill-opacity": 1, + fill: wbColor.white, + stroke: lockedFigureColors["green"], + }); + expect(translucentPolygon).toHaveStyle({ + "fill-opacity": "0.4", + fill: lockedFigureColors["pink"], + stroke: lockedFigureColors["pink"], + }); }); - }); - it("should render locked polygons with white fill", async () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedPolygonWhite, { - flags: { - mafs: { - segment: true, + it("should render vertices of locked polygons with showVertices", async () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedPolygons, { + flags: { + mafs: { + segment: true, + }, }, - }, - }); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const polygons = container.querySelectorAll(".locked-polygon polygon"); - const whitePolygon = polygons[0]; - const translucentPolygon = polygons[1]; - - // Assert - expect(whitePolygon).toHaveStyle({ - "fill-opacity": 1, - fill: wbColor.white, - stroke: lockedFigureColors["green"], - }); - expect(translucentPolygon).toHaveStyle({ - "fill-opacity": "0.4", - fill: lockedFigureColors["pink"], - stroke: lockedFigureColors["pink"], - }); - }); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const polygonVertices = container.querySelectorAll( + ".locked-polygon circle", + ); - it("should render vertices of locked polygons with showVertices", async () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedPolygons, { - flags: { - mafs: { - segment: true, - }, - }, + // Assert + // There should be 4 vertices on the square polygon + expect(polygonVertices).toHaveLength(4); + + // The square polygon is green + expect(polygonVertices[0]).toHaveStyle({ + fill: lockedFigureColors["green"], + }); + expect(polygonVertices[1]).toHaveStyle({ + fill: lockedFigureColors["green"], + }); + expect(polygonVertices[2]).toHaveStyle({ + fill: lockedFigureColors["green"], + }); + expect(polygonVertices[3]).toHaveStyle({ + fill: lockedFigureColors["green"], + }); }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const polygonVertices = container.querySelectorAll( - ".locked-polygon circle", - ); + it("should render a locked label within a locked polygon", async () => { + // Arrange + const {container} = renderQuestion(graphWithLabeledPolygon, { + flags: { + mafs: { + segment: true, + "interactive-graph-locked-features-labels": true, + "locked-polygon-labels": true, + }, + }, + }); - // Assert - // There should be 4 vertices on the square polygon - expect(polygonVertices).toHaveLength(4); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const labels = container.querySelectorAll(".locked-label"); + const label = labels[0]; - // The square polygon is green - expect(polygonVertices[0]).toHaveStyle({ - fill: lockedFigureColors["green"], - }); - expect(polygonVertices[1]).toHaveStyle({ - fill: lockedFigureColors["green"], - }); - expect(polygonVertices[2]).toHaveStyle({ - fill: lockedFigureColors["green"], - }); - expect(polygonVertices[3]).toHaveStyle({ - fill: lockedFigureColors["green"], + // Assert + expect(labels).toHaveLength(1); + expect(label).toHaveTextContent("E"); + expect(label).toHaveStyle({ + color: lockedFigureColors["grayH"], + fontSize: "16px", + left: "200px", + top: "200px", + }); }); - }); - it("should render a locked label within a locked polygon", async () => { - // Arrange - const {container} = renderQuestion(graphWithLabeledPolygon, { - flags: { - mafs: { - segment: true, - "interactive-graph-locked-features-labels": true, - "locked-polygon-labels": true, + it("should render locked polygon with aria label when one is provided", () => { + // Arrange + const lockedPolygonWithAriaLabelQuestion = + interactiveGraphQuestionBuilder() + .addLockedPolygon( + [ + [0, 0], + [0, 1], + [1, 1], + ], + { + ariaLabel: "Polygon A", + }, + ) + .build(); + const {container} = renderQuestion( + lockedPolygonWithAriaLabelQuestion, + { + flags: { + mafs: { + segment: true, + "locked-figures-aria": true, + }, + }, }, - }, - }); + ); + + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const polygon = container.querySelector(".locked-polygon"); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const labels = container.querySelectorAll(".locked-label"); - const label = labels[0]; - - // Assert - expect(labels).toHaveLength(1); - expect(label).toHaveTextContent("E"); - expect(label).toHaveStyle({ - color: lockedFigureColors["grayH"], - fontSize: "16px", - left: "200px", - top: "200px", + // Assert + expect(polygon).toHaveAttribute("aria-label", "Polygon A"); }); - }); - it("should render locked polygon with aria label when one is provided", () => { - // Arrange - const lockedPolygonWithAriaLabelQuestion = - interactiveGraphQuestionBuilder() - .addLockedPolygon( - [ + it("should render locked polygon without aria label by default", () => { + // Arrange + const simpleLockedPolygonQuestion = + interactiveGraphQuestionBuilder() + .addLockedPolygon([ [0, 0], [0, 1], [1, 1], - ], - { - ariaLabel: "Polygon A", + ]) + .build(); + const {container} = renderQuestion(simpleLockedPolygonQuestion, { + flags: { + mafs: { + segment: true, }, - ) - .build(); - const {container} = renderQuestion(lockedPolygonWithAriaLabelQuestion, { - flags: { - mafs: { - segment: true, - "locked-figures-aria": true, }, - }, - }); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const polygon = container.querySelector(".locked-polygon"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const polygon = container.querySelector(".locked-polygon"); - // Assert - expect(polygon).toHaveAttribute("aria-label", "Polygon A"); - }); + // Assert + expect(polygon).not.toHaveAttribute("aria-label"); + }); - it("should render locked polygon without aria label by default", () => { - // Arrange - const simpleLockedPolygonQuestion = interactiveGraphQuestionBuilder() - .addLockedPolygon([ - [0, 0], - [0, 1], - [1, 1], - ]) - .build(); - const {container} = renderQuestion(simpleLockedPolygonQuestion, { - flags: { - mafs: { - segment: true, + it("should render locked function with style", () => { + // Arrange + const {container} = renderQuestion( + segmentWithLockedFunction("x^2", { + color: "green", + strokeStyle: "dashed", + }), + { + flags: { + mafs: { + segment: true, + }, + }, }, - }, - }); + ); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const polygon = container.querySelector(".locked-polygon"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const functionPlots = container.querySelectorAll( + ".locked-function path", + ); - // Assert - expect(polygon).not.toHaveAttribute("aria-label"); - }); + // Assert + expect(functionPlots).toHaveLength(1); + expect(functionPlots[0]).toHaveStyle({ + "stroke-dasharray": "var(--mafs-line-stroke-dash-style)", + stroke: lockedFigureColors["green"], + }); + }); - it("should render locked function with style", () => { - // Arrange - const {container} = renderQuestion( - segmentWithLockedFunction("x^2", { - color: "green", - strokeStyle: "dashed", - }), - { + it("plots the supplied equation on the axis specified", () => { + // Arrange + const apiOptions = { flags: { mafs: { segment: true, }, }, - }, - ); + }; + const PlotOfXMock = jest + .spyOn(Plot, "OfX") + .mockReturnValue(
OfX
); + const PlotOfYMock = jest + .spyOn(Plot, "OfY") + .mockReturnValue(
OfY
); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const functionPlots = container.querySelectorAll( - ".locked-function path", - ); + // Act - Render f(x) + renderQuestion(segmentWithLockedFunction("x^2"), apiOptions); - // Assert - expect(functionPlots).toHaveLength(1); - expect(functionPlots[0]).toHaveStyle({ - "stroke-dasharray": "var(--mafs-line-stroke-dash-style)", - stroke: lockedFigureColors["green"], + // Assert + expect(PlotOfXMock).toHaveBeenCalledTimes(1); + expect(PlotOfYMock).toHaveBeenCalledTimes(0); + + // Arrange - reset mocks + PlotOfXMock.mockClear(); + + // Act - Render f(y) + renderQuestion( + segmentWithLockedFunction("x^2", { + directionalAxis: "y", + }), + apiOptions, + ); + + // Assert + expect(PlotOfXMock).toHaveBeenCalledTimes(0); + expect(PlotOfYMock).toHaveBeenCalledTimes(1); }); - }); - it("plots the supplied equation on the axis specified", () => { - // Arrange - const apiOptions = { - flags: { - mafs: { - segment: true, + it("should render locked function with aria label when one is provided", () => { + // Arrange + const lockedFunctionWithAriaLabelQuestion = + interactiveGraphQuestionBuilder() + .addLockedFunction("x^2", { + ariaLabel: "Function A", + }) + .build(); + const {container} = renderQuestion( + lockedFunctionWithAriaLabelQuestion, + { + flags: { + mafs: { + segment: true, + "locked-figures-aria": true, + }, + }, }, - }, - }; - const PlotOfXMock = jest - .spyOn(Plot, "OfX") - .mockReturnValue(
OfX
); - const PlotOfYMock = jest - .spyOn(Plot, "OfY") - .mockReturnValue(
OfY
); - - // Act - Render f(x) - renderQuestion(segmentWithLockedFunction("x^2"), apiOptions); - - // Assert - expect(PlotOfXMock).toHaveBeenCalledTimes(1); - expect(PlotOfYMock).toHaveBeenCalledTimes(0); - - // Arrange - reset mocks - PlotOfXMock.mockClear(); - - // Act - Render f(y) - renderQuestion( - segmentWithLockedFunction("x^2", { - directionalAxis: "y", - }), - apiOptions, - ); + ); - // Assert - expect(PlotOfXMock).toHaveBeenCalledTimes(0); - expect(PlotOfYMock).toHaveBeenCalledTimes(1); - }); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-function"); - it("should render locked function with aria label when one is provided", () => { - // Arrange - const lockedFunctionWithAriaLabelQuestion = - interactiveGraphQuestionBuilder() - .addLockedFunction("x^2", { - ariaLabel: "Function A", - }) - .build(); - const {container} = renderQuestion( - lockedFunctionWithAriaLabelQuestion, - { + // Assert + expect(point).toHaveAttribute("aria-label", "Function A"); + }); + + it("should render locked function without aria label by default", () => { + // Arrange + const simpleLockedFunctionquestion = + interactiveGraphQuestionBuilder() + .addLockedFunction("x^2") + .build(); + const {container} = renderQuestion(simpleLockedFunctionquestion, { flags: { mafs: { segment: true, - "locked-figures-aria": true, }, }, - }, - ); - - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-function"); + }); - // Assert - expect(point).toHaveAttribute("aria-label", "Function A"); - }); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-function"); - it("should render locked function without aria label by default", () => { - // Arrange - const simpleLockedFunctionquestion = interactiveGraphQuestionBuilder() - .addLockedFunction("x^2") - .build(); - const {container} = renderQuestion(simpleLockedFunctionquestion, { - flags: { - mafs: { - segment: true, - }, - }, + // Assert + expect(point).not.toHaveAttribute("aria-label"); }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-function"); + it("should render locked labels", async () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedLabels, { + flags: { + mafs: { + segment: true, + "interactive-graph-locked-features-labels": true, + }, + }, + }); - // Assert - expect(point).not.toHaveAttribute("aria-label"); - }); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const labels = container.querySelectorAll(".locked-label"); - it("should render locked labels", async () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedLabels, { - flags: { - mafs: { - segment: true, - "interactive-graph-locked-features-labels": true, - }, - }, + // Assert + expect(labels).toHaveLength(3); + + // content + expect(labels[0]).toHaveTextContent("\\text{small $\\frac{1}{2}$}"); + expect(labels[1]).toHaveTextContent("\\text{medium $E_0 = mc^2$}"); + expect(labels[2]).toHaveTextContent("\\text{large $\\sqrt{2a}$}"); + + // styles + expect(labels[0]).toHaveStyle({ + color: lockedFigureColors["pink"], + fontSize: "14px", // small + left: "80px", + top: "160px", + }); + expect(labels[1]).toHaveStyle({ + color: lockedFigureColors["blue"], + fontSize: "16px", // medium + left: "220px", + top: "160px", + }); + expect(labels[2]).toHaveStyle({ + color: lockedFigureColors["green"], + fontSize: "20px", // large + left: "140px", + top: "240px", + }); }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const labels = container.querySelectorAll(".locked-label"); - - // Assert - expect(labels).toHaveLength(3); + it("should render a locked label within a locked point", async () => { + // Arrange + const {container} = renderQuestion(graphWithLabeledPoint, { + flags: { + mafs: { + segment: true, + "interactive-graph-locked-features-labels": true, + "locked-point-labels": true, + }, + }, + }); - // content - expect(labels[0]).toHaveTextContent("\\text{small $\\frac{1}{2}$}"); - expect(labels[1]).toHaveTextContent("\\text{medium $E_0 = mc^2$}"); - expect(labels[2]).toHaveTextContent("\\text{large $\\sqrt{2a}$}"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const labels = container.querySelectorAll(".locked-label"); + const label = labels[0]; - // styles - expect(labels[0]).toHaveStyle({ - color: lockedFigureColors["pink"], - fontSize: "14px", // small - left: "80px", - top: "160px", - }); - expect(labels[1]).toHaveStyle({ - color: lockedFigureColors["blue"], - fontSize: "16px", // medium - left: "220px", - top: "160px", - }); - expect(labels[2]).toHaveStyle({ - color: lockedFigureColors["green"], - fontSize: "20px", // large - left: "140px", - top: "240px", + // Assert + expect(labels).toHaveLength(1); + expect(label).toHaveTextContent("A"); + expect(label).toHaveStyle({ + color: lockedFigureColors["grayH"], + fontSize: "16px", + left: "210px", + top: "200px", + }); }); - }); - it("should render a locked label within a locked point", async () => { - // Arrange - const {container} = renderQuestion(graphWithLabeledPoint, { - flags: { - mafs: { - segment: true, - "interactive-graph-locked-features-labels": true, - "locked-point-labels": true, + it("should render a locked label within a locked line", async () => { + const {container} = renderQuestion(graphWithLabeledLine, { + flags: { + mafs: { + segment: true, + "interactive-graph-locked-features-labels": true, + "locked-line-labels": true, + }, }, - }, - }); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const labels = container.querySelectorAll(".locked-label"); - const label = labels[0]; - - // Assert - expect(labels).toHaveLength(1); - expect(label).toHaveTextContent("A"); - expect(label).toHaveStyle({ - color: lockedFigureColors["grayH"], - fontSize: "16px", - left: "210px", - top: "200px", + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const labels = container.querySelectorAll(".locked-label"); + const label = labels[0]; + + // Assert + expect(labels).toHaveLength(1); + expect(label).toHaveTextContent("B"); + expect(label).toHaveStyle({ + color: lockedFigureColors["grayH"], + fontSize: "16px", + left: "150px", + top: "280px", + }); }); - }); - it("should render a locked label within a locked line", async () => { - const {container} = renderQuestion(graphWithLabeledLine, { - flags: { - mafs: { - segment: true, - "interactive-graph-locked-features-labels": true, - "locked-line-labels": true, + it("should render a locked label within a locked point within a locked line", async () => { + const question = {...graphWithLabeledLine}; + invariant( + question.widgets["interactive-graph 1"].options + .lockedFigures?.[0]?.type === "line", + ); + question.widgets[ + "interactive-graph 1" + ].options.lockedFigures[0].points = [ + { + ...getDefaultFigureForType("point"), + labels: [ + {...getDefaultFigureForType("label"), text: "point A"}, + ], }, - }, - }); + { + ...getDefaultFigureForType("point"), + labels: [ + {...getDefaultFigureForType("label"), text: "point B"}, + ], + }, + ]; + const {container} = renderQuestion(graphWithLabeledLine, { + flags: { + mafs: { + segment: true, + "interactive-graph-locked-features-labels": true, + "locked-line-labels": true, + "locked-point-labels": true, + }, + }, + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const labels = container.querySelectorAll(".locked-label"); - const label = labels[0]; - - // Assert - expect(labels).toHaveLength(1); - expect(label).toHaveTextContent("B"); - expect(label).toHaveStyle({ - color: lockedFigureColors["grayH"], - fontSize: "16px", - left: "150px", - top: "280px", - }); - }); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const labels = container.querySelectorAll(".locked-label"); + const lineLabel = labels[0]; + const point1Label = labels[1]; + const point2Label = labels[2]; - it("should render a locked label within a locked point within a locked line", async () => { - const question = {...graphWithLabeledLine}; - invariant( - question.widgets["interactive-graph 1"].options.lockedFigures?.[0] - ?.type === "line", - ); - question.widgets[ - "interactive-graph 1" - ].options.lockedFigures[0].points = [ - { - ...getDefaultFigureForType("point"), - labels: [ - {...getDefaultFigureForType("label"), text: "point A"}, - ], - }, - { - ...getDefaultFigureForType("point"), - labels: [ - {...getDefaultFigureForType("label"), text: "point B"}, - ], - }, - ]; - const {container} = renderQuestion(graphWithLabeledLine, { - flags: { - mafs: { - segment: true, - "interactive-graph-locked-features-labels": true, - "locked-line-labels": true, - "locked-point-labels": true, - }, - }, + // Assert + expect(labels).toHaveLength(3); + expect(lineLabel).toHaveTextContent("B"); + expect(point1Label).toHaveTextContent("point A"); + expect(point2Label).toHaveTextContent("point B"); }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const labels = container.querySelectorAll(".locked-label"); - const lineLabel = labels[0]; - const point1Label = labels[1]; - const point2Label = labels[2]; - - // Assert - expect(labels).toHaveLength(3); - expect(lineLabel).toHaveTextContent("B"); - expect(point1Label).toHaveTextContent("point A"); - expect(point2Label).toHaveTextContent("point B"); - }); - - it("should render a locked label within a locked vector", async () => { - // Arrange - const {container} = renderQuestion(graphWithLabeledVector, { - flags: { - mafs: { - segment: true, - "interactive-graph-locked-features-labels": true, - "locked-vector-labels": true, + it("should render a locked label within a locked vector", async () => { + // Arrange + const {container} = renderQuestion(graphWithLabeledVector, { + flags: { + mafs: { + segment: true, + "interactive-graph-locked-features-labels": true, + "locked-vector-labels": true, + }, }, - }, - }); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const labels = container.querySelectorAll(".locked-label"); - const label = labels[0]; - - // Assert - expect(labels).toHaveLength(1); - expect(label).toHaveTextContent("C"); - expect(label).toHaveStyle({ - color: lockedFigureColors["grayH"], - fontSize: "16px", - left: "280px", - top: "180px", + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const labels = container.querySelectorAll(".locked-label"); + const label = labels[0]; + + // Assert + expect(labels).toHaveLength(1); + expect(label).toHaveTextContent("C"); + expect(label).toHaveStyle({ + color: lockedFigureColors["grayH"], + fontSize: "16px", + left: "280px", + top: "180px", + }); }); - }); - it("should render a locked label within a locked ellipse", async () => { - // Arrange - const {container} = renderQuestion(graphWithLabeledEllipse, { - flags: { - mafs: { - segment: true, - "interactive-graph-locked-features-labels": true, - "locked-ellipse-labels": true, + it("should render a locked label within a locked ellipse", async () => { + // Arrange + const {container} = renderQuestion(graphWithLabeledEllipse, { + flags: { + mafs: { + segment: true, + "interactive-graph-locked-features-labels": true, + "locked-ellipse-labels": true, + }, }, - }, - }); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const labels = container.querySelectorAll(".locked-label"); - const label = labels[0]; - - // Assert - expect(labels).toHaveLength(1); - expect(label).toHaveTextContent("D"); - expect(label).toHaveStyle({ - color: lockedFigureColors["grayH"], - fontSize: "16px", - left: "200px", - top: "200px", + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const labels = container.querySelectorAll(".locked-label"); + const label = labels[0]; + + // Assert + expect(labels).toHaveLength(1); + expect(label).toHaveTextContent("D"); + expect(label).toHaveStyle({ + color: lockedFigureColors["grayH"], + fontSize: "16px", + left: "200px", + top: "200px", + }); }); - }); - it("should render a locked label within a locked function", async () => { - // Arrange - const {container} = renderQuestion(graphWithLabeledFunction, { - flags: { - mafs: { - segment: true, - "interactive-graph-locked-features-labels": true, - "locked-function-labels": true, + it("should render a locked label within a locked function", async () => { + // Arrange + const {container} = renderQuestion(graphWithLabeledFunction, { + flags: { + mafs: { + segment: true, + "interactive-graph-locked-features-labels": true, + "locked-function-labels": true, + }, }, - }, - }); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const labels = container.querySelectorAll(".locked-label"); - const label = labels[0]; - - // Assert - expect(labels).toHaveLength(1); - expect(label).toHaveTextContent("F"); - expect(label).toHaveStyle({ - color: lockedFigureColors["grayH"], - fontSize: "16px", - left: "200px", - top: "200px", + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const labels = container.querySelectorAll(".locked-label"); + const label = labels[0]; + + // Assert + expect(labels).toHaveLength(1); + expect(label).toHaveTextContent("F"); + expect(label).toHaveStyle({ + color: lockedFigureColors["grayH"], + fontSize: "16px", + left: "200px", + top: "200px", + }); }); - }); - it("should have an aria-label and description if they are provided", async () => { - // Arrange - const {container} = renderQuestion(interactiveGraphWithAriaLabel, { - flags: { - mafs: { - segment: true, - "interactive-graph-locked-features-labels": true, + it("should have an aria-label and description if they are provided", async () => { + // Arrange + const {container} = renderQuestion(interactiveGraphWithAriaLabel, { + flags: { + mafs: { + segment: true, + "interactive-graph-locked-features-labels": true, + }, }, - }, - }); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const graph = container.querySelector(".mafs-graph"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const graph = container.querySelector(".mafs-graph"); - // Assert - expect(graph).toHaveAttribute("aria-label", "Segment Graph Title"); - // The aria-describedby attribute is set to the description - // element's ID. This ID is unique to the graph instance, so - // we can't predict it in this test. - expect(graph).toHaveAttribute("aria-describedby"); - }); + // Assert + expect(graph).toHaveAttribute("aria-label", "Segment Graph Title"); + // The aria-describedby attribute is set to the description + // element's ID. This ID is unique to the graph instance, so + // we can't predict it in this test. + expect(graph).toHaveAttribute("aria-describedby"); + }); - it("should not have an aria-label or description if they are not provided", async () => { - // Arrange - const {container} = renderQuestion(segmentQuestion, { - flags: { - mafs: { - segment: true, - "interactive-graph-locked-features-labels": true, + it("should not have an aria-label or description if they are not provided", async () => { + // Arrange + const {container} = renderQuestion(segmentQuestion, { + flags: { + mafs: { + segment: true, + "interactive-graph-locked-features-labels": true, + }, }, - }, - }); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const graph = container.querySelector(".mafs-graph"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const graph = container.querySelector(".mafs-graph"); - // Assert - expect(graph).not.toHaveAttribute("aria-label"); - expect(graph).not.toHaveAttribute("aria-describedby"); + // Assert + expect(graph).not.toHaveAttribute("aria-label"); + expect(graph).not.toHaveAttribute("aria-describedby"); + }); }); }); diff --git a/packages/perseus/src/widgets/label-image/__tests__/label-image.test.ts b/packages/perseus/src/widgets/label-image/__tests__/label-image.test.ts index 54e54d1dfd..b105a4c185 100644 --- a/packages/perseus/src/widgets/label-image/__tests__/label-image.test.ts +++ b/packages/perseus/src/widgets/label-image/__tests__/label-image.test.ts @@ -27,6 +27,14 @@ describe("LabelImage", function () { userEvent = userEventLib.setup({ advanceTimers: jest.advanceTimersByTime, }); + + // Mocked for loading graphie in svg-image + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => "", + ok: true, + }), + ) as jest.Mock; }); describe("imageSideForMarkerPosition", function () { diff --git a/packages/perseus/src/widgets/radio/__tests__/__snapshots__/radio.test.ts.snap b/packages/perseus/src/widgets/radio/__tests__/__snapshots__/radio.test.ts.snap index 3aa1b49f93..ae81490a72 100644 --- a/packages/perseus/src/widgets/radio/__tests__/__snapshots__/radio.test.ts.snap +++ b/packages/perseus/src/widgets/radio/__tests__/__snapshots__/radio.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`multi-choice question should snapshot the same when invalid: invalid state 1`] = ` +exports[`Radio Widget multi-choice question should snapshot the same when invalid: invalid state 1`] = `
`; -exports[`single-choice question reviewMode: false should snapshot the same with correct answer: correct answer 1`] = ` +exports[`Radio Widget single-choice question reviewMode: false should snapshot the same with correct answer: correct answer 1`] = `
`; -exports[`single-choice question reviewMode: false should snapshot the same with incorrect answer: incorrect answer 1`] = ` +exports[`Radio Widget single-choice question reviewMode: false should snapshot the same with incorrect answer: incorrect answer 1`] = `
`; -exports[`single-choice question reviewMode: false should snapshot the same: first render 1`] = ` +exports[`Radio Widget single-choice question reviewMode: false should snapshot the same: first render 1`] = `
`; -exports[`single-choice question reviewMode: true should snapshot the same with correct answer: correct answer 1`] = ` +exports[`Radio Widget single-choice question reviewMode: true should snapshot the same with correct answer: correct answer 1`] = `
`; -exports[`single-choice question reviewMode: true should snapshot the same with incorrect answer: incorrect answer 1`] = ` +exports[`Radio Widget single-choice question reviewMode: true should snapshot the same with incorrect answer: incorrect answer 1`] = `
`; -exports[`single-choice question reviewMode: true should snapshot the same: first render 1`] = ` +exports[`Radio Widget single-choice question reviewMode: true should snapshot the same: first render 1`] = `
{ +describe("Radio Widget", () => { let userEvent: UserEvent; beforeEach(() => { userEvent = userEventLib.setup({ @@ -52,1023 +51,1044 @@ describe("single-choice question", () => { jest.spyOn(Dependencies, "getDependencies").mockReturnValue( testDependencies, ); + + // Mocked for loading graphie in svg-image + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => "", + ok: true, + }), + ) as jest.Mock; }); - const [question, correct, incorrect] = questionAndAnswer; - const apiOptions = Object.freeze({}); + describe("single-choice question", () => { + const [question, correct, incorrect] = questionAndAnswer; + const apiOptions = Object.freeze({}); - describe.each([[true], [false]])( - "reviewMode: %s", - (reviewMode: boolean) => { - it("should snapshot the same", async () => { - // Arrange & Act - const {container} = renderQuestion(question, apiOptions, { - reviewMode, - }); - - // Assert - expect(container).toMatchSnapshot("first render"); - }); + describe.each([[true], [false]])( + "reviewMode: %s", + (reviewMode: boolean) => { + it("should snapshot the same", async () => { + // Arrange & Act + const {container} = renderQuestion(question, apiOptions, { + reviewMode, + }); - it("should snapshot the same with correct answer", async () => { - // Arrange - const {container} = renderQuestion(question, apiOptions); + // Assert + expect(container).toMatchSnapshot("first render"); + }); - // Act - await selectOption(userEvent, correct); + it("should snapshot the same with correct answer", async () => { + // Arrange + const {container} = renderQuestion(question, apiOptions); - // Assert - expect(container).toMatchSnapshot("correct answer"); - }); + // Act + await selectOption(userEvent, correct); - it("should snapshot the same with incorrect answer", async () => { - // Arrange - const {container} = renderQuestion(question, apiOptions); + // Assert + expect(container).toMatchSnapshot("correct answer"); + }); - // Act - await selectOption(userEvent, incorrect[0]); + it("should snapshot the same with incorrect answer", async () => { + // Arrange + const {container} = renderQuestion(question, apiOptions); - // Assert - expect(container).toMatchSnapshot("incorrect answer"); - }); + // Act + await selectOption(userEvent, incorrect[0]); - it("should accept the right answer (mouse)", async () => { - // Arrange - const {renderer} = renderQuestion(question, apiOptions, { - reviewMode, + // Assert + expect(container).toMatchSnapshot("incorrect answer"); }); - // Act - await selectOption(userEvent, correct); - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); - - // Assert - expect(score).toHaveBeenAnsweredCorrectly(); - }); - - it("should accept the right answer (touch)", async () => { - // Arrange - const {renderer} = renderQuestion(question, apiOptions); - const correctRadio = screen.getAllByRole("radio")[correct]; + it("should accept the right answer (mouse)", async () => { + // Arrange + const {renderer} = renderQuestion(question, apiOptions, { + reviewMode, + }); - // Act - fireEvent.touchStart(correctRadio); - fireEvent.touchEnd(correctRadio); - // We're using fireEvent.click() because mobile browsers synthesize - // click events from matching touchStart/touchEnd events. user-event - // does not support touch events so we have to do this ourselves. - // eslint-disable-next-line testing-library/prefer-user-event - fireEvent.click(correctRadio); - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); + // Act + await selectOption(userEvent, correct); + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); - // Assert - expect(score).toHaveBeenAnsweredCorrectly(); - }); + // Assert + expect(score).toHaveBeenAnsweredCorrectly(); + }); - it.each(incorrect)( - "should reject incorrect answer - choice %d", - async (incorrect: number) => { + it("should accept the right answer (touch)", async () => { // Arrange const {renderer} = renderQuestion(question, apiOptions); + const correctRadio = screen.getAllByRole("radio")[correct]; // Act - await selectOption(userEvent, incorrect); + fireEvent.touchStart(correctRadio); + fireEvent.touchEnd(correctRadio); + // We're using fireEvent.click() because mobile browsers synthesize + // click events from matching touchStart/touchEnd events. user-event + // does not support touch events so we have to do this ourselves. + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.click(correctRadio); const score = scorePerseusItemTesting( question, renderer.getUserInputMap(), ); // Assert - expect(score).toHaveBeenAnsweredIncorrectly(); - }, - ); - - it("calling .focus() on the renderer should succeed", async () => { - // Arrange - const {renderer} = renderQuestion(question, apiOptions, { - reviewMode, + expect(score).toHaveBeenAnsweredCorrectly(); }); - // Act - const gotFocus = await act(() => renderer.focus()); + it.each(incorrect)( + "should reject incorrect answer - choice %d", + async (incorrect: number) => { + // Arrange + const {renderer} = renderQuestion(question, apiOptions); + + // Act + await selectOption(userEvent, incorrect); + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); + + // Assert + expect(score).toHaveBeenAnsweredIncorrectly(); + }, + ); - // Assert - expect(gotFocus).toBe(true); - }); + it("calling .focus() on the renderer should succeed", async () => { + // Arrange + const {renderer} = renderQuestion(question, apiOptions, { + reviewMode, + }); - it("should deselect incorrect selected choices", async () => { - // Arrange - const {renderer} = renderQuestion(question, apiOptions, { - reviewMode, + // Act + const gotFocus = await act(() => renderer.focus()); + + // Assert + expect(gotFocus).toBe(true); }); - // Act - // Since this is a single-select setup, just select the first - // incorrect choice. - await selectOption(userEvent, incorrect[0]); - act(() => renderer.deselectIncorrectSelectedChoices()); + it("should deselect incorrect selected choices", async () => { + // Arrange + const {renderer} = renderQuestion(question, apiOptions, { + reviewMode, + }); - // Assert - screen.getAllByRole("radio").forEach((r) => { - expect(r).not.toBeChecked(); + // Act + // Since this is a single-select setup, just select the first + // incorrect choice. + await selectOption(userEvent, incorrect[0]); + act(() => renderer.deselectIncorrectSelectedChoices()); + + // Assert + screen.getAllByRole("radio").forEach((r) => { + expect(r).not.toBeChecked(); + }); }); - }); - it("should disable all radio inputs when static is true", async () => { - // Arrange - const staticQuestion = { - ...question, - widgets: { - ...question.widgets, - "radio 1": { - ...question.widgets["radio 1"], - static: true, + it("should disable all radio inputs when static is true", async () => { + // Arrange + const staticQuestion = { + ...question, + widgets: { + ...question.widgets, + "radio 1": { + ...question.widgets["radio 1"], + static: true, + }, }, - }, - } as const; - renderQuestion(staticQuestion, apiOptions, {reviewMode}); + } as const; + renderQuestion(staticQuestion, apiOptions, {reviewMode}); - // Act - await selectOption(userEvent, correct); + // Act + await selectOption(userEvent, correct); - // Assert - // Everything's read-only so no selections made - // Note(TB): The visual buttons are hidden from screen - // readers, so they need to be identified as hidden; - // the visual button has the aria attributes - screen.getAllByRole("button", {hidden: true}).forEach((r) => { - expect(r).toHaveAttribute("aria-disabled", "true"); - }); - // Note(TB): Confirms the screen reader only - // radio options are also disabled - screen.getAllByRole("radio").forEach((r) => { - expect(r).toHaveAttribute("disabled"); + // Assert + // Everything's read-only so no selections made + // Note(TB): The visual buttons are hidden from screen + // readers, so they need to be identified as hidden; + // the visual button has the aria attributes + screen + .getAllByRole("button", {hidden: true}) + .forEach((r) => { + expect(r).toHaveAttribute("aria-disabled", "true"); + }); + // Note(TB): Confirms the screen reader only + // radio options are also disabled + screen.getAllByRole("radio").forEach((r) => { + expect(r).toHaveAttribute("disabled"); + }); }); - }); - }, - ); - - it("should be able to navigate down by keyboard", async () => { - // Arrange - renderQuestion(question, apiOptions); - - // Act - await userEvent.tab(); - await userEvent.tab(); - - // Assert - // Note(TB): The visual buttons are hidden from screen readers - // so they need to be identified as hidden; - // cannot access screen reader buttons via tabbing - expect(screen.getAllByRole("button", {hidden: true})[1]).toHaveFocus(); - }); + }, + ); - it("should be able to navigate up by keyboard", async () => { - // Arrange - renderQuestion(question, apiOptions); + it("should be able to navigate down by keyboard", async () => { + // Arrange + renderQuestion(question, apiOptions); - // Act - await userEvent.tab(); - // Note(TB): The visual buttons are hidden from screen readers - // so they need to be identified as hidden - expect(screen.getAllByRole("button", {hidden: true})[0]).toHaveFocus(); + // Act + await userEvent.tab(); + await userEvent.tab(); - await userEvent.tab(); - expect(screen.getAllByRole("button", {hidden: true})[1]).toHaveFocus(); + // Assert + // Note(TB): The visual buttons are hidden from screen readers + // so they need to be identified as hidden; + // cannot access screen reader buttons via tabbing + expect( + screen.getAllByRole("button", {hidden: true})[1], + ).toHaveFocus(); + }); - await userEvent.tab({shift: true}); + it("should be able to navigate up by keyboard", async () => { + // Arrange + renderQuestion(question, apiOptions); - // Assert - expect(screen.getAllByRole("button", {hidden: true})[0]).toHaveFocus(); - }); + // Act + await userEvent.tab(); + // Note(TB): The visual buttons are hidden from screen readers + // so they need to be identified as hidden + expect( + screen.getAllByRole("button", {hidden: true})[0], + ).toHaveFocus(); - it("should be able to select an option by keyboard", async () => { - // Arrange - renderQuestion(question, apiOptions); + await userEvent.tab(); + expect( + screen.getAllByRole("button", {hidden: true})[1], + ).toHaveFocus(); - // Act - await userEvent.tab(); - await userEvent.keyboard(" "); + await userEvent.tab({shift: true}); - // Assert - expect(screen.getAllByRole("radio")[0]).toBeChecked(); - }); - - it("should be able to navigate to 'None of the above' choice by keyboard", async () => { - // Arrange - const q = clone(question); - q.widgets["radio 1"].options.choices[3].isNoneOfTheAbove = true; - renderQuestion(q, apiOptions); - - // Act - await userEvent.tab(); - await userEvent.tab(); - await userEvent.tab(); - - // Assert - // Note(TB): The visual buttons are hidden from screen readers - // so they need to be identified as hidden - expect(screen.getAllByRole("button", {hidden: true})[2]).toHaveFocus(); - }); + // Assert + expect( + screen.getAllByRole("button", {hidden: true})[0], + ).toHaveFocus(); + }); - it.each([ - ["No", "Yes"], - ["False", "True"], - ])("should enforce ordering for common answers: %j", async (...answers) => { - // Arrange - const q = clone(question); - q.widgets["radio 1"].options.choices = answers.map((answer, idx) => ({ - content: answer, - correct: idx === 1, // Correct answer is the "truthy" item - })); - - // Act - const {renderer} = renderQuestion(q, apiOptions); - // We click on the first item, which was the second (index == 1) - // item in the original choices. But because of enforced ordering, - // it is now at the top of the list (and thus our correct answer). - await userEvent.click(screen.getAllByRole("radio")[0]); - const score = scorePerseusItemTesting(q, renderer.getUserInputMap()); - - // Assert - const items = screen.getAllByRole("listitem"); - expect(items[0]).toHaveTextContent(answers[1]); - expect(items[1]).toHaveTextContent(answers[0]); - expect(score).toHaveBeenAnsweredCorrectly(); - }); + it("should be able to select an option by keyboard", async () => { + // Arrange + renderQuestion(question, apiOptions); - it("should not change ordering of non-common answers", async () => { - // Arrange - const answers = ["Last", "First"]; - const q = clone(question); - q.widgets["radio 1"].options.choices = answers.map((answer, idx) => ({ - content: answer, - correct: idx === 1, - })); - - // Act - renderQuestion(q, apiOptions); - - // Assert - const items = screen.getAllByRole("listitem"); - expect(items[0]).toHaveTextContent(answers[0]); - expect(items[1]).toHaveTextContent(answers[1]); - }); + // Act + await userEvent.tab(); + await userEvent.keyboard(" "); - it("should transform inline passage-refs to references to passage widgets", async () => { - const question: PerseusRenderer = { - content: "[[\u2603 passage 1]]\n\n[[☃ radio 1]]", - images: {}, - widgets: { - "passage 1": { - type: "passage", - options: { - footnotes: "", - passageText: "{{First ref}} and {{Second ref}}", - passageTitle: "", - showLineNumbers: true, - static: false, - }, - }, - "radio 1": { - type: "radio", - options: { - choices: [ - { - correct: true, - // Passage refs reference a passage widget in - // the main content. The first value is the - // passage widget (eg. "passage 1", "passage - // 2") and the second value is the ref within - // that passage widget. Note that both are - // 1-based! - content: `{{passage-ref 1 1 "the 1st ref in the 1st passage"}}`, - }, - {content: `Answer 2`}, - {content: `Answer 3`}, - ], - }, - }, - }, - }; - - // Arrange - // We mock this one function on Passage as its where all the magic DOM - // measurement happens. This ensures our assertions in this test don't - // have to assert NaN and make sense. - jest.spyOn( - PassageWidget.widget.prototype, - "getReference", - ).mockReturnValue({content: "", startLine: 1, endLine: 2}); - - // Act - renderQuestion(question, apiOptions); - - // Assert - // By using a `passage-ref` in a choice, we should now have the - // reference on the radio we used it on. - // NOTE: We get by listitem role here because the 'radio' role - // element does not contain the HTML that provides the label - // for it. - await waitFor(() => { - const passageRefRadio = screen.getAllByRole("listitem")[0]; - expect(passageRefRadio).toHaveTextContent("lines 1–2"); + // Assert + expect(screen.getAllByRole("radio")[0]).toBeChecked(); }); - }); - - it("should render rationales for selected choices using method", async () => { - // Arrange - const {renderer} = renderQuestion(question, apiOptions); - // Act - await selectOption(userEvent, incorrect[0]); - act(() => renderer.showRationalesForCurrentlySelectedChoices()); + it("should be able to navigate to 'None of the above' choice by keyboard", async () => { + // Arrange + const q = clone(question); + q.widgets["radio 1"].options.choices[3].isNoneOfTheAbove = true; + renderQuestion(q, apiOptions); - // Assert - expect( - screen.queryAllByTestId(/perseus-radio-rationale-content/), - ).toHaveLength(1); - }); + // Act + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); - it("should render rationales for selected choices using prop", async () => { - // Arrange - const {rerender} = renderQuestion(question, apiOptions); + // Assert + // Note(TB): The visual buttons are hidden from screen readers + // so they need to be identified as hidden + expect( + screen.getAllByRole("button", {hidden: true})[2], + ).toHaveFocus(); + }); - // Act - await selectOption(userEvent, incorrect[0]); + it.each([ + ["No", "Yes"], + ["False", "True"], + ])( + "should enforce ordering for common answers: %j", + async (...answers) => { + // Arrange + const q = clone(question); + q.widgets["radio 1"].options.choices = answers.map( + (answer, idx) => ({ + content: answer, + correct: idx === 1, // Correct answer is the "truthy" item + }), + ); - rerender(question, {showSolutions: "selected"}); + // Act + const {renderer} = renderQuestion(q, apiOptions); + // We click on the first item, which was the second (index == 1) + // item in the original choices. But because of enforced ordering, + // it is now at the top of the list (and thus our correct answer). + await userEvent.click(screen.getAllByRole("radio")[0]); + const score = scorePerseusItemTesting( + q, + renderer.getUserInputMap(), + ); - // Assert - expect( - screen.queryAllByTestId(/perseus-radio-rationale-content/), - ).toHaveLength(1); - }); + // Assert + const items = screen.getAllByRole("listitem"); + expect(items[0]).toHaveTextContent(answers[1]); + expect(items[1]).toHaveTextContent(answers[0]); + expect(score).toHaveBeenAnsweredCorrectly(); + }, + ); - it("should render all rationales when showSolutions is 'all'", async () => { - // Arrange - renderQuestion(question, apiOptions, { - showSolutions: "all", - }); + it("should not change ordering of non-common answers", async () => { + // Arrange + const answers = ["Last", "First"]; + const q = clone(question); + q.widgets["radio 1"].options.choices = answers.map( + (answer, idx) => ({ + content: answer, + correct: idx === 1, + }), + ); - // Assert - expect( - screen.queryAllByTestId(/perseus-radio-rationale-content/), - ).toHaveLength(4); - }); + // Act + renderQuestion(q, apiOptions); - it("should render no rationales when showSolutions is 'none'", async () => { - // Arrange - renderQuestion(question, apiOptions, { - showSolutions: "none", + // Assert + const items = screen.getAllByRole("listitem"); + expect(items[0]).toHaveTextContent(answers[0]); + expect(items[1]).toHaveTextContent(answers[1]); }); - // Assert - expect( - screen.queryAllByTestId(/perseus-radio-rationale-content/), - ).toHaveLength(0); - }); + it("should transform inline passage-refs to references to passage widgets", async () => { + const question: PerseusRenderer = { + content: "[[\u2603 passage 1]]\n\n[[☃ radio 1]]", + images: {}, + widgets: { + "passage 1": { + type: "passage", + options: { + footnotes: "", + passageText: "{{First ref}} and {{Second ref}}", + passageTitle: "", + showLineNumbers: true, + static: false, + }, + }, + "radio 1": { + type: "radio", + options: { + choices: [ + { + correct: true, + // Passage refs reference a passage widget in + // the main content. The first value is the + // passage widget (eg. "passage 1", "passage + // 2") and the second value is the ref within + // that passage widget. Note that both are + // 1-based! + content: `{{passage-ref 1 1 "the 1st ref in the 1st passage"}}`, + }, + {content: `Answer 2`}, + {content: `Answer 3`}, + ], + }, + }, + }, + }; - describe("cross-out is enabled", () => { - const crossOutApiOptions = { - ...apiOptions, - crossOutEnabled: true, - } as const; + // Arrange + // We mock this one function on Passage as its where all the magic DOM + // measurement happens. This ensures our assertions in this test don't + // have to assert NaN and make sense. + jest.spyOn( + PassageWidget.widget.prototype, + "getReference", + ).mockReturnValue({content: "", startLine: 1, endLine: 2}); - it("should render cross-out menu button", async () => { - // Arrange & Act - renderQuestion(question, crossOutApiOptions); + // Act + renderQuestion(question, apiOptions); // Assert - expect( - screen.getByRole("button", { - name: /Open menu for Choice B/, - }), - ).toBeVisible(); + // By using a `passage-ref` in a choice, we should now have the + // reference on the radio we used it on. + // NOTE: We get by listitem role here because the 'radio' role + // element does not contain the HTML that provides the label + // for it. + await waitFor(() => { + const passageRefRadio = screen.getAllByRole("listitem")[0]; + expect(passageRefRadio).toHaveTextContent("lines 1–2"); + }); }); - it("should open the cross-out menu when button clicked", async () => { + it("should render rationales for selected choices using method", async () => { // Arrange - renderQuestion(question, crossOutApiOptions); + const {renderer} = renderQuestion(question, apiOptions); // Act - await userEvent.click( - screen.getByRole("button", { - name: /Open menu for Choice B/, - }), - ); + await selectOption(userEvent, incorrect[0]); + act(() => renderer.showRationalesForCurrentlySelectedChoices()); // Assert expect( - screen.getByRole("button", { - name: /Cross out Choice B/, - }), - ).toBeVisible(); + screen.queryAllByTestId(/perseus-radio-rationale-content/), + ).toHaveLength(1); }); - it("should open the cross-out menu when focused and spacebar pressed", async () => { + it("should render rationales for selected choices using prop", async () => { // Arrange - renderQuestion(question, crossOutApiOptions); - await userEvent.tab(); // Choice icon - await userEvent.tab(); // Cross-out menu ellipsis + const {rerender} = renderQuestion(question, apiOptions); // Act - await userEvent.keyboard(" "); + await selectOption(userEvent, incorrect[0]); + + rerender(question, {showSolutions: "selected"}); // Assert expect( - screen.getByRole("button", { - name: /Cross out Choice A/, - }), - ).toBeVisible(); + screen.queryAllByTestId(/perseus-radio-rationale-content/), + ).toHaveLength(1); }); - it("should cross-out selection and dismiss button when clicked", async () => { + it("should render all rationales when showSolutions is 'all'", async () => { // Arrange - renderQuestion(question, crossOutApiOptions); - await userEvent.click( - screen.getByRole("button", { - name: /Open menu for Choice B/, - }), - ); - - // Act - await userEvent.click( - screen.getByRole("button", { - name: /Cross out Choice B/, - }), - ); + renderQuestion(question, apiOptions, { + showSolutions: "all", + }); // Assert expect( - screen.queryAllByRole("button", { - name: /Cross out Choice B/, - }), - ).toHaveLength(0); - - expect( - screen.getByTestId("choice-icon__cross-out-line"), - ).toBeVisible(); + screen.queryAllByTestId(/perseus-radio-rationale-content/), + ).toHaveLength(4); }); - it("should remove cross-out line on selection", async () => { + it("should render no rationales when showSolutions is 'none'", async () => { // Arrange - renderQuestion(question, crossOutApiOptions); - await userEvent.click( - screen.getByRole("button", { - name: /Open menu for Choice B/, - }), - ); - - // Act - await userEvent.click( - screen.getByRole("button", { - name: /Cross out Choice B/, - }), - ); - jest.runAllTimers(); - - await userEvent.click( - screen.getByRole("radio", { - name: "(Choice B, Crossed out) -8", - }), - ); + renderQuestion(question, apiOptions, { + showSolutions: "none", + }); // Assert expect( - screen.queryByTestId("choice-icon__cross-out-line"), - ).not.toBeInTheDocument(); + screen.queryAllByTestId(/perseus-radio-rationale-content/), + ).toHaveLength(0); }); - it("should dismiss cross-out button with {tab} key", async () => { - // Arrange - renderQuestion(question, crossOutApiOptions); - await userEvent.tab(); // Choice icon - await userEvent.tab(); // Cross-out menu ellipsis + describe("cross-out is enabled", () => { + const crossOutApiOptions = { + ...apiOptions, + crossOutEnabled: true, + } as const; - // Act - await userEvent.keyboard(" "); - jest.runAllTimers(); + it("should render cross-out menu button", async () => { + // Arrange & Act + renderQuestion(question, crossOutApiOptions); - expect( - screen.getByRole("button", { - name: /Cross out Choice A/, - }), - ).toBeVisible(); + // Assert + expect( + screen.getByRole("button", { + name: /Open menu for Choice B/, + }), + ).toBeVisible(); + }); - await userEvent.keyboard(" "); - jest.runAllTimers(); + it("should open the cross-out menu when button clicked", async () => { + // Arrange + renderQuestion(question, crossOutApiOptions); - // Assert - expect( - screen.queryAllByRole("button", { - name: /Cross out Choice A/, - }), - ).toHaveLength(0); - }); - }); + // Act + await userEvent.click( + screen.getByRole("button", { + name: /Open menu for Choice B/, + }), + ); - it("should be invalid when first rendered", async () => { - // Arrange - const apiOptions: APIOptions = { - crossOutEnabled: false, - }; - - // Act - const {renderer} = renderQuestion(question, apiOptions); - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); + // Assert + expect( + screen.getByRole("button", { + name: /Cross out Choice B/, + }), + ).toBeVisible(); + }); - // Assert - expect(score).toHaveInvalidInput(); - }); + it("should open the cross-out menu when focused and spacebar pressed", async () => { + // Arrange + renderQuestion(question, crossOutApiOptions); + await userEvent.tab(); // Choice icon + await userEvent.tab(); // Cross-out menu ellipsis - it("Should render correct option select statuses (rationales) when review mode enabled", async () => { - // Arrange - const apiOptions: APIOptions = { - crossOutEnabled: false, - }; - renderQuestion(question, apiOptions, {reviewMode: true}); + // Act + await userEvent.keyboard(" "); - // Act - await selectOption(userEvent, correct); + // Assert + expect( + screen.getByRole("button", { + name: /Cross out Choice A/, + }), + ).toBeVisible(); + }); - // Assert - expect(screen.getAllByText("Correct (selected)")).toHaveLength(1); - }); + it("should cross-out selection and dismiss button when clicked", async () => { + // Arrange + renderQuestion(question, crossOutApiOptions); + await userEvent.click( + screen.getByRole("button", { + name: /Open menu for Choice B/, + }), + ); - it("Should render incorrect option select statuses (rationales) when review mode enabled", async () => { - // Arrange - const apiOptions: APIOptions = { - crossOutEnabled: false, - }; - renderQuestion(question, apiOptions, {reviewMode: true}); + // Act + await userEvent.click( + screen.getByRole("button", { + name: /Cross out Choice B/, + }), + ); - // Act - await selectOption(userEvent, incorrect[0]); + // Assert + expect( + screen.queryAllByRole("button", { + name: /Cross out Choice B/, + }), + ).toHaveLength(0); + + expect( + screen.getByTestId("choice-icon__cross-out-line"), + ).toBeVisible(); + }); - // Assert - expect(screen.getAllByText("Incorrect (selected)")).toHaveLength(1); - }); + it("should remove cross-out line on selection", async () => { + // Arrange + renderQuestion(question, crossOutApiOptions); + await userEvent.click( + screen.getByRole("button", { + name: /Open menu for Choice B/, + }), + ); - it("should display all rationales when static is true", async () => { - // Arrange - const staticQuestion = { - ...question, - widgets: { - ...question.widgets, - "radio 1": { - ...question.widgets["radio 1"], - static: true, - }, - }, - } as const; - - // Act - renderQuestion(staticQuestion); - - // Assert - // Part of Choice A rationale - expect( - screen.getByText( - /the positive square root when performed on a number, so/, - ), - ).toBeVisible(); - // Part of Choice B rationale - expect(screen.getByText(/, the square root operation/)).toBeVisible(); - // Part of Choice C rationale - expect( - screen.getByText(/is the positive square root of/), - ).toBeVisible(); - // Part of Choice D rationale - expect(screen.getAllByText(/satisfies the equation./)[3]).toBeVisible(); - }); + // Act + await userEvent.click( + screen.getByRole("button", { + name: /Cross out Choice B/, + }), + ); + jest.runAllTimers(); + + await userEvent.click( + screen.getByRole("radio", { + name: "(Choice B, Crossed out) -8", + }), + ); + + // Assert + expect( + screen.queryByTestId("choice-icon__cross-out-line"), + ).not.toBeInTheDocument(); + }); - it("should register as correct when none of the above option selected", async () => { - // Arrange - const q = clone(question); - const choices = q.widgets["radio 1"].options.choices; - choices[2].correct = false; - choices[3].isNoneOfTheAbove = true; - choices[3].correct = true; + it("should dismiss cross-out button with {tab} key", async () => { + // Arrange + renderQuestion(question, crossOutApiOptions); + await userEvent.tab(); // Choice icon + await userEvent.tab(); // Cross-out menu ellipsis - const {renderer} = renderQuestion(q); + // Act + await userEvent.keyboard(" "); + jest.runAllTimers(); - // Act - const noneOption = screen.getByRole("radio", { - name: "(Choice D) None of the above", + expect( + screen.getByRole("button", { + name: /Cross out Choice A/, + }), + ).toBeVisible(); + + await userEvent.keyboard(" "); + jest.runAllTimers(); + + // Assert + expect( + screen.queryAllByRole("button", { + name: /Cross out Choice A/, + }), + ).toHaveLength(0); + }); }); - await userEvent.click(noneOption); - const score = scorePerseusItemTesting(q, renderer.getUserInputMap()); - // Assert - expect(score).toHaveBeenAnsweredCorrectly(); - }); -}); + it("should be invalid when first rendered", async () => { + // Arrange + const apiOptions: APIOptions = { + crossOutEnabled: false, + }; + + // Act + const {renderer} = renderQuestion(question, apiOptions); + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); + + // Assert + expect(score).toHaveInvalidInput(); + }); -describe("multi-choice question", () => { - const [question, correct, incorrect, invalid] = - multiChoiceQuestionAndAnswer; + it("Should render correct option select statuses (rationales) when review mode enabled", async () => { + // Arrange + const apiOptions: APIOptions = { + crossOutEnabled: false, + }; + renderQuestion(question, apiOptions, {reviewMode: true}); - const apiOptions: APIOptions = Object.freeze({}); + // Act + await selectOption(userEvent, correct); - let userEvent: UserEvent; - beforeEach(() => { - userEvent = userEventLib.setup({ - advanceTimers: jest.advanceTimersByTime, + // Assert + expect(screen.getAllByText("Correct (selected)")).toHaveLength(1); }); - jest.spyOn(Dependencies, "getDependencies").mockReturnValue( - testDependencies, - ); - }); + it("Should render incorrect option select statuses (rationales) when review mode enabled", async () => { + // Arrange + const apiOptions: APIOptions = { + crossOutEnabled: false, + }; + renderQuestion(question, apiOptions, {reviewMode: true}); - it("should accept the right answer", async () => { - // Arrange - const {renderer} = renderQuestion(question, apiOptions); - - // Act - const options = screen.getAllByRole("checkbox"); - for (const i of correct) { - await userEvent.click(options[i]); - } - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); + // Act + await selectOption(userEvent, incorrect[0]); - // Assert - expect(score).toHaveBeenAnsweredCorrectly(); - }); + // Assert + expect(screen.getAllByText("Incorrect (selected)")).toHaveLength(1); + }); - it("should select multiple options when clicked", async () => { - // Arrange - const apiOptions: APIOptions = { - crossOutEnabled: false, - }; - const radio1Widget = question.widgets["radio 1"]; - const radioOptions = radio1Widget.options; - - const multipleCorrectChoicesQuestion: PerseusRenderer = { - ...question, - widgets: { - ...question.widgets, - "radio 1": { - ...radio1Widget, - options: { - ...radioOptions, - choices: [ - {content: "$x=-6$", correct: true}, - {content: "$x=4$", correct: true}, - {content: "$x=7$", correct: false}, - { - content: "There is no such input value.", - isNoneOfTheAbove: true, - correct: false, - }, - ], + it("should display all rationales when static is true", async () => { + // Arrange + const staticQuestion = { + ...question, + widgets: { + ...question.widgets, + "radio 1": { + ...question.widgets["radio 1"], + static: true, }, }, - }, - }; + } as const; - renderQuestion(multipleCorrectChoicesQuestion, apiOptions); + // Act + renderQuestion(staticQuestion); + + // Assert + // Part of Choice A rationale + expect( + screen.getByText( + /the positive square root when performed on a number, so/, + ), + ).toBeVisible(); + // Part of Choice B rationale + expect( + screen.getByText(/, the square root operation/), + ).toBeVisible(); + // Part of Choice C rationale + expect( + screen.getByText(/is the positive square root of/), + ).toBeVisible(); + // Part of Choice D rationale + expect( + screen.getAllByText(/satisfies the equation./)[3], + ).toBeVisible(); + }); + + it("should register as correct when none of the above option selected", async () => { + // Arrange + const q = clone(question); + const choices = q.widgets["radio 1"].options.choices; + choices[2].correct = false; + choices[3].isNoneOfTheAbove = true; + choices[3].correct = true; - // Act - const options = screen.getAllByRole("checkbox"); - await userEvent.click(options[2]); - await userEvent.click(options[3]); + const {renderer} = renderQuestion(q); - // Assert - expect(options[2]).toBeChecked(); - expect(options[3]).toBeChecked(); + // Act + const noneOption = screen.getByRole("radio", { + name: "(Choice D) None of the above", + }); + await userEvent.click(noneOption); + const score = scorePerseusItemTesting( + q, + renderer.getUserInputMap(), + ); + + // Assert + expect(score).toHaveBeenAnsweredCorrectly(); + }); }); - it("should deselect selected options when clicked", async () => { - // Arrange - const apiOptions: APIOptions = { - crossOutEnabled: false, - }; - const radio1Widget = question.widgets["radio 1"]; - const radioOptions = radio1Widget.options; - - const multipleCorrectChoicesQuestion: PerseusRenderer = { - ...question, - widgets: { - ...question.widgets, - "radio 1": { - ...radio1Widget, - options: { - ...radioOptions, - choices: [ - {content: "$x=-6$", correct: true}, - {content: "$x=4$", correct: true}, - {content: "$x=7$", correct: false}, - { - content: "There is no such input value.", - isNoneOfTheAbove: true, - correct: false, - }, - ], - }, - }, - }, - }; + describe("multi-choice question", () => { + const [question, correct, incorrect, invalid] = + multiChoiceQuestionAndAnswer; - renderQuestion(multipleCorrectChoicesQuestion, apiOptions); + const apiOptions: APIOptions = Object.freeze({}); - // Act - const options = screen.getAllByRole("checkbox"); - await userEvent.click(options[2]); - await userEvent.click(options[3]); - await userEvent.click(options[2]); - await userEvent.click(options[3]); + it("should accept the right answer", async () => { + // Arrange + const {renderer} = renderQuestion(question, apiOptions); - // Assert - expect(options[2]).not.toBeChecked(); - expect(options[3]).not.toBeChecked(); - }); + // Act + const options = screen.getAllByRole("checkbox"); + for (const i of correct) { + await userEvent.click(options[i]); + } + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); - it("should snapshot the same when invalid", async () => { - // Arrange - const {container} = renderQuestion(question, apiOptions); + // Assert + expect(score).toHaveBeenAnsweredCorrectly(); + }); - // Act - const options = screen.getAllByRole("checkbox"); - for (const i of incorrect[0]) { - await userEvent.click(options[i]); - } + it("should select multiple options when clicked", async () => { + // Arrange + const apiOptions: APIOptions = { + crossOutEnabled: false, + }; + const radio1Widget = question.widgets["radio 1"]; + const radioOptions = radio1Widget.options; + + const multipleCorrectChoicesQuestion: PerseusRenderer = { + ...question, + widgets: { + ...question.widgets, + "radio 1": { + ...radio1Widget, + options: { + ...radioOptions, + choices: [ + {content: "$x=-6$", correct: true}, + {content: "$x=4$", correct: true}, + {content: "$x=7$", correct: false}, + { + content: "There is no such input value.", + isNoneOfTheAbove: true, + correct: false, + }, + ], + }, + }, + }, + }; - // Assert - expect(container).toMatchSnapshot("invalid state"); - }); + renderQuestion(multipleCorrectChoicesQuestion, apiOptions); - it("should be invalid when first rendered", async () => { - // Arrange - const apiOptions: APIOptions = { - crossOutEnabled: false, - }; - - // Act - const {renderer} = renderQuestion(question, apiOptions); - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); + // Act + const options = screen.getAllByRole("checkbox"); + await userEvent.click(options[2]); + await userEvent.click(options[3]); - // Assert - expect(score).toHaveInvalidInput(); - }); + // Assert + expect(options[2]).toBeChecked(); + expect(options[3]).toBeChecked(); + }); - it("should be invalid when incorrect number of choices selected", async () => { - // Arrange - const apiOptions: APIOptions = { - crossOutEnabled: false, - }; - const radio1Widget = question.widgets["radio 1"]; - const radioOptions = radio1Widget.options; - - const multipleCorrectChoicesQuestion: PerseusRenderer = { - ...question, - widgets: { - ...question.widgets, - "radio 1": { - ...radio1Widget, - options: { - ...radioOptions, - choices: [ - {content: "$x=-6$", correct: true}, - {content: "$x=4$", correct: true}, - {content: "$x=7$", correct: false}, - { - content: "There is no such input value.", - isNoneOfTheAbove: true, - correct: false, - }, - ], + it("should deselect selected options when clicked", async () => { + // Arrange + const apiOptions: APIOptions = { + crossOutEnabled: false, + }; + const radio1Widget = question.widgets["radio 1"]; + const radioOptions = radio1Widget.options; + + const multipleCorrectChoicesQuestion: PerseusRenderer = { + ...question, + widgets: { + ...question.widgets, + "radio 1": { + ...radio1Widget, + options: { + ...radioOptions, + choices: [ + {content: "$x=-6$", correct: true}, + {content: "$x=4$", correct: true}, + {content: "$x=7$", correct: false}, + { + content: "There is no such input value.", + isNoneOfTheAbove: true, + correct: false, + }, + ], + }, }, }, - }, - }; + }; - const {renderer} = renderQuestion( - multipleCorrectChoicesQuestion, - apiOptions, - ); + renderQuestion(multipleCorrectChoicesQuestion, apiOptions); - // Act - const option = screen.getAllByRole("checkbox"); - await userEvent.click(option[0]); // correct - await userEvent.click(option[1]); // correct - await userEvent.click(option[2]); // incorrect - const score = scorePerseusItemTesting( - multipleCorrectChoicesQuestion, - renderer.getUserInputMap(), - ); + // Act + const options = screen.getAllByRole("checkbox"); + await userEvent.click(options[2]); + await userEvent.click(options[3]); + await userEvent.click(options[2]); + await userEvent.click(options[3]); - // Assert - expect(score).toHaveInvalidInput( - "Please choose the correct number of answers", - ); - }); + // Assert + expect(options[2]).not.toBeChecked(); + expect(options[3]).not.toBeChecked(); + }); - it.each(incorrect)( - "should reject an incorrect answer - test #%#", - async (...choices) => { + it("should snapshot the same when invalid", async () => { // Arrange - const {renderer} = renderQuestion(question, apiOptions); + const {container} = renderQuestion(question, apiOptions); // Act - const option = screen.getAllByRole("checkbox"); - for (let i = 0; i < choices.length; i++) { - await userEvent.click(option[i]); + const options = screen.getAllByRole("checkbox"); + for (const i of incorrect[0]) { + await userEvent.click(options[i]); } + + // Assert + expect(container).toMatchSnapshot("invalid state"); + }); + + it("should be invalid when first rendered", async () => { + // Arrange + const apiOptions: APIOptions = { + crossOutEnabled: false, + }; + + // Act + const {renderer} = renderQuestion(question, apiOptions); const score = scorePerseusItemTesting( question, renderer.getUserInputMap(), ); // Assert - expect(score).toHaveBeenAnsweredIncorrectly(); - }, - ); + expect(score).toHaveInvalidInput(); + }); - it.each(invalid)( - "should reject an invalid answer - test #%#", - async (...choices) => { + it("should be invalid when incorrect number of choices selected", async () => { // Arrange - const {renderer} = renderQuestion(question, apiOptions); + const apiOptions: APIOptions = { + crossOutEnabled: false, + }; + const radio1Widget = question.widgets["radio 1"]; + const radioOptions = radio1Widget.options; + + const multipleCorrectChoicesQuestion: PerseusRenderer = { + ...question, + widgets: { + ...question.widgets, + "radio 1": { + ...radio1Widget, + options: { + ...radioOptions, + choices: [ + {content: "$x=-6$", correct: true}, + {content: "$x=4$", correct: true}, + {content: "$x=7$", correct: false}, + { + content: "There is no such input value.", + isNoneOfTheAbove: true, + correct: false, + }, + ], + }, + }, + }, + }; + + const {renderer} = renderQuestion( + multipleCorrectChoicesQuestion, + apiOptions, + ); // Act const option = screen.getAllByRole("checkbox"); - for (const i of choices) { - await userEvent.click(option[i]); - } + await userEvent.click(option[0]); // correct + await userEvent.click(option[1]); // correct + await userEvent.click(option[2]); // incorrect const score = scorePerseusItemTesting( - question, + multipleCorrectChoicesQuestion, renderer.getUserInputMap(), ); // Assert - expect(score).toHaveInvalidInput(); - }, - ); -}); - -describe("scoring", () => { - let userEvent: UserEvent; - beforeEach(() => { - userEvent = userEventLib.setup({ - advanceTimers: jest.advanceTimersByTime, + expect(score).toHaveInvalidInput( + "Please choose the correct number of answers", + ); }); - jest.spyOn(Dependencies, "getDependencies").mockReturnValue( - testDependencies, - ); - }); + it.each(incorrect)( + "should reject an incorrect answer - test #%#", + async (...choices) => { + // Arrange + const {renderer} = renderQuestion(question, apiOptions); - /** - * (LEMS-2435) We want to be sure that we're able to score shuffled - * Radio widgets outside of the component which means `getUserInput` - * should return the same order that the rubric provides - */ - it("can be scored correctly when shuffled", async () => { - // Arrange - const {renderer} = renderQuestion(shuffledQuestion); - - // Act - await userEvent.click( - screen.getByRole("radio", {name: /Correct Choice$/}), - ); + // Act + const option = screen.getAllByRole("checkbox"); + for (let i = 0; i < choices.length; i++) { + await userEvent.click(option[i]); + } + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); - const userInput = renderer.getUserInput()[0] as PerseusRadioUserInput; - const rubric = shuffledQuestion.widgets["radio 1"].options; - const widgetScore = scoreRadio(userInput, rubric, mockStrings); - const rendererScore = scorePerseusItemTesting( - shuffledQuestion, - renderer.getUserInputMap(), + // Assert + expect(score).toHaveBeenAnsweredIncorrectly(); + }, ); - // Assert - expect(widgetScore).toHaveBeenAnsweredCorrectly(); - expect(rendererScore).toHaveBeenAnsweredCorrectly(); - }); + it.each(invalid)( + "should reject an invalid answer - test #%#", + async (...choices) => { + // Arrange + const {renderer} = renderQuestion(question, apiOptions); - /** - * (LEMS-2435) We want to be sure that we're able to score shuffled - * Radio widgets outside of the component which means `getUserInput` - * should return the same order that the rubric provides - */ - it("can be scored incorrectly when shuffled", async () => { - // Arrange - const {renderer} = renderQuestion(shuffledQuestion); - - // Act - await userEvent.click( - screen.getByRole("radio", {name: /Incorrect Choice 1$/}), - ); + // Act + const option = screen.getAllByRole("checkbox"); + for (const i of choices) { + await userEvent.click(option[i]); + } + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); - const userInput = renderer.getUserInput()[0] as PerseusRadioUserInput; - const rubric = shuffledQuestion.widgets["radio 1"].options; - const widgetScore = scoreRadio(userInput, rubric, mockStrings); - const rendererScore = scorePerseusItemTesting( - shuffledQuestion, - renderer.getUserInputMap(), + // Assert + expect(score).toHaveInvalidInput(); + }, ); - - // Assert - expect(widgetScore).toHaveBeenAnsweredIncorrectly(); - expect(rendererScore).toHaveBeenAnsweredIncorrectly(); }); - /** - * (LEMS-2435) We want to be sure that we're able to score shuffled - * Radio widgets outside of the component which means `getUserInput` - * should return the same order that the rubric provides - */ - it("can be scored correctly when shuffled with none of the above", async () => { - // Arrange - const {renderer} = renderQuestion(shuffledNoneQuestion); - - // Act - await userEvent.click( - screen.getByRole("radio", {name: /None of the above$/}), - ); + describe("scoring", () => { + /** + * (LEMS-2435) We want to be sure that we're able to score shuffled + * Radio widgets outside of the component which means `getUserInput` + * should return the same order that the rubric provides + */ + it("can be scored correctly when shuffled", async () => { + // Arrange + const {renderer} = renderQuestion(shuffledQuestion); - const userInput = renderer.getUserInput()[0] as PerseusRadioUserInput; - const rubric = shuffledNoneQuestion.widgets["radio 1"].options; - const widgetScore = scoreRadio(userInput, rubric, mockStrings); - const rendererScore = scorePerseusItemTesting( - shuffledNoneQuestion, - renderer.getUserInputMap(), - ); + // Act + await userEvent.click( + screen.getByRole("radio", {name: /Correct Choice$/}), + ); - // Assert - expect(widgetScore).toHaveBeenAnsweredCorrectly(); - expect(rendererScore).toHaveBeenAnsweredCorrectly(); - }); + const userInput = + renderer.getUserInput()[0] as PerseusRadioUserInput; + const rubric = shuffledQuestion.widgets["radio 1"].options; + const widgetScore = scoreRadio(userInput, rubric, mockStrings); + const rendererScore = scorePerseusItemTesting( + shuffledQuestion, + renderer.getUserInputMap(), + ); - /** - * (LEMS-2435) We want to be sure that we're able to score shuffled - * Radio widgets outside of the component which means `getUserInput` - * should return the same order that the rubric provides - */ - it("can be scored incorrectly when shuffled with none of the above", async () => { - // Arrange - const {renderer} = renderQuestion(shuffledNoneQuestion); - - // Act - await userEvent.click( - screen.getByRole("radio", {name: /Incorrect Choice 1$/}), - ); + // Assert + expect(widgetScore).toHaveBeenAnsweredCorrectly(); + expect(rendererScore).toHaveBeenAnsweredCorrectly(); + }); - const userInput = renderer.getUserInput()[0] as PerseusRadioUserInput; - const rubric = shuffledNoneQuestion.widgets["radio 1"].options; - const widgetScore = scoreRadio(userInput, rubric, mockStrings); - const rendererScore = scorePerseusItemTesting( - shuffledQuestion, - renderer.getUserInputMap(), - ); + /** + * (LEMS-2435) We want to be sure that we're able to score shuffled + * Radio widgets outside of the component which means `getUserInput` + * should return the same order that the rubric provides + */ + it("can be scored incorrectly when shuffled", async () => { + // Arrange + const {renderer} = renderQuestion(shuffledQuestion); - // Assert - expect(widgetScore).toHaveBeenAnsweredIncorrectly(); - expect(rendererScore).toHaveBeenAnsweredIncorrectly(); - }); -}); + // Act + await userEvent.click( + screen.getByRole("radio", {name: /Incorrect Choice 1$/}), + ); -describe("propsUpgrade", () => { - it("can upgrade from v0 to v1", () => { - const v0props = { - choices: [{content: "Choice 1"}, {content: "Choice 2"}], - }; + const userInput = + renderer.getUserInput()[0] as PerseusRadioUserInput; + const rubric = shuffledQuestion.widgets["radio 1"].options; + const widgetScore = scoreRadio(userInput, rubric, mockStrings); + const rendererScore = scorePerseusItemTesting( + shuffledQuestion, + renderer.getUserInputMap(), + ); - const expected: PerseusRadioWidgetOptions = { - choices: [{content: "Choice 1"}, {content: "Choice 2"}], - hasNoneOfTheAbove: false, - }; + // Assert + expect(widgetScore).toHaveBeenAnsweredIncorrectly(); + expect(rendererScore).toHaveBeenAnsweredIncorrectly(); + }); - const result: PerseusRadioWidgetOptions = - RadioWidgetExport.propUpgrades["1"](v0props); + /** + * (LEMS-2435) We want to be sure that we're able to score shuffled + * Radio widgets outside of the component which means `getUserInput` + * should return the same order that the rubric provides + */ + it("can be scored correctly when shuffled with none of the above", async () => { + // Arrange + const {renderer} = renderQuestion(shuffledNoneQuestion); - expect(result).toEqual(expected); + // Act + await userEvent.click( + screen.getByRole("radio", {name: /None of the above$/}), + ); + + const userInput = + renderer.getUserInput()[0] as PerseusRadioUserInput; + const rubric = shuffledNoneQuestion.widgets["radio 1"].options; + const widgetScore = scoreRadio(userInput, rubric, mockStrings); + const rendererScore = scorePerseusItemTesting( + shuffledNoneQuestion, + renderer.getUserInputMap(), + ); + + // Assert + expect(widgetScore).toHaveBeenAnsweredCorrectly(); + expect(rendererScore).toHaveBeenAnsweredCorrectly(); + }); + + /** + * (LEMS-2435) We want to be sure that we're able to score shuffled + * Radio widgets outside of the component which means `getUserInput` + * should return the same order that the rubric provides + */ + it("can be scored incorrectly when shuffled with none of the above", async () => { + // Arrange + const {renderer} = renderQuestion(shuffledNoneQuestion); + + // Act + await userEvent.click( + screen.getByRole("radio", {name: /Incorrect Choice 1$/}), + ); + + const userInput = + renderer.getUserInput()[0] as PerseusRadioUserInput; + const rubric = shuffledNoneQuestion.widgets["radio 1"].options; + const widgetScore = scoreRadio(userInput, rubric, mockStrings); + const rendererScore = scorePerseusItemTesting( + shuffledQuestion, + renderer.getUserInputMap(), + ); + + // Assert + expect(widgetScore).toHaveBeenAnsweredIncorrectly(); + expect(rendererScore).toHaveBeenAnsweredIncorrectly(); + }); }); - it("throws from noneOfTheAbove", () => { - const v0props = { - choices: [{content: "Choice 1"}, {content: "Choice 2"}], - noneOfTheAbove: true, - }; + describe("propsUpgrade", () => { + it("can upgrade from v0 to v1", () => { + const v0props = { + choices: [{content: "Choice 1"}, {content: "Choice 2"}], + }; - expect(() => RadioWidgetExport.propUpgrades["1"](v0props)).toThrow( - "radio widget v0 no longer supports auto noneOfTheAbove", - ); + const expected: PerseusRadioWidgetOptions = { + choices: [{content: "Choice 1"}, {content: "Choice 2"}], + hasNoneOfTheAbove: false, + }; + + const result: PerseusRadioWidgetOptions = + RadioWidgetExport.propUpgrades["1"](v0props); + + expect(result).toEqual(expected); + }); + + it("throws from noneOfTheAbove", () => { + const v0props = { + choices: [{content: "Choice 1"}, {content: "Choice 2"}], + noneOfTheAbove: true, + }; + + expect(() => RadioWidgetExport.propUpgrades["1"](v0props)).toThrow( + "radio widget v0 no longer supports auto noneOfTheAbove", + ); + }); }); });