Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Locked Figure Labels] Util function to generate spoken math + use it within Locked Point aria labels #1839

Merged
merged 13 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/loud-emus-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus-editor": patch
---

[Locked Figure Labels] Util function to generate spoken math + use it within Locked Point aria labels
1 change: 1 addition & 0 deletions packages/perseus-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@khanacademy/math-input": "^21.1.4",
"@khanacademy/perseus": "^41.2.0",
"@khanacademy/perseus-core": "1.5.3",
"@khanacademy/pure-markdown": "^0.3.11",
"mafs": "^0.19.0"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ const defaultProps = {

const defaultLabel = getDefaultFigureForType("label");

// Mock the async function generateSpokenMathDetails
jest.mock("./util", () => ({
...jest.requireActual("./util"),
generateSpokenMathDetails: (input) => {
return `Spoken math details for ${input}`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the actual generateSpokenMathDetails function is async, do we want to return a Promise here to make sure the result is correctly being awaited? (Or maybe we trust TypeScript to tell us if we get that wrong?)

Suggested change
return `Spoken math details for ${input}`;
return Promise.resolve(`Spoken math details for ${input}`);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great point! I'll change it to the promise.

},
}));

describe("LockedPointSettings", () => {
let userEvent: UserEvent;
beforeEach(() => {
Expand Down Expand Up @@ -409,8 +417,11 @@ describe("LockedPointSettings", () => {
await userEvent.click(autoGenButton);

// Assert
// generateSpokenMathDetails is mocked to return the input string
// with "Spoken math details for " prepended.
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel: "Point at (0, 0). Appearance solid gray.",
ariaLabel:
"Spoken math details for Point at (0, 0). Appearance solid gray.",
});
});

Expand Down Expand Up @@ -439,8 +450,11 @@ describe("LockedPointSettings", () => {
await userEvent.click(autoGenButton);

// Assert
// generateSpokenMathDetails is mocked to return the input string
// with "Spoken math details for " prepended.
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel: "Point A at (0, 0). Appearance solid gray.",
ariaLabel:
"Spoken math details for Point A at (0, 0). Appearance solid gray.",
});
});

Expand Down Expand Up @@ -473,8 +487,11 @@ describe("LockedPointSettings", () => {
await userEvent.click(autoGenButton);

// Assert
// generateSpokenMathDetails is mocked to return the input string
// with "Spoken math details for " prepended.
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel: "Point A, B at (0, 0). Appearance solid gray.",
ariaLabel:
"Spoken math details for Point A, B at (0, 0). Appearance solid gray.",
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import LockedFigureSettingsActions from "./locked-figure-settings-actions";
import LockedLabelSettings from "./locked-label-settings";
import {
generateLockedFigureAppearanceDescription,
generateSpokenMathDetails,
getDefaultFigureForType,
} from "./util";

Expand Down Expand Up @@ -104,28 +105,39 @@ const LockedPointSettings = (props: Props) => {

const isDefiningPoint = !onMove && !onRemove;

/**
* Get a prepopulated aria label for the point.
*
* If the point has no labels, the aria label will just be
* "Point at (x, y)".
*
* If the point has labels, the aria label will be
* "Point at (x, y) with label1, label2, label3".
*/
function getPrepopulatedAriaLabel() {
let visiblelabel = "";
if (labels && labels.length > 0) {
visiblelabel += ` ${labels.map((l) => l.text).join(", ")}`;
}
let str = `Point${visiblelabel} at (${coord[0]}, ${coord[1]})`;
const [prepopulatedAriaLabel, setPrepopulatedAriaLabel] =
React.useState("");

React.useEffect(() => {
/**
* Get a prepopulated aria label for the point, with the math
* details converted into spoken words.
*
* If the point has no labels, the aria label will just be
* "Point at (x, y)".
*
* If the point has labels, the aria label will be
* "Point at (x, y) with label1, label2, label3".
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this comment should be something like

Suggested change
* If the point has labels, the aria label will be
* "Point at (x, y) with label1, label2, label3".
* If the point has labels, the aria label will be
* "Point label1, label2, label3 at (x, y)".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!

*/
async function getPrepopulatedAriaLabel() {
let visiblelabel = "";
if (labels && labels.length > 0) {
visiblelabel += ` ${labels.map((l) => l.text).join(", ")}`;
}

const pointAppearance =
generateLockedFigureAppearanceDescription(pointColor);
str += pointAppearance;
let str = await generateSpokenMathDetails(
`Point${visiblelabel} at (${coord[0]}, ${coord[1]})`,
);

return str;
}
const pointAppearance =
generateLockedFigureAppearanceDescription(pointColor);
str += pointAppearance;

return str;
}

getPrepopulatedAriaLabel().then(setPrepopulatedAriaLabel);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a race condition here because the last getPrepopulatedAriaLabel promise to resolve might not be one from the most recent render. This could result in an aria label being generated with the wrong coords, labels, or pointColor.

I think we could guard against that race condition by doing this in the useEffect callback:

let canceled = false;
getPrepopulatedAriaLabel().then((label) => {
  !canceled && setPrepopulatedAriaLabel(label);
})

return () => canceled = true;

The annoying thing is that this is really hard to test.

What we really want here is something like a useMemo, but unfortunately useMemo doesn't work when the function we want to memoize is async. I don't think it would be too hard to write a useAsyncMemo hook ourselves, though.

Then we could just write this:

const prepopulatedAriaLabel = useAsyncMemo(
  /* initial value */ "",
  () => getPrepopulatedAriaLabel(coord, labels, pointColor),
  [coord, labels, pointColor],
);

}, [coord, labels, pointColor]);

function handleColorChange(newValue) {
const newProps: Partial<LockedPointType> = {
Expand Down Expand Up @@ -247,7 +259,7 @@ const LockedPointSettings = (props: Props) => {

<LockedFigureAria
ariaLabel={ariaLabel}
prePopulatedAriaLabel={getPrepopulatedAriaLabel()}
prePopulatedAriaLabel={prepopulatedAriaLabel}
onChangeProps={(newProps) => {
onChangeProps(newProps);
}}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
generateLockedFigureAppearanceDescription,
generateSpokenMathDetails,
getDefaultFigureForType,
} from "./util";

Expand Down Expand Up @@ -258,3 +259,84 @@ describe("generateLockedFigureAppearanceDescription", () => {
},
);
});

// TODO(LEMS-2616): Update these tests to mock SpeechRuleEngine.setup()
// so that the tests don't have to make HTTP requests.
describe("generateMathDetails", () => {
test("should convert TeX to spoken language (root, fraction)", async () => {
const mathString = "$\\sqrt{\\frac{1}{2}}$";
const convertedString = await generateSpokenMathDetails(mathString);

expect(convertedString).toBe("StartRoot one half EndRoot");
});

test("should convert TeX to spoken language (exponent)", async () => {
const mathString = "$x^{2}$";
const convertedString = await generateSpokenMathDetails(mathString);

expect(convertedString).toBe("x Superscript 2");
});

test("should convert TeX to spoken language (negative)", async () => {
const mathString = "$-2$";
const convertedString = await generateSpokenMathDetails(mathString);

expect(convertedString).toBe("negative 2");
});

test("should converte TeX to spoken language (subtraction)", async () => {
const mathString = "$2-1$";
const convertedString = await generateSpokenMathDetails(mathString);

expect(convertedString).toBe("2 minus 1");
});

test("should convert TeX to spoken language (normal words)", async () => {
const mathString = "$\\text{square b}$";
const convertedString = await generateSpokenMathDetails(mathString);

expect(convertedString).toBe("square b");
});

test("should convert TeX to spoken language (random letters)", async () => {
const mathString = "$cat$";
const convertedString = await generateSpokenMathDetails(mathString);

expect(convertedString).toBe("c a t");
});

test("should keep non-math text as is", async () => {
const mathString = "Circle with radius $\\frac{1}{2}$ units";
const convertedString = await generateSpokenMathDetails(mathString);

expect(convertedString).toBe("Circle with radius one half units");
});

test("should read dollar signs as dollars inside tex", async () => {
const mathString = "This sandwich costs ${$}12.34$";
const convertedString = await generateSpokenMathDetails(mathString);

expect(convertedString).toBe("This sandwich costs dollar sign 12.34");
});

test("should read dollar signs as dollars outside tex", async () => {
const mathString = "This sandwich costs \\$12.34";
const convertedString = await generateSpokenMathDetails(mathString);

expect(convertedString).toBe("This sandwich costs $12.34");
});

test("should read curly braces", async () => {
const mathString = "Hello}{";
const convertedString = await generateSpokenMathDetails(mathString);

expect(convertedString).toBe("Hello}{");
});

test("should read backslashes", async () => {
const mathString = "\\";
const convertedString = await generateSpokenMathDetails(mathString);

expect(convertedString).toBe("\\");
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {SpeechRuleEngine} from "@khanacademy/mathjax-renderer";
import * as SimpleMarkdown from "@khanacademy/pure-markdown";
import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core";

import type {
Expand Down Expand Up @@ -124,3 +126,29 @@ export function generateLockedFigureAppearanceDescription(
throw new UnreachableCaseError(fill);
}
}

export async function generateSpokenMathDetails(mathString: string) {
const engine = await SpeechRuleEngine.setup("en");
let convertedSpeech = "";

// All the information we need is in the first section,
// whether it's typed as "blockmath" or "paragraph"
const firstSection = SimpleMarkdown.parse(mathString)[0];

// If it's blockMath, the outer level has the full math content.
if (firstSection.type === "blockMath") {
convertedSpeech += engine.texToSpeech(firstSection.content);
}

// If it's a paragraph, we need to iterate through the sections
// to look for individual math blocks.
if (firstSection.type === "paragraph") {
for (const piece of firstSection.content) {
piece.type === "math"
? (convertedSpeech += engine.texToSpeech(piece.content))
: (convertedSpeech += piece.content);
}
}

return convertedSpeech;
}
4 changes: 4 additions & 0 deletions packages/perseus/src/widgets/interactive-graphs/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ type ParsedNode = {
function condenseTextNodes(nodes: Array<ParsedNode>): Array<ParsedNode> {
const result: ParsedNode[] = [];

if (!nodes) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the types, this condition should never be met, because nodes is an array, and all arrays are truthy. I think we need to change the nodes parameter type to something like ParsedNode[] | undefined to match reality.

return result;
}

let currentText = "";
for (const node of nodes) {
if (node.type === "math") {
Expand Down
Loading