mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-08-07 01:03:57 +08:00
feat(account): polish reviews favorites and account UI
This commit is contained in:
@@ -3,12 +3,18 @@ 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";
|
||||
@@ -20,8 +26,13 @@ const emptySummary: RatingSummary = {
|
||||
};
|
||||
|
||||
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(),
|
||||
}));
|
||||
|
||||
@@ -35,13 +46,70 @@ const tags: ReviewTags = {
|
||||
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", () => {
|
||||
@@ -171,6 +239,402 @@ describe("ReviewsPanel", () => {
|
||||
).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,
|
||||
|
||||
Reference in New Issue
Block a user