feat(account): polish reviews favorites and account UI

This commit is contained in:
2026-05-29 21:34:42 +08:00
parent abeb511c06
commit 439af8c26f
40 changed files with 3158 additions and 250 deletions
@@ -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]) =>
+48 -2
View File
@@ -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);
});
});
+22 -1
View File
@@ -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" }),
);
});
});
+39 -6
View File
@@ -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$/,
);
});
});
+464
View File
@@ -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")',
});
});
});
+39
View File
@@ -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);
});
});
+2
View File
@@ -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");
+63
View File
@@ -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);
});
});
+2
View File
@@ -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,
+27
View File
@@ -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");
});
});