diff --git a/.changeset/gentle-ties-drum.md b/.changeset/smooth-hairs-jam.md similarity index 53% rename from .changeset/gentle-ties-drum.md rename to .changeset/smooth-hairs-jam.md index 4aa405345e..5c52279fa1 100644 --- a/.changeset/gentle-ties-drum.md +++ b/.changeset/smooth-hairs-jam.md @@ -2,4 +2,4 @@ "@khanacademy/perseus": minor --- -Updating how svg-image loads data +Undoing changes to svg-image diff --git a/packages/perseus/src/__tests__/renderer.test.tsx b/packages/perseus/src/__tests__/renderer.test.tsx index 8db770a186..fccdbddebb 100644 --- a/packages/perseus/src/__tests__/renderer.test.tsx +++ b/packages/perseus/src/__tests__/renderer.test.tsx @@ -63,14 +63,6 @@ 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 deleted file mode 100644 index 448e7ebb6e..0000000000 --- a/packages/perseus/src/components/__tests__/__snapshots__/svg-image.test.tsx.snap +++ /dev/null @@ -1,61 +0,0 @@ -// 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 deleted file mode 100644 index e3ee0c66ef..0000000000 --- a/packages/perseus/src/components/__tests__/svg-image.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -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 3a63f7ad2a..fc62b434ce 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,12 +24,101 @@ 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() { - 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, + 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, }); + }; + + 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 bbcf1a6059..9c071022e0 100644 --- a/packages/perseus/src/index.ts +++ b/packages/perseus/src/index.ts @@ -156,14 +156,6 @@ 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 18dd808d92..4b32b8471e 100644 --- a/packages/perseus/src/renderer-util.test.ts +++ b/packages/perseus/src/renderer-util.test.ts @@ -78,7 +78,8 @@ function getLegacyExpressionWidget() { }, }; } -describe("renderer utils", () => { + +describe("emptyWidgetsFunctional", () => { beforeAll(() => { registerAllWidgetsForTesting(); }); @@ -91,691 +92,679 @@ describe("renderer utils", () => { 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; }); - describe("emptyWidgetsFunctional", () => { - it("returns an empty array if there are no widgets", () => { - // Arrange / Act - const result = emptyWidgetsFunctional( - {}, - [], - {}, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual([]); - }); + it("returns an empty array if there are no widgets", () => { + // Arrange / Act + const result = emptyWidgetsFunctional({}, [], {}, mockStrings, "en"); - 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"]); - }); + // Assert + expect(result).toEqual([]); + }); - 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("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", + ); - 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).toEqual(["dropdown 1"]); + }); - 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("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"]); + }); - it("properly identifies groups with empty widgets", () => { - // Arrange - const widgets: PerseusWidgetsMap = { - "group 1": { - type: "group", - options: { - content: "[[☃ dropdown 1]]", - widgets: { - "dropdown 1": getTestDropdownWidget(), - }, - images: {}, + 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, - }, + }, + }; + 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"]); - }); + }, + }; + + // Act + const result = emptyWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result).toEqual(["group 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: {}, + 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, - }, + }, + }; + 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([]); - }); + }, + }; + + // Act + const result = emptyWidgetsFunctional( + 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).toEqual([]); + }); - 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 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", + ); - 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).toEqual(["expression 1"]); + }); - 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("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"]); }); - describe("scoreWidgetsFunctional", () => { - it("returns an empty object when there's no widgets", () => { - // Arrange / Act - const result = scoreWidgetsFunctional( - {}, - [], - {}, - mockStrings, - "en", - ); - - // Assert - expect(result).toEqual({}); - }); + 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", + ); - 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["dropdown 1"]).toHaveInvalidInput(); - }); + // Assert + expect(result).toEqual([]); + }); - 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["dropdown 1"]).toHaveBeenAnsweredCorrectly(); - }); + 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", + ); - 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", - ); - - // Assert - expect(result["dropdown 1"]).toHaveBeenAnsweredIncorrectly(); - }); + // Assert + expect(result).toEqual([]); + }); +}); - 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(); - }); +describe("scoreWidgetsFunctional", () => { + beforeAll(() => { + registerAllWidgetsForTesting(); + }); - 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); - }); + 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({}); + }); + + 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["dropdown 1"]).toHaveInvalidInput(); + }); + + 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["dropdown 1"]).toHaveBeenAnsweredCorrectly(); + }); + + 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", + ); + + // Assert + expect(result["dropdown 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("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: {}, + 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); + }); + + 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, - }, + }, + }; + 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(); - }); + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result["group 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: {}, + 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, - }, + }, + }; + 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(); - }); + }, + }; + + // Act + const result = scoreWidgetsFunctional( + widgets, + widgetIds, + userInputMap, + mockStrings, + "en", + ); + + // Assert + expect(result["group 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: {}, + 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, - }, + }, + }; + 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(); - }); + }, + }; + + // 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["group 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 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("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"]).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", - ); - - // 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 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(); + }); + + 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 deleted file mode 100644 index 36fdb41919..0000000000 --- a/packages/perseus/src/util/graphie-utils.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index a66bef3b37..0000000000 --- a/packages/perseus/src/util/graphie-utils.testdata.ts +++ /dev/null @@ -1,456 +0,0 @@ -// 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 deleted file mode 100644 index 055d2a22c2..0000000000 --- a/packages/perseus/src/util/graphie-utils.ts +++ /dev/null @@ -1,240 +0,0 @@ -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 4d589ada76..56bddb3c41 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,14 +21,6 @@ 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 63d4227a19..cccb08b76d 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,16 +6,6 @@ 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 2819f5a88c..15a2c9bbb8 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,16 +40,6 @@ 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 c81f0bdce7..b4632503a1 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,14 +29,6 @@ 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 05c8865078..df7ff58e19 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,14 +76,6 @@ 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 9d4b0c6ba0..65e429794b 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,14 +19,6 @@ 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 8aca5ed4c4..424ade0a82 100644 --- a/packages/perseus/src/widgets/grapher/grapher.test.ts +++ b/packages/perseus/src/widgets/grapher/grapher.test.ts @@ -13,14 +13,6 @@ 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 7d7a7c5b01..7f090d3e46 100644 --- a/packages/perseus/src/widgets/group/group.test.tsx +++ b/packages/perseus/src/widgets/group/group.test.tsx @@ -27,14 +27,6 @@ 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 11645a9de1..8f8b4234ea 100644 --- a/packages/perseus/src/widgets/image/image.test.ts +++ b/packages/perseus/src/widgets/image/image.test.ts @@ -17,14 +17,6 @@ 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 b32598067b..bdcdde6579 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 interactive-graph widget A none-type graph renders predictably: first render 1`] = ` +exports[`interactive-graph widget A none-type graph renders predictably: first render 1`] = `
`; -exports[`Interactive Graph interactive-graph widget question Should render predictably: after interaction 1`] = ` +exports[`interactive-graph widget question Should render predictably: after interaction 1`] = `
`; -exports[`Interactive Graph interactive-graph widget question Should render predictably: after interaction 2`] = ` +exports[`interactive-graph widget question Should render predictably: after interaction 2`] = `
`; -exports[`Interactive Graph interactive-graph widget question Should render predictably: after interaction 3`] = ` +exports[`interactive-graph widget question Should render predictably: after interaction 3`] = `
`; -exports[`Interactive Graph interactive-graph widget question Should render predictably: after interaction 4`] = ` +exports[`interactive-graph widget question Should render predictably: after interaction 4`] = `
`; -exports[`Interactive Graph interactive-graph widget question Should render predictably: first render 1`] = ` +exports[`interactive-graph widget question Should render predictably: first render 1`] = `
`; -exports[`Interactive Graph interactive-graph widget question Should render predictably: first render 2`] = ` +exports[`interactive-graph widget question Should render predictably: first render 2`] = `
`; -exports[`Interactive Graph interactive-graph widget question Should render predictably: first render 3`] = ` +exports[`interactive-graph widget question Should render predictably: first render 3`] = `
`; -exports[`Interactive Graph interactive-graph widget question Should render predictably: first render 4`] = ` +exports[`interactive-graph widget question Should render predictably: first render 4`] = `
{ const blankOptions: APIOptions = Object.freeze(ApiOptions.defaults); -describe("Interactive Graph", function () { - let userEvent: UserEvent; +describe("interactive-graph widget", function () { 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("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(); - }); - - 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"); - }); - - it("should reject no interaction", async () => { - // Arrange - const {renderer} = renderQuestion(question, blankOptions); + describe.each(questionsAndAnswers)( + "question", + ( + question: PerseusRenderer, + correct: ReadonlyArray, + incorrect: ReadonlyArray, + ) => { + it("Should accept the right answer", async () => { + // Arrange + const {renderer} = renderQuestion(question, blankOptions); - // Act - await waitForInitialGraphieRender(); - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); + // 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).toHaveInvalidInput(); - }); + // Assert + expect(score).toHaveBeenAnsweredCorrectly(); + }); - 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(); - }); - }, - ); + it("Should render predictably", async () => { + // Arrange + const {renderer, container} = renderQuestion( + question, + blankOptions, + ); + expect(container).toMatchSnapshot("first render"); - describe("A none-type graph", () => { - it("renders predictably", () => { - const question = interactiveGraphQuestionBuilder() - .withNoInteractiveFigure() - .build(); - const {container} = renderQuestion(question, blankOptions); + // Act + act(() => + updateWidgetState( + renderer, + "interactive-graph 1", + (state) => (state.graph.coords = correct), + ), + ); + await waitForInitialGraphieRender(); - expect(container).toMatchSnapshot("first render"); + // Assert + expect(container).toMatchSnapshot("after interaction"); }); - it("treats no interaction as a correct answer", async () => { - const question = interactiveGraphQuestionBuilder() - .withNoInteractiveFigure() - .build(); + it("should reject no interaction", async () => { + // Arrange const {renderer} = renderQuestion(question, blankOptions); + + // Act + await waitForInitialGraphieRender(); const score = scorePerseusItemTesting( question, renderer.getUserInputMap(), ); - expect(score).toHaveBeenAnsweredCorrectly({ - shouldHavePoints: false, - }); + // Assert + expect(score).toHaveInvalidInput(); }); - }); - }); - - describe("a mafs graph", () => { - // 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 an incorrect answer", async () => { + // Arrange + const {renderer} = renderQuestion(question, blankOptions); - it("should reject when has not been interacted with", () => { - // Arrange - const {renderer} = renderQuestion(question, apiOptions); + // Act + act(() => + updateWidgetState( + renderer, + "interactive-graph 1", + (state) => (state.graph.coords = incorrect), + ), + ); + await waitForInitialGraphieRender(); + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); - // Act - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); + // Assert + expect(score).toHaveBeenAnsweredIncorrectly(); + }); + }, + ); - // Assert - expect(score).toHaveInvalidInput(); - }); - }, - ); + describe("A none-type graph", () => { + it("renders predictably", () => { + const question = interactiveGraphQuestionBuilder() + .withNoInteractiveFigure() + .build(); + const {container} = renderQuestion(question, blankOptions); - describe.each(Object.entries(graphQuestionRenderersCorrect))( - "graph type %s: default correct", - (_type, question) => { - it("should render", () => { - renderQuestion(question, apiOptions); - }); + expect(container).toMatchSnapshot("first render"); + }); - // 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}, - ); - }); + 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(), + ); - // 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}, - ); - }); + expect(score).toHaveBeenAnsweredCorrectly({ + shouldHavePoints: false, + }); + }); + }); +}); - 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}, - ); - }); - }, - ); +describe("a mafs graph", () => { + let userEvent: UserEvent; + beforeEach(() => { + userEvent = userEventLib.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + }); - describe("locked layer", () => { - it("should render locked points", async () => { + // 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", () => { // 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'])", - ); + const {renderer} = renderQuestion(question, apiOptions); // Act + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); // Assert - expect(points).toHaveLength(2); + expect(score).toHaveInvalidInput(); + }); + }, + ); + + describe.each(Object.entries(graphQuestionRenderersCorrect))( + "graph type %s: default correct", + (_type, question) => { + it("should render", () => { + renderQuestion(question, apiOptions); }); - it("should render locked points with styles", async () => { + // 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 {container} = renderQuestion( - segmentWithLockedPointsQuestion, - apiOptions, - ); + const {renderer} = renderQuestion(question, apiOptions); + + await userEvent.tab(); // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const points = container.querySelectorAll( - "circle:not([class*='movable-point'])", - ); + await userEvent.keyboard("{arrowup}{arrowright}"); // Assert - expect(points[0]).toHaveStyle({ - fill: lockedFigureColors.grayH, - stroke: lockedFigureColors.grayH, - }); - expect(points[1]).toHaveStyle({ - fill: wbColor.white, - stroke: lockedFigureColors.grayH, - }); - }); - }); - }); - - 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 waitFor( + () => { + const score = scorePerseusItemTesting( + question, + renderer.getUserInputMap(), + ); + expect(score).toHaveBeenAnsweredIncorrectly(); + }, + {timeout: 5000}, + ); }); - 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[0]).toHaveFocus(); - }); - - it("focuses the whole segment third", async () => { - const {container} = renderQuestion(segmentQuestion, { - flags: {mafs: {segment: true}}, - }); + // 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(); - 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).toHaveBeenAnsweredCorrectly(); + }, + {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}}, - }); + 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(); - await userEvent.tab({shift: true}); + 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("moves focus from the whole segment to the first point", 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({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( @@ -517,7 +377,7 @@ describe("Interactive Graph", function () { expect(points).toHaveLength(2); }); - it("should render locked points with styles when color is not specified", async () => { + it("should render locked points with styles", async () => { // Arrange const {container} = renderQuestion( segmentWithLockedPointsQuestion, @@ -540,1007 +400,1128 @@ describe("Interactive Graph", function () { stroke: lockedFigureColors.grayH, }); }); + }); +}); - it("should render locked points with styles when color is specified", async () => { - // Arrange - const {container} = renderQuestion( - segmentWithLockedPointsWithColorQuestion, - { - flags: { - mafs: { - segment: true, - }, - }, - }, - ); +describe("tabbing forward 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 points = container.querySelectorAll( - "circle:not([class*='movable-point'])", - ); + it("focuses the first endpoint of a segment first", 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(); + + // 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(); + }); + + it("focuses the whole segment third", async () => { + const {container} = renderQuestion(segmentQuestion, { + flags: {mafs: {segment: true}}, }); - 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, - }, - }, - }, - ); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-point"); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const movableLine = container.querySelector( + "[data-testid=movable-line]", + ); + expect(movableLine).toHaveFocus(); + }); - // Assert - expect(point).toHaveAttribute("aria-label", "Point A"); + it("focuses the second point third", async () => { + const {container} = renderQuestion(segmentQuestion, { + flags: {mafs: {segment: true}}, }); - 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, - }, - }, - }); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-point"); + // 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(); + }); +}); - // Assert - expect(point).not.toHaveAttribute("aria-label"); +describe("tabbing backward on a Mafs segment graph", () => { + let userEvent: UserEvent; + beforeEach(() => { + userEvent = userEventLib.setup({ + advanceTimers: jest.advanceTimersByTime, }); + }); - it("should render locked lines", () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedLineQuestion, { - flags: { - mafs: { - segment: true, - }, - }, - }); + it("moves focus from the last point to the whole segment", async () => { + const {container} = renderQuestion(segmentQuestion, { + 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"); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab({shift: true}); - // Assert - expect(lines).toHaveLength(2); - expect(rays).toHaveLength(1); + // 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}}, }); - it("should render locked lines with styles", () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedLineQuestion, { - flags: { - mafs: { - segment: true, - }, - }, - }); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab({shift: true}); - // 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"); + // 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 - expect(lines).toHaveLength(2); - expect(lines[0]).toHaveStyle({stroke: lockedFigureColors.green}); - expect(lines[1]).toHaveStyle({stroke: lockedFigureColors.grayH}); - expect(ray).toHaveStyle({stroke: lockedFigureColors.pink}); +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, + ); + + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const points = container.querySelectorAll( + "circle:not([class*='movable-point'])", + ); + + // Assert + expect(points[0]).toHaveStyle({ + fill: lockedFigureColors.grayH, + stroke: lockedFigureColors.grayH, + }); + expect(points[1]).toHaveStyle({ + fill: wbColor.white, + stroke: lockedFigureColors.grayH, }); + }); - it("should render locked lines with shown points", async () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedLineQuestion, { + it("should render locked points with styles when color is specified", async () => { + // Arrange + const {container} = renderQuestion( + segmentWithLockedPointsWithColorQuestion, + { flags: { mafs: { segment: 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"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const points = container.querySelectorAll( + "circle:not([class*='movable-point'])", + ); - // 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, - }); + // Assert + expect(points[0]).toHaveStyle({ + fill: lockedFigureColors.green, + stroke: lockedFigureColors.green, }); + expect(points[1]).toHaveStyle({ + fill: lockedFigureColors.green, + stroke: lockedFigureColors.green, + }); + }); - 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, - }, - }, + 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, }, - ); + }, + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-line"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-point"); - // Assert - expect(point).toHaveAttribute("aria-label", "Line A"); - }); + // Assert + expect(point).toHaveAttribute("aria-label", "Point A"); + }); - 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, - }, + 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, }, - }); + }, + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-line"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-point"); - // Assert - expect(point).not.toHaveAttribute("aria-label"); - }); + // 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 lines", () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedLineQuestion, { + flags: { + mafs: { + segment: true, }, - }); + }, + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const vectors = container.querySelectorAll(".locked-vector"); + // 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"); - // 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)", - ); + // Assert + expect(lines).toHaveLength(2); + expect(rays).toHaveLength(1); + }); - // 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)", - ); + it("should render locked lines with styles", () => { + // 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 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}); + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-vector"); + it("should render locked lines with shown points", async () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedLineQuestion, { + flags: { + mafs: { + segment: true, + }, + }, + }); - // Assert - expect(point).toHaveAttribute("aria-label", "Vector A"); + // 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, + }); + }); - it("should render locked vector without aria label by default", () => { - // Arrange - const simpleLockedVectorquestion = interactiveGraphQuestionBuilder() - .addLockedVector([0, 0], [2, 2]) + 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(simpleLockedVectorquestion, { - flags: { - mafs: { - segment: true, - }, + 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-vector"); + // 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).toHaveAttribute("aria-label", "Line A"); + }); - it("should render locked ellipses", async () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedEllipses, { - 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 circles = container.querySelectorAll("ellipse"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-line"); - // 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).not.toHaveAttribute("aria-label"); + }); - it("should render locked ellipses with white fill", async () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedEllipseWhite, { - 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 circles = container.querySelectorAll("ellipse"); - const whiteCircle = circles[0]; - const translucentCircle = circles[1]; + // 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)", + ); - // 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"], - }); + // 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)", + ); + }); - 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, - }, - }, + 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 point = container.querySelector(".locked-ellipse"); + // 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", "Ellipse A"); + // Assert + expect(point).toHaveAttribute("aria-label", "Vector A"); + }); + + 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, + }, + }, }); - 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 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, }, - }); + }, + }); - // 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 circles = container.querySelectorAll("ellipse"); - // Assert - expect(point).not.toHaveAttribute("aria-label"); + // 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"], + }); + }); - it("should render locked polygons with style", async () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedPolygons, { - flags: { - mafs: { - segment: 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 polygons = container.querySelectorAll( - ".locked-polygon polygon", - ); + }, + }); - // 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"], - }); + // 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"], + }); + }); - it("should render locked polygons with white fill", async () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedPolygonWhite, { - 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 polygons = container.querySelectorAll( - ".locked-polygon polygon", - ); - const whitePolygon = polygons[0]; - const translucentPolygon = polygons[1]; + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-ellipse"); - // 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"], - }); + // 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 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 point = container.querySelector(".locked-ellipse"); + + // Assert + expect(point).not.toHaveAttribute("aria-label"); + }); + + 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 polygonVertices = container.querySelectorAll( - ".locked-polygon circle", - ); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const polygons = container.querySelectorAll(".locked-polygon polygon"); - // Assert - // There should be 4 vertices on the square polygon - expect(polygonVertices).toHaveLength(4); + // 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"], + }); + }); - // 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"], - }); + it("should render locked polygons with white fill", async () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedPolygonWhite, { + flags: { + mafs: { + segment: true, + }, + }, }); - 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, - }, + // 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"], + }); + }); + + 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 labels = container.querySelectorAll(".locked-label"); - const label = labels[0]; + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const polygonVertices = container.querySelectorAll( + ".locked-polygon circle", + ); - // Assert - expect(labels).toHaveLength(1); - expect(label).toHaveTextContent("E"); - expect(label).toHaveStyle({ - color: lockedFigureColors["grayH"], - fontSize: "16px", - left: "200px", - top: "200px", - }); + // 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"], + }); + }); - 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, - }, - }, + 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, }, - ); - - // 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"); + // 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", }); + }); - it("should render locked polygon without aria label by default", () => { - // Arrange - const simpleLockedPolygonQuestion = - interactiveGraphQuestionBuilder() - .addLockedPolygon([ + it("should render locked polygon with aria label when one is provided", () => { + // Arrange + const lockedPolygonWithAriaLabelQuestion = + interactiveGraphQuestionBuilder() + .addLockedPolygon( + [ [0, 0], [0, 1], [1, 1], - ]) - .build(); - const {container} = renderQuestion(simpleLockedPolygonQuestion, { - flags: { - mafs: { - segment: true, + ], + { + 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 polygon = container.querySelector(".locked-polygon"); - // Assert - expect(polygon).not.toHaveAttribute("aria-label"); - }); + // Assert + expect(polygon).toHaveAttribute("aria-label", "Polygon A"); + }); - it("should render locked function with style", () => { - // Arrange - const {container} = renderQuestion( - segmentWithLockedFunction("x^2", { - color: "green", - strokeStyle: "dashed", - }), - { - flags: { - mafs: { - segment: true, - }, - }, + 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, }, - ); + }, + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const functionPlots = container.querySelectorAll( - ".locked-function path", - ); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const polygon = container.querySelector(".locked-polygon"); - // Assert - expect(functionPlots).toHaveLength(1); - expect(functionPlots[0]).toHaveStyle({ - "stroke-dasharray": "var(--mafs-line-stroke-dash-style)", - stroke: lockedFigureColors["green"], - }); - }); + // Assert + expect(polygon).not.toHaveAttribute("aria-label"); + }); - it("plots the supplied equation on the axis specified", () => { - // Arrange - const apiOptions = { + it("should render locked function with style", () => { + // Arrange + const {container} = renderQuestion( + segmentWithLockedFunction("x^2", { + color: "green", + strokeStyle: "dashed", + }), + { flags: { mafs: { segment: 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, - ); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const functionPlots = container.querySelectorAll( + ".locked-function path", + ); - // Assert - expect(PlotOfXMock).toHaveBeenCalledTimes(0); - expect(PlotOfYMock).toHaveBeenCalledTimes(1); + // 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 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, - }, - }, + it("plots the supplied equation on the axis specified", () => { + // Arrange + const apiOptions = { + flags: { + mafs: { + segment: true, }, - ); - - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-function"); + }, + }; + 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(point).toHaveAttribute("aria-label", "Function A"); - }); + // Assert + expect(PlotOfXMock).toHaveBeenCalledTimes(0); + expect(PlotOfYMock).toHaveBeenCalledTimes(1); + }); - it("should render locked function without aria label by default", () => { - // Arrange - const simpleLockedFunctionquestion = - interactiveGraphQuestionBuilder() - .addLockedFunction("x^2") - .build(); - const {container} = renderQuestion(simpleLockedFunctionquestion, { + 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, }, }, - }); + }, + ); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const point = container.querySelector(".locked-function"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-function"); - // Assert - expect(point).not.toHaveAttribute("aria-label"); - }); + // Assert + expect(point).toHaveAttribute("aria-label", "Function A"); + }); - it("should render locked labels", async () => { - // Arrange - const {container} = renderQuestion(segmentWithLockedLabels, { - flags: { - mafs: { - segment: true, - "interactive-graph-locked-features-labels": true, - }, + 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, }, - }); + }, + }); - // Act - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const labels = container.querySelectorAll(".locked-label"); + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const point = container.querySelector(".locked-function"); - // 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", - }); - }); + // Assert + expect(point).not.toHaveAttribute("aria-label"); + }); - 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 locked labels", async () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedLabels, { + 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 labels = container.querySelectorAll(".locked-label"); - const label = labels[0]; + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const labels = container.querySelectorAll(".locked-label"); - // Assert - expect(labels).toHaveLength(1); - expect(label).toHaveTextContent("A"); - expect(label).toHaveStyle({ - color: lockedFigureColors["grayH"], - fontSize: "16px", - left: "210px", - top: "200px", - }); + // 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", + }); + }); - 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", async () => { + // Arrange + const {container} = renderQuestion(graphWithLabeledPoint, { + flags: { + mafs: { + segment: true, + "interactive-graph-locked-features-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 label = labels[0]; + + // 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 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, - }, + 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 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"); + // 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 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 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]; + // 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"); + }); - // 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 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 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("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]; + 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, + }, + }, + }); - // 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 b105a4c185..54e54d1dfd 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,14 +27,6 @@ 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 ae81490a72..3aa1b49f93 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[`Radio Widget multi-choice question should snapshot the same when invalid: invalid state 1`] = ` +exports[`multi-choice question should snapshot the same when invalid: invalid state 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 correct answer: correct 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 with incorrect answer: incorrect answer 1`] = `
`; -exports[`Radio Widget single-choice question reviewMode: false should snapshot the same: first render 1`] = ` +exports[`single-choice question reviewMode: false should snapshot the same: first render 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 correct answer: correct 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 with incorrect answer: incorrect answer 1`] = `
`; -exports[`Radio Widget single-choice question reviewMode: true should snapshot the same: first render 1`] = ` +exports[`single-choice question reviewMode: true should snapshot the same: first render 1`] = `
{ + +describe("single-choice question", () => { let userEvent: UserEvent; beforeEach(() => { userEvent = userEventLib.setup({ @@ -51,1044 +52,1023 @@ describe("Radio 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; }); - describe("single-choice question", () => { - const [question, correct, incorrect] = questionAndAnswer; - const apiOptions = Object.freeze({}); + 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]); + + // Assert + expect(container).toMatchSnapshot("incorrect answer"); + }); + + it("should accept the right answer (mouse)", async () => { + // Arrange + const {renderer} = renderQuestion(question, apiOptions, { + reviewMode, }); - it("should accept the right answer (mouse)", async () => { - // Arrange - const {renderer} = renderQuestion(question, apiOptions, { - reviewMode, - }); + // Act + await selectOption(userEvent, correct); + 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("should accept the right answer (touch)", async () => { + // Arrange + const {renderer} = renderQuestion(question, apiOptions); + const correctRadio = screen.getAllByRole("radio")[correct]; + + // 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(), + ); - it("should accept the right answer (touch)", async () => { + // Assert + expect(score).toHaveBeenAnsweredCorrectly(); + }); + + it.each(incorrect)( + "should reject incorrect answer - choice %d", + async (incorrect: number) => { // Arrange const {renderer} = renderQuestion(question, apiOptions); - const correctRadio = screen.getAllByRole("radio")[correct]; // 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); + await selectOption(userEvent, incorrect); const score = scorePerseusItemTesting( question, renderer.getUserInputMap(), ); // Assert - expect(score).toHaveBeenAnsweredCorrectly(); - }); + expect(score).toHaveBeenAnsweredIncorrectly(); + }, + ); - 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(); - }, - ); + it("calling .focus() on the renderer should succeed", async () => { + // Arrange + const {renderer} = renderQuestion(question, apiOptions, { + reviewMode, + }); - it("calling .focus() on the renderer should succeed", async () => { - // Arrange - const {renderer} = renderQuestion(question, apiOptions, { - reviewMode, - }); + // Act + const gotFocus = await act(() => renderer.focus()); - // Act - const gotFocus = await act(() => renderer.focus()); + // Assert + expect(gotFocus).toBe(true); + }); - // Assert - expect(gotFocus).toBe(true); + it("should deselect incorrect selected choices", async () => { + // Arrange + const {renderer} = renderQuestion(question, apiOptions, { + reviewMode, }); - it("should deselect incorrect selected choices", async () => { - // Arrange - const {renderer} = renderQuestion(question, apiOptions, { - reviewMode, - }); - - // Act - // Since this is a single-select setup, just select the first - // incorrect choice. - await selectOption(userEvent, incorrect[0]); - act(() => renderer.deselectIncorrectSelectedChoices()); + // 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(); - }); + // 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 down by keyboard", async () => { - // Arrange - renderQuestion(question, apiOptions); + it("should be able to navigate up by keyboard", async () => { + // Arrange + renderQuestion(question, apiOptions); - // Act - await userEvent.tab(); - await userEvent.tab(); + // 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(); - // 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(); + expect(screen.getAllByRole("button", {hidden: true})[1]).toHaveFocus(); - it("should be able to navigate up by keyboard", async () => { - // Arrange - renderQuestion(question, apiOptions); + await userEvent.tab({shift: true}); - // 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(); + // Assert + expect(screen.getAllByRole("button", {hidden: true})[0]).toHaveFocus(); + }); - await userEvent.tab(); - expect( - screen.getAllByRole("button", {hidden: true})[1], - ).toHaveFocus(); + it("should be able to select an option by keyboard", async () => { + // Arrange + renderQuestion(question, apiOptions); - await userEvent.tab({shift: true}); + // Act + await userEvent.tab(); + await userEvent.keyboard(" "); - // Assert - expect( - screen.getAllByRole("button", {hidden: true})[0], - ).toHaveFocus(); - }); + // Assert + expect(screen.getAllByRole("radio")[0]).toBeChecked(); + }); - it("should be able to select an option by keyboard", async () => { - // Arrange - renderQuestion(question, apiOptions); + 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(); + }); - // Act - await userEvent.tab(); - await userEvent.keyboard(" "); + 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(); + }); - // Assert - expect(screen.getAllByRole("radio")[0]).toBeChecked(); + 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]); + }); + + 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"); }); + }); - 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); + it("should render rationales for selected choices using method", async () => { + // Arrange + const {renderer} = renderQuestion(question, apiOptions); - // Act - await userEvent.tab(); - await userEvent.tab(); - await userEvent.tab(); + // Act + await selectOption(userEvent, incorrect[0]); + act(() => renderer.showRationalesForCurrentlySelectedChoices()); - // 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.queryAllByTestId(/perseus-radio-rationale-content/), + ).toHaveLength(1); + }); - 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 - }), - ); + it("should render rationales for selected choices using prop", async () => { + // Arrange + const {rerender} = renderQuestion(question, apiOptions); - // 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(), - ); + // Act + await selectOption(userEvent, incorrect[0]); - // Assert - const items = screen.getAllByRole("listitem"); - expect(items[0]).toHaveTextContent(answers[1]); - expect(items[1]).toHaveTextContent(answers[0]); - expect(score).toHaveBeenAnsweredCorrectly(); - }, - ); + rerender(question, {showSolutions: "selected"}); - 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(1); + }); - // Act - renderQuestion(q, apiOptions); + it("should render all rationales when showSolutions is 'all'", async () => { + // Arrange + renderQuestion(question, apiOptions, { + showSolutions: "all", + }); - // 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(4); + }); + + it("should render no rationales when showSolutions is 'none'", async () => { + // Arrange + renderQuestion(question, apiOptions, { + showSolutions: "none", }); - 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`}, - ], - }, - }, - }, - }; + // Assert + expect( + screen.queryAllByTestId(/perseus-radio-rationale-content/), + ).toHaveLength(0); + }); - // 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}); + describe("cross-out is enabled", () => { + const crossOutApiOptions = { + ...apiOptions, + crossOutEnabled: true, + } as const; - // Act - renderQuestion(question, apiOptions); + it("should render cross-out menu button", async () => { + // Arrange & Act + renderQuestion(question, crossOutApiOptions); // 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"); - }); + expect( + screen.getByRole("button", { + name: /Open menu for Choice B/, + }), + ).toBeVisible(); }); - it("should render rationales for selected choices using method", async () => { + it("should open the cross-out menu when button clicked", async () => { // Arrange - const {renderer} = renderQuestion(question, apiOptions); + renderQuestion(question, crossOutApiOptions); // Act - await selectOption(userEvent, incorrect[0]); - act(() => renderer.showRationalesForCurrentlySelectedChoices()); + await userEvent.click( + screen.getByRole("button", { + name: /Open menu for Choice B/, + }), + ); // Assert expect( - screen.queryAllByTestId(/perseus-radio-rationale-content/), - ).toHaveLength(1); + screen.getByRole("button", { + name: /Cross out Choice B/, + }), + ).toBeVisible(); }); - it("should render rationales for selected choices using prop", async () => { + it("should open the cross-out menu when focused and spacebar pressed", async () => { // Arrange - const {rerender} = renderQuestion(question, apiOptions); + renderQuestion(question, crossOutApiOptions); + await userEvent.tab(); // Choice icon + await userEvent.tab(); // Cross-out menu ellipsis // Act - await selectOption(userEvent, incorrect[0]); - - rerender(question, {showSolutions: "selected"}); + await userEvent.keyboard(" "); // Assert expect( - screen.queryAllByTestId(/perseus-radio-rationale-content/), - ).toHaveLength(1); + screen.getByRole("button", { + name: /Cross out Choice A/, + }), + ).toBeVisible(); }); - it("should render all rationales when showSolutions is 'all'", async () => { + it("should cross-out selection and dismiss button when clicked", async () => { // Arrange - renderQuestion(question, apiOptions, { - showSolutions: "all", - }); - - // Assert - expect( - screen.queryAllByTestId(/perseus-radio-rationale-content/), - ).toHaveLength(4); - }); + renderQuestion(question, crossOutApiOptions); + await userEvent.click( + screen.getByRole("button", { + name: /Open menu for Choice B/, + }), + ); - it("should render no rationales when showSolutions is 'none'", async () => { - // Arrange - renderQuestion(question, apiOptions, { - showSolutions: "none", - }); + // Act + await userEvent.click( + screen.getByRole("button", { + name: /Cross out Choice B/, + }), + ); // Assert expect( - screen.queryAllByTestId(/perseus-radio-rationale-content/), + screen.queryAllByRole("button", { + name: /Cross out Choice B/, + }), ).toHaveLength(0); + + expect( + screen.getByTestId("choice-icon__cross-out-line"), + ).toBeVisible(); }); - describe("cross-out is enabled", () => { - const crossOutApiOptions = { - ...apiOptions, - crossOutEnabled: true, - } as const; + 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 render cross-out menu button", async () => { - // Arrange & Act - renderQuestion(question, crossOutApiOptions); + // Act + await userEvent.click( + screen.getByRole("button", { + name: /Cross out Choice B/, + }), + ); + jest.runAllTimers(); - // Assert - expect( - screen.getByRole("button", { - name: /Open menu for Choice B/, - }), - ).toBeVisible(); - }); + await userEvent.click( + screen.getByRole("radio", { + name: "(Choice B, Crossed out) -8", + }), + ); - it("should open the cross-out menu when button clicked", async () => { - // Arrange - renderQuestion(question, crossOutApiOptions); + // Assert + expect( + screen.queryByTestId("choice-icon__cross-out-line"), + ).not.toBeInTheDocument(); + }); - // Act - await userEvent.click( - screen.getByRole("button", { - name: /Open menu for Choice B/, - }), - ); + 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 - // Assert - expect( - screen.getByRole("button", { - name: /Cross out Choice B/, - }), - ).toBeVisible(); - }); + // Act + await userEvent.keyboard(" "); + jest.runAllTimers(); - 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 + expect( + screen.getByRole("button", { + name: /Cross out Choice A/, + }), + ).toBeVisible(); - // Act - await userEvent.keyboard(" "); + await userEvent.keyboard(" "); + jest.runAllTimers(); - // Assert - expect( - screen.getByRole("button", { - name: /Cross out Choice A/, - }), - ).toBeVisible(); - }); + // Assert + expect( + screen.queryAllByRole("button", { + name: /Cross out Choice A/, + }), + ).toHaveLength(0); + }); + }); - 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 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 - await userEvent.click( - screen.getByRole("button", { - name: /Cross out Choice B/, - }), - ); + // Assert + expect(score).toHaveInvalidInput(); + }); - // Assert - expect( - screen.queryAllByRole("button", { - name: /Cross out Choice B/, - }), - ).toHaveLength(0); - - expect( - screen.getByTestId("choice-icon__cross-out-line"), - ).toBeVisible(); - }); + it("Should render correct option select statuses (rationales) when review mode enabled", async () => { + // Arrange + const apiOptions: APIOptions = { + crossOutEnabled: false, + }; + renderQuestion(question, apiOptions, {reviewMode: true}); - 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/, - }), - ); + // Act + await selectOption(userEvent, correct); - // Act - await userEvent.click( - screen.getByRole("button", { - name: /Cross out Choice B/, - }), - ); - jest.runAllTimers(); + // Assert + expect(screen.getAllByText("Correct (selected)")).toHaveLength(1); + }); - await userEvent.click( - screen.getByRole("radio", { - name: "(Choice B, Crossed out) -8", - }), - ); + it("Should render incorrect option select statuses (rationales) when review mode enabled", async () => { + // Arrange + const apiOptions: APIOptions = { + crossOutEnabled: false, + }; + renderQuestion(question, apiOptions, {reviewMode: true}); - // Assert - expect( - screen.queryByTestId("choice-icon__cross-out-line"), - ).not.toBeInTheDocument(); - }); + // Act + await selectOption(userEvent, incorrect[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 + // Assert + expect(screen.getAllByText("Incorrect (selected)")).toHaveLength(1); + }); - // Act - await userEvent.keyboard(" "); - jest.runAllTimers(); + 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(); + }); - expect( - screen.getByRole("button", { - name: /Cross out Choice A/, - }), - ).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; - await userEvent.keyboard(" "); - jest.runAllTimers(); + const {renderer} = renderQuestion(q); - // Assert - expect( - screen.queryAllByRole("button", { - name: /Cross out Choice A/, - }), - ).toHaveLength(0); - }); + // Act + const noneOption = screen.getByRole("radio", { + name: "(Choice D) None of the above", }); + await userEvent.click(noneOption); + const score = scorePerseusItemTesting(q, renderer.getUserInputMap()); - 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(); - }); + // Assert + expect(score).toHaveBeenAnsweredCorrectly(); + }); +}); - it("Should render correct option select statuses (rationales) when review mode enabled", async () => { - // Arrange - const apiOptions: APIOptions = { - crossOutEnabled: false, - }; - renderQuestion(question, apiOptions, {reviewMode: true}); +describe("multi-choice question", () => { + const [question, correct, incorrect, invalid] = + multiChoiceQuestionAndAnswer; - // Act - await selectOption(userEvent, correct); + const apiOptions: APIOptions = Object.freeze({}); - // Assert - expect(screen.getAllByText("Correct (selected)")).toHaveLength(1); + let userEvent: UserEvent; + beforeEach(() => { + userEvent = userEventLib.setup({ + advanceTimers: jest.advanceTimersByTime, }); - it("Should render incorrect option select statuses (rationales) when review mode enabled", async () => { - // Arrange - const apiOptions: APIOptions = { - crossOutEnabled: false, - }; - renderQuestion(question, apiOptions, {reviewMode: true}); + jest.spyOn(Dependencies, "getDependencies").mockReturnValue( + testDependencies, + ); + }); - // Act - await selectOption(userEvent, incorrect[0]); + 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(), + ); - // Assert - expect(screen.getAllByText("Incorrect (selected)")).toHaveLength(1); - }); + // Assert + expect(score).toHaveBeenAnsweredCorrectly(); + }); - 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, + 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, + }, + ], }, }, - } 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(); - }); - - 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; + }, + }; - const {renderer} = renderQuestion(q); + renderQuestion(multipleCorrectChoicesQuestion, apiOptions); - // Act - const noneOption = screen.getByRole("radio", { - name: "(Choice D) None of the above", - }); - await userEvent.click(noneOption); - const score = scorePerseusItemTesting( - q, - renderer.getUserInputMap(), - ); + // Act + const options = screen.getAllByRole("checkbox"); + await userEvent.click(options[2]); + await userEvent.click(options[3]); - // Assert - expect(score).toHaveBeenAnsweredCorrectly(); - }); + // Assert + expect(options[2]).toBeChecked(); + expect(options[3]).toBeChecked(); }); - describe("multi-choice question", () => { - const [question, correct, incorrect, invalid] = - multiChoiceQuestionAndAnswer; + 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 apiOptions: APIOptions = Object.freeze({}); + renderQuestion(multipleCorrectChoicesQuestion, apiOptions); - it("should accept the right answer", async () => { - // Arrange - const {renderer} = renderQuestion(question, apiOptions); + // 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]); - // Act - const options = screen.getAllByRole("checkbox"); - for (const i of correct) { - await userEvent.click(options[i]); - } - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); + // Assert + expect(options[2]).not.toBeChecked(); + expect(options[3]).not.toBeChecked(); + }); - // Assert - expect(score).toHaveBeenAnsweredCorrectly(); - }); + it("should snapshot the same when invalid", async () => { + // Arrange + const {container} = renderQuestion(question, apiOptions); - 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, - }, - ], - }, - }, - }, - }; + // Act + const options = screen.getAllByRole("checkbox"); + for (const i of incorrect[0]) { + await userEvent.click(options[i]); + } - renderQuestion(multipleCorrectChoicesQuestion, apiOptions); + // Assert + expect(container).toMatchSnapshot("invalid state"); + }); - // Act - const options = screen.getAllByRole("checkbox"); - await userEvent.click(options[2]); - await userEvent.click(options[3]); + 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(options[2]).toBeChecked(); - expect(options[3]).toBeChecked(); - }); + // Assert + expect(score).toHaveInvalidInput(); + }); - 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, - }, - ], - }, + 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, + }, + ], }, }, - }; + }, + }; - renderQuestion(multipleCorrectChoicesQuestion, apiOptions); + const {renderer} = renderQuestion( + multipleCorrectChoicesQuestion, + apiOptions, + ); - // 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]); + // 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(), + ); - // Assert - expect(options[2]).not.toBeChecked(); - expect(options[3]).not.toBeChecked(); - }); + // Assert + expect(score).toHaveInvalidInput( + "Please choose the correct number of answers", + ); + }); - it("should snapshot the same when invalid", async () => { + it.each(incorrect)( + "should reject an incorrect answer - test #%#", + async (...choices) => { // Arrange - const {container} = renderQuestion(question, apiOptions); + const {renderer} = renderQuestion(question, apiOptions); // Act - const options = screen.getAllByRole("checkbox"); - for (const i of incorrect[0]) { - await userEvent.click(options[i]); + const option = screen.getAllByRole("checkbox"); + for (let i = 0; i < choices.length; i++) { + await userEvent.click(option[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).toHaveInvalidInput(); - }); + expect(score).toHaveBeenAnsweredIncorrectly(); + }, + ); - it("should be invalid when incorrect number of choices selected", async () => { + it.each(invalid)( + "should reject an invalid answer - test #%#", + async (...choices) => { // 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, - ); + const {renderer} = renderQuestion(question, 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 + for (const i of choices) { + await userEvent.click(option[i]); + } const score = scorePerseusItemTesting( - multipleCorrectChoicesQuestion, + question, renderer.getUserInputMap(), ); // Assert - expect(score).toHaveInvalidInput( - "Please choose the correct number of answers", - ); - }); - - it.each(incorrect)( - "should reject an incorrect answer - test #%#", - async (...choices) => { - // Arrange - const {renderer} = renderQuestion(question, apiOptions); - - // 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(), - ); - - // Assert - expect(score).toHaveBeenAnsweredIncorrectly(); - }, - ); - - it.each(invalid)( - "should reject an invalid answer - test #%#", - async (...choices) => { - // Arrange - const {renderer} = renderQuestion(question, apiOptions); + expect(score).toHaveInvalidInput(); + }, + ); +}); - // Act - const option = screen.getAllByRole("checkbox"); - for (const i of choices) { - await userEvent.click(option[i]); - } - const score = scorePerseusItemTesting( - question, - renderer.getUserInputMap(), - ); +describe("scoring", () => { + let userEvent: UserEvent; + beforeEach(() => { + userEvent = userEventLib.setup({ + advanceTimers: jest.advanceTimersByTime, + }); - // Assert - expect(score).toHaveInvalidInput(); - }, + jest.spyOn(Dependencies, "getDependencies").mockReturnValue( + testDependencies, ); }); - 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); - - // Act - await userEvent.click( - screen.getByRole("radio", {name: /Correct Choice$/}), - ); - - 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(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", async () => { - // Arrange - const {renderer} = renderQuestion(shuffledQuestion); + /** + * (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 - await userEvent.click( - screen.getByRole("radio", {name: /Incorrect Choice 1$/}), - ); + 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 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(widgetScore).toHaveBeenAnsweredCorrectly(); + expect(rendererScore).toHaveBeenAnsweredCorrectly(); + }); - // 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 incorrectly when shuffled", async () => { + // Arrange + const {renderer} = renderQuestion(shuffledQuestion); + + // Act + await userEvent.click( + screen.getByRole("radio", {name: /Incorrect Choice 1$/}), + ); - /** - * (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); + 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(), + ); - // Act - await userEvent.click( - screen.getByRole("radio", {name: /None of the above$/}), - ); + // Assert + expect(widgetScore).toHaveBeenAnsweredIncorrectly(); + expect(rendererScore).toHaveBeenAnsweredIncorrectly(); + }); - 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(), - ); + /** + * (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$/}), + ); - // 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( + shuffledNoneQuestion, + 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); + // Assert + expect(widgetScore).toHaveBeenAnsweredCorrectly(); + expect(rendererScore).toHaveBeenAnsweredCorrectly(); + }); - // Act - await userEvent.click( - screen.getByRole("radio", {name: /Incorrect Choice 1$/}), - ); + /** + * (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(), - ); + 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(); - }); + // Assert + expect(widgetScore).toHaveBeenAnsweredIncorrectly(); + expect(rendererScore).toHaveBeenAnsweredIncorrectly(); }); +}); - describe("propsUpgrade", () => { - it("can upgrade from v0 to v1", () => { - const v0props = { - choices: [{content: "Choice 1"}, {content: "Choice 2"}], - }; +describe("propsUpgrade", () => { + it("can upgrade from v0 to v1", () => { + const v0props = { + choices: [{content: "Choice 1"}, {content: "Choice 2"}], + }; - const expected: PerseusRadioWidgetOptions = { - choices: [{content: "Choice 1"}, {content: "Choice 2"}], - hasNoneOfTheAbove: false, - }; + const expected: PerseusRadioWidgetOptions = { + choices: [{content: "Choice 1"}, {content: "Choice 2"}], + hasNoneOfTheAbove: false, + }; - const result: PerseusRadioWidgetOptions = - RadioWidgetExport.propUpgrades["1"](v0props); + const result: PerseusRadioWidgetOptions = + RadioWidgetExport.propUpgrades["1"](v0props); - expect(result).toEqual(expected); - }); + expect(result).toEqual(expected); + }); - it("throws from noneOfTheAbove", () => { - const v0props = { - choices: [{content: "Choice 1"}, {content: "Choice 2"}], - noneOfTheAbove: true, - }; + 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", - ); - }); + expect(() => RadioWidgetExport.propUpgrades["1"](v0props)).toThrow( + "radio widget v0 no longer supports auto noneOfTheAbove", + ); }); });