feat(sync): add installed app cloud sync

This commit is contained in:
2026-05-19 01:43:28 +08:00
parent ac1f46bd73
commit acffb6c5ee
7 changed files with 570 additions and 3 deletions
@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import AppListRestoreModal from "@/components/AppListRestoreModal.vue";
import type { SyncedAppListItem } from "@/global/typedefinition";
const createItem = (
overrides: Partial<SyncedAppListItem> = {},
): SyncedAppListItem => ({
pkgname: "spark-notes",
origin: "spark",
category: "office",
version: "1.0.0",
packageArch: "amd64",
appName: "Spark Notes",
iconUrl: "",
...overrides,
});
describe("AppListRestoreModal", () => {
it("emits selected installable cloud items", async () => {
const rendered = render(AppListRestoreModal, {
props: {
show: true,
loading: false,
error: "",
items: [
createItem(),
createItem({ pkgname: "amber-ce", appName: "Amber CE" }),
],
installedKeys: new Set<string>(),
},
});
await fireEvent.click(screen.getByLabelText("Spark Notes"));
await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" }));
expect(rendered.emitted("install-selected")?.[0]?.[0]).toEqual([
expect.objectContaining({ pkgname: "spark-notes" }),
]);
});
it("disables already installed cloud items", () => {
render(AppListRestoreModal, {
props: {
show: true,
loading: false,
error: "",
items: [createItem()],
installedKeys: new Set(["spark:spark-notes"]),
},
});
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
expect(screen.getByText("已安装")).toBeTruthy();
});
});
@@ -37,6 +37,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
},
});
@@ -57,6 +59,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
},
});
@@ -75,6 +79,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
},
});
@@ -97,6 +103,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
},
});
@@ -119,6 +127,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
},
});
@@ -136,9 +146,75 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
},
});
expect(screen.queryByRole("button", { name: "查看详情" })).toBeNull();
});
it("requests login for cloud actions when logged out", async () => {
const rendered = render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
},
});
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
await fireEvent.click(screen.getByRole("button", { name: "从账号恢复" }));
expect(rendered.emitted("request-login")).toHaveLength(2);
});
it("emits cloud sync and restore events when logged in", async () => {
const rendered = render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: true,
syncing: false,
},
});
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
await fireEvent.click(screen.getByRole("button", { name: "从账号恢复" }));
expect(rendered.emitted("sync-to-account")).toHaveLength(1);
expect(rendered.emitted("restore-from-account")).toHaveLength(1);
});
it("disables sync button while syncing", () => {
render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: true,
syncing: true,
},
});
expect(screen.getByRole("button", { name: "同步中" })).toBeDisabled();
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { buildSyncItems, cloudItemKey } from "@/modules/appListSync";
import type { App } from "@/global/typedefinition";
const createApp = (overrides: Partial<App> = {}): App => ({
name: "Spark Notes",
pkgname: "spark-notes",
version: "1.0.0",
filename: "spark-notes_1.0.0_amd64.deb",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "1 MB",
more: "",
tags: "",
img_urls: [],
icons: "https://example.test/icon.png",
category: "office",
origin: "spark",
currentStatus: "installed",
...overrides,
});
describe("appListSync", () => {
it("builds cloud sync items for installed store-recognized user apps", () => {
expect(buildSyncItems([createApp()])).toEqual([
{
pkgname: "spark-notes",
origin: "spark",
category: "office",
version: "1.0.0",
packageArch: "amd64",
appName: "Spark Notes",
iconUrl: "https://example.test/icon.png",
},
]);
});
it("filters out non-installed unknown dependency and unusable package entries", () => {
const items = buildSyncItems([
createApp({ pkgname: "not-installed", currentStatus: "not-installed" }),
createApp({ pkgname: "unknown-app", category: "unknown" }),
createApp({ pkgname: "dependency", isDependency: true }),
createApp({ pkgname: "" }),
createApp({ pkgname: "blank-origin", origin: "spark" }),
createApp({ pkgname: "kept", origin: "apm", arch: "arm64" }),
]);
expect(items).toEqual([
expect.objectContaining({ pkgname: "blank-origin" }),
expect.objectContaining({ pkgname: "kept", packageArch: "arm64" }),
]);
});
it("uses pkgname as appName and blank icon when optional display fields are missing", () => {
const app = createApp({ icons: "", pkgname: "fallback-name" });
app.name = "";
const [syncItem] = buildSyncItems([app]);
expect(syncItem).toMatchObject({
appName: "fallback-name",
iconUrl: "",
});
});
it("builds stable installed keys from origin and package", () => {
expect(cloudItemKey({ origin: "apm", pkgname: "amber-ce" })).toBe(
"apm:amber-ce",
);
});
});