Files
spark-store/src/__tests__/unit/ReviewsPanel.test.ts
T

673 lines
20 KiB
TypeScript

import { fireEvent, render, screen } from "@testing-library/vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ReviewsPanel from "@/components/ReviewsPanel.vue";
import {
createReviewReply,
deleteReview,
deleteReviewReply,
fetchRatingSummary,
fetchReviews,
likeReview,
likeReviewReply,
submitReview,
} from "@/modules/backendApi";
import type {
AppReview,
AppReviewReply,
RatingSummary,
ReviewTags,
} from "@/global/typedefinition";
const emptySummary: RatingSummary = {
averageRating: 0,
reviewCount: 0,
starCounts: {},
};
vi.mock("@/modules/backendApi", () => ({
createReviewReply: vi.fn(),
deleteReview: vi.fn(),
deleteReviewReply: vi.fn(),
fetchRatingSummary: vi.fn(async () => emptySummary),
fetchReviews: vi.fn(async () => []),
likeReview: vi.fn(),
likeReviewReply: vi.fn(),
submitReview: vi.fn(),
}));
const tags: ReviewTags = {
origin: "apm",
category: "office",
pkgname: "wps",
version: "1.0.0",
packageArch: "amd64",
clientArch: "amd64",
distro: "deepin 25",
};
const makeReview = (overrides: Partial<AppReview>): AppReview => ({
id: 1,
rating: 5,
content: "默认评价",
version: tags.version,
packageArch: tags.packageArch,
clientArch: tags.clientArch,
distro: tags.distro,
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "星火用户",
userAvatarUrl: "",
likeCount: 0,
likedByCurrentUser: false,
canDelete: false,
isAuthor: false,
isDeleted: false,
replies: [],
...overrides,
});
const makeReply = (overrides: Partial<AppReviewReply>): AppReviewReply => ({
id: 101,
reviewId: 1,
parentId: null,
content: "默认回复",
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "回复用户",
userAvatarUrl: "",
likeCount: 0,
likedByCurrentUser: false,
canDelete: false,
isAuthor: false,
isDeleted: false,
replies: [],
...overrides,
});
describe("ReviewsPanel", () => {
beforeEach(() => {
vi.mocked(fetchRatingSummary).mockReset();
vi.mocked(fetchReviews).mockReset();
vi.mocked(submitReview).mockReset();
vi.mocked(likeReview).mockReset();
vi.mocked(createReviewReply).mockReset();
vi.mocked(deleteReview).mockReset();
vi.mocked(likeReviewReply).mockReset();
vi.mocked(deleteReviewReply).mockReset();
vi.mocked(fetchRatingSummary).mockResolvedValue(emptySummary);
vi.mocked(fetchReviews).mockResolvedValue([]);
vi.mocked(likeReview).mockResolvedValue({
likedByCurrentUser: true,
likeCount: 1,
});
vi.mocked(createReviewReply).mockResolvedValue(makeReply({}));
vi.mocked(deleteReview).mockResolvedValue(undefined);
vi.mocked(likeReviewReply).mockResolvedValue({
likedByCurrentUser: true,
likeCount: 1,
});
vi.mocked(deleteReviewReply).mockResolvedValue(undefined);
});
it("shows anonymous login prompt and read-only review tags", () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: false },
});
expect(screen.getByText("登录后发表评论")).toBeTruthy();
expect(screen.getByText("1.0.0")).toBeTruthy();
expect(screen.getByText("deepin 25")).toBeTruthy();
expect(fetchRatingSummary).not.toHaveBeenCalled();
expect(fetchReviews).not.toHaveBeenCalled();
});
it("hides the submit form when reviews are read-only", () => {
render(ReviewsPanel, {
props: {
appKey: "apm:amd64-apm:office:wps",
tags,
loggedIn: true,
canSubmit: false,
},
});
expect(screen.queryByRole("button", { name: "发表评论" })).toBeNull();
expect(screen.getByText("安装应用后可发表评论。")).toBeTruthy();
});
it("ignores stale review responses after app key changes", async () => {
let resolveFirstSummary!: (summary: RatingSummary) => void;
let resolveFirstReviews!: (reviews: AppReview[]) => void;
let resolveSecondSummary!: (summary: RatingSummary) => void;
let resolveSecondReviews!: (reviews: AppReview[]) => void;
vi.mocked(fetchRatingSummary)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstSummary = resolve;
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecondSummary = resolve;
}),
);
vi.mocked(fetchReviews)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstReviews = resolve;
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecondReviews = resolve;
}),
);
const rendered = render(ReviewsPanel, {
props: { appKey: "first", tags, loggedIn: true },
});
await rendered.rerender({ appKey: "second", tags, loggedIn: true });
resolveSecondSummary({ averageRating: 5, reviewCount: 1, starCounts: {} });
resolveSecondReviews([
{
id: 2,
rating: 5,
content: "second review",
version: tags.version,
packageArch: tags.packageArch,
clientArch: tags.clientArch,
distro: tags.distro,
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
userDisplayName: "Second User",
userAvatarUrl: "",
},
]);
expect(await screen.findByText("second review")).toBeTruthy();
expect(screen.getByText("5.0 / 5 (1)")).toBeTruthy();
resolveFirstSummary({ averageRating: 1, reviewCount: 1, starCounts: {} });
resolveFirstReviews([
{
id: 1,
rating: 1,
content: "first review",
version: tags.version,
packageArch: tags.packageArch,
clientArch: tags.clientArch,
distro: tags.distro,
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
userDisplayName: "First User",
userAvatarUrl: "",
},
]);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(screen.getByText("second review")).toBeTruthy();
expect(screen.getByText("5.0 / 5 (1)")).toBeTruthy();
expect(screen.queryByText("first review")).toBeNull();
expect(screen.queryByText("1.0 / 5 (1)")).toBeNull();
});
it("shows a friendly submit error instead of raw network errors", async () => {
vi.mocked(submitReview).mockRejectedValueOnce(new Error("Network Error"));
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
await fireEvent.update(
screen.getByPlaceholderText("分享你的使用体验"),
"好用",
);
await fireEvent.click(screen.getByRole("button", { name: "发表评论" }));
expect(
await screen.findByText("无法连接星火账号服务,请稍后重试。"),
).toBeTruthy();
});
it("shows a re-login prompt when loading reviews with a stale token", async () => {
vi.mocked(fetchRatingSummary).mockRejectedValueOnce(
new Error("Request failed with status code 401"),
);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(
await screen.findByText("登录状态已失效,请重新登录星火账号。"),
).toBeTruthy();
});
it("submits the rating selected by sliding over stars", async () => {
vi.mocked(submitReview).mockResolvedValueOnce(
makeReview({ rating: 3, content: "一般" }),
);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
vi.spyOn(slider, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
width: 500,
height: 40,
top: 0,
left: 0,
right: 500,
bottom: 40,
toJSON: () => ({}),
});
const pointerDown = new MouseEvent("pointerdown", {
bubbles: true,
clientX: 250,
});
const pointerMove = new MouseEvent("pointermove", {
bubbles: true,
clientX: 250,
});
const pointerUp = new MouseEvent("pointerup", {
bubbles: true,
clientX: 250,
});
Object.defineProperty(pointerDown, "pointerId", { value: 1 });
Object.defineProperty(pointerMove, "pointerId", { value: 1 });
Object.defineProperty(pointerUp, "pointerId", { value: 1 });
await fireEvent(slider, pointerDown);
await fireEvent(slider, pointerMove);
await fireEvent(slider, pointerUp);
await fireEvent.update(
screen.getByPlaceholderText("分享你的使用体验"),
"一般",
);
await fireEvent.click(screen.getByRole("button", { name: "发表评论" }));
expect(submitReview).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
expect.objectContaining({ rating: 3 }),
);
});
it("updates rating preview from hovering over the star hitbox", async () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
vi.spyOn(slider, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
width: 500,
height: 40,
top: 0,
left: 0,
right: 500,
bottom: 40,
toJSON: () => ({}),
});
const pointerMove = new MouseEvent("pointermove", {
bubbles: true,
clientX: 250,
});
Object.defineProperty(pointerMove, "pointerId", { value: 1 });
await fireEvent(slider, pointerMove);
expect(slider).toHaveAttribute("aria-valuenow", "3");
});
it("does not include a trailing rating label in the star slider hitbox", () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
expect(slider).not.toHaveTextContent("星");
expect(slider).not.toHaveClass("border");
expect(slider).not.toHaveClass("bg-amber-50");
});
it("supports keyboard changes for the sliding star rating", async () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
await fireEvent.keyDown(slider, { key: "ArrowLeft" });
await fireEvent.keyDown(slider, { key: "ArrowLeft" });
expect(slider).toHaveAttribute("aria-valuenow", "3");
});
it("filters loaded reviews by package architecture and distro", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
{
id: 11,
rating: 5,
content: "amd64 deepin",
version: tags.version,
packageArch: "amd64",
clientArch: tags.clientArch,
distro: "deepin 25",
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "Deepin User",
userAvatarUrl: "",
},
{
id: 12,
rating: 4,
content: "arm64 gxde",
version: tags.version,
packageArch: "arm64",
clientArch: tags.clientArch,
distro: "GXDE OS 25",
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "GXDE User",
userAvatarUrl: "",
},
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("amd64 deepin")).toBeTruthy();
expect(screen.getByText("arm64 gxde")).toBeTruthy();
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
target: { value: "arm64" },
});
expect(screen.queryByText("amd64 deepin")).toBeNull();
expect(screen.getByText("arm64 gxde")).toBeTruthy();
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
target: { value: "" },
});
await fireEvent.change(screen.getByLabelText("按发行版筛选"), {
target: { value: "GXDE OS 25" },
});
expect(screen.queryByText("amd64 deepin")).toBeNull();
expect(screen.getByText("arm64 gxde")).toBeTruthy();
});
it("resets stale review filters when the app key changes", async () => {
vi.mocked(fetchReviews)
.mockResolvedValueOnce([
makeReview({ id: 21, content: "first amd64", packageArch: "amd64" }),
makeReview({
id: 22,
content: "first arm64",
packageArch: "arm64",
distro: "GXDE OS 25",
}),
])
.mockResolvedValueOnce([
makeReview({
id: 23,
content: "second only amd64",
packageArch: "amd64",
}),
]);
const rendered = render(ReviewsPanel, {
props: { appKey: "first", tags, loggedIn: true },
});
expect(await screen.findByText("first amd64")).toBeTruthy();
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
target: { value: "arm64" },
});
expect(screen.queryByText("first amd64")).toBeNull();
expect(screen.getByText("first arm64")).toBeTruthy();
await rendered.rerender({ appKey: "second", tags, loggedIn: true });
expect(await screen.findByText("second only amd64")).toBeTruthy();
expect(screen.queryByText("没有符合筛选条件的评价")).toBeNull();
});
it("exposes reviewer detail affordances from avatar and name buttons", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({
id: 31,
content: "用户资料入口",
userDisplayName: "Detail User",
}),
]);
const { emitted } = render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
await fireEvent.click(
await screen.findByRole("button", { name: "查看Detail User的资料" }),
);
expect(screen.getByText("正在查看 Detail User 的资料")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "Detail User" }));
expect(emitted()["show-user"]).toHaveLength(2);
});
it("calls backend review actions and only shows delete from backend metadata", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({ id: 41, content: "普通评价", userDisplayName: "Reader" }),
makeReview({
id: 42,
content: "作者评价",
userDisplayName: "Author",
isAuthor: true,
canDelete: true,
}),
]);
const rendered = render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("普通评价")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "点赞" })).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "回复" })).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "删除" })).toHaveLength(1);
await fireEvent.click(screen.getAllByRole("button", { name: "点赞" })[0]);
expect(likeReview).toHaveBeenCalledWith("apm:amd64-apm:office:wps", 41);
expect(fetchReviews).toHaveBeenCalledTimes(2);
expect(await screen.findByText("作者评价")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[0]);
expect(deleteReview).toHaveBeenCalledWith("apm:amd64-apm:office:wps", 42);
await rendered.rerender({
appKey: "apm:amd64-apm:office:wps",
tags,
loggedIn: true,
currentUserIsAdmin: true,
});
expect(screen.getAllByRole("button", { name: "删除" })).toHaveLength(1);
});
it("creates review replies and nested replies through backend APIs", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({
id: 51,
content: "可回复评价",
replies: [
makeReply({
id: 151,
reviewId: 51,
content: "已有回复",
replies: [
makeReply({
id: 152,
reviewId: 51,
parentId: 151,
content: "已有二级回复",
}),
],
}),
],
}),
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
await fireEvent.click(
(await screen.findAllByRole("button", { name: "回复" }))[0],
);
expect(screen.getByText("已有二级回复")).toBeTruthy();
await fireEvent.update(
screen.getByPlaceholderText("写下你的回复"),
"一级回复",
);
await fireEvent.click(screen.getByRole("button", { name: "发送回复" }));
expect(createReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
51,
{ content: "一级回复" },
);
expect(await screen.findByText("已有回复")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "回复" })[1]);
await fireEvent.update(
screen.getByPlaceholderText("写下你的回复"),
"二级回复",
);
await fireEvent.click(screen.getByRole("button", { name: "发送回复" }));
expect(createReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
51,
{ content: "二级回复", parentId: 151 },
);
});
it("calls backend reply actions and shows permission errors", async () => {
vi.mocked(deleteReview).mockRejectedValueOnce(
new Error("请登录星火账号后重试。"),
);
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({
id: 61,
content: "带回复评价",
canDelete: true,
replies: [
makeReply({
id: 161,
reviewId: 61,
content: "可操作回复",
canDelete: true,
}),
],
}),
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("可操作回复")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "点赞" })[1]);
expect(likeReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
61,
161,
);
expect(await screen.findByText("可操作回复")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[0]);
expect(await screen.findByText("请登录星火账号后重试。"));
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[1]);
expect(deleteReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
61,
161,
);
});
it("preserves stale-token prompts from review action failures", async () => {
vi.mocked(likeReview).mockRejectedValueOnce(
new Error("登录状态已失效,请重新登录星火账号。"),
);
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({ id: 71, content: "旧登录态评价" }),
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("旧登录态评价")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "点赞" }));
expect(
await screen.findByText("登录状态已失效,请重新登录星火账号。"),
).toBeTruthy();
});
it("shows reviewer avatars when available", async () => {
vi.mocked(fetchRatingSummary).mockResolvedValue({
averageRating: 5,
reviewCount: 1,
starCounts: { 5: 1 },
});
vi.mocked(fetchReviews).mockResolvedValue([
{
id: 3,
rating: 5,
content: "头像正常显示",
version: tags.version,
packageArch: tags.packageArch,
clientArch: tags.clientArch,
distro: tags.distro,
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-19T00:00:00Z",
updatedAt: "2026-05-19T00:00:00Z",
userDisplayName: "Avatar User",
userAvatarUrl: "https://bbs.spark-app.store/avatar.png",
},
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const avatar = await screen.findByAltText("Avatar User 的头像");
expect(avatar).toHaveAttribute(
"src",
"https://bbs.spark-app.store/avatar.png",
);
});
});