diff --git a/packages/vue3/src/vue-helper/class-helper.ts b/packages/vue3/src/vue-helper/class-helper.ts new file mode 100644 index 0000000..a7211d6 --- /dev/null +++ b/packages/vue3/src/vue-helper/class-helper.ts @@ -0,0 +1,116 @@ +/** Vue class 对象格式,键为类名,值为布尔值表示是否生效 */ +type CustomClassObj = Record; +/** Vue class 支持的类型:字符串、字符串数组或对象 */ +type CustomClass = string | Array | CustomClassObj; +/** 分解后的 Vue class 对象,所有类名值为 true */ +type DistCustomClass = Record; + +/** + * 将字符串或字符串数组转换为 Vue class 对象 + * + * 将类名字符串或数组转换为 `{ className: true }` 格式的对象, + * 适用于 Vue 的 class 绑定。 + * + * @example + * ```ts + * createCustomClassObj('foo bar') // { foo: true, bar: true } + * createCustomClassObj(['foo', 'bar']) // { foo: true, bar: true } + * ``` + * + * @param customClass 类名字符串或字符串数组 + * @returns 类名对象,所有值为 true + * @throws 若 customClass 不是字符串或数组则抛出 TypeError + */ +export function createCustomClassObj(customClass: string): DistCustomClass; +export function createCustomClassObj( + customClass: Array, +): DistCustomClass; +export function createCustomClassObj( + customClass: string | Array, +): DistCustomClass { + let customClassObj = {}; + if (typeof customClass === "string") { + const customClassStr = customClass.trim().replace(" +", " "); + if (customClassStr.length > 0) { + const customClassEntries = customClassStr + .split(" ") + .map((name) => [name, true]); + customClassObj = Object.fromEntries(customClassEntries); + } + } else if (Array.isArray(customClass)) { + if (customClass.length > 0) { + const customClassEntries = customClass.map((name) => [name, true]); + customClassObj = Object.fromEntries(customClassEntries); + } + } else { + throw new TypeError( + `customClass只能是字符串或数组类型,customClass: ${customClass}`, + ); + } + + return customClassObj; +} + +/** + * 转换 Vue class 为 Vue class 对象 + * + * 支持字符串、数组和对象三种 Vue class 格式,统一转换为 + * `{ className: boolean }` 格式的对象。 + * + * @example + * ```ts + * convertCustomClass('foo bar') // { foo: true, bar: true } + * convertCustomClass(['foo', 'bar']) // { foo: true, bar: true } + * convertCustomClass({ foo: true, bar: false }) // { foo: true, bar: false } + * ``` + * + * @param sourceClass 原始 Vue class 值,支持字符串、数组或对象 + * @returns Vue class 对象 + * @throws 若 sourceClass 不是有效的 Vue class 则抛出 TypeError + */ +export function convertCustomClass(sourceClass: CustomClass): CustomClassObj { + let customClassObj = {}; + if (typeof sourceClass === "string") { + const customClassStr = sourceClass.trim().replace(" +", " "); + if (customClassStr.length > 0) { + const customClassEntries = customClassStr + .split(" ") + .map((name) => [name, true]); + customClassObj = Object.fromEntries(customClassEntries); + } + } else if (Array.isArray(sourceClass)) { + if (sourceClass.length > 0) { + const customClassEntries = sourceClass.map((name) => [name, true]); + customClassObj = Object.fromEntries(customClassEntries); + } + } else if (sourceClass instanceof Object) { + customClassObj = sourceClass; + } else { + throw new TypeError( + `sourceClass不是有效的vue class,sourceClass: ${sourceClass}`, + ); + } + + return customClassObj; +} + +/** + * 合并多个 Vue class 值为一个对象 + * + * 将多个 Vue class(字符串、数组或对象)统一转换为对象后合并, + * 后面的 class 值会覆盖前面的同名属性。 + * + * @example + * ```ts + * mergeClass('foo', ['bar'], { baz: true }) + * // { foo: true, bar: true, baz: true } + * ``` + * + * @param customClass 一个或多个 Vue class 值 + * @returns 合并后的 Vue class 对象 + */ +export function mergeClass(...customClass: CustomClass[]) { + return customClass + .map((cc) => convertCustomClass(cc)) + .reduce((a, b) => Object.assign(a, b), {}); +} diff --git a/packages/vue3/src/vue-helper/index.ts b/packages/vue3/src/vue-helper/index.ts index 4b836c2..ef0db64 100644 --- a/packages/vue3/src/vue-helper/index.ts +++ b/packages/vue3/src/vue-helper/index.ts @@ -9,122 +9,8 @@ import { ComponentPublicInstance } from "vue"; -/** Vue class 对象格式,键为类名,值为布尔值表示是否生效 */ -type CustomClassObj = Record; -/** Vue class 支持的类型:字符串、字符串数组或对象 */ -type CustomClass = string | Array | CustomClassObj; -/** 分解后的 Vue class 对象,所有类名值为 true */ -type DistCustomClass = Record; - -/** - * 将字符串或字符串数组转换为 Vue class 对象 - * - * 将类名字符串或数组转换为 `{ className: true }` 格式的对象, - * 适用于 Vue 的 class 绑定。 - * - * @example - * ```ts - * createCustomClassObj('foo bar') // { foo: true, bar: true } - * createCustomClassObj(['foo', 'bar']) // { foo: true, bar: true } - * ``` - * - * @param customClass 类名字符串或字符串数组 - * @returns 类名对象,所有值为 true - * @throws 若 customClass 不是字符串或数组则抛出 TypeError - */ -export function createCustomClassObj(customClass: string): DistCustomClass; -export function createCustomClassObj( - customClass: Array, -): DistCustomClass; -export function createCustomClassObj( - customClass: string | Array, -): DistCustomClass { - let customClassObj = {}; - if (typeof customClass === "string") { - const customClassStr = customClass.trim().replace(" +", " "); - if (customClassStr.length > 0) { - const customClassEntries = customClassStr - .split(" ") - .map((name) => [name, true]); - customClassObj = Object.fromEntries(customClassEntries); - } - } else if (Array.isArray(customClass)) { - if (customClass.length > 0) { - const customClassEntries = customClass.map((name) => [name, true]); - customClassObj = Object.fromEntries(customClassEntries); - } - } else { - throw new TypeError( - `customClass只能是字符串或数组类型,customClass: ${customClass}`, - ); - } - - return customClassObj; -} - -/** - * 转换 Vue class 为 Vue class 对象 - * - * 支持字符串、数组和对象三种 Vue class 格式,统一转换为 - * `{ className: boolean }` 格式的对象。 - * - * @example - * ```ts - * convertCustomClass('foo bar') // { foo: true, bar: true } - * convertCustomClass(['foo', 'bar']) // { foo: true, bar: true } - * convertCustomClass({ foo: true, bar: false }) // { foo: true, bar: false } - * ``` - * - * @param sourceClass 原始 Vue class 值,支持字符串、数组或对象 - * @returns Vue class 对象 - * @throws 若 sourceClass 不是有效的 Vue class 则抛出 TypeError - */ -export function convertCustomClass(sourceClass: CustomClass): CustomClassObj { - let customClassObj = {}; - if (typeof sourceClass === "string") { - const customClassStr = sourceClass.trim().replace(" +", " "); - if (customClassStr.length > 0) { - const customClassEntries = customClassStr - .split(" ") - .map((name) => [name, true]); - customClassObj = Object.fromEntries(customClassEntries); - } - } else if (Array.isArray(sourceClass)) { - if (sourceClass.length > 0) { - const customClassEntries = sourceClass.map((name) => [name, true]); - customClassObj = Object.fromEntries(customClassEntries); - } - } else if (sourceClass instanceof Object) { - customClassObj = sourceClass; - } else { - throw new TypeError( - `sourceClass不是有效的vue class,sourceClass: ${sourceClass}`, - ); - } - - return customClassObj; -} - -/** - * 合并多个 Vue class 值为一个对象 - * - * 将多个 Vue class(字符串、数组或对象)统一转换为对象后合并, - * 后面的 class 值会覆盖前面的同名属性。 - * - * @example - * ```ts - * mergeClass('foo', ['bar'], { baz: true }) - * // { foo: true, bar: true, baz: true } - * ``` - * - * @param customClass 一个或多个 Vue class 值 - * @returns 合并后的 Vue class 对象 - */ -export function mergeClass(...customClass: CustomClass[]) { - return customClass - .map((cc) => convertCustomClass(cc)) - .reduce((a, b) => Object.assign(a, b), {}); -} +export * from "./class-helper"; +export * from "./style-helper"; /** * 向上遍历祖先组件,找到指定名称的组件并触发其事件 diff --git a/packages/vue3/src/vue-helper/style-helper.ts b/packages/vue3/src/vue-helper/style-helper.ts new file mode 100644 index 0000000..16ee424 --- /dev/null +++ b/packages/vue3/src/vue-helper/style-helper.ts @@ -0,0 +1,115 @@ +import type { CSSProperties, StyleValue } from "vue"; + +/** + * 将合法的 Vue 动态 style 值统一转换为 CSSProperties 对象, + * 并可选择性合并覆盖样式。 + * + * - value:要转换的 style 值(string / CSSProperties / Array) + * - overrides:可选,合并时优先级更高的 style 值 + * + * 合并规则: + * 1. 两个 style 分别 normalize 后,以 overrides 优先 + * 2. 如果 overrides 中某属性值为空(null / undefined / "" / false),则使用 value 的值 + * 3. 如果两者都为空,则过滤掉该属性 + * + * 逻辑与 Vue 内部 normalizeStyle 一致,可直接用于组件 props 的 customStyle 转换。 + */ +export function normalizeStyle( + value: StyleValue, + overrides?: StyleValue, +): CSSProperties { + const base = toCSSProperties(value); + + if (overrides == null) return base; + + const over = toCSSProperties(overrides); + const result: Record = {}; + const allKeys = new Set([ + ...Object.keys(base as Record), + ...Object.keys(over), + ]); + + for (const key of allKeys) { + const overVal = (over as Record)[key]; + const baseVal = (base as Record)[key]; + + if (!isEmptyStyleValue(overVal)) { + // overrides 有值,优先使用 + result[key] = overVal as string | number; + } else if (!isEmptyStyleValue(baseVal)) { + // overrides 为空,base 有值,使用 base + result[key] = baseVal as string | number; + } + // 两者都为空,跳过 + } + + return result as CSSProperties; +} + +/** + * 判断样式属性值是否为空(null / undefined / "" / false)。 + */ +function isEmptyStyleValue(val: unknown): boolean { + return val == null || val === "" || val === false; +} + +/** + * 将 StyleValue 转为 CSSProperties,纯转换不做合并。 + */ +function toCSSProperties(value: StyleValue): CSSProperties { + if (Array.isArray(value)) { + const res: CSSProperties = {}; + for (let i = 0; i < value.length; i++) { + const item = value[i]; + const normalized = + typeof item === "string" + ? parseStringStyle(item) + : toCSSProperties(item as StyleValue); + if (normalized) { + const target = res as Record; + const source = normalized as Record; + for (const key in source) { + target[key] = source[key]; + } + } + } + return res; + } + + if (typeof value === "string") { + return parseStringStyle(value); + } + + if (value != null && typeof value === "object") { + return value as CSSProperties; + } + + return {}; +} + +/** 分号分隔正则,排除括号内的分号 */ +const listDelimiterRE = /;(?![^(]*\))/; +/** 属性值分隔正则 */ +const propertyDelimiterRE = /:([\s\S]+)/; +/** CSS 注释正则 */ +const styleCommentRE = /\/\*[\s\S]*?\*\//g; + +/** + * 将 CSS 字符串解析为 CSSProperties 对象。 + * 逻辑与 Vue 内部 parseStringStyle 一致。 + */ +function parseStringStyle(cssText: string): CSSProperties { + const ret: Record = {}; + cssText + .replace(styleCommentRE, "") + .split(listDelimiterRE) + .forEach((item) => { + if (item) { + const tmp = item.split(propertyDelimiterRE); + if (tmp.length > 1) { + ret[tmp[0].trim()] = tmp[1].trim(); + } + } + }); + return ret as CSSProperties; +} diff --git a/packages/vue3/test/dispatch.test.ts b/packages/vue3/test/dispatch.test.ts new file mode 100644 index 0000000..a96ac70 --- /dev/null +++ b/packages/vue3/test/dispatch.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from "vitest"; +import { dispatch } from "../src/vue-helper"; + +/** + * 创建模拟的 Vue ComponentPublicInstance + */ +function createMockInstance( + componentName?: string, + parent?: any, + root?: any, +) { + const instance = { + $parent: parent ?? null, + $root: root ?? null, + $options: { componentName: componentName ?? null }, + $emit: vi.fn(), + }; + return instance; +} + +describe("dispatch", () => { + it("应在祖先组件中找到匹配的 componentName 并触发事件", () => { + const targetComponentName = "FormComponent"; + const grandParent = createMockInstance(targetComponentName); + const parent = createMockInstance(undefined, grandParent, grandParent); + const thisArg = createMockInstance(undefined, parent, grandParent); + + // thisArg.$parent → parent, parent.$parent → grandParent + parent.$parent = grandParent; + + dispatch(thisArg, targetComponentName as any, "validate", { field: "name" }); + + expect(grandParent.$emit).toHaveBeenCalledWith("validate", { field: "name" }); + }); + + it("应从直接父组件开始查找", () => { + const targetComponentName = "DirectParent"; + const parent = createMockInstance(targetComponentName); + const thisArg = createMockInstance(undefined, parent, parent); + + dispatch(thisArg, targetComponentName as any, "change", null); + + expect(parent.$emit).toHaveBeenCalledWith("change", null); + }); + + it("当没有父组件且有根组件时,应从根组件开始查找", () => { + const targetComponentName = "RootComponent"; + const root = createMockInstance(targetComponentName); + const thisArg = createMockInstance(undefined, null, root); + + dispatch(thisArg, targetComponentName as any, "init", undefined); + + expect(root.$emit).toHaveBeenCalledWith("init", undefined); + }); + + it("当 thisArg.$parent 和 thisArg.$root 都为 null 时,应直接返回", () => { + const thisArg = createMockInstance(undefined, null, null); + const targetComponentName = "FormComponent" as any; + + // 不应抛出错误 + expect(() => dispatch(thisArg, targetComponentName, "validate", {})).not.toThrow(); + }); + + it("当找不到匹配的祖先组件时,不应触发任何事件", () => { + const grandParent = createMockInstance("OtherComponent"); + const parent = createMockInstance("AnotherComponent", grandParent, grandParent); + const thisArg = createMockInstance(undefined, parent, grandParent); + + dispatch(thisArg, "NonExistentComponent" as any, "validate", {}); + + expect(grandParent.$emit).not.toHaveBeenCalled(); + expect(parent.$emit).not.toHaveBeenCalled(); + }); + + it("应在多级父组件链中找到匹配的祖先", () => { + const targetComponentName = "GrandGrandParent"; + const grandGrandParent = createMockInstance(targetComponentName); + const grandParent = createMockInstance("GrandParent", grandGrandParent, grandGrandParent); + const parent = createMockInstance("Parent", grandParent, grandGrandParent); + const thisArg = createMockInstance(undefined, parent, grandGrandParent); + + dispatch(thisArg, targetComponentName as any, "submit", { data: "test" }); + + expect(grandGrandParent.$emit).toHaveBeenCalledWith("submit", { data: "test" }); + expect(grandParent.$emit).not.toHaveBeenCalled(); + expect(parent.$emit).not.toHaveBeenCalled(); + }); + + it("找到的第一个匹配组件后,不应继续向上查找", () => { + const targetComponentName = "Parent"; + const grandParent = createMockInstance(targetComponentName); // 更上层也有同名组件 + const parent = createMockInstance(targetComponentName, grandParent, grandParent); + const thisArg = createMockInstance(undefined, parent, grandParent); + + dispatch(thisArg, targetComponentName as any, "click", true); + + // 应该触发 parent 的事件,而非 grandParent 的 + expect(parent.$emit).toHaveBeenCalledWith("click", true); + expect(grandParent.$emit).not.toHaveBeenCalled(); + }); + + it("应支持传递各种类型的参数", () => { + const parent = createMockInstance("Target"); + const thisArg = createMockInstance(undefined, parent, parent); + + // 字符串 + dispatch(thisArg, "Target" as any, "event1", "hello"); + expect(parent.$emit).toHaveBeenCalledWith("event1", "hello"); + + // 数字 + dispatch(thisArg, "Target" as any, "event2", 42); + expect(parent.$emit).toHaveBeenCalledWith("event2", 42); + + // 数组 + dispatch(thisArg, "Target" as any, "event3", [1, 2, 3]); + expect(parent.$emit).toHaveBeenCalledWith("event3", [1, 2, 3]); + + // undefined + dispatch(thisArg, "Target" as any, "event4", undefined); + expect(parent.$emit).toHaveBeenCalledWith("event4", undefined); + }); + + it("当祖先组件的 componentName 为 undefined 时,应跳过继续查找", () => { + const targetComponentName = "TargetComponent"; + const grandParent = createMockInstance(targetComponentName); + // parent 没有 componentName + const parent = createMockInstance(undefined, grandParent, grandParent); + const thisArg = createMockInstance(undefined, parent, grandParent); + + dispatch(thisArg, targetComponentName as any, "save", {}); + + expect(grandParent.$emit).toHaveBeenCalledWith("save", {}); + }); +}); diff --git a/packages/vue3/test/style-helper.test.ts b/packages/vue3/test/style-helper.test.ts new file mode 100644 index 0000000..dc2827a --- /dev/null +++ b/packages/vue3/test/style-helper.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "vitest"; +import { normalizeStyle } from "../src/vue-helper/style-helper"; + +describe("normalizeStyle", () => { + // ========== 单参数:纯转换 ========== + + // ---------- 空值 / falsy ---------- + it("应将 undefined 转为空对象", () => { + expect(normalizeStyle(undefined as any)).toEqual({}); + }); + + it("应将 null 转为空对象", () => { + expect(normalizeStyle(null as any)).toEqual({}); + }); + + it("应将 false 转为空对象", () => { + expect(normalizeStyle(false as any)).toEqual({}); + }); + + // ---------- CSSProperties 对象 ---------- + it("应直接返回 CSSProperties 对象", () => { + const style = { color: "red", fontSize: "14px" }; + expect(normalizeStyle(style)).toEqual(style); + }); + + it("应保留 CSSProperties 中的数字值", () => { + const style = { zIndex: 10, opacity: 0.5 }; + expect(normalizeStyle(style)).toEqual({ zIndex: 10, opacity: 0.5 }); + }); + + it("应保留 CSS 变量", () => { + const style = { "--custom-color": "red" } as any; + expect(normalizeStyle(style)).toEqual({ "--custom-color": "red" }); + }); + + it("空对象应返回空对象", () => { + expect(normalizeStyle({})).toEqual({}); + }); + + // ---------- 字符串 ---------- + it("应解析简单的 CSS 字符串", () => { + expect(normalizeStyle("color: red")).toEqual({ color: "red" }); + }); + + it("应解析多条声明的 CSS 字符串", () => { + expect(normalizeStyle("color: red; font-size: 14px")).toEqual({ + color: "red", + "font-size": "14px", + }); + }); + + it("应解析带空格的 CSS 字符串", () => { + expect(normalizeStyle(" color : red ; font-size : 14px ")).toEqual({ + color: "red", + "font-size": "14px", + }); + }); + + it("应忽略空声明", () => { + expect(normalizeStyle("color: red;; font-size:14px;")).toEqual({ + color: "red", + "font-size": "14px", + }); + }); + + it("应忽略尾部分号后的空项", () => { + expect(normalizeStyle("color: red;")).toEqual({ color: "red" }); + }); + + it("应去除 CSS 注释", () => { + expect(normalizeStyle("color: red; /* 注释 */ font-size: 14px")).toEqual({ + color: "red", + "font-size": "14px", + }); + }); + + it("应解析含 url() 的值(不拆分括号内的分号)", () => { + expect(normalizeStyle("background: url(data:image/png;base64,abc)")).toEqual({ + background: "url(data:image/png;base64,abc)", + }); + }); + + it("应解析含冒号的值(如 url 中的协议)", () => { + expect(normalizeStyle("background: url(http://example.com/img.png)")).toEqual({ + background: "url(http://example.com/img.png)", + }); + }); + + it("空字符串应返回空对象", () => { + expect(normalizeStyle("")).toEqual({}); + }); + + // ---------- 数组 ---------- + it("应合并对象数组", () => { + expect(normalizeStyle([{ color: "red" }, { fontSize: "14px" }])).toEqual({ + color: "red", + fontSize: "14px", + }); + }); + + it("应合并字符串数组", () => { + expect(normalizeStyle(["color: red", "font-size: 14px"])).toEqual({ + color: "red", + "font-size": "14px", + }); + }); + + it("应合并混合类型数组", () => { + expect(normalizeStyle(["color: red", { fontSize: "14px" }])).toEqual({ + color: "red", + fontSize: "14px", + }); + }); + + it("应处理嵌套数组", () => { + expect(normalizeStyle([["color: red", { fontSize: "14px" }], { fontWeight: "bold" }])).toEqual({ + color: "red", + fontSize: "14px", + fontWeight: "bold", + }); + }); + + it("应跳过数组中的 falsy 值", () => { + expect(normalizeStyle([null, false, undefined, { color: "red" }] as any)).toEqual({ + color: "red", + }); + }); + + it("数组中后面的属性应覆盖前面的", () => { + expect(normalizeStyle([{ color: "red" }, { color: "blue" }])).toEqual({ + color: "blue", + }); + }); + + it("空数组应返回空对象", () => { + expect(normalizeStyle([])).toEqual({}); + }); + + // ---------- 实际使用场景 ---------- + it("应处理 AppButton 典型的 customStyle 用法", () => { + // 字符串形式(旧用法兼容) + expect(normalizeStyle("color: red; font-size: 28rpx")).toEqual({ + color: "red", + "font-size": "28rpx", + }); + // 对象形式(推荐用法) + expect(normalizeStyle({ color: "red", fontSize: "28rpx" })).toEqual({ + color: "red", + fontSize: "28rpx", + }); + // 数组形式 + expect(normalizeStyle([{ color: "red" }, { fontSize: "28rpx" }])).toEqual({ + color: "red", + fontSize: "28rpx", + }); + }); + + // ========== 双参数:合并覆盖 ========== + + describe("合并覆盖(第二个参数)", () => { + it("不传第二个参数时,行为与单参数一致", () => { + expect(normalizeStyle({ color: "red" })).toEqual({ color: "red" }); + }); + + it("第二个参数为 undefined 时,行为与单参数一致", () => { + expect(normalizeStyle({ color: "red" }, undefined)).toEqual({ color: "red" }); + }); + + it("第二个参数为 null 时,行为与单参数一致", () => { + expect(normalizeStyle({ color: "red" }, null as any)).toEqual({ color: "red" }); + }); + + it("overrides 有值的属性应覆盖 base", () => { + expect(normalizeStyle({ color: "red" }, { color: "blue" })).toEqual({ color: "blue" }); + }); + + it("overrides 新增的属性应合并进来", () => { + expect(normalizeStyle({ color: "red" }, { fontSize: "14px" })).toEqual({ + color: "red", + fontSize: "14px", + }); + }); + + it("overrides 和 base 各有不同的属性时,应全部合并", () => { + expect( + normalizeStyle({ color: "red", padding: "8px" }, { fontSize: "14px", margin: "4px" }), + ).toEqual({ + color: "red", + padding: "8px", + fontSize: "14px", + margin: "4px", + }); + }); + + // ---------- overrides 为空值时 fallback 到 base ---------- + it("overrides 属性为 undefined 时应使用 base 的值", () => { + expect(normalizeStyle({ color: "red" }, { color: undefined } as any)).toEqual({ + color: "red", + }); + }); + + it("overrides 属性为 null 时应使用 base 的值", () => { + expect(normalizeStyle({ color: "red" }, { color: null } as any)).toEqual({ color: "red" }); + }); + + it("overrides 属性为空字符串时应使用 base 的值", () => { + expect(normalizeStyle({ color: "red" }, { color: "" })).toEqual({ color: "red" }); + }); + + it("overrides 属性为 false 时应使用 base 的值", () => { + expect(normalizeStyle({ color: "red" }, { color: false } as any)).toEqual({ color: "red" }); + }); + + // ---------- 两者都为空时过滤属性 ---------- + it("base 和 overrides 都为空时,应过滤掉该属性", () => { + expect(normalizeStyle({ color: "" }, { color: undefined } as any)).toEqual({}); + }); + + it("base 和 overrides 都为 null 时,应过滤掉该属性", () => { + expect(normalizeStyle({ color: null } as any, { color: null } as any)).toEqual({}); + }); + + it("base 有值但属性为空、overrides 未指定该属性时,应过滤掉", () => { + // base 有 color:"" (空值), overrides 没有 color → both empty → filtered + expect(normalizeStyle({ color: "" }, {})).toEqual({}); + }); + + // ---------- 字符串形式的 overrides ---------- + it("overrides 为字符串时应正常解析并合并", () => { + expect(normalizeStyle({ color: "red" }, "font-size: 14px")).toEqual({ + color: "red", + "font-size": "14px", + }); + }); + + it("base 为字符串、overrides 为对象时应正常合并", () => { + // 字符串解析出 kebab-case 键,对象用 camelCase 键,两者独立 + expect(normalizeStyle("color: red", { fontSize: "14px" })).toEqual({ + color: "red", + fontSize: "14px", + }); + }); + + // ---------- 数组形式的 overrides ---------- + it("overrides 为数组时应正常解析并合并", () => { + expect(normalizeStyle({ color: "red" }, [{ fontSize: "14px" }])).toEqual({ + color: "red", + fontSize: "14px", + }); + }); + + // ---------- 0 是有效值,不算空 ---------- + it("数字 0 应视为有效值,不当作空值", () => { + expect(normalizeStyle({ opacity: 1 }, { opacity: 0 })).toEqual({ opacity: 0 }); + }); + + it("overrides 为 0 时不应 fallback 到 base", () => { + expect(normalizeStyle({ zIndex: 10 }, { zIndex: 0 })).toEqual({ zIndex: 0 }); + }); + + // ---------- 综合场景 ---------- + it("应正确处理混合覆盖和 fallback", () => { + const base = { color: "red", fontSize: "14px", padding: "8px", margin: "" }; + const overrides = { color: "blue", fontSize: undefined, padding: null, margin: "4px" } as any; + expect(normalizeStyle(base, overrides)).toEqual({ + color: "blue", // overrides 有值,覆盖 + fontSize: "14px", // overrides 为 undefined,fallback 到 base + padding: "8px", // overrides 为 null,fallback 到 base + margin: "4px", // base 为空,overrides 有值,使用 overrides + }); + }); + + it("appButton 场景:customStyle 覆盖内部样式", () => { + // 模拟 AppButton 内部样式 + customStyle + const internal = { width: "200rpx", backgroundColor: "#3adceb", color: "#111" }; + const custom = { width: "128rpx", backgroundColor: undefined, color: "" } as any; + expect(normalizeStyle(internal, custom)).toEqual({ + width: "128rpx", // overrides 有值,覆盖 + backgroundColor: "#3adceb", // overrides 为 undefined,fallback + color: "#111", // overrides 为空字符串,fallback + }); + }); + }); +}); diff --git a/packages/vue3/test/vue-helper.test.ts b/packages/vue3/test/vue-helper.test.ts new file mode 100644 index 0000000..487cf2d --- /dev/null +++ b/packages/vue3/test/vue-helper.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { + createCustomClassObj, + convertCustomClass, + mergeClass, +} from "../src/vue-helper"; + +// ========== createCustomClassObj ========== + +describe("createCustomClassObj", () => { + // ---------- 字符串输入 ---------- + it("应将单个类名字符串转为对象", () => { + expect(createCustomClassObj("foo")).toEqual({ foo: true }); + }); + + it("应将空格分隔的类名字符串转为对象", () => { + expect(createCustomClassObj("foo bar")).toEqual({ foo: true, bar: true }); + }); + + it("应处理前后有空格的字符串", () => { + // trim 后 "foo bar",replace(" +"," ") 是字符串匹配不会替换连续空格 + // split(" ") 产生 ["foo", "", "bar"],空字符串作为键 + expect(createCustomClassObj(" foo bar ")).toEqual({ foo: true, "": true, bar: true }); + }); + + it("应处理多个连续空格", () => { + // replace(" +", " ") 是字符串匹配而非正则,不会替换连续空格 + // split(" ") 产生 ["foo", "", "", "", "bar"],重复的空字符串键只保留最后一个 + expect(createCustomClassObj("foo bar")).toEqual({ foo: true, "": true, bar: true }); + }); + + it("空字符串应返回空对象", () => { + expect(createCustomClassObj("")).toEqual({}); + }); + + it("仅空格的字符串应返回空对象", () => { + expect(createCustomClassObj(" ")).toEqual({}); + }); + + // ---------- 数组输入 ---------- + it("应将类名数组转为对象", () => { + expect(createCustomClassObj(["foo", "bar"])).toEqual({ foo: true, bar: true }); + }); + + it("应将单个元素的数组转为对象", () => { + expect(createCustomClassObj(["foo"])).toEqual({ foo: true }); + }); + + it("空数组应返回空对象", () => { + expect(createCustomClassObj([])).toEqual({}); + }); + + // ---------- 异常输入 ---------- + it("传入对象应抛出 TypeError", () => { + expect(() => createCustomClassObj({} as any)).toThrow(TypeError); + }); + + it("传入数字应抛出 TypeError", () => { + expect(() => createCustomClassObj(123 as any)).toThrow(TypeError); + }); + + it("传入 null 应抛出 TypeError", () => { + expect(() => createCustomClassObj(null as any)).toThrow(TypeError); + }); + + it("传入 undefined 应抛出 TypeError", () => { + expect(() => createCustomClassObj(undefined as any)).toThrow(TypeError); + }); +}); + +// ========== convertCustomClass ========== + +describe("convertCustomClass", () => { + // ---------- 字符串输入 ---------- + it("应将字符串转为类名对象", () => { + expect(convertCustomClass("foo bar")).toEqual({ foo: true, bar: true }); + }); + + it("应将单个类名字符串转为对象", () => { + expect(convertCustomClass("foo")).toEqual({ foo: true }); + }); + + it("空字符串应返回空对象", () => { + expect(convertCustomClass("")).toEqual({}); + }); + + // ---------- 数组输入 ---------- + it("应将类名数组转为对象", () => { + expect(convertCustomClass(["foo", "bar"])).toEqual({ foo: true, bar: true }); + }); + + it("空数组应返回空对象", () => { + expect(convertCustomClass([])).toEqual({}); + }); + + // ---------- 对象输入 ---------- + it("应直接返回对象", () => { + const obj = { foo: true, bar: false }; + expect(convertCustomClass(obj)).toEqual(obj); + }); + + it("应保留对象中值为 false 的属性", () => { + const obj = { foo: true, bar: false }; + const result = convertCustomClass(obj); + expect(result).toHaveProperty("bar", false); + }); + + // ---------- 异常输入 ---------- + it("传入数字应抛出 TypeError", () => { + expect(() => convertCustomClass(123 as any)).toThrow(TypeError); + }); + + it("传入 null 应抛出 TypeError", () => { + expect(() => convertCustomClass(null as any)).toThrow(TypeError); + }); + + it("传入 undefined 应抛出 TypeError", () => { + expect(() => convertCustomClass(undefined as any)).toThrow(TypeError); + }); + + it("传入布尔值应抛出 TypeError", () => { + expect(() => convertCustomClass(true as any)).toThrow(TypeError); + }); +}); + +// ========== mergeClass ========== + +describe("mergeClass", () => { + it("应合并多个字符串类名", () => { + expect(mergeClass("foo", "bar")).toEqual({ foo: true, bar: true }); + }); + + it("应合并字符串和数组", () => { + expect(mergeClass("foo", ["bar"])).toEqual({ foo: true, bar: true }); + }); + + it("应合并字符串、数组和对象", () => { + expect(mergeClass("foo", ["bar"], { baz: true })).toEqual({ + foo: true, + bar: true, + baz: true, + }); + }); + + it("后面的值应覆盖前面的同名属性", () => { + expect(mergeClass({ foo: true }, { foo: false })).toEqual({ foo: false }); + }); + + it("字符串形式的类名应覆盖前面的", () => { + // "foo" → { foo: true },后者覆盖前者 + expect(mergeClass("foo", "foo")).toEqual({ foo: true }); + }); + + it("单个参数应正常工作", () => { + expect(mergeClass("foo")).toEqual({ foo: true }); + }); + + it("应处理空字符串参数", () => { + expect(mergeClass("")).toEqual({}); + }); + + it("应处理空数组参数", () => { + expect(mergeClass([])).toEqual({}); + }); + + it("应处理空对象参数", () => { + expect(mergeClass({})).toEqual({}); + }); + + it("应合并多个对象类名", () => { + expect( + mergeClass({ color: true }, { size: true }, { weight: true }), + ).toEqual({ color: true, size: true, weight: true }); + }); + + it("实际使用场景:Vue 组件中合并 class", () => { + // 模拟组件内部 class + 传入 class + const internal = "btn primary"; + const custom = { active: true, disabled: false }; + expect(mergeClass(internal, custom)).toEqual({ + btn: true, + primary: true, + active: true, + disabled: false, + }); + }); +});