mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 22:23:49 +08:00
feat(account): polish reviews favorites and account UI
This commit is contained in:
@@ -9,9 +9,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import App from "@/App.vue";
|
||||
import {
|
||||
addFavoriteItem,
|
||||
deleteFavoriteItem,
|
||||
fetchRatingSummary,
|
||||
fetchReviews,
|
||||
fetchSyncedAppList,
|
||||
createFavoriteFolder,
|
||||
listDownloadedApps,
|
||||
listFavoriteFolders,
|
||||
listFavoriteItems,
|
||||
uploadSyncedAppList,
|
||||
} from "@/modules/backendApi";
|
||||
import { setAuthSession } from "@/global/authState";
|
||||
@@ -158,8 +164,39 @@ vi.mock("@/modules/backendApi", () => ({
|
||||
addFavoriteItem: vi.fn(),
|
||||
bulkDeleteFavoriteItems: vi.fn(),
|
||||
createFavoriteFolder: vi.fn(),
|
||||
deleteFavoriteItem: vi.fn(),
|
||||
exchangeFlarumToken: vi.fn(),
|
||||
fetchRatingSummary: vi.fn(async () => ({
|
||||
averageRating: 5,
|
||||
reviewCount: 1,
|
||||
starCounts: { 5: 1 },
|
||||
})),
|
||||
fetchReviews: vi.fn(async () => [
|
||||
{
|
||||
id: 31,
|
||||
rating: 5,
|
||||
content: "profile entry",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
clientArch: "amd64",
|
||||
distro: "deepin 25",
|
||||
origin: "apm",
|
||||
category: "office",
|
||||
createdAt: "2026-05-20T00:00:00Z",
|
||||
updatedAt: "2026-05-20T00:00:00Z",
|
||||
userDisplayName: "Detail User",
|
||||
userAvatarUrl: "https://bbs.spark-app.store/avatar-detail.png",
|
||||
canDelete: false,
|
||||
replies: [],
|
||||
},
|
||||
]),
|
||||
fetchSyncedAppList: vi.fn(async () => null),
|
||||
submitReview: vi.fn(),
|
||||
likeReview: vi.fn(),
|
||||
createReviewReply: vi.fn(),
|
||||
deleteReview: vi.fn(),
|
||||
likeReviewReply: vi.fn(),
|
||||
deleteReviewReply: vi.fn(),
|
||||
listDownloadedApps: vi.fn(async () => downloadedList([])),
|
||||
listFavoriteFolders: vi.fn(async () => favoriteFolders),
|
||||
listFavoriteItems: vi.fn(async () => favoriteItems),
|
||||
@@ -197,10 +234,36 @@ describe("App account placeholders", () => {
|
||||
username: "momen",
|
||||
displayName: "Momen",
|
||||
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||
coverUrl:
|
||||
"https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg",
|
||||
forumLevel: "管理员",
|
||||
forumGroups: ["管理员"],
|
||||
},
|
||||
});
|
||||
vi.mocked(fetchRatingSummary).mockResolvedValue({
|
||||
averageRating: 5,
|
||||
reviewCount: 1,
|
||||
starCounts: { 5: 1 },
|
||||
});
|
||||
vi.mocked(fetchReviews).mockResolvedValue([
|
||||
{
|
||||
id: 31,
|
||||
rating: 5,
|
||||
content: "profile entry",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
clientArch: "amd64",
|
||||
distro: "deepin 25",
|
||||
origin: "apm",
|
||||
category: "office",
|
||||
createdAt: "2026-05-20T00:00:00Z",
|
||||
updatedAt: "2026-05-20T00:00:00Z",
|
||||
userDisplayName: "Detail User",
|
||||
userAvatarUrl: "https://bbs.spark-app.store/avatar-detail.png",
|
||||
canDelete: false,
|
||||
replies: [],
|
||||
},
|
||||
]);
|
||||
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
@@ -219,18 +282,91 @@ describe("App account placeholders", () => {
|
||||
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
|
||||
});
|
||||
|
||||
it("shows the user management placeholder from the logged-in quick menu", async () => {
|
||||
it("shows user management as a global modal from the logged-in quick menu", async () => {
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(await screen.findByText("全部应用"));
|
||||
expect(await screen.findByText("wps · 1.0.0")).toBeTruthy();
|
||||
|
||||
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||
await fireEvent.click(screen.getByText("用户管理"));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "用户管理" }),
|
||||
await screen.findByRole("dialog", { name: "用户管理" }),
|
||||
).toBeTruthy();
|
||||
const frame = await screen.findByTitle("星火账号用户管理");
|
||||
expect(frame).toBeTruthy();
|
||||
expect((frame as HTMLIFrameElement).src).toContain(
|
||||
"account.spark-app.store",
|
||||
);
|
||||
expect(screen.getByText("wps · 1.0.0")).toBeTruthy();
|
||||
expect(screen.queryByText("请登录后查看和管理账号信息。")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens an in-app profile modal from review author clicks", async () => {
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(await screen.findByText("全部应用"));
|
||||
await fireEvent.click(await screen.findByText("WPS"));
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Detail User" }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "用户资料" });
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(within(dialog).getByText("Detail User")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("enriches the current user's own review profile without a review user id", async () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
vi.mocked(fetchReviews).mockResolvedValueOnce([
|
||||
{
|
||||
id: 32,
|
||||
rating: 5,
|
||||
content: "own profile entry",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
clientArch: "amd64",
|
||||
distro: "deepin 25",
|
||||
origin: "apm",
|
||||
category: "office",
|
||||
createdAt: "2026-05-20T00:00:00Z",
|
||||
updatedAt: "2026-05-20T00:00:00Z",
|
||||
userDisplayName: "Momen",
|
||||
userAvatarUrl: "",
|
||||
isAuthor: true,
|
||||
canDelete: false,
|
||||
replies: [],
|
||||
},
|
||||
]);
|
||||
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(await screen.findByText("全部应用"));
|
||||
await fireEvent.click(await screen.findByText("WPS"));
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "查看Momen的资料" }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "用户资料" });
|
||||
expect(within(dialog).getByText("@momen")).toBeTruthy();
|
||||
expect(within(dialog).getByText("管理员")).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "查看论坛资料" }),
|
||||
).toBeTruthy();
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
|
||||
await fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: "查看论坛资料" }),
|
||||
);
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/u/momen"),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the favorites placeholder from the logged-in quick menu", async () => {
|
||||
render(App);
|
||||
|
||||
@@ -245,6 +381,301 @@ describe("App account placeholders", () => {
|
||||
expect(screen.queryByText("请登录后查看我的收藏。")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows favorite management as a standalone page without category pills", async () => {
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||
);
|
||||
await fireEvent.click(screen.getByText("我的收藏"));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "我的收藏" }),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByText("默认收藏夹 (1)")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /办公/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("loads favorite state when opening app detail directly", async () => {
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(await screen.findByText("全部应用"));
|
||||
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "已收藏 · 默认收藏夹" }),
|
||||
).toBeTruthy();
|
||||
expect(listFavoriteFolders).toHaveBeenCalled();
|
||||
expect(listFavoriteItems).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it("shows a newly created favorite folder immediately on the management page", async () => {
|
||||
const createdFolder: FavoriteFolder = {
|
||||
id: 8,
|
||||
name: "办公收藏",
|
||||
itemCount: 0,
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
updatedAt: "2026-05-18T00:00:00Z",
|
||||
};
|
||||
vi.spyOn(window, "prompt").mockReturnValue("办公收藏");
|
||||
vi.mocked(createFavoriteFolder).mockResolvedValueOnce(createdFolder);
|
||||
vi.mocked(listFavoriteFolders).mockResolvedValue(favoriteFolders);
|
||||
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||
);
|
||||
await fireEvent.click(screen.getByText("我的收藏"));
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "我的收藏" }),
|
||||
).toBeTruthy();
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "新建收藏夹" }));
|
||||
|
||||
expect(createFavoriteFolder).toHaveBeenCalledWith("办公收藏");
|
||||
expect(await screen.findByText("办公收藏 (0)")).toBeTruthy();
|
||||
expect(await screen.findByText("当前收藏夹暂无应用。")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows detail favorite state for an app added to a non-active folder", async () => {
|
||||
const folders = [
|
||||
favoriteFolders[0],
|
||||
{
|
||||
id: 8,
|
||||
name: "办公收藏",
|
||||
itemCount: 0,
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
updatedAt: "2026-05-18T00:00:00Z",
|
||||
},
|
||||
];
|
||||
vi.mocked(listFavoriteFolders)
|
||||
.mockResolvedValueOnce(folders)
|
||||
.mockResolvedValueOnce(folders);
|
||||
vi.mocked(listFavoriteItems).mockResolvedValue([]);
|
||||
vi.mocked(addFavoriteItem).mockResolvedValueOnce({
|
||||
...favoriteItems[0],
|
||||
id: 12,
|
||||
});
|
||||
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(await screen.findByText("全部应用"));
|
||||
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
|
||||
expect(await screen.findByRole("heading", { name: "WPS" })).toBeTruthy();
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
|
||||
await fireEvent.click(await screen.findByLabelText("收藏到 办公收藏"));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "已收藏 · 办公收藏" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("adds a favorite through the fallback default folder", async () => {
|
||||
vi.mocked(listFavoriteFolders)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(favoriteFolders);
|
||||
vi.mocked(listFavoriteItems).mockResolvedValueOnce([]);
|
||||
vi.mocked(addFavoriteItem).mockResolvedValueOnce(favoriteItems[0]);
|
||||
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(await screen.findByText("全部应用"));
|
||||
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
|
||||
expect(await screen.findByRole("heading", { name: "WPS" })).toBeTruthy();
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
|
||||
await fireEvent.click(await screen.findByLabelText("收藏到 默认收藏夹"));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
|
||||
|
||||
expect(addFavoriteItem).toHaveBeenCalledWith(
|
||||
"default",
|
||||
expect.objectContaining({ pkgname: "wps" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores stale favorite refreshes while opening the detail selector", async () => {
|
||||
const refreshFolders = createDeferred<FavoriteFolder[]>();
|
||||
const selectorFolders = createDeferred<FavoriteFolder[]>();
|
||||
const staleFolder = {
|
||||
id: 9,
|
||||
name: "旧收藏夹",
|
||||
itemCount: 0,
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
updatedAt: "2026-05-18T00:00:00Z",
|
||||
};
|
||||
const currentFolder = {
|
||||
id: 8,
|
||||
name: "办公收藏",
|
||||
itemCount: 0,
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
updatedAt: "2026-05-18T00:00:00Z",
|
||||
};
|
||||
vi.mocked(listFavoriteFolders)
|
||||
.mockReturnValueOnce(refreshFolders.promise)
|
||||
.mockReturnValueOnce(selectorFolders.promise);
|
||||
vi.mocked(listFavoriteItems).mockResolvedValue([]);
|
||||
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||
);
|
||||
await fireEvent.click(screen.getByText("我的收藏"));
|
||||
await fireEvent.click(await screen.findByText("全部应用"));
|
||||
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
|
||||
expect(await screen.findByRole("heading", { name: "WPS" })).toBeTruthy();
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
|
||||
selectorFolders.resolve([currentFolder]);
|
||||
expect(await screen.findByLabelText("收藏到 办公收藏")).toBeTruthy();
|
||||
|
||||
refreshFolders.resolve([staleFolder]);
|
||||
await refreshFolders.promise;
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(screen.getByLabelText("收藏到 办公收藏")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("收藏到 旧收藏夹")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows detail favorite state from a non-active backend folder", async () => {
|
||||
const folders = [
|
||||
favoriteFolders[0],
|
||||
{
|
||||
id: 8,
|
||||
name: "办公收藏",
|
||||
itemCount: 1,
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
updatedAt: "2026-05-18T00:00:00Z",
|
||||
},
|
||||
];
|
||||
vi.mocked(listFavoriteFolders).mockResolvedValueOnce(folders);
|
||||
vi.mocked(listFavoriteItems)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(favoriteItems);
|
||||
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||
);
|
||||
await fireEvent.click(screen.getByText("我的收藏"));
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "我的收藏" }),
|
||||
).toBeTruthy();
|
||||
|
||||
await fireEvent.click(await screen.findByText("全部应用"));
|
||||
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "已收藏 · 办公收藏" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("saves favorite folder checkbox changes from the detail selector", async () => {
|
||||
const folders = [
|
||||
favoriteFolders[0],
|
||||
{
|
||||
id: 8,
|
||||
name: "办公收藏",
|
||||
itemCount: 0,
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
updatedAt: "2026-05-18T00:00:00Z",
|
||||
},
|
||||
];
|
||||
vi.mocked(listFavoriteFolders).mockResolvedValue(folders);
|
||||
vi.mocked(listFavoriteItems).mockImplementation(async (folderId: number) =>
|
||||
folderId === 7 ? favoriteItems : [],
|
||||
);
|
||||
vi.mocked(addFavoriteItem).mockResolvedValueOnce({
|
||||
...favoriteItems[0],
|
||||
id: 12,
|
||||
});
|
||||
vi.mocked(deleteFavoriteItem).mockResolvedValueOnce(undefined);
|
||||
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||
await fireEvent.click(screen.getByText("我的收藏"));
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "我的收藏" }),
|
||||
).toBeTruthy();
|
||||
|
||||
await fireEvent.click(await screen.findByText("全部应用"));
|
||||
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
|
||||
expect(await screen.findByRole("heading", { name: "WPS" })).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "已收藏 · 默认收藏夹" }),
|
||||
).toBeTruthy();
|
||||
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "已收藏 · 默认收藏夹" }),
|
||||
);
|
||||
await fireEvent.click(await screen.findByLabelText("收藏到 默认收藏夹"));
|
||||
await fireEvent.click(await screen.findByLabelText("收藏到 办公收藏"));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
|
||||
|
||||
expect(deleteFavoriteItem).toHaveBeenCalledWith(7, 11);
|
||||
expect(addFavoriteItem).toHaveBeenCalledWith(
|
||||
8,
|
||||
expect.objectContaining({
|
||||
pkgname: "wps",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a folder from the favorite selector without closing it", async () => {
|
||||
const createdFolder = {
|
||||
id: 8,
|
||||
name: "办公收藏",
|
||||
itemCount: 0,
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
updatedAt: "2026-05-18T00:00:00Z",
|
||||
};
|
||||
vi.spyOn(window, "prompt").mockReturnValue("办公收藏");
|
||||
vi.mocked(createFavoriteFolder).mockResolvedValueOnce(createdFolder);
|
||||
vi.mocked(listFavoriteFolders)
|
||||
.mockResolvedValueOnce(favoriteFolders)
|
||||
.mockResolvedValueOnce([favoriteFolders[0], createdFolder]);
|
||||
vi.mocked(listFavoriteItems)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
vi.mocked(addFavoriteItem).mockResolvedValueOnce(favoriteItems[0]);
|
||||
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||
await fireEvent.click(screen.getByText("我的收藏"));
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "我的收藏" }),
|
||||
).toBeTruthy();
|
||||
|
||||
await fireEvent.click(await screen.findByText("全部应用"));
|
||||
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
|
||||
expect(await screen.findByRole("heading", { name: "WPS" })).toBeTruthy();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "新建收藏夹" }),
|
||||
);
|
||||
|
||||
expect(createFavoriteFolder).toHaveBeenCalledWith("办公收藏");
|
||||
expect(
|
||||
await screen.findByRole("dialog", { name: "选择收藏夹" }),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByLabelText("收藏到 办公收藏")).toBeTruthy();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
|
||||
|
||||
expect(addFavoriteItem).toHaveBeenCalledWith(
|
||||
8,
|
||||
expect.objectContaining({ pkgname: "wps" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes installed apps before resolving favorite management state", async () => {
|
||||
invoke.mockImplementation(async (channel: string) => {
|
||||
if (channel === "get-store-filter") return "both";
|
||||
@@ -416,7 +847,7 @@ describe("App account placeholders", () => {
|
||||
|
||||
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||
await fireEvent.click(screen.getByText("用户管理"));
|
||||
expect(await screen.findByText("正在加载下载历史...")).toBeTruthy();
|
||||
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
|
||||
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||
@@ -437,7 +868,8 @@ describe("App account placeholders", () => {
|
||||
}
|
||||
await fireEvent.click(await screen.findByText("用户管理"));
|
||||
secondHistory.resolve(downloadedList([]));
|
||||
expect(await screen.findByText("暂无下载记录。")).toBeTruthy();
|
||||
const secondUserFrame = await screen.findByTitle("星火账号用户管理");
|
||||
expect((secondUserFrame as HTMLIFrameElement).src).toContain("user=second");
|
||||
|
||||
firstHistory.resolve(
|
||||
downloadedList([
|
||||
@@ -459,7 +891,7 @@ describe("App account placeholders", () => {
|
||||
await Promise.resolve();
|
||||
|
||||
expect(screen.queryByText("旧账号应用")).toBeNull();
|
||||
expect(screen.getByText("暂无下载记录。")).toBeTruthy();
|
||||
expect(screen.getByTitle("星火账号用户管理")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ignores older downloaded history refreshes for the same user", async () => {
|
||||
@@ -489,7 +921,7 @@ describe("App account placeholders", () => {
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(await screen.findByText("新下载应用")).toBeTruthy();
|
||||
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
|
||||
|
||||
firstHistory.resolve(
|
||||
downloadedList([
|
||||
@@ -511,7 +943,7 @@ describe("App account placeholders", () => {
|
||||
await Promise.resolve();
|
||||
|
||||
expect(screen.queryByText("旧下载应用")).toBeNull();
|
||||
expect(screen.getByText("新下载应用")).toBeTruthy();
|
||||
expect(screen.queryByText("新下载应用")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores older favorite folder refreshes for the same user", async () => {
|
||||
@@ -675,16 +1107,9 @@ describe("App account placeholders", () => {
|
||||
|
||||
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||
await fireEvent.click(screen.getByText("用户管理"));
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "立即同步" }),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "同步中..." })).toBeDisabled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadSyncedAppList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
|
||||
syncUpload.resolve(syncedList([]));
|
||||
expect(await screen.findByText("同步完成")).toBeTruthy();
|
||||
expect(uploadSyncedAppList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears manual sync feedback before another user opens account management", async () => {
|
||||
@@ -703,15 +1128,9 @@ describe("App account placeholders", () => {
|
||||
|
||||
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||
await fireEvent.click(screen.getByText("用户管理"));
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "立即同步" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadSyncedAppList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
|
||||
syncUpload.resolve(syncedList([]));
|
||||
expect(await screen.findByText("同步完成")).toBeTruthy();
|
||||
expect(screen.queryByText("同步完成")).toBeNull();
|
||||
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||
@@ -735,6 +1154,7 @@ describe("App account placeholders", () => {
|
||||
}
|
||||
await fireEvent.click(await screen.findByText("用户管理"));
|
||||
|
||||
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
|
||||
expect(screen.queryByText("同步完成")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -769,9 +1189,7 @@ describe("App account placeholders", () => {
|
||||
|
||||
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||
await fireEvent.click(screen.getByText("用户管理"));
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "立即同步" }),
|
||||
);
|
||||
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||
);
|
||||
@@ -806,15 +1224,7 @@ describe("App account placeholders", () => {
|
||||
await fireEvent.click(secondUserButton);
|
||||
}
|
||||
await fireEvent.click(await screen.findByText("用户管理"));
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "立即同步" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadSyncedAppList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ items: [] }),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
|
||||
const uploadedItemNames = vi
|
||||
.mocked(uploadSyncedAppList)
|
||||
.mock.calls.flatMap(([payload]) =>
|
||||
|
||||
@@ -14,8 +14,9 @@ vi.mock("@/components/ReviewsPanel.vue", () => ({
|
||||
default: {
|
||||
name: "ReviewsPanel",
|
||||
props: ["appKey", "tags", "loggedIn", "canSubmit"],
|
||||
emits: ["request-login", "show-user"],
|
||||
template:
|
||||
'<div data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)"></div>',
|
||||
'<button type="button" data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)" @click="$emit(\'show-user\', { id: 31, userDisplayName: \'Detail User\', userAvatarUrl: \'\', rating: 5, content: \'\', version: tags.version, packageArch: tags.packageArch, clientArch: tags.clientArch, distro: tags.distro, origin: tags.origin, category: tags.category, createdAt: \'\', updatedAt: \'\' })"></button>',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -110,7 +111,7 @@ describe("AppDetailModal", () => {
|
||||
});
|
||||
|
||||
it("updates review identity when switching a merged app origin", async () => {
|
||||
render(AppDetailModal, {
|
||||
const rendered = render(AppDetailModal, {
|
||||
props: {
|
||||
show: true,
|
||||
app: mergedApp,
|
||||
@@ -142,6 +143,7 @@ describe("AppDetailModal", () => {
|
||||
"data-version",
|
||||
"1.0.0",
|
||||
);
|
||||
expect(rendered.emitted("select-origin")?.[0]?.[0]).toBe("apm");
|
||||
});
|
||||
|
||||
it("marks reviews read-only when the selected origin is not installed", () => {
|
||||
@@ -163,4 +165,48 @@ describe("AppDetailModal", () => {
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards review user profile events", async () => {
|
||||
const rendered = render(AppDetailModal, {
|
||||
props: {
|
||||
show: true,
|
||||
app,
|
||||
screenshots: [],
|
||||
sparkInstalled: true,
|
||||
apmInstalled: true,
|
||||
loggedIn: true,
|
||||
reviewAppKey: "apm:amd64-apm:office:wps",
|
||||
reviewTags: sparkTags,
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByTestId("reviews-panel"));
|
||||
|
||||
expect(rendered.emitted("show-user")?.[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ userDisplayName: "Detail User" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders favorited state with folder name and still emits favorite", async () => {
|
||||
const rendered = render(AppDetailModal, {
|
||||
props: {
|
||||
show: true,
|
||||
app,
|
||||
screenshots: [],
|
||||
sparkInstalled: false,
|
||||
apmInstalled: false,
|
||||
loggedIn: true,
|
||||
reviewAppKey: "apm:amd64-apm:office:wps",
|
||||
reviewTags: sparkTags,
|
||||
favorited: true,
|
||||
favoriteFolderName: "办公收藏",
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "已收藏 · 办公收藏" }),
|
||||
);
|
||||
|
||||
expect(rendered.emitted("favorite")?.[0]?.[0]).toEqual(app);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,9 @@ vi.mock("@/components/ReviewsPanel.vue", () => ({
|
||||
default: {
|
||||
name: "ReviewsPanel",
|
||||
props: ["appKey", "tags", "loggedIn", "canSubmit"],
|
||||
emits: ["request-login", "show-user"],
|
||||
template:
|
||||
'<div data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)"></div>',
|
||||
'<button type="button" data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)" @click="$emit(\'show-user\', { id: 31, userDisplayName: \'Detail User\', userAvatarUrl: \'\', rating: 5, content: \'\', version: tags.version, packageArch: tags.packageArch, clientArch: tags.clientArch, distro: tags.distro, origin: tags.origin, category: tags.category, createdAt: \'\', updatedAt: \'\' })"></button>',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -169,4 +170,24 @@ describe("AppDetailPage", () => {
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards review user profile events", async () => {
|
||||
const rendered = render(AppDetailPage, {
|
||||
props: {
|
||||
app,
|
||||
screenshots: [],
|
||||
sparkInstalled: true,
|
||||
apmInstalled: true,
|
||||
loggedIn: true,
|
||||
reviewAppKey: "apm:amd64-apm:office:wps",
|
||||
reviewTags: sparkTags,
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByTestId("reviews-panel"));
|
||||
|
||||
expect(rendered.emitted("show-user")?.[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ userDisplayName: "Detail User" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,10 +46,22 @@ describe("AppSidebar account entry", () => {
|
||||
expect(screen.getByText("退出登录")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes the quick menu after clicking outside the account area", async () => {
|
||||
render(AppSidebar, { props: { ...baseProps, currentUser: user } });
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
|
||||
expect(screen.getByText("用户管理")).toBeTruthy();
|
||||
|
||||
await fireEvent.mouseDown(document.body);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps long account names inside the sidebar account entry", () => {
|
||||
const longUser: SparkUser = {
|
||||
...user,
|
||||
displayName: "SuperEndermanSMSuperEndermanSMSuperEndermanSM",
|
||||
username: "SuperEndermanSMSuperEndermanSMSuperEndermanSM",
|
||||
displayName: "",
|
||||
};
|
||||
|
||||
const { container } = render(AppSidebar, {
|
||||
@@ -62,22 +74,43 @@ describe("AppSidebar account entry", () => {
|
||||
const textWrapper = accountButton.querySelector(
|
||||
"[data-testid='account-text']",
|
||||
);
|
||||
const accountName = screen.getByText(longUser.displayName);
|
||||
const accountName = screen.getByText(longUser.username);
|
||||
|
||||
expect(textWrapper?.className).toContain("min-w-0");
|
||||
expect(accountName.className).toContain("truncate");
|
||||
expect(container.textContent).toContain(longUser.displayName);
|
||||
expect(container.textContent).toContain(longUser.username);
|
||||
});
|
||||
|
||||
it("closes the quick menu after selecting an account action", async () => {
|
||||
it.each([
|
||||
["用户管理", "open-user-management"],
|
||||
["我的收藏", "open-favorites"],
|
||||
["论坛首页", "open-forum"],
|
||||
["修改论坛资料", "edit-profile"],
|
||||
["退出登录", "logout"],
|
||||
] as const)(
|
||||
"closes the quick menu after selecting %s",
|
||||
async (label, eventName) => {
|
||||
const rendered = render(AppSidebar, {
|
||||
props: { ...baseProps, currentUser: user },
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: label }));
|
||||
|
||||
expect(rendered.emitted(eventName)).toHaveLength(1);
|
||||
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("closes the quick menu after selecting a sidebar action", async () => {
|
||||
const rendered = render(AppSidebar, {
|
||||
props: { ...baseProps, currentUser: user },
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "用户管理" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "应用管理" }));
|
||||
|
||||
expect(rendered.emitted("open-user-management")).toHaveLength(1);
|
||||
expect(rendered.emitted("list")).toHaveLength(1);
|
||||
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,4 +88,69 @@ describe("FavoriteFolderManager", () => {
|
||||
expect(rendered.emitted("open-detail")?.[0]?.[0]).toEqual(selectedApp);
|
||||
expect(rendered.emitted("remove-selected")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps checkbox selection isolated from opening app detail", async () => {
|
||||
const rendered = render(FavoriteFolderManager, {
|
||||
props: {
|
||||
folders: [folder],
|
||||
activeFolderId: 1,
|
||||
items: [{ ...item, status: "installable", selectedApp }],
|
||||
loading: false,
|
||||
error: "",
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText("选择 WPS"));
|
||||
|
||||
expect(rendered.emitted("open-detail")).toBeUndefined();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "移除选中" }));
|
||||
expect(rendered.emitted("remove-selected")?.[0]?.[0]).toEqual([2]);
|
||||
});
|
||||
|
||||
it("selects installable favorites and emits them for installation", async () => {
|
||||
const installableItem: ResolvedFavoriteItem = {
|
||||
...item,
|
||||
status: "installable",
|
||||
reason: "可安装",
|
||||
selectedApp,
|
||||
};
|
||||
const installedItem: ResolvedFavoriteItem = {
|
||||
...item,
|
||||
item: {
|
||||
...item.item,
|
||||
id: 3,
|
||||
pkgname: "installed-app",
|
||||
name: "已安装应用",
|
||||
},
|
||||
status: "installed",
|
||||
reason: "已安装",
|
||||
selectedApp: {
|
||||
...selectedApp,
|
||||
pkgname: "installed-app",
|
||||
name: "已安装应用",
|
||||
},
|
||||
};
|
||||
const rendered = render(FavoriteFolderManager, {
|
||||
props: {
|
||||
folders: [folder],
|
||||
activeFolderId: 1,
|
||||
items: [installableItem, installedItem],
|
||||
loading: false,
|
||||
error: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled();
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "选择可安装" }));
|
||||
|
||||
expect(screen.getByLabelText("选择 WPS")).toBeChecked();
|
||||
expect(screen.getByLabelText("选择 已安装应用")).not.toBeChecked();
|
||||
expect(screen.getByText("已选择 1 个可安装应用")).toBeTruthy();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" }));
|
||||
|
||||
expect(rendered.emitted("install-selected")?.[0]?.[0]).toEqual([
|
||||
installableItem,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,9 +34,22 @@ describe("FavoriteFolderSelector", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getAllByRole("button", { name: "默认收藏夹" })).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
screen.getAllByRole("checkbox", { name: "收藏到 默认收藏夹" }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("normalizes backend default folder names before adding fallback default", () => {
|
||||
render(FavoriteFolderSelector, {
|
||||
props: {
|
||||
show: true,
|
||||
folders: [{ ...defaultFolder, name: " 默认收藏夹 " }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("checkbox", { name: /收藏到\s*默认收藏夹/ }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("offers creating a folder while selecting favorites", async () => {
|
||||
@@ -51,4 +64,75 @@ describe("FavoriteFolderSelector", () => {
|
||||
|
||||
expect(rendered.emitted("create-folder")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits the current draft selection when creating a folder", async () => {
|
||||
const rendered = render(FavoriteFolderSelector, {
|
||||
props: {
|
||||
show: true,
|
||||
folders: [defaultFolder],
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "新建收藏夹" }));
|
||||
|
||||
expect(rendered.emitted("create-folder")?.[0]?.[0]).toEqual([1]);
|
||||
});
|
||||
|
||||
it("emits checked folder ids only after confirmation", async () => {
|
||||
const rendered = render(FavoriteFolderSelector, {
|
||||
props: {
|
||||
show: true,
|
||||
folders: [
|
||||
defaultFolder,
|
||||
{ ...defaultFolder, id: 2, name: "办公收藏", itemCount: 0 },
|
||||
],
|
||||
selectedFolderIds: [defaultFolder.id],
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
|
||||
await fireEvent.click(screen.getByLabelText("收藏到 办公收藏"));
|
||||
|
||||
expect(rendered.emitted("save-selection")).toBeUndefined();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
|
||||
|
||||
expect(rendered.emitted("save-selection")?.[0]?.[0]).toEqual([2]);
|
||||
});
|
||||
|
||||
it("emits the fallback default folder selection after confirmation", async () => {
|
||||
const rendered = render(FavoriteFolderSelector, {
|
||||
props: {
|
||||
show: true,
|
||||
folders: [],
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
|
||||
|
||||
expect(rendered.emitted("save-selection")?.[0]?.[0]).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("preserves unsaved folder checks when the folder list changes", async () => {
|
||||
const rendered = render(FavoriteFolderSelector, {
|
||||
props: {
|
||||
show: true,
|
||||
folders: [defaultFolder],
|
||||
selectedFolderIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
|
||||
await rendered.rerender({
|
||||
folders: [
|
||||
defaultFolder,
|
||||
{ ...defaultFolder, id: 2, name: "办公收藏", itemCount: 0 },
|
||||
],
|
||||
});
|
||||
await fireEvent.click(screen.getByLabelText("收藏到 办公收藏"));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
|
||||
|
||||
expect(rendered.emitted("save-selection")?.[0]?.[0]).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("InstalledAppsModal", () => {
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -61,6 +62,7 @@ describe("InstalledAppsModal", () => {
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,6 +83,7 @@ describe("InstalledAppsModal", () => {
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,6 +108,7 @@ describe("InstalledAppsModal", () => {
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -129,6 +133,7 @@ describe("InstalledAppsModal", () => {
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -148,6 +153,7 @@ describe("InstalledAppsModal", () => {
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -167,6 +173,7 @@ describe("InstalledAppsModal", () => {
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -189,6 +196,7 @@ describe("InstalledAppsModal", () => {
|
||||
apmAvailable: true,
|
||||
loggedIn: true,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -212,9 +220,30 @@ describe("InstalledAppsModal", () => {
|
||||
apmAvailable: true,
|
||||
loggedIn: true,
|
||||
syncing: true,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "同步中" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows account sync feedback in the installed apps modal", () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: true,
|
||||
syncing: false,
|
||||
syncMessage: "同步完成",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("同步完成")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import ReviewUserProfileModal from "@/components/ReviewUserProfileModal.vue";
|
||||
import type { ReviewUserProfile } from "@/global/typedefinition";
|
||||
|
||||
const profile = (
|
||||
overrides: Partial<ReviewUserProfile> = {},
|
||||
): ReviewUserProfile => ({
|
||||
displayName: "Momen",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("ReviewUserProfileModal", () => {
|
||||
it("does not insert unsafe cover URLs into background image styles", () => {
|
||||
const { container } = render(ReviewUserProfileModal, {
|
||||
props: {
|
||||
show: true,
|
||||
profile: profile({ coverUrl: "javascript:alert(1)" }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.innerHTML).not.toContain("javascript:alert(1)");
|
||||
const cover = container.querySelector("[data-testid='review-user-cover']");
|
||||
expect((cover as HTMLElement | null)?.style.backgroundImage).toBe("");
|
||||
});
|
||||
|
||||
it("emits close when Escape is pressed inside the dialog", async () => {
|
||||
const rendered = render(ReviewUserProfileModal, {
|
||||
props: {
|
||||
show: true,
|
||||
profile: profile(),
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.keyDown(screen.getByRole("dialog", { name: "用户资料" }), {
|
||||
key: "Escape",
|
||||
});
|
||||
|
||||
expect(rendered.emitted("close")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits the forum profile URL when opening a username profile", async () => {
|
||||
const rendered = render(ReviewUserProfileModal, {
|
||||
props: {
|
||||
show: true,
|
||||
profile: profile({ username: "momen" }),
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "查看论坛资料" }));
|
||||
|
||||
expect(rendered.emitted("open-forum-profile")?.[0]?.[0]).toMatch(
|
||||
/\/u\/momen$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import UserManagementModal from "@/components/UserManagementModal.vue";
|
||||
import type { SparkUser } from "@/global/typedefinition";
|
||||
|
||||
const user: SparkUser = {
|
||||
id: 1,
|
||||
flarumUserId: "42",
|
||||
username: "momen",
|
||||
displayName: "Momen",
|
||||
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||
coverUrl: "https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg",
|
||||
forumLevel: "管理员",
|
||||
forumGroups: ["管理员"],
|
||||
};
|
||||
|
||||
describe("UserManagementModal", () => {
|
||||
it("renders account management in an independent iframe without token query params", () => {
|
||||
render(UserManagementModal, {
|
||||
props: {
|
||||
show: true,
|
||||
user,
|
||||
downloadedApps: [],
|
||||
syncEnabled: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
},
|
||||
});
|
||||
|
||||
const frame = screen.getByTitle("星火账号用户管理") as HTMLIFrameElement;
|
||||
expect(frame).toBeTruthy();
|
||||
expect(frame.src).toContain("account.spark-app.store");
|
||||
expect(frame.src).toContain("/account");
|
||||
expect(frame.src).not.toMatch(/token|jwt|password|access/i);
|
||||
});
|
||||
|
||||
it("shows retry controls when iframe reports a load failure", async () => {
|
||||
render(UserManagementModal, {
|
||||
props: {
|
||||
show: true,
|
||||
user,
|
||||
downloadedApps: [],
|
||||
syncEnabled: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.error(screen.getByTitle("星火账号用户管理"));
|
||||
|
||||
expect(screen.getByText("账号页面加载失败")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "重试" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -67,4 +67,27 @@ describe("UserManagementView", () => {
|
||||
|
||||
expect(screen.getByText("同步完成")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the forum profile cover when available", () => {
|
||||
render(UserManagementView, {
|
||||
props: {
|
||||
user: {
|
||||
...user,
|
||||
coverUrl:
|
||||
"https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg",
|
||||
},
|
||||
downloadedApps: [],
|
||||
syncEnabled: false,
|
||||
loading: false,
|
||||
error: "",
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("profile-cover")).toHaveStyle({
|
||||
backgroundImage:
|
||||
'url("https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg")',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import WindowTitleBar from "@/components/WindowTitleBar.vue";
|
||||
|
||||
const windowControls = {
|
||||
minimize: vi.fn(),
|
||||
toggleMaximize: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
describe("WindowTitleBar", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, "windowControls", {
|
||||
value: windowControls,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends window control requests", async () => {
|
||||
render(WindowTitleBar);
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "最小化" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "最大化或还原" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "关闭" }));
|
||||
|
||||
expect(windowControls.minimize).toHaveBeenCalledTimes(1);
|
||||
expect(windowControls.toggleMaximize).toHaveBeenCalledTimes(1);
|
||||
expect(windowControls.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays below modal overlays in the stacking order", () => {
|
||||
const { container } = render(WindowTitleBar);
|
||||
|
||||
expect(container.firstElementChild?.className).toContain("z-20");
|
||||
expect(container.firstElementChild?.className).not.toContain("z-[60]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildAccountFrameUrl } from "@/modules/accountCenterUrl";
|
||||
|
||||
describe("accountCenterUrl", () => {
|
||||
it("falls back to the production account URL when configured URL is malformed", () => {
|
||||
const url = buildAccountFrameUrl("not a url", "momen");
|
||||
|
||||
expect(url).toContain("https://account.spark-app.store/account");
|
||||
expect(url).toContain("view=management");
|
||||
expect(url).toContain("user=momen");
|
||||
expect(url).not.toMatch(/token|jwt|password|access/i);
|
||||
});
|
||||
|
||||
it("falls back when configured URL uses an unsafe protocol", () => {
|
||||
const url = buildAccountFrameUrl("javascript:alert(1)", "momen");
|
||||
|
||||
expect(url).toContain("https://account.spark-app.store/account");
|
||||
expect(url).toContain("view=management");
|
||||
expect(url).toContain("user=momen");
|
||||
expect(url).not.toMatch(/^javascript:/i);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ describe("account shared types", () => {
|
||||
username: "momen",
|
||||
displayName: "Momen",
|
||||
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||
coverUrl: "https://bbs.spark-app.store/assets/covers/user.jpg",
|
||||
forumLevel: "管理员",
|
||||
forumGroups: ["管理员"],
|
||||
};
|
||||
@@ -91,6 +92,7 @@ describe("account shared types", () => {
|
||||
expect(FLARUM_BASE_URL).toContain("bbs.spark-app.store");
|
||||
expect(FLARUM_REGISTER_URL).toContain("register");
|
||||
expect(user.forumGroups).toEqual(["管理员"]);
|
||||
expect(user.coverUrl).toContain("/assets/covers/");
|
||||
expect(folder.itemCount).toBe(1);
|
||||
expect(favorite.appKey).toBe("app:office:wps");
|
||||
expect(download.selectedOrigin).toBe("apm");
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
cloudItemKey,
|
||||
cloudPackageKey,
|
||||
mergeInstalledApps,
|
||||
resolveCloudInstallCandidate,
|
||||
} from "@/modules/appListSync";
|
||||
import type { App } from "@/global/typedefinition";
|
||||
|
||||
@@ -95,4 +96,66 @@ describe("appListSync", () => {
|
||||
expect.objectContaining({ origin: "apm", pkgname: "apm-installed" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves cloud restore items by exact source before package fallback", () => {
|
||||
const sparkCloudItem = {
|
||||
pkgname: "shared-app",
|
||||
origin: "spark" as const,
|
||||
category: "office",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
appName: "Shared App",
|
||||
iconUrl: "",
|
||||
};
|
||||
const apmCandidate = createApp({
|
||||
origin: "apm",
|
||||
pkgname: "shared-app",
|
||||
category: "office",
|
||||
});
|
||||
const sparkCandidate = createApp({
|
||||
origin: "spark",
|
||||
pkgname: "shared-app",
|
||||
category: "office",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCloudInstallCandidate(sparkCloudItem, [
|
||||
apmCandidate,
|
||||
sparkCandidate,
|
||||
]),
|
||||
).toBe(sparkCandidate);
|
||||
expect(resolveCloudInstallCandidate(sparkCloudItem, [apmCandidate])).toBe(
|
||||
apmCandidate,
|
||||
);
|
||||
expect(resolveCloudInstallCandidate(sparkCloudItem, [])).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers same-source package fallback when the category changed", () => {
|
||||
const sparkCloudItem = {
|
||||
pkgname: "shared-app",
|
||||
origin: "spark" as const,
|
||||
category: "legacy-office",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
appName: "Shared App",
|
||||
iconUrl: "",
|
||||
};
|
||||
const apmCandidate = createApp({
|
||||
origin: "apm",
|
||||
pkgname: "shared-app",
|
||||
category: "office",
|
||||
});
|
||||
const sparkCandidate = createApp({
|
||||
origin: "spark",
|
||||
pkgname: "shared-app",
|
||||
category: "productivity",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCloudInstallCandidate(sparkCloudItem, [
|
||||
apmCandidate,
|
||||
sparkCandidate,
|
||||
]),
|
||||
).toBe(sparkCandidate);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ describe("authState", () => {
|
||||
username: "momen",
|
||||
displayName: "Momen",
|
||||
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||
coverUrl: "https://bbs.spark-app.store/assets/covers/user.jpg",
|
||||
forumLevel: "管理员",
|
||||
forumGroups: ["管理员"],
|
||||
},
|
||||
@@ -26,6 +27,7 @@ describe("authState", () => {
|
||||
|
||||
expect(authSession.value?.accessToken).toBe("jwt");
|
||||
expect(currentUser.value?.displayName).toBe("Momen");
|
||||
expect(currentUser.value?.coverUrl).toContain("/assets/covers/");
|
||||
expect(isLoggedIn.value).toBe(true);
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("spark-store-auth") || "{}").accessToken,
|
||||
|
||||
@@ -128,4 +128,31 @@ describe("backend API auth exchange", () => {
|
||||
}),
|
||||
).rejects.toThrow("星火账号服务异常,请稍后重试。");
|
||||
});
|
||||
|
||||
it("maps review submission 401 responses to a re-login prompt", async () => {
|
||||
const error = Object.assign(
|
||||
new Error("Request failed with status code 401"),
|
||||
{
|
||||
isAxiosError: true,
|
||||
response: { status: 401 },
|
||||
},
|
||||
);
|
||||
axiosMocks.post.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
submitReview("apm:amd64-apm:office:wps", {
|
||||
rating: 5,
|
||||
content: "好用",
|
||||
tags: {
|
||||
origin: "apm",
|
||||
category: "office",
|
||||
pkgname: "wps",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
clientArch: "amd64",
|
||||
distro: "deepin 25",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("登录状态已失效,请重新登录星火账号。");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const mainSource = readFileSync(
|
||||
resolve(testDir, "../../../electron/main/index.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
const preloadSource = readFileSync(
|
||||
resolve(testDir, "../../../electron/preload/index.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
const viteEnvSource = readFileSync(
|
||||
resolve(testDir, "../../vite-env.d.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
describe("frameless window shell config", () => {
|
||||
it("creates the main BrowserWindow without a native frame", () => {
|
||||
expect(mainSource).toMatch(/new BrowserWindow\(\{[\s\S]*frame:\s*false/);
|
||||
});
|
||||
|
||||
it("routes titlebar close through the guarded BrowserWindow close path", () => {
|
||||
expect(mainSource).toContain('ipcMain.on("window-control-close"');
|
||||
expect(mainSource).toMatch(
|
||||
/ipcMain\.on\("window-control-close"[\s\S]*win\?\.close\(\)/,
|
||||
);
|
||||
expect(mainSource).not.toMatch(
|
||||
/ipcMain\.on\("window-control-close"[\s\S]*win\?\.destroy\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes typed window controls from preload", () => {
|
||||
expect(preloadSource).toContain('exposeInMainWorld("windowControls"');
|
||||
expect(viteEnvSource).toContain("windowControls: WindowControlBridge");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user