feat(chat): add selection-based speech playback
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import "@testing-library/jest-dom";
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { AgentTurn } from "./AgentTurn";
|
||||
|
||||
jest.mock("next/image", () => ({
|
||||
__esModule: true,
|
||||
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img {...props} alt={props.alt ?? ""} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("framer-motion", () => ({
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
motion: {
|
||||
div: ({
|
||||
children,
|
||||
animate: _animate,
|
||||
exit: _exit,
|
||||
initial: _initial,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
span: ({
|
||||
children,
|
||||
animate: _animate,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & Record<string, unknown>) => (
|
||||
<span {...props}>{children}</span>
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("./AgentMarkdownBlock", () => ({
|
||||
MarkdownBlock: ({ children }: { children: string }) => (
|
||||
<div>{children.split(/\n+/u).map((line) => <p key={line}>{line}</p>)}</div>
|
||||
),
|
||||
normalizeClipboardText: (value: string) => value.replace(/\s+$/u, ""),
|
||||
}));
|
||||
|
||||
describe("AgentTurn speech selection", () => {
|
||||
it("shows a floating action and reads from the selected text", async () => {
|
||||
const content = "第一段内容。\n\n第二段内容。";
|
||||
const speechText = "第一段内容。\n第二段内容。";
|
||||
const onSpeak = jest.fn();
|
||||
const removeAllRanges = jest.fn();
|
||||
|
||||
render(
|
||||
<AgentTurn
|
||||
message={{ id: "assistant-1", role: "assistant", content }}
|
||||
isStreaming={false}
|
||||
messageSpeechState="idle"
|
||||
onSpeak={onSpeak}
|
||||
onPause={jest.fn()}
|
||||
onResume={jest.fn()}
|
||||
onStopSpeech={jest.fn()}
|
||||
isTtsSupported
|
||||
onCreateBranch={jest.fn()}
|
||||
onReplyPermission={jest.fn()}
|
||||
onReplyQuestion={jest.fn()}
|
||||
onRejectQuestion={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selectedParagraph = screen.getByText("第二段内容。");
|
||||
const selectedTextNode = selectedParagraph.firstChild as Text;
|
||||
const range = {
|
||||
commonAncestorContainer: selectedTextNode,
|
||||
getBoundingClientRect: () => ({
|
||||
width: 72,
|
||||
height: 20,
|
||||
top: 120,
|
||||
right: 172,
|
||||
bottom: 140,
|
||||
left: 100,
|
||||
x: 100,
|
||||
y: 120,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
} as unknown as Range;
|
||||
const selection = {
|
||||
rangeCount: 1,
|
||||
isCollapsed: false,
|
||||
getRangeAt: () => range,
|
||||
toString: () => "第二段",
|
||||
removeAllRanges,
|
||||
} as unknown as Selection;
|
||||
|
||||
jest.spyOn(window, "getSelection").mockReturnValue(selection);
|
||||
jest.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
|
||||
fireEvent.pointerUp(selectedParagraph);
|
||||
|
||||
const speechAction = await screen.findByRole("button", { name: "从这里开始朗读" });
|
||||
fireEvent.click(speechAction);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSpeak).toHaveBeenCalledWith("assistant-1", speechText, {
|
||||
startOffset: speechText.indexOf("第二段"),
|
||||
});
|
||||
});
|
||||
expect(removeAllRanges).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: "从这里开始朗读" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user