@nualang/nualang-ui-components 0.1.1427 → 0.1.1428
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Chat/Messages/Message/Message.js +91 -4
- package/dist/Chat/Messages/Message/Message.test.js +109 -0
- package/dist/Chat/Messages/Messages.js +1 -1
- package/dist/Chat/TopBar/TopBar.js +1 -1
- package/dist/Dialogs/ExerciseMenu/ExerciseMenu.js +6 -5
- package/dist/Dialogs/ExerciseMenu/Menus/CompletedExcercise/CompletedExcercise.js +47 -3
- package/dist/Dialogs/ExerciseMenu/Menus/CompletedExcercise/CompletedExcercise.test.js +44 -0
- package/dist/Exercises/Bot/Bot.js +21 -0
- package/dist/Exercises/Bot/Bot.test.js +108 -13
- package/dist/hooks/useExerciseState.js +24 -1
- package/dist/hooks/useRecognition.js +27 -7
- package/dist/utils/correction.js +21 -0
- package/dist/utils/index.js +10 -2
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect } from "react";
|
|
2
|
+
import { keyframes } from "@emotion/react";
|
|
2
3
|
import { makeStyles } from "tss-react/mui";
|
|
3
4
|
import { useTheme } from "@mui/material/styles";
|
|
4
5
|
import Avatar from "@mui/material/Avatar";
|
|
@@ -15,14 +16,22 @@ import UpdateIcon from "@mui/icons-material/Create";
|
|
|
15
16
|
import Button from "@mui/material/Button";
|
|
16
17
|
import HoverText from "../../../Misc/HoverText/HoverText";
|
|
17
18
|
import VoiceMessage from "../../VoiceMessage/VoiceMessage";
|
|
18
|
-
import { Checkbox, FormGroup, FormControlLabel, Box } from "@mui/material";
|
|
19
|
+
import { Checkbox, FormGroup, FormControlLabel, Box, Collapse } from "@mui/material";
|
|
19
20
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
20
21
|
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
|
21
22
|
import Grid from "@mui/material/Grid";
|
|
22
23
|
import BorderColorIcon from "@mui/icons-material/BorderColor";
|
|
23
24
|
import AiExperienceIcon from "../../../Misc/AiExperienceIcon/AiExperienceIcon";
|
|
24
25
|
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
|
26
|
+
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
|
27
|
+
import { normaliseCorrection } from "../../../utils/correction";
|
|
28
|
+
|
|
29
|
+
// Draws the eye to the correction icon when it first appears, then settles.
|
|
25
30
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
31
|
+
const correctionShine = keyframes`
|
|
32
|
+
0%, 100% { opacity: 1; transform: scale(1); filter: brightness(1); }
|
|
33
|
+
50% { opacity: 0.85; transform: scale(1.2); filter: brightness(1.7); }
|
|
34
|
+
`;
|
|
26
35
|
const useStyles = makeStyles()((theme, {
|
|
27
36
|
isMyMessage,
|
|
28
37
|
isButton,
|
|
@@ -47,6 +56,35 @@ const useStyles = makeStyles()((theme, {
|
|
|
47
56
|
right: 0,
|
|
48
57
|
margin: 4
|
|
49
58
|
},
|
|
59
|
+
correctionToggle: {
|
|
60
|
+
color: theme.palette.mode === "dark" ? theme.palette.success.light : theme.palette.success.dark,
|
|
61
|
+
animation: `${correctionShine} 1.4s ease-in-out 3`
|
|
62
|
+
},
|
|
63
|
+
correctionDetail: {
|
|
64
|
+
// width:0 + minWidth:100% keeps this out of the column's max-content calculation,
|
|
65
|
+
// so revealing a long correction cannot stretch the chat bubble beside it.
|
|
66
|
+
width: 0,
|
|
67
|
+
minWidth: "100%",
|
|
68
|
+
overflowWrap: "anywhere",
|
|
69
|
+
marginTop: theme.spacing(0.5),
|
|
70
|
+
paddingLeft: theme.spacing(1.5),
|
|
71
|
+
borderLeft: `3px solid ${theme.palette.mode === "dark" ? theme.palette.success.light : theme.palette.success.dark}`,
|
|
72
|
+
"@media print": {
|
|
73
|
+
"-webkit-print-color-adjust": "exact",
|
|
74
|
+
"print-color-adjust": "exact",
|
|
75
|
+
"page-break-inside": "avoid"
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
correctionOriginal: {
|
|
79
|
+
display: "block",
|
|
80
|
+
textDecoration: "line-through",
|
|
81
|
+
color: theme.palette.text.secondary
|
|
82
|
+
},
|
|
83
|
+
correctionCorrected: {
|
|
84
|
+
display: "block",
|
|
85
|
+
fontWeight: 600,
|
|
86
|
+
color: theme.palette.mode === "dark" ? theme.palette.success.light : theme.palette.success.dark
|
|
87
|
+
},
|
|
50
88
|
message: {
|
|
51
89
|
display: "flex",
|
|
52
90
|
maxWidth: 500,
|
|
@@ -308,7 +346,7 @@ export default function Message({
|
|
|
308
346
|
isEdited = false,
|
|
309
347
|
isDynamicResponsesEnabled,
|
|
310
348
|
studentInappropriate,
|
|
311
|
-
|
|
349
|
+
correction
|
|
312
350
|
}) {
|
|
313
351
|
const theme = useTheme();
|
|
314
352
|
const messageContainerId = `message${messageIndex}`;
|
|
@@ -325,6 +363,12 @@ export default function Message({
|
|
|
325
363
|
index,
|
|
326
364
|
isHighlighted: isCurrentMessagePlaying
|
|
327
365
|
});
|
|
366
|
+
// A correction describes what the student wrote, so it belongs on their own
|
|
367
|
+
// message - the same turn studentInappropriate is surfaced on. Excluded from the
|
|
368
|
+
// button and listen-only variants, which have nowhere sensible to put it.
|
|
369
|
+
const safeCorrection = isMyMessage && !isButton && !isListenOnly ? normaliseCorrection(correction) : null;
|
|
370
|
+
const showCorrection = Boolean(safeCorrection);
|
|
371
|
+
const [correctionOpen, setCorrectionOpen] = useState(false);
|
|
328
372
|
const showPhonetics = learnLang === "chinese" || learnLang === "japanese";
|
|
329
373
|
const [translation, setTranslation] = useState({
|
|
330
374
|
translatedText: "",
|
|
@@ -595,9 +639,9 @@ export default function Message({
|
|
|
595
639
|
})
|
|
596
640
|
})]
|
|
597
641
|
})
|
|
598
|
-
}), /*#__PURE__*/
|
|
642
|
+
}), /*#__PURE__*/_jsxs("div", {
|
|
599
643
|
className: classes.additionalInfo,
|
|
600
|
-
children: !isLivePlayer && /*#__PURE__*/_jsxs(_Fragment, {
|
|
644
|
+
children: [!isLivePlayer && /*#__PURE__*/_jsxs(_Fragment, {
|
|
601
645
|
children: [/*#__PURE__*/_jsx("div", {
|
|
602
646
|
children: isCurrentMessagePlaying ? /*#__PURE__*/_jsx(Tooltip, {
|
|
603
647
|
title: t("stop_speaking"),
|
|
@@ -670,6 +714,49 @@ export default function Message({
|
|
|
670
714
|
className: classes.visuallyHidden,
|
|
671
715
|
children: liveMessage
|
|
672
716
|
})]
|
|
717
|
+
}), showCorrection && /*#__PURE__*/_jsx(Tooltip, {
|
|
718
|
+
title: t("language_correction"),
|
|
719
|
+
children: /*#__PURE__*/_jsx(IconButton, {
|
|
720
|
+
color: "inherit",
|
|
721
|
+
size: "small",
|
|
722
|
+
onClick: () => setCorrectionOpen(open => !open),
|
|
723
|
+
"aria-expanded": correctionOpen,
|
|
724
|
+
"aria-label": "View correction",
|
|
725
|
+
"data-cy": "message-correction-toggle",
|
|
726
|
+
className: classes.correctionToggle,
|
|
727
|
+
sx: {
|
|
728
|
+
border: "1px solid transparent",
|
|
729
|
+
"&:hover": {
|
|
730
|
+
border: `1px solid ${theme.palette.primary.main}`
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
children: /*#__PURE__*/_jsx(AutoFixHighIcon, {
|
|
734
|
+
fontSize: "inherit",
|
|
735
|
+
"aria-hidden": "true"
|
|
736
|
+
})
|
|
737
|
+
})
|
|
738
|
+
})]
|
|
739
|
+
}), showCorrection &&
|
|
740
|
+
/*#__PURE__*/
|
|
741
|
+
// Deliberately a sibling of the bubble rather than a child: highlightSpeechText
|
|
742
|
+
// index-maps every [data-text] node inside #messageN onto a speech mark, so any
|
|
743
|
+
// extra text in there desyncs word highlighting during playback. Plain
|
|
744
|
+
// Typography, never HoverText, for the same reason.
|
|
745
|
+
_jsx(Collapse, {
|
|
746
|
+
in: correctionOpen,
|
|
747
|
+
unmountOnExit: true,
|
|
748
|
+
children: /*#__PURE__*/_jsxs("div", {
|
|
749
|
+
className: classes.correctionDetail,
|
|
750
|
+
"data-cy": "message-correction",
|
|
751
|
+
children: [safeCorrection.original && /*#__PURE__*/_jsx(Typography, {
|
|
752
|
+
variant: "body2",
|
|
753
|
+
className: classes.correctionOriginal,
|
|
754
|
+
children: safeCorrection.original
|
|
755
|
+
}), /*#__PURE__*/_jsx(Typography, {
|
|
756
|
+
variant: "body2",
|
|
757
|
+
className: classes.correctionCorrected,
|
|
758
|
+
children: safeCorrection.corrected
|
|
759
|
+
})]
|
|
673
760
|
})
|
|
674
761
|
}), /*#__PURE__*/_jsxs("div", {
|
|
675
762
|
className: classes.actions,
|
|
@@ -52,4 +52,113 @@ describe("Message replay", () => {
|
|
|
52
52
|
}));
|
|
53
53
|
expect(speak.mock.calls[0][4]).toBeUndefined();
|
|
54
54
|
});
|
|
55
|
+
});
|
|
56
|
+
describe("Message correction", () => {
|
|
57
|
+
afterEach(cleanup);
|
|
58
|
+
const correction = {
|
|
59
|
+
original: "Yo tengo 12 anos",
|
|
60
|
+
corrected: "Tengo 12 a\u00f1os"
|
|
61
|
+
};
|
|
62
|
+
const toggle = () => document.querySelector("[data-cy='message-correction-toggle']");
|
|
63
|
+
const block = () => document.querySelector("[data-cy='message-correction']");
|
|
64
|
+
it("offers the correction on the student's own message but keeps it collapsed", () => {
|
|
65
|
+
renderMessage({
|
|
66
|
+
isMyMessage: true,
|
|
67
|
+
correction
|
|
68
|
+
});
|
|
69
|
+
expect(toggle()).not.toBeNull();
|
|
70
|
+
// Collapse unmounts its children, so the detail is genuinely absent until asked for.
|
|
71
|
+
expect(screen.queryByText("Tengo 12 a\u00f1os")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
it("reveals the correction when the student asks for it", () => {
|
|
74
|
+
renderMessage({
|
|
75
|
+
isMyMessage: true,
|
|
76
|
+
correction
|
|
77
|
+
});
|
|
78
|
+
fireEvent.click(toggle());
|
|
79
|
+
expect(screen.getByText("Tengo 12 a\u00f1os")).toBeTruthy();
|
|
80
|
+
expect(screen.getByText("Yo tengo 12 anos")).toBeTruthy();
|
|
81
|
+
});
|
|
82
|
+
it("offers nothing when there is no correction", () => {
|
|
83
|
+
renderMessage({
|
|
84
|
+
isMyMessage: true
|
|
85
|
+
});
|
|
86
|
+
expect(toggle()).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
it("offers nothing for an incomplete or no-op correction", () => {
|
|
89
|
+
renderMessage({
|
|
90
|
+
isMyMessage: true,
|
|
91
|
+
correction: {
|
|
92
|
+
original: "a"
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
expect(toggle()).toBeNull();
|
|
96
|
+
cleanup();
|
|
97
|
+
renderMessage({
|
|
98
|
+
isMyMessage: true,
|
|
99
|
+
correction: {
|
|
100
|
+
original: "Tengo",
|
|
101
|
+
corrected: "Tengo"
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
expect(toggle()).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// The correction is about what the student wrote, so it must not appear on the
|
|
108
|
+
// bot's reply even if one is somehow passed through.
|
|
109
|
+
it("never offers a correction on the bot's message", () => {
|
|
110
|
+
renderMessage({
|
|
111
|
+
isMyMessage: false,
|
|
112
|
+
correction
|
|
113
|
+
});
|
|
114
|
+
expect(toggle()).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Regression guard: highlightSpeechText index-maps every [data-text] node inside
|
|
118
|
+
// #messageN onto a speech mark. If the correction moves inside that container, or
|
|
119
|
+
// starts using HoverText, word highlighting silently desyncs during playback.
|
|
120
|
+
it("keeps the correction outside the speech-highlight container", () => {
|
|
121
|
+
renderMessage({
|
|
122
|
+
isMyMessage: true,
|
|
123
|
+
correction
|
|
124
|
+
});
|
|
125
|
+
fireEvent.click(toggle());
|
|
126
|
+
expect(document.getElementById("message0").contains(block())).toBe(false);
|
|
127
|
+
expect(block().querySelectorAll("[data-text]").length).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
it("puts the toggle in the same row as speak and translate", () => {
|
|
130
|
+
renderMessage({
|
|
131
|
+
isMyMessage: true,
|
|
132
|
+
correction
|
|
133
|
+
});
|
|
134
|
+
const speakButton = screen.getByRole("button", {
|
|
135
|
+
name: /Speak message/i
|
|
136
|
+
});
|
|
137
|
+
expect(toggle().parentElement).toBe(speakButton.closest("div").parentElement);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Revealing a long correction must not stretch the chat bubble beside it, so the
|
|
141
|
+
// detail is kept out of the column's max-content calculation.
|
|
142
|
+
it("keeps the correction detail out of the bubble's width calculation", () => {
|
|
143
|
+
renderMessage({
|
|
144
|
+
isMyMessage: true,
|
|
145
|
+
correction
|
|
146
|
+
});
|
|
147
|
+
fireEvent.click(toggle());
|
|
148
|
+
const detail = block();
|
|
149
|
+
expect(detail).not.toBeNull();
|
|
150
|
+
expect(document.getElementById("message0").contains(detail)).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
it("speaks only the message text, never the correction", () => {
|
|
153
|
+
const speak = renderMessage({
|
|
154
|
+
isMyMessage: true,
|
|
155
|
+
correction
|
|
156
|
+
});
|
|
157
|
+
fireEvent.click(toggle());
|
|
158
|
+
fireEvent.click(screen.getByRole("button", {
|
|
159
|
+
name: /Speak message/i
|
|
160
|
+
}));
|
|
161
|
+
expect(speak.mock.calls[0][0]).toBe("Kia ora");
|
|
162
|
+
expect(JSON.stringify(speak.mock.calls[0])).not.toContain("Tengo 12 a\u00f1os");
|
|
163
|
+
});
|
|
55
164
|
});
|
|
@@ -59,7 +59,7 @@ export default function TopBar({
|
|
|
59
59
|
return /*#__PURE__*/_jsxs(Paper, {
|
|
60
60
|
square: true,
|
|
61
61
|
className: classes.paper,
|
|
62
|
-
component: "
|
|
62
|
+
component: "header",
|
|
63
63
|
children: [/*#__PURE__*/_jsxs(Toolbar, {
|
|
64
64
|
className: classes.root,
|
|
65
65
|
children: [handleBack && /*#__PURE__*/_jsx(Paper, {
|
|
@@ -2,7 +2,7 @@ import { useEffect } from "react";
|
|
|
2
2
|
import { Dialog, DialogContent, Button, Tooltip } from "@mui/material";
|
|
3
3
|
import { makeStyles } from "tss-react/mui";
|
|
4
4
|
import { randomId } from "../../utils/index";
|
|
5
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
5
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
6
6
|
const useStyles = makeStyles()(theme => ({
|
|
7
7
|
buttonStyle: {
|
|
8
8
|
margin: theme.spacing(1, 0, 1, 0)
|
|
@@ -11,7 +11,8 @@ const useStyles = makeStyles()(theme => ({
|
|
|
11
11
|
function ExerciseMenu({
|
|
12
12
|
open,
|
|
13
13
|
handleClose,
|
|
14
|
-
actions = []
|
|
14
|
+
actions = [],
|
|
15
|
+
children = null
|
|
15
16
|
}) {
|
|
16
17
|
const {
|
|
17
18
|
classes
|
|
@@ -39,9 +40,9 @@ function ExerciseMenu({
|
|
|
39
40
|
role: "status",
|
|
40
41
|
"aria-live": "polite",
|
|
41
42
|
onKeyDown: handleKeyDown,
|
|
42
|
-
children: /*#__PURE__*/
|
|
43
|
+
children: /*#__PURE__*/_jsxs(DialogContent, {
|
|
43
44
|
id: "exercise-menu-dialog-content",
|
|
44
|
-
children: actions.map((action = {}, i) => {
|
|
45
|
+
children: [children, actions.map((action = {}, i) => {
|
|
45
46
|
const {
|
|
46
47
|
dataCypress,
|
|
47
48
|
text,
|
|
@@ -85,7 +86,7 @@ function ExerciseMenu({
|
|
|
85
86
|
children: text
|
|
86
87
|
})
|
|
87
88
|
}, `exermenu_${randomId()}${i}`);
|
|
88
|
-
})
|
|
89
|
+
})]
|
|
89
90
|
})
|
|
90
91
|
});
|
|
91
92
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import ExerciseMenu from "../../ExerciseMenu";
|
|
2
2
|
import { useLocation } from "react-router";
|
|
3
|
-
import {
|
|
3
|
+
import { Box, Typography } from "@mui/material";
|
|
4
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
5
|
function CompletedExerciseMenu({
|
|
5
6
|
open,
|
|
6
7
|
close,
|
|
@@ -12,7 +13,8 @@ function CompletedExerciseMenu({
|
|
|
12
13
|
inTopic = false,
|
|
13
14
|
hasNextGame,
|
|
14
15
|
handleNextGame,
|
|
15
|
-
goBackToAssignment
|
|
16
|
+
goBackToAssignment,
|
|
17
|
+
corrections = []
|
|
16
18
|
}) {
|
|
17
19
|
const location = useLocation();
|
|
18
20
|
const searchParams = new URLSearchParams(location.search);
|
|
@@ -54,7 +56,49 @@ function CompletedExerciseMenu({
|
|
|
54
56
|
color: "",
|
|
55
57
|
onClick: goBack,
|
|
56
58
|
disabled: false
|
|
57
|
-
}])]
|
|
59
|
+
}])],
|
|
60
|
+
children: corrections.length > 0 && /*#__PURE__*/_jsxs(Box, {
|
|
61
|
+
sx: {
|
|
62
|
+
mb: 2
|
|
63
|
+
},
|
|
64
|
+
"data-cy": "completed-corrections",
|
|
65
|
+
children: [/*#__PURE__*/_jsx(Typography, {
|
|
66
|
+
variant: "subtitle2",
|
|
67
|
+
component: "h3",
|
|
68
|
+
sx: {
|
|
69
|
+
fontWeight: "bold"
|
|
70
|
+
},
|
|
71
|
+
children: t("language_corrections_to_review")
|
|
72
|
+
}), corrections.map(correction => /*#__PURE__*/_jsxs(Box, {
|
|
73
|
+
sx: {
|
|
74
|
+
mt: 1
|
|
75
|
+
},
|
|
76
|
+
children: [/*#__PURE__*/_jsx(Typography, {
|
|
77
|
+
variant: "body2",
|
|
78
|
+
component: "span",
|
|
79
|
+
sx: {
|
|
80
|
+
textDecoration: "line-through",
|
|
81
|
+
color: "text.secondary"
|
|
82
|
+
},
|
|
83
|
+
children: correction.original
|
|
84
|
+
}), /*#__PURE__*/_jsx(Typography, {
|
|
85
|
+
variant: "body2",
|
|
86
|
+
component: "span",
|
|
87
|
+
sx: {
|
|
88
|
+
mx: 0.5,
|
|
89
|
+
color: "text.secondary"
|
|
90
|
+
},
|
|
91
|
+
children: "\u2192"
|
|
92
|
+
}), /*#__PURE__*/_jsx(Typography, {
|
|
93
|
+
variant: "body2",
|
|
94
|
+
component: "span",
|
|
95
|
+
sx: {
|
|
96
|
+
fontWeight: 600
|
|
97
|
+
},
|
|
98
|
+
children: correction.corrected
|
|
99
|
+
})]
|
|
100
|
+
}, `${correction.original}|${correction.corrected}`))]
|
|
101
|
+
})
|
|
58
102
|
});
|
|
59
103
|
}
|
|
60
104
|
export default CompletedExerciseMenu;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { render, screen, cleanup } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
3
|
+
import { MemoryRouter } from "react-router";
|
|
4
|
+
import CompletedExcercise from "./CompletedExcercise";
|
|
5
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
6
|
+
const renderMenu = (overrides = {}) => render(/*#__PURE__*/_jsx(MemoryRouter, {
|
|
7
|
+
children: /*#__PURE__*/_jsx(CompletedExcercise, {
|
|
8
|
+
open: true,
|
|
9
|
+
t: k => k,
|
|
10
|
+
close: () => {},
|
|
11
|
+
goBack: () => {},
|
|
12
|
+
restartExcercise: () => {},
|
|
13
|
+
viewReport: () => {},
|
|
14
|
+
...overrides
|
|
15
|
+
})
|
|
16
|
+
}));
|
|
17
|
+
describe("CompletedExcercise corrections recap", () => {
|
|
18
|
+
afterEach(cleanup);
|
|
19
|
+
it("lists each correction collected during the attempt", () => {
|
|
20
|
+
renderMenu({
|
|
21
|
+
corrections: [{
|
|
22
|
+
original: "Yo tengo",
|
|
23
|
+
corrected: "Tengo"
|
|
24
|
+
}, {
|
|
25
|
+
original: "anos",
|
|
26
|
+
corrected: "años"
|
|
27
|
+
}]
|
|
28
|
+
});
|
|
29
|
+
expect(screen.getByText("Yo tengo")).toBeTruthy();
|
|
30
|
+
expect(screen.getByText("Tengo")).toBeTruthy();
|
|
31
|
+
expect(screen.getByText("anos")).toBeTruthy();
|
|
32
|
+
expect(screen.getByText("años")).toBeTruthy();
|
|
33
|
+
});
|
|
34
|
+
it("renders no recap section when there were no corrections", () => {
|
|
35
|
+
renderMenu({
|
|
36
|
+
corrections: []
|
|
37
|
+
});
|
|
38
|
+
expect(document.querySelector("[data-cy='completed-corrections']")).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
it("renders no recap section when the prop is omitted entirely", () => {
|
|
41
|
+
renderMenu();
|
|
42
|
+
expect(document.querySelector("[data-cy='completed-corrections']")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -22,6 +22,7 @@ import VoiceSpeedSelector from "../../Misc/VoiceSpeed/VoiceSpeed";
|
|
|
22
22
|
import ReportProblem from "../../Misc/ReportProblem/ReportProblem";
|
|
23
23
|
import NualaSignInImage from "../../img/nuala_sign_in.svg";
|
|
24
24
|
import { startJoyrideWhenReady } from "../../utils/joyride";
|
|
25
|
+
import { normaliseCorrection } from "../../utils/correction";
|
|
25
26
|
import { useTheme } from "@mui/material/styles";
|
|
26
27
|
import config from "../../config";
|
|
27
28
|
import { useNavigate } from "react-router";
|
|
@@ -243,6 +244,25 @@ function Bot({
|
|
|
243
244
|
setIsAssignmentExercise
|
|
244
245
|
} = exerciseState;
|
|
245
246
|
const currentPhrase = botMessages.length > 0 ? botMessages[botMessages.length - 1].text : "N/A";
|
|
247
|
+
|
|
248
|
+
// Corrections collected across the attempt, for the completion recap. Deduped so a
|
|
249
|
+
// repeated mistake is listed once. Re-validated on the way in, so the recap lists
|
|
250
|
+
// exactly what Message showed inline rather than trusting whatever a saved attempt
|
|
251
|
+
// happens to carry.
|
|
252
|
+
const collectedCorrections = [];
|
|
253
|
+
const seenCorrections = new Set();
|
|
254
|
+
botMessages.forEach(message => {
|
|
255
|
+
const correction = message.isMyMessage ? normaliseCorrection(message.correction) : null;
|
|
256
|
+
if (!correction) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const key = `${correction.original}|${correction.corrected}`;
|
|
260
|
+
if (seenCorrections.has(key)) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
seenCorrections.add(key);
|
|
264
|
+
collectedCorrections.push(correction);
|
|
265
|
+
});
|
|
246
266
|
const joyrideSteps = [{
|
|
247
267
|
content: /*#__PURE__*/_jsx("h2", {
|
|
248
268
|
children: t("bot_exercise_welcome")
|
|
@@ -665,6 +685,7 @@ function Bot({
|
|
|
665
685
|
})
|
|
666
686
|
})
|
|
667
687
|
}), /*#__PURE__*/_jsx(CompletedExcercise, {
|
|
688
|
+
corrections: collectedCorrections,
|
|
668
689
|
open: isCompletionModalOpen,
|
|
669
690
|
restartExcercise: handleRestartExercise,
|
|
670
691
|
onClose: () => isCompletionModalOpen(false),
|
|
@@ -1,25 +1,120 @@
|
|
|
1
|
-
import { render } from "@testing-library/react";
|
|
2
|
-
import { describe, it } from "vitest";
|
|
1
|
+
import { render, within } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
|
|
3
3
|
import { MemoryRouter } from "react-router";
|
|
4
4
|
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
|
5
5
|
import Bot from "./Bot";
|
|
6
|
+
|
|
7
|
+
// Lets a test drive the slice of exercise state the completion recap reads, while the
|
|
8
|
+
// rest of the hook goes on behaving normally.
|
|
6
9
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
10
|
+
let exerciseStateOverrides = {};
|
|
11
|
+
vi.mock("../../hooks/useExerciseState", async importOriginal => {
|
|
12
|
+
const actual = await importOriginal();
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
default: args => ({
|
|
16
|
+
...actual.default(args),
|
|
17
|
+
...exerciseStateOverrides
|
|
18
|
+
})
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
const renderBot = (props = {}) => {
|
|
22
|
+
const theme = createTheme();
|
|
23
|
+
return render(/*#__PURE__*/_jsx(ThemeProvider, {
|
|
24
|
+
theme: theme,
|
|
25
|
+
children: /*#__PURE__*/_jsx(MemoryRouter, {
|
|
26
|
+
children: /*#__PURE__*/_jsx(Bot, {
|
|
27
|
+
learnLang: "spanish",
|
|
28
|
+
...props
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
}));
|
|
32
|
+
};
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
// Messages scrolls the transcript on a timer after render; jsdom has no
|
|
35
|
+
// Element.prototype.scrollTo, so the timer would throw once messages exist.
|
|
36
|
+
Element.prototype.scrollTo = () => {};
|
|
37
|
+
});
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
exerciseStateOverrides = {};
|
|
40
|
+
});
|
|
7
41
|
describe("Bot", () => {
|
|
8
42
|
it("renders without crashing", () => {
|
|
9
|
-
|
|
10
|
-
|
|
43
|
+
const {
|
|
44
|
+
unmount
|
|
45
|
+
} = renderBot();
|
|
46
|
+
unmount();
|
|
47
|
+
});
|
|
48
|
+
it("lists only real, deduped corrections in the completion recap", () => {
|
|
49
|
+
exerciseStateOverrides = {
|
|
50
|
+
isCompletionModalOpen: true,
|
|
51
|
+
botMessages: [{
|
|
52
|
+
isMyMessage: true,
|
|
53
|
+
text: "student turn one",
|
|
54
|
+
correction: {
|
|
55
|
+
original: "Yo tengo 12 anos",
|
|
56
|
+
corrected: "Tengo 12 años",
|
|
57
|
+
explanation: "Drop the pronoun."
|
|
58
|
+
}
|
|
59
|
+
}, {
|
|
60
|
+
isMyMessage: true,
|
|
61
|
+
text: "student turn two",
|
|
62
|
+
// Changes nothing, so there is nothing to review.
|
|
63
|
+
correction: {
|
|
64
|
+
original: "Buenos dias",
|
|
65
|
+
corrected: "Buenos dias"
|
|
66
|
+
}
|
|
67
|
+
}, {
|
|
68
|
+
isMyMessage: true,
|
|
69
|
+
text: "student turn three",
|
|
70
|
+
// Persisted without the corrected text - not showable.
|
|
71
|
+
correction: {
|
|
72
|
+
original: "sin corregir"
|
|
73
|
+
}
|
|
74
|
+
}, {
|
|
75
|
+
isMyMessage: true,
|
|
76
|
+
text: "student turn four",
|
|
77
|
+
// An accent fix on its own is a real correction and belongs in the recap.
|
|
78
|
+
correction: {
|
|
79
|
+
original: "dias",
|
|
80
|
+
corrected: "días"
|
|
81
|
+
}
|
|
82
|
+
}, {
|
|
83
|
+
isMyMessage: true,
|
|
84
|
+
text: "student turn five",
|
|
85
|
+
// The same mistake again, padded differently.
|
|
86
|
+
// Still carries the explanation that saved attempts were written with; it is
|
|
87
|
+
// ignored rather than rejected.
|
|
88
|
+
correction: {
|
|
89
|
+
original: " Yo tengo 12 anos ",
|
|
90
|
+
corrected: " Tengo 12 años ",
|
|
91
|
+
explanation: " Drop the pronoun. "
|
|
92
|
+
}
|
|
93
|
+
}, {
|
|
94
|
+
isMyMessage: false,
|
|
95
|
+
text: "bot turn",
|
|
96
|
+
// A correction on the bot's own turn is never the student's.
|
|
97
|
+
correction: {
|
|
98
|
+
original: "no es del alumno",
|
|
99
|
+
corrected: "nunca del alumno"
|
|
100
|
+
}
|
|
101
|
+
}]
|
|
11
102
|
};
|
|
12
|
-
|
|
103
|
+
|
|
104
|
+
// The exercise body, recap included, only mounts while the exercise is open.
|
|
13
105
|
const {
|
|
14
106
|
unmount
|
|
15
|
-
} =
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
107
|
+
} = renderBot({
|
|
108
|
+
open: true
|
|
109
|
+
});
|
|
110
|
+
const recap = document.querySelector('[data-cy="completed-corrections"]');
|
|
111
|
+
expect(recap).not.toBeNull();
|
|
112
|
+
const inRecap = within(recap);
|
|
113
|
+
expect(inRecap.getAllByText("Tengo 12 años")).toHaveLength(1);
|
|
114
|
+
expect(inRecap.queryByText("Buenos dias")).toBeNull();
|
|
115
|
+
expect(inRecap.queryByText("sin corregir")).toBeNull();
|
|
116
|
+
expect(inRecap.getByText("días")).toBeTruthy();
|
|
117
|
+
expect(inRecap.queryByText("nunca del alumno")).toBeNull();
|
|
23
118
|
unmount();
|
|
24
119
|
});
|
|
25
120
|
});
|
|
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router";
|
|
|
3
3
|
import distance from "jaro-winkler";
|
|
4
4
|
import { shuffle, removeAllSymbols, removeAllSymbolsFillInBlanks, formatMarkdownNewlines, localeCompare, localeCompareCase, removeExtraWhiteSpaces } from "../utils/index";
|
|
5
5
|
import { botErrorMessage } from "../utils/constants";
|
|
6
|
+
import { normaliseCorrection } from "../utils/correction";
|
|
6
7
|
import { convertNumberToText, getLearnLangCode } from "../utils/languageHelpers";
|
|
7
8
|
import { isSpaceFreeLanguage } from "./useLanguageTokens";
|
|
8
9
|
import BotNualaImage from "../img/bot_nuala.svg";
|
|
@@ -1698,7 +1699,29 @@ export default function useExerciseState({
|
|
|
1698
1699
|
if (clearAttemptAudio) {
|
|
1699
1700
|
clearAttemptAudio();
|
|
1700
1701
|
}
|
|
1701
|
-
|
|
1702
|
+
const safeCorrection = normaliseCorrection(response.correction);
|
|
1703
|
+
setBotMessages(prevMessages => {
|
|
1704
|
+
const withoutLoading = prevMessages.filter(message => message.isLoading !== true);
|
|
1705
|
+
if (!safeCorrection) {
|
|
1706
|
+
return withoutLoading;
|
|
1707
|
+
}
|
|
1708
|
+
// The correction describes the student's own turn, so it is stamped onto
|
|
1709
|
+
// their message rather than the reply that happens to carry it back.
|
|
1710
|
+
let lastStudentIndex = -1;
|
|
1711
|
+
for (let i = withoutLoading.length - 1; i >= 0; i -= 1) {
|
|
1712
|
+
if (withoutLoading[`${i}`].isMyMessage) {
|
|
1713
|
+
lastStudentIndex = i;
|
|
1714
|
+
break;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
if (lastStudentIndex === -1) {
|
|
1718
|
+
return withoutLoading;
|
|
1719
|
+
}
|
|
1720
|
+
return withoutLoading.map((message, i) => i === lastStudentIndex ? {
|
|
1721
|
+
...message,
|
|
1722
|
+
correction: safeCorrection
|
|
1723
|
+
} : message);
|
|
1724
|
+
});
|
|
1702
1725
|
addBotMessage(response.reply, false, true);
|
|
1703
1726
|
setIsFirstMessage(false);
|
|
1704
1727
|
}
|
|
@@ -102,6 +102,9 @@ export default function useRecognition(props = {}) {
|
|
|
102
102
|
const streamRef = useRef();
|
|
103
103
|
const serverErrorRef = useRef();
|
|
104
104
|
const hasAlertedConnectionErrorRef = useRef(false);
|
|
105
|
+
// Set to the { capture, target } sample rates when a recording starts, so
|
|
106
|
+
// the no-transcript event can report them (they're locals of handleSuccess).
|
|
107
|
+
const negotiatedSampleRateRef = useRef(null);
|
|
105
108
|
// Guards against a second click landing during the async start-up window,
|
|
106
109
|
// before recognizing/isRecognizingDisabled state has re-rendered the button.
|
|
107
110
|
const isStartingRef = useRef(false);
|
|
@@ -307,10 +310,24 @@ export default function useRecognition(props = {}) {
|
|
|
307
310
|
closeAll(true);
|
|
308
311
|
return;
|
|
309
312
|
}
|
|
313
|
+
|
|
314
|
+
// Capture rate is whatever the AudioContext gave us (some devices,
|
|
315
|
+
// e.g. a Bluetooth headset mic in HFP mode, capture at 8kHz). Target
|
|
316
|
+
// 16kHz when we have at least that (downsampling keeps upload small),
|
|
317
|
+
// but never upsample - for a sub-16kHz source send it at its native
|
|
318
|
+
// rate instead. This keeps the declared sampleRateHertz and the actual
|
|
319
|
+
// audio in agreement, and guarantees target <= capture so
|
|
320
|
+
// downsampleBuffer never hits its "output rate > input rate" throw.
|
|
321
|
+
const captureSampleRate = audioContextRef.current?.sampleRate || 44100;
|
|
322
|
+
const targetSampleRate = Math.min(captureSampleRate, 16000);
|
|
323
|
+
negotiatedSampleRateRef.current = {
|
|
324
|
+
captureSampleRate,
|
|
325
|
+
targetSampleRate
|
|
326
|
+
};
|
|
310
327
|
const gcloudConfig = {
|
|
311
328
|
config: {
|
|
312
329
|
encoding: "LINEAR16",
|
|
313
|
-
sampleRateHertz:
|
|
330
|
+
sampleRateHertz: targetSampleRate,
|
|
314
331
|
languageCode: gcloudParams.languageCode || "en-US",
|
|
315
332
|
profanityFilter: true,
|
|
316
333
|
enableWordTimeOffsets: false,
|
|
@@ -362,11 +379,10 @@ export default function useRecognition(props = {}) {
|
|
|
362
379
|
clearTimeout(firstAudioFrameTimeoutId);
|
|
363
380
|
resolveFirstAudioFrame();
|
|
364
381
|
const audioData = event.data;
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const result = downsampleBuffer(audioData, sampleRate, 16000);
|
|
382
|
+
// Resample toward the same target the config declared. When capture
|
|
383
|
+
// already equals target (e.g. a native 16kHz or 8kHz source) this is
|
|
384
|
+
// an identity conversion to Int16 rather than a no-op passthrough.
|
|
385
|
+
const result = downsampleBuffer(audioData, captureSampleRate, targetSampleRate);
|
|
370
386
|
socket.emit("binaryData", result, version);
|
|
371
387
|
};
|
|
372
388
|
if (window.MediaRecorder) {
|
|
@@ -500,7 +516,11 @@ export default function useRecognition(props = {}) {
|
|
|
500
516
|
A6_machine_info: `${machineInfo}`,
|
|
501
517
|
A7_user_language: `${language}`,
|
|
502
518
|
A8_learn_language: `${learnLang}`,
|
|
503
|
-
A9_exercise: gameId ? `${exerciseName}-${gameId}` : `${exerciseName}
|
|
519
|
+
A9_exercise: gameId ? `${exerciseName}-${gameId}` : `${exerciseName}`,
|
|
520
|
+
// A narrowband (e.g. 8kHz Bluetooth HFP) capture rate here helps confirm
|
|
521
|
+
// whether sub-16kHz devices correlate with no-transcript failures.
|
|
522
|
+
A10_capture_sample_rate: `${negotiatedSampleRateRef.current?.captureSampleRate ?? "unknown"}`,
|
|
523
|
+
A11_target_sample_rate: `${negotiatedSampleRateRef.current?.targetSampleRate ?? "unknown"}`
|
|
504
524
|
};
|
|
505
525
|
trackRecommendedEvent("speech_recognition_no_transcript", eventBody);
|
|
506
526
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// A language correction returned alongside a bot reply, as { original, corrected }.
|
|
2
|
+
// Returns null for anything not worth showing, so callers can render on a single
|
|
3
|
+
// truthiness check.
|
|
4
|
+
//
|
|
5
|
+
// Applied on both the write side (when the lambda response arrives) and the read
|
|
6
|
+
// side (when rendering), so saved attempts from older or hand-edited data are
|
|
7
|
+
// re-validated rather than trusted. Older saved attempts may still carry an
|
|
8
|
+
// `explanation` field; it is simply ignored.
|
|
9
|
+
export const normaliseCorrection = correction => {
|
|
10
|
+
if (!correction || typeof correction !== "object") return null;
|
|
11
|
+
const str = value => typeof value === "string" ? value.trim() : "";
|
|
12
|
+
const original = str(correction.original);
|
|
13
|
+
const corrected = str(correction.corrected);
|
|
14
|
+
if (!corrected) return null;
|
|
15
|
+
// A correction that changes nothing is noise.
|
|
16
|
+
if (original && original === corrected) return null;
|
|
17
|
+
return {
|
|
18
|
+
original,
|
|
19
|
+
corrected
|
|
20
|
+
};
|
|
21
|
+
};
|
package/dist/utils/index.js
CHANGED
|
@@ -756,7 +756,15 @@ export const getMicrophoneInfo = async mediaDevices => {
|
|
|
756
756
|
};
|
|
757
757
|
export const downsampleBuffer = (buffer, sampleRate, outSampleRate) => {
|
|
758
758
|
if (outSampleRate == sampleRate) {
|
|
759
|
-
|
|
759
|
+
// Server always expects LINEAR16 (Int16) PCM, so the Float32 worklet
|
|
760
|
+
// samples must still be converted even when no resampling is needed -
|
|
761
|
+
// previously this returned the raw Float32Array verbatim, which the
|
|
762
|
+
// server would then misinterpret as Int16 PCM.
|
|
763
|
+
var identityResult = new Int16Array(buffer.length);
|
|
764
|
+
for (var j = 0; j < buffer.length; j += 1) {
|
|
765
|
+
identityResult[j] = Math.max(-1, Math.min(1, buffer[j])) * 0x7fff;
|
|
766
|
+
}
|
|
767
|
+
return identityResult.buffer;
|
|
760
768
|
}
|
|
761
769
|
if (outSampleRate > sampleRate) {
|
|
762
770
|
throw new Error("downsampling rate show be smaller than original sample rate");
|
|
@@ -774,7 +782,7 @@ export const downsampleBuffer = (buffer, sampleRate, outSampleRate) => {
|
|
|
774
782
|
accum += buffer[i];
|
|
775
783
|
count += 1;
|
|
776
784
|
}
|
|
777
|
-
result[offsetResult] = Math.min(1, accum / count) * 0x7fff;
|
|
785
|
+
result[offsetResult] = Math.max(-1, Math.min(1, accum / count)) * 0x7fff;
|
|
778
786
|
offsetResult += 1;
|
|
779
787
|
offsetBuffer = nextOffsetBuffer;
|
|
780
788
|
}
|