Files
YschcmsFront/src/__tests__/homeLayoutEditor.spec.ts
T

330 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
/**
* 首页布局编辑器(v2 工作项 B → docs/20 F2 三栏升级)挂载回归:
* T-B-09 quick_links/notice_bar 属性面板行为不变;
* F2 新增:三栏结构(序列/预览/属性)、预览视口三档切换、类型卡 SVG 缩略图(R-40)、
* 展示形态 RadioCard、首用自动编排(无 home_layout + 有列表栏目 → 自动生成)。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import ViewUIPlus from "view-ui-plus";
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
const treeMock = vi.hoisted(() => ({ send: vi.fn() }));
vi.mock("@/api/module/cms", () => ({
categoryApi: { tree: () => treeMock },
}));
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
import { CategoryTreeNode } from "@/types/cms";
const cleanups: (() => void)[] = [];
/** 排空微任务 + 一个宏任务(view-ui-plus 的 Select 标签回填走 nextTick/setTimeout) */
async function settle(frames = 4) {
for (let i = 0; i < frames; i++) {
await nextTick();
}
await new Promise(r => setTimeout(r, 0));
for (let i = 0; i < frames; i++) {
await nextTick();
}
}
const CONFIG = JSON.stringify({
logo: "/a.png",
home_layout: {
version: 1,
blocks: [
{ id: "q1", type: "quick_links", items: [{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }] },
{ id: "n1", type: "notice_bar", text: "国庆放假安排", url: "/notice/gq", level: "warn" },
],
},
});
const TREE: CategoryTreeNode[] = [
{ id: 11, name: "校园时讯", type: 1, children: [] },
{ id: 12, name: "媒体聚焦", type: 1, children: [] },
] as unknown as CategoryTreeNode[];
async function mountEditor(
configJson: string,
opts?: { siteId?: number; portalUrl?: string; siteIdRef?: ReturnType<typeof ref<number | undefined>> },
) {
const el = document.createElement("div");
document.body.appendChild(el);
const model = ref(configJson);
const app = createApp(
defineComponent({
render: () =>
h(HomeLayoutEditor, {
modelValue: model.value,
siteId: opts?.siteIdRef ? opts.siteIdRef.value : opts?.siteId,
portalUrl: opts?.portalUrl,
"onUpdate:modelValue": (v: string) => (model.value = v),
}),
}),
);
app.use(ViewUIPlus);
app.mount(el);
cleanups.push(() => {
app.unmount();
el.remove();
});
await settle(6);
return { el, model };
}
/** 取属性面板中指定 label 的字段容器 */
function fieldOf(el: HTMLElement, label: string): HTMLElement | null {
return (
Array.from(el.querySelectorAll<HTMLElement>(".hle-field")).find(
f => f.querySelector(".hle-field-label")?.textContent?.trim() === label,
) || null
);
}
/** 第 index 行的删除按钮 */
function del(field: HTMLElement, index: number): HTMLElement {
return field.querySelectorAll<HTMLElement>(".hle-link-del")[index];
}
beforeEach(() => {
treeMock.send.mockReset();
treeMock.send.mockResolvedValue([]);
});
afterEach(() => {
cleanups.splice(0).forEach(fn => fn());
});
describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09,F2 改造后回归)", () => {
it("quick_links:入口行编辑器渲染 name+url 双输入,添加/删除可用且回写 items", async () => {
const { el, model } = await mountEditor(CONFIG);
// 序列行摘要走 blockSummary 新增分支
expect(el.textContent).toContain("1 个入口");
expect(el.textContent).toContain("醒目提示");
// 选中 quick_links 行 → 属性面板出现入口列表编辑器
(el.querySelectorAll(".hle-row")[0] as HTMLElement).click();
await settle();
const field = fieldOf(el as HTMLElement, "入口列表");
expect(field).not.toBeNull();
expect(field!.querySelectorAll(".hle-link-row").length).toBe(1);
expect(field!.querySelectorAll(".hle-link-row input").length).toBe(2);
const add = field!.querySelector(".hle-link-add") as HTMLElement;
expect(add.textContent).toContain("添加入口");
add.click();
await settle();
expect(field!.querySelectorAll(".hle-link-row").length).toBe(2);
// 新增行是空行:回写布局时空行由 cleanBlock 剔除,故提交形态仍只 1 项
const blocks = JSON.parse(model.value).home_layout.blocks;
expect(blocks[0].items).toEqual([{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }]);
// 其余键无损
expect(JSON.parse(model.value).logo).toBe("/a.png");
// 删掉尾部的空行 → 回到 1 行,提交形态不变
del(field!, 1).click();
await settle();
expect(field!.querySelectorAll(".hle-link-row").length).toBe(1);
expect(JSON.parse(model.value).home_layout.blocks[0].items).toEqual([{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }]);
// 再删唯一入口行 → 空列表(提交时 items 不落键,由后端校验拦下「至少 1 个入口」)
del(field!, 0).click();
await settle();
expect(field!.querySelectorAll(".hle-link-row").length).toBe(0);
expect(JSON.parse(model.value).home_layout.blocks[0].items).toBeUndefined();
});
it("quick_links:12 项上限时添加按钮禁用", async () => {
const items = Array.from({ length: 12 }, (_, i) => ({ name: `入口${i}`, url: `/e${i}` }));
const config = JSON.stringify({ home_layout: { version: 1, blocks: [{ id: "q1", type: "quick_links", items }] } });
const { el } = await mountEditor(config);
(el.querySelectorAll(".hle-row")[0] as HTMLElement).click();
await settle();
const field = fieldOf(el as HTMLElement, "入口列表")!;
expect(field.querySelectorAll(".hle-link-row").length).toBe(12);
expect((field.querySelector(".hle-link-add") as HTMLButtonElement).disabled).toBe(true);
});
it("notice_bar:正文输入放宽到 200 字、跳转可选、level 为 info/warn 选择器", async () => {
const { el } = await mountEditor(CONFIG);
(el.querySelectorAll(".hle-row")[1] as HTMLElement).click();
await settle();
const text = fieldOf(el as HTMLElement, "公告正文")!.querySelector("input")!;
expect(text.getAttribute("maxlength")).toBe("200");
expect(fieldOf(el as HTMLElement, "跳转地址")).not.toBeNull();
const levelField = fieldOf(el as HTMLElement, "提示级别")!;
expect(levelField.querySelector(".ivu-select")).not.toBeNull();
expect(levelField.textContent).toContain("醒目");
});
});
describe("HomeLayoutEditor - F2 三栏 + 预览 + 缩略图 + 自动编排(docs/20)", () => {
it("三栏结构:序列 / 实时预览 iframe / 属性面板;视口三档与刷新按钮齐备", async () => {
const { el } = await mountEditor(CONFIG, { portalUrl: "http://x/portal/demo/" });
expect(el.querySelector(".hle-sequence")).toBeTruthy();
expect(el.querySelector(".hle-props")).toBeTruthy();
const preview = el.querySelector(".hle-preview")!;
expect(preview).toBeTruthy();
expect(preview.querySelector("iframe")).toBeTruthy();
expect(preview.querySelector("iframe")!.getAttribute("src")).toBe("http://x/portal/demo/");
// 视口三档
expect(preview.textContent).toContain("桌面 1200");
expect(preview.textContent).toContain("平板 768");
expect(preview.textContent).toContain("手机 390");
const refresh = [...preview.querySelectorAll("button")].find(b => b.textContent?.includes("刷新预览"));
expect(refresh).toBeTruthy();
});
it("添加抽屉:每个类型卡配 SVG 缩略示意图(R-40,注册表全量)", async () => {
const { el } = await mountEditor(CONFIG);
const addBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("添加区块"))!;
addBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await settle();
// Drawer 渲染到 body 级传送门
const drawer = document.querySelector(".hle-drawer") || document.body;
const cards = drawer.querySelectorAll(".hle-type-card");
expect(cards.length).toBeGreaterThanOrEqual(6);
cards.forEach(c => {
expect(c.querySelector(".hle-type-thumb svg")).toBeTruthy();
});
});
it("展示形态 = RadioCard 可视化选择(5 张缩略图 + 文字,非纯文字下拉)", async () => {
const config = JSON.stringify({
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 }] },
});
const { el, model } = await mountEditor(config);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const styleField = fieldOf(el as HTMLElement, "展示形态")!;
expect(styleField.querySelector(".hle-style-cards")).toBeTruthy();
expect(styleField.querySelectorAll(".hle-style-card").length).toBe(5);
expect(styleField.querySelectorAll(".hle-style-card svg").length).toBe(5);
// 点击第 2 张卡(news_grid)→ 选中态 + 回写布局
(styleField.querySelectorAll(".hle-style-card")[1] as HTMLElement).click();
await settle();
expect(styleField.querySelectorAll(".hle-style-card")[1].className).toContain("active");
expect(JSON.parse(model.value).home_layout.blocks[0].style).toBe("news_grid");
});
it("首用自动编排:无 home_layout 键 + 站点有列表栏目 → 自动生成初始序列(D-4)", async () => {
treeMock.send.mockResolvedValue(TREE);
const noLayoutConfig = JSON.stringify({ logo: "/a.png" });
const { model } = await mountEditor(noLayoutConfig, { siteId: 1 });
const layout = JSON.parse(model.value).home_layout;
expect(layout).toBeTruthy();
expect(layout.blocks.length).toBeGreaterThanOrEqual(4); // ticker + 2 栏目区块 + gallery
expect(layout.blocks[0].type).toBe("ticker");
expect(layout.blocks.some((b: { type: string; title?: string }) => b.type === "article_list" && b.title === "校园时讯")).toBe(true);
expect(layout.blocks.some((b: { type: string }) => b.type === "gallery")).toBe(true);
// 其余键无损
expect(JSON.parse(model.value).logo).toBe("/a.png");
});
it("显式空布局(blocks=[])不触发自动编排(用户选择优先)", async () => {
treeMock.send.mockResolvedValue(TREE);
const emptyLayout = JSON.stringify({ home_layout: { version: 1, blocks: [] } });
const { model } = await mountEditor(emptyLayout, { siteId: 1 });
expect(JSON.parse(model.value).home_layout.blocks).toEqual([]);
});
it("无列表栏目不触发自动编排", async () => {
treeMock.send.mockResolvedValue([]);
const { model } = await mountEditor(JSON.stringify({ logo: "/a.png" }), { siteId: 1 });
expect(JSON.parse(model.value).home_layout).toBeUndefined();
});
});
describe("HomeLayoutEditor - 栏目树加载与首用编排解耦(docs/20 返工 #1)", () => {
it("已有布局 + 有列表栏目 + siteId:栏目树仍无条件加载,数据栏目可勾选", async () => {
treeMock.send.mockResolvedValue(TREE);
const config = JSON.stringify({
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 }] },
});
const { el, model } = await mountEditor(config, { siteId: 1 });
// 树加载发生(幂等标记下 treeMock 恰好 1 次调用)
expect(treeMock.send).toHaveBeenCalledTimes(1);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const cats = fieldOf(el as HTMLElement, "数据栏目")!;
expect(cats).not.toBeNull();
expect(cats.querySelectorAll(".hle-cat").length).toBe(2);
expect(cats.textContent).toContain("校园时讯");
expect(cats.textContent).toContain("媒体聚焦");
// 已有布局不被自动编排改写
expect(JSON.parse(model.value).home_layout.blocks[0].id).toBe("a1");
});
it("siteId 晚于挂载到位(父级异步种入):watch 补跑加载,数据栏目可用", async () => {
treeMock.send.mockResolvedValue(TREE);
const siteIdRef = ref<number | undefined>(undefined);
const config = JSON.stringify({
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [], style: "card", size: 8 }] },
});
const { el } = await mountEditor(config, { siteIdRef });
await settle();
// siteId 未到位时不请求
expect(treeMock.send).not.toHaveBeenCalled();
siteIdRef.value = 1;
await settle(6);
expect(treeMock.send).toHaveBeenCalledTimes(1);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const cats = fieldOf(el as HTMLElement, "数据栏目")!;
expect(cats.querySelectorAll(".hle-cat").length).toBe(2);
});
it("watch + onMounted 双触发下幂等:同 siteId 只请求一次", async () => {
treeMock.send.mockResolvedValue(TREE);
const siteIdRef = ref<number | undefined>(1);
await mountEditor(JSON.stringify({ logo: "/a.png" }), { siteIdRef });
await settle(6);
expect(treeMock.send).toHaveBeenCalledTimes(1);
});
});
describe("HomeLayoutEditor - D 组件属性面板(docs/20 D-1/D-8)", () => {
it("stats_band:统计项三列编辑器(名称/数值/单位)渲染并可增行", async () => {
const config = JSON.stringify({
home_layout: { version: 1, blocks: [
{ id: "s1", type: "stats_band", stats: [{ label: "在校生", value: "3.2", unit: "万" }] },
] },
});
const { el } = await mountEditor(config);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const field = fieldOf(el as HTMLElement, "统计项")!;
expect(field.querySelector(".hle-stat-row")).toBeTruthy();
expect((field.querySelector(".hle-stat-row input") as HTMLInputElement)).toBeTruthy();
const add = field.querySelector(".hle-stat-add, .hle-link-add") as HTMLElement;
add.click();
await settle();
expect(field.querySelectorAll(".hle-stat-row").length).toBe(2);
});
it("gallery:布局形态下拉含瀑布流(campus_wall)选项且选中回写", async () => {
const config = JSON.stringify({
home_layout: { version: 1, blocks: [{ id: "g1", type: "gallery" }] },
});
const { el, model } = await mountEditor(config);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const field = fieldOf(el as HTMLElement, "布局形态")!;
const select = field.querySelector(".ivu-select") as HTMLElement;
expect(select).toBeTruthy();
// 展开下拉点选 masonry
select.querySelector(".ivu-select-selection").dispatchEvent(new MouseEvent("click", { bubbles: true }));
await settle(8);
const opt = [...document.querySelectorAll(".ivu-select-item")].find(n => n.textContent?.includes("瀑布流")) as HTMLElement;
expect(opt).toBeTruthy();
opt.click();
await settle(8);
expect(JSON.parse(model.value).home_layout.blocks[0].layout).toBe("masonry");
});
});