@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.
@@ -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
- isUserInternal
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__*/_jsx("div", {
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
  });
@@ -261,7 +261,7 @@ function Messages({
261
261
  ...m,
262
262
  ...otherProps
263
263
  }, `messy_${i}`))]
264
- });
264
+ }, `messwrap_${i}`);
265
265
  })]
266
266
  })
267
267
  }), afterElements]
@@ -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: "banner",
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__*/_jsx(DialogContent, {
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 { jsx as _jsx } from "react/jsx-runtime";
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
- let props = {
10
- learnLang: "spanish"
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
- const theme = createTheme();
103
+
104
+ // The exercise body, recap included, only mounts while the exercise is open.
13
105
  const {
14
106
  unmount
15
- } = render(/*#__PURE__*/_jsx(ThemeProvider, {
16
- theme: theme,
17
- children: /*#__PURE__*/_jsx(MemoryRouter, {
18
- children: /*#__PURE__*/_jsx(Bot, {
19
- ...props
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
- setBotMessages(prevMessages => prevMessages.filter(message => message.isLoading !== true));
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: 16000,
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
- let sampleRate = 44100; // Default sample rate
366
- if (audioContextRef?.current?.sampleRate && audioContextRef?.current?.sampleRate !== 44100) {
367
- sampleRate = audioContextRef.current.sampleRate;
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
+ };
@@ -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
- return buffer;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nualang/nualang-ui-components",
3
- "version": "0.1.1427",
3
+ "version": "0.1.1428",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [