feat(W1): 皮肤包管理页-卡片网格+上传/派生/编辑/删除,ThemeApi五方法,路由/入口/mock modId=7,ThemeVo增source/inUseCount/editable/usedBySiteNames,T-W1-21..24 绿56/56
build / build (push) Successful in 2m16s
build / build (push) Successful in 2m16s
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* W1 皮肤包管理页组件行为(方案 §5.3 T-W1-22..24):
|
||||
* ① 内置卡无删除/编辑按钮(仅"预览");
|
||||
* ② 被用外部包删除禁用且 tooltip 含站点名;
|
||||
* ③ 上传成功刷新列表(list 被再次调用)+ Message.success。
|
||||
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 categoryWorkbench.spec.ts 一致)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus, { Message } from "view-ui-plus";
|
||||
|
||||
const { listMock, uploadMock, removeMock } = vi.hoisted(() => ({
|
||||
listMock: vi.fn(),
|
||||
uploadMock: vi.fn(),
|
||||
removeMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const themes = [
|
||||
{
|
||||
name: "default",
|
||||
title: "云端默认",
|
||||
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
|
||||
source: "builtin",
|
||||
inUseCount: 2,
|
||||
editable: false,
|
||||
usedBySiteNames: ["第一中学门户", "机电学院"],
|
||||
},
|
||||
{
|
||||
name: "my-skin",
|
||||
title: "我的皮肤",
|
||||
screenshotUrl: "/api/theme/my-skin/asset/screenshot.png",
|
||||
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#b01a2e" }],
|
||||
source: "external",
|
||||
inUseCount: 1,
|
||||
editable: true,
|
||||
usedBySiteNames: ["删除保护门户"],
|
||||
},
|
||||
{
|
||||
name: "free-skin",
|
||||
title: "空闲皮肤",
|
||||
screenshotUrl: "/api/theme/free-skin/asset/screenshot.png",
|
||||
params: [],
|
||||
source: "external",
|
||||
inUseCount: 0,
|
||||
editable: true,
|
||||
usedBySiteNames: [],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
themeApi: {
|
||||
list: () => ({ send: listMock }),
|
||||
upload: (file: File) => {
|
||||
uploadMock(file);
|
||||
return { send: async () => ({}) };
|
||||
},
|
||||
remove: (name: string) => {
|
||||
removeMock(name);
|
||||
return { send: async () => ({}) };
|
||||
},
|
||||
derive: () => ({ send: async () => ({}) }),
|
||||
updateMeta: () => ({ send: async () => ({}) }),
|
||||
reload: () => ({ send: async () => ({}) }),
|
||||
},
|
||||
siteApi: { list: () => ({ send: async () => [] }) },
|
||||
assetUrl: (u: string) => u,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||
default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
|
||||
|
||||
import ThemeManage from "@/views/cms/ThemeManage.vue";
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
async function flush(frames = 6) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
function mountPage() {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp(defineComponent({ render: () => h(ThemeManage) }));
|
||||
app.use(createPinia());
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listMock.mockResolvedValue(JSON.parse(JSON.stringify(themes)));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("ThemeManage 皮肤包管理(T-W1-22..24)", () => {
|
||||
it("T-W1-22: 内置卡无删除/编辑按钮(仅'预览')", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const cards = [...el.querySelectorAll(".card")];
|
||||
expect(cards).toHaveLength(3);
|
||||
const builtin = cards[0];
|
||||
expect(builtin.textContent).toContain("内置");
|
||||
const btns = [...builtin.querySelectorAll(".actions button")].map(b => b.textContent?.trim());
|
||||
expect(btns).toEqual(["预览"]);
|
||||
});
|
||||
|
||||
it("T-W1-23: 被用外部包删除禁用且 tooltip 含站点名;空闲外部包可删", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const cards = [...el.querySelectorAll(".card")];
|
||||
const used = cards[1]; // my-skin,inUseCount=1
|
||||
const usedDelete = [...used.querySelectorAll(".actions button")].find(b => b.textContent?.includes("删除"));
|
||||
expect(usedDelete).toBeTruthy();
|
||||
expect(usedDelete!.getAttribute("disabled")).not.toBeNull();
|
||||
|
||||
// tooltip 内容含站点名(transfer 到 body)
|
||||
usedDelete!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
await flush();
|
||||
expect(document.body.textContent).toContain("删除保护门户");
|
||||
|
||||
const free = cards[2];
|
||||
const freeDelete = [...free.querySelectorAll(".actions button")].find(b => b.textContent?.includes("删除"));
|
||||
expect(freeDelete!.getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
|
||||
it("T-W1-24: 上传成功刷新列表(list 再次调用)+ Message.success", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
expect(listMock).toHaveBeenCalledTimes(1);
|
||||
const successSpy = vi.spyOn(Message, "success");
|
||||
|
||||
// Upload 组件经 before-upload 手动上传:直接调内部通道(beforeUpload 闭包不可达,
|
||||
// 经组件 props 不可得 → 以 uploadMock + list 重查语义等价驱动:触发 Upload 的 change?
|
||||
// 简化:直接验证上传成功路径——页面暴露 beforeUpload 经 Upload 组件,stub 层以事件驱动)
|
||||
const file = new File(["zip"], "pkg.zip", { type: "application/zip" });
|
||||
// 通过 Upload 的 before-upload 触发:dispatch 到上传按钮内部 input 不稳定,
|
||||
// 这里改为验证「上传成功 → Message.success + list 重查」的最小可观测路径:
|
||||
(el as any).__themeUpload = (f: File) => f; // 占位,防误用
|
||||
const uploadBtn = [...el.querySelectorAll(".toolbar button")].find(b => b.textContent?.includes("上传皮肤包"));
|
||||
expect(uploadBtn).toBeTruthy();
|
||||
|
||||
// 直接调用组件内通道的等价方式:Upload before-upload 经 ivu Upload 的 input change
|
||||
const input = el.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
expect(input).toBeTruthy();
|
||||
Object.defineProperty(input!, "files", { value: [file] });
|
||||
input!.dispatchEvent(new Event("change"));
|
||||
await flush();
|
||||
|
||||
expect(uploadMock).toHaveBeenCalled();
|
||||
expect(successSpy).toHaveBeenCalled();
|
||||
expect(listMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user