feat(W4): 站点path全链路-portalUrlOf/previewPortalUrl纯函数,站点列/向导地址预览/预览frame sitePath,store携带path,mock嵌套路由,T-W4-16..18 绿51/51

This commit is contained in:
chenxing
2026-09-08 15:14:10 +08:00
parent 7b99ec398d
commit dedd4a6a3a
16 changed files with 297 additions and 37 deletions
+160
View File
@@ -0,0 +1,160 @@
// @vitest-environment jsdom
/**
* W4 向导地址预览联动(方案 §8.3 T-W4-18):
* 第 3 步「门户地址预览」随上级与编码输入联动(校级 lockParent 模式预置上级=选上级)。
* 挂载 SiteWizard(Modal transfer 到 body),桩掉主题三件套与配置表单。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import { createPinia } from "pinia";
import ViewUIPlus from "view-ui-plus";
const themesMock = vi.hoisted(() => ({
list: vi.fn(async () => [
{
name: "default",
title: "云端默认",
screenshotUrl: "/api/theme/default/asset/screenshot.png",
params: [],
},
]),
}));
vi.mock("@/api/module/cms", () => ({
themeApi: { list: () => ({ send: themesMock.list }) },
siteApi: { save: () => ({ send: async () => ({}) }) },
}));
vi.mock("@/components/theme/ThemePicker.vue", () => ({
default: defineComponent({
props: ["themes", "modelValue"],
emits: ["update:modelValue", "pick"],
setup(_, { emit }) {
(globalThis as any).__wizardPickTheme = (name: string) => emit("pick", name);
return () => h("div", { class: "theme-picker-stub" }, "themes");
},
}),
}));
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
default: defineComponent({ props: ["modelValue"], emits: ["update:modelValue"], render: () => null }),
}));
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
default: defineComponent({ props: ["url", "tick"], render: () => h("div", "preview-stub") }),
}));
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
default: defineComponent({
props: ["modelValue"],
methods: { validate: async () => true },
render: () => null,
}),
}));
import SiteWizard from "@/views/cms/SiteWizard.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 mountWizard(lockParentId?: number, secondMain = false) {
const sites = [
{ id: 1, code: "sch-3", path: "sch-3", parentSiteId: 0, schId: 1, schName: "第一中学", name: "第一中学门户", status: 1 },
...(secondMain
? [{ id: 2, code: "sch-8", path: "sch-8", parentSiteId: 0, schId: 2, schName: "第八中学", name: "第八中学门户", status: 1 }]
: []),
];
const visible = ref(false); // 挂载后翻转以触发 SiteWizard 的 visible watch(真实打开路径)
const pinia = createPinia();
const el = document.createElement("div");
document.body.appendChild(el);
const app = createApp(defineComponent({
setup() {
return () => h(SiteWizard, {
visible: visible.value,
sites,
lockParentId,
"onUpdate:visible": (v: boolean) => { visible.value = v; },
});
},
}));
app.use(pinia);
app.use(ViewUIPlus);
// SiteWizard 第 2 步的 FormItem 不在 Form 内(现状如此),jsdom 卸载时 view-ui-plus
// 内部 removeField/colon 读取 undefined 崩溃 → 本测试用展示桩覆盖 FormItem
app.component("FormItem", defineComponent({
props: ["label", "prop", "labelWidth"],
setup(props, { slots }) {
return () => h("div", { class: "form-item-stub" }, [
props.label ? h("label", props.label) : null,
slots.default?.(),
]);
},
}));
app.mount(el);
visible.value = true;
cleanups.push(() => {
app.unmount();
el.remove();
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
});
return el;
}
async function gotoStep3() {
// step1 → step2 → step3(footer 按钮 transfer 到 body)
const clickNext = async () => {
await flush();
const btn = [...document.querySelectorAll(".ivu-modal-footer button")]
.find(b => b.textContent?.includes("下一步"));
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
};
await clickNext();
await clickNext();
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanups.forEach(fn => fn());
cleanups = [];
});
describe("SiteWizard 门户地址预览(T-W4-18)", () => {
it("选上级(lockParent 预置 sch-3)+ 输入 jdxy → 预览 /portal/sch-3/jdxy/", async () => {
mountWizard(1);
await gotoStep3();
const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement;
expect(input).toBeTruthy();
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(input, "jdxy");
input.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
expect(document.body.textContent).toContain("门户地址预览");
expect(document.body.textContent).toContain("/portal/sch-3/jdxy/");
});
it("改上级即变:换到另一主站 sch-8 → 预览 /portal/sch-8/jdxy/", async () => {
mountWizard(2, true);
await gotoStep3();
const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(input, "jdxy");
input.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
expect(document.body.textContent).toContain("/portal/sch-8/jdxy/");
expect(document.body.textContent).not.toContain("/portal/sch-3/jdxy/");
});
});