This commit is contained in:
oneao committed 2026-10-15 17:31:35 +08:00
1 parent b4498e5350
commit bf9dca19de
47 files changed
+8592 -313

No files matched your search

@@ -0,0 +1,119 @@
/**
* MenuTreePanel:行尾启用勾选。
*
* 与模块树面板同构(勾选框不走 G3Tree 内置的 checkable,而是画在 #title 插槽行尾),
* 因此同样要锁住三件事:
* 1. 勾选上报 toggleEnabled,由父级落库;
* 2. 点击不冒泡(行上挂了 click 选中 / mousedown 武装拖拽);
* 3. 未保存草稿不可勾(库里没有这行),且面板必须开 block-node 才能贴到行尾。
*/
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { G3Checkbox, G3Tree } from '@g3soft/ui'
import MenuTreePanel from '@/views/module/menu-management/MenuTreePanel.vue'
import type { MenuNode } from '@/views/module/menu-management/utils'
const TREE = [
{ b_id: 'group_base', b_name: '基础资料', b_menu_type: 'directory', b_canuse: 1 },
{ b_id: 'base_contact', b_name: '联系人', b_menu_type: 'page', b_canuse: 0 },
{ b_id: '-1', b_name: '未保存草稿', b_menu_type: 'page', b_canuse: 1 },
] as unknown as MenuNode[]
function mountPanel(unsavedIds = new Set<string>()) {
return mount(MenuTreePanel, {
props: { treeData: TREE, unsavedIds },
})
}
const rowOf = (wrapper: VueWrapper, label: string) =>
wrapper.findAll('[role="treeitem"]').find((row) => row.text().includes(label))
const checkboxOf = (wrapper: VueWrapper, label: string) =>
rowOf(wrapper, label)?.findComponent(G3Checkbox)
describe('MenuTreePanel 行尾启用勾选', () => {
it('每个节点行都带勾选框,勾选态反映 b_canuse', () => {
const wrapper = mountPanel()
expect(checkboxOf(wrapper, '基础资料')?.props('modelValue')).toBe(true)
expect(checkboxOf(wrapper, '联系人')?.props('modelValue')).toBe(false)
})
it('勾选上报 toggleEnabled(id + 目标启用态)', async () => {
const wrapper = mountPanel()
checkboxOf(wrapper, '联系人')?.vm.$emit('update:modelValue', true)
await wrapper.vm.$nextTick()
expect(wrapper.emitted('toggleEnabled')?.[0]?.[0]).toEqual({
id: 'base_contact',
enabled: true,
})
})
it('取消勾选同样上报(enabled: false)', async () => {
const wrapper = mountPanel()
checkboxOf(wrapper, '基础资料')?.vm.$emit('update:modelValue', false)
await wrapper.vm.$nextTick()
expect(wrapper.emitted('toggleEnabled')?.[0]?.[0]).toEqual({
id: 'group_base',
enabled: false,
})
})
it('点击勾选框不触发行的选中(事件不冒泡)', async () => {
const wrapper = mountPanel()
const row = rowOf(wrapper, '基础资料')!
const box = checkboxOf(wrapper, '基础资料')!.find('input')
await box.trigger('click')
expect(wrapper.emitted('select')).toBeUndefined()
// 行自身仍可正常选中,说明拦的是勾选框那一下、不是把整行点击弄坏了
await row.trigger('click')
expect(wrapper.emitted('select')?.[0]?.[0]).toBe('group_base')
})
it('未保存草稿的勾选框禁用且不上报', async () => {
const wrapper = mountPanel(new Set(['-1']))
const box = checkboxOf(wrapper, '未保存草稿')!
expect(box.props('disabled')).toBe(true)
box.vm.$emit('update:modelValue', false)
await wrapper.vm.$nextTick()
expect(wrapper.emitted('toggleEnabled')).toBeUndefined()
})
it('勾选框渲染在名称之后的行尾', () => {
const wrapper = mountPanel()
const row = rowOf(wrapper, '基础资料')!
const nameEl = row.find('.mm-tree-node__name')
const enableEl = row.find('.mm-tree-node__enable')
expect(nameEl.exists()).toBe(true)
expect(enableEl.exists()).toBe(true)
const relation = nameEl.element.compareDocumentPosition(enableEl.element)
expect(relation & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('面板必须开 block-node:否则内容层不撑满,勾选框贴不到行尾', () => {
const wrapper = mountPanel()
// 断言落在 G3Tree 上:BizTreePanel / BizTree 是 inheritAttrs:false,
// kebab 的 'block-node' 会留在中间层的 $attrs 里(那些层取不到),
// 只有到 G3Tree 才被归一成 blockNode prop。
expect(wrapper.findComponent(G3Tree).props('blockNode')).toBe(true)
})
it('面板不启用 G3Tree 内置 checkable(避免行首出现多选勾选框)', () => {
const wrapper = mountPanel()
expect(wrapper.find('.g3-tree-node__checkbox').exists()).toBe(false)
})
})
@@ -0,0 +1,73 @@
/**
* menuRouteOptions:菜单管理「路由地址」下拉候选。
*
* 关键口径(对照 fms-vue 的 collectMenuRoutes):
* - meta.adminOnly 的超管专用页(模块中心 /module/*)必须整体排除 ——
* 菜单管理自己的入口若落进 s_menu,误删后就没有修复入口了;
* - 403 / 500 兜底页排除;
* - 含动态段(:id)/ 通配(*)的路由不能作为固定菜单入口。
*/
import { describe, expect, it } from 'vitest'
import { collectMenuRoutes, menuRouteOptions } from '@/router/index'
describe('collectMenuRoutes', () => {
it('排除 adminOnly 的模块中心页面', () => {
const paths = menuRouteOptions.map((option) => option.value)
expect(paths).not.toContain('/module/module-management')
expect(paths).not.toContain('/module/menu-management')
expect(paths).not.toContain('/module/i18n-type')
})
it('排除 403 / 500 兜底页', () => {
const paths = menuRouteOptions.map((option) => option.value)
expect(paths).not.toContain('/403')
expect(paths).not.toContain('/500')
})
it('保留普通业务页面,label 同时含路径与标题', () => {
const byValue = new Map(menuRouteOptions.map((option) => [option.value, option.label]))
expect(byValue.get('/dashboard')).toBe('/dashboard(首页)')
expect(byValue.get('/base/contact')).toBe('/base/contact(联系人)')
})
it('按路径排序', () => {
const paths = menuRouteOptions.map((option) => option.value)
const sorted = [...paths].toSorted((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
expect(paths).toEqual(sorted)
})
it('过滤掉动态段与通配路由', () => {
const list = collectMenuRoutes([
{ path: '/ok', meta: { title: '正常' } },
{ path: '/detail/:id', meta: { title: '详情' } },
{ path: '/any/*', meta: { title: '通配' } },
{ path: '/admin', meta: { title: '超管', adminOnly: true } },
] as never)
expect(list.map((option) => option.value)).toEqual(['/ok'])
})
it('含 children 的容器节点只向下递归、自身不作为候选', () => {
const list = collectMenuRoutes([
{
path: '/group',
meta: { title: '分组' },
children: [{ path: 'child', meta: { title: '子页' } }],
},
] as never)
expect(list).toEqual([{ value: '/group/child', label: '/group/child(子页)' }])
})
it('每个候选都是完整绝对路径,且无重复', () => {
const paths = menuRouteOptions.map((option) => option.value)
for (const path of paths) expect(path.startsWith('/')).toBe(true)
expect(new Set(paths).size).toBe(paths.length)
})
})
@@ -0,0 +1,308 @@
/**
* 菜单管理:菜单入口权限点(s_power)派生 + 新增/删除时的落库请求。
*
* 这两块是保存编排里最容易出错、也最难靠肉眼验证的部分:
* - 权限点行完全由菜单行派生(menu.{编码}),名称 / 顺序 / 启用态跟随菜单,
* 目录不产生权限点;
* - 删除菜单要连带清掉多语言资源、模块绑定与入口权限点,
* 且权限点删除必须带 dependent_deletes(授权行随权限点删除)。
*/
import { describe, expect, it } from 'vitest'
import {
buildMenuI18nKey,
buildMenuPowerCode,
buildMenuPowerRow,
normalizeMenuPowerRow,
menuRowOf,
MENU_NON_LEAF_TYPES,
MENU_TYPE_ICON_MAP,
MENU_TYPE_OPTIONS,
type MenuNode,
} from '@/views/module/menu-management/utils'
import { diffRow, diffRows, stripInternalKeys, cloneData } from '@/utils/dataChanges'
describe('菜单类型与图标', () => {
it('三种菜单类型都有图标映射,且只有目录能承载子节点', () => {
for (const option of MENU_TYPE_OPTIONS) {
expect(MENU_TYPE_ICON_MAP[option.value]).toBeTruthy()
}
expect(MENU_NON_LEAF_TYPES).toEqual(['directory'])
})
})
describe('菜单入口权限点派生', () => {
it('页面菜单派生 menu.{编码} 行,字段跟随菜单', () => {
const row = buildMenuPowerRow({
b_id: 'base_contact',
b_name: '联系人',
b_menu_type: 'page',
b_canuse: 1,
b_xh: 30,
})
expect(row).toEqual({
b_id: 'menu.base_contact',
b_name: '联系人',
b_i18n: 'menu.base_contact',
b_type: 'menu',
b_object_type: 'menu',
b_object_id: 'base_contact',
b_action: 'access',
b_canuse: 1,
b_xh: 30,
})
})
it('外链菜单同样派生权限点(外链也是「能否进入」的入口)', () => {
const row = buildMenuPowerRow({
b_id: 'ext_docs',
b_name: '外部文档',
b_menu_type: 'external',
b_canuse: 1,
})
expect(row?.b_id).toBe('menu.ext_docs')
})
it('目录不派生权限点(目录只是导航分组)', () => {
const row = buildMenuPowerRow({
b_id: 'group_base',
b_name: '基础资料',
b_menu_type: 'directory',
})
expect(row).toBeNull()
})
it('编码为空时返回 null(避免派生出 menu. 这种空编码权限点)', () => {
expect(buildMenuPowerRow({ b_id: '', b_menu_type: 'page' })).toBeNull()
expect(buildMenuPowerRow(null)).toBeNull()
})
it('停用菜单派生出停用的权限点(b_canuse 跟随)', () => {
const row = buildMenuPowerRow({
b_id: 'base_contact',
b_menu_type: 'page',
b_canuse: 0,
})
expect(row?.b_canuse).toBe(0)
})
it('b_canuse 缺省时按启用处理(不把历史数据的空值当停用)', () => {
const row = buildMenuPowerRow({ b_id: 'base_contact', b_menu_type: 'page' })
expect(row?.b_canuse).toBe(1)
})
})
describe('权限点基线归一化', () => {
it('剔除 b_bz 等不参与同步的列,数字列统一为 number', () => {
const normalized = normalizeMenuPowerRow({
b_id: 'menu.base_contact',
b_name: '联系人',
b_bz: '备注不该参与比较',
b_canuse: '1',
b_xh: '30',
})
expect(normalized).not.toHaveProperty('b_bz')
expect(normalized?.b_canuse).toBe(1)
expect(normalized?.b_xh).toBe(30)
})
it('空基线返回 null', () => {
expect(normalizeMenuPowerRow(null)).toBeNull()
})
it('归一化后与派生行比较:仅备注不同不算变更(避免「打开即脏」)', () => {
// 库里那行带了 b_bz,而派生行没有;归一化会把 b_bz 摘掉,两者应判为无差异
const org = normalizeMenuPowerRow({
b_id: 'menu.base_contact',
b_name: '联系人',
b_i18n: 'menu.base_contact',
b_type: 'menu',
b_object_type: 'menu',
b_object_id: 'base_contact',
b_action: 'access',
b_canuse: 1,
b_xh: 30,
b_bz: '人工写的备注',
})
const target = buildMenuPowerRow({
b_id: 'base_contact',
b_name: '联系人',
b_menu_type: 'page',
b_canuse: 1,
b_xh: 30,
})
const changes = diffRows([target!], [org!], { keyFields: ['b_id'] })
expect(changes.inserts).toHaveLength(0)
expect(changes.updates).toHaveLength(0)
expect(changes.deletes).toHaveLength(0)
})
})
describe('权限点随菜单变化同步', () => {
const org = buildMenuPowerRow({
b_id: 'base_contact',
b_name: '联系人',
b_menu_type: 'page',
b_canuse: 1,
b_xh: 30,
})!
it('菜单改名 → 权限点 update', () => {
const target = buildMenuPowerRow({
b_id: 'base_contact',
b_name: '联系人(新)',
b_menu_type: 'page',
b_canuse: 1,
b_xh: 30,
})!
const changes = diffRows([target], [org], { keyFields: ['b_id'] })
expect(changes.updates).toHaveLength(1)
expect(changes.updates[0]?.b_name).toBe('联系人(新)')
})
it('菜单由页面改成目录 → 权限点 delete(目录没有入口权限)', () => {
const target = buildMenuPowerRow({
b_id: 'base_contact',
b_menu_type: 'directory',
})
const changes = diffRows(target ? [target] : [], [org], { keyFields: ['b_id'] })
expect(changes.deletes).toEqual([{ b_id: 'menu.base_contact' }])
})
it('菜单由目录改成页面 → 权限点 insert', () => {
const target = buildMenuPowerRow({
b_id: 'group_base',
b_name: '基础资料',
b_menu_type: 'page',
b_canuse: 1,
})!
const changes = diffRows([target], [], { keyFields: ['b_id'] })
expect(changes.inserts).toHaveLength(1)
expect(changes.inserts[0]?.b_id).toBe('menu.group_base')
})
it('无任何变化 → 不产生请求(保存时提示「没有需要保存的变更」)', () => {
const target = buildMenuPowerRow({
b_id: 'base_contact',
b_name: '联系人',
b_menu_type: 'page',
b_canuse: 1,
b_xh: 30,
})!
const changes = diffRows([target], [org], { keyFields: ['b_id'] })
expect(
changes.inserts.length || changes.updates.length || changes.deletes.length,
).toBeFalsy()
})
})
describe('多语言键与权限编码', () => {
it('多语言键与权限编码同形但语义不同(一个进 s_i18n,一个进 s_power)', () => {
expect(buildMenuI18nKey('base_contact')).toBe('menu.base_contact')
expect(buildMenuPowerCode('base_contact')).toBe('menu.base_contact')
})
it('编码为空时两者都返回 null', () => {
expect(buildMenuI18nKey('')).toBeNull()
expect(buildMenuPowerCode('')).toBeNull()
})
})
describe('menuRowOf:树节点 → 主表行', () => {
it('剔除 children / isLeaf / icon 等树注入字段', () => {
const node = {
b_id: 'base_contact',
b_parent_id: 'group_base',
b_name: '联系人',
b_menu_type: 'page',
b_route: '/base/contact',
b_xh: 10,
b_canuse: 1,
children: [{ b_id: 'x' }],
isLeaf: false,
icon: 'file-text',
} as unknown as MenuNode
const row = menuRowOf(node)
expect(row).not.toHaveProperty('children')
expect(row).not.toHaveProperty('isLeaf')
expect(row).not.toHaveProperty('icon')
expect(row.b_id).toBe('base_contact')
})
it('b_path 缺省时退化为空串(非空列,不能为 null)', () => {
const row = menuRowOf({ b_id: 'a', b_menu_type: 'page' })
expect(row.b_path).toBe('')
expect(row.b_depth).toBe(0)
})
it('b_parent_id 空值归一成 null', () => {
const row = menuRowOf({ b_id: 'a', b_parent_id: '', b_menu_type: 'page' })
expect(row.b_parent_id).toBeNull()
})
})
describe('菜单主表 diff', () => {
it('新建(无基线)→ 整行 insert', () => {
const change = diffRow({
current: { b_id: 'new_menu', b_name: '新菜单', b_menu_type: 'page' },
original: null,
})
expect(change.hasChanges).toBe(true)
expect(change.inserts).toHaveLength(1)
expect(change.inserts[0]?.b_id).toBe('new_menu')
})
it('字段级 update 只携带变化列 + 主键', () => {
const change = diffRow({
current: { b_id: 'a', b_name: '新名', b_route: '/x', b_xh: 10 },
original: { b_id: 'a', b_name: '旧名', b_route: '/x', b_xh: 10 },
})
expect(change.updates).toHaveLength(1)
expect(change.updates[0]).toEqual({ b_id: 'a', b_name: '新名' })
expect(change.updates[0]).not.toHaveProperty('b_route')
})
it('派生字段(_org / v_*)不参与比较', () => {
const change = diffRow({
current: { b_id: 'a', b_name: '同名', _org: { x: 1 } },
original: { b_id: 'a', b_name: '同名', _org: { x: 2 } },
})
expect(change.hasChanges).toBe(false)
})
it('回环序列化:cloneData 的基线与草稿修改前不产生差异', () => {
const org = { b_id: 'a', b_name: '联系人', b_xh: 10 }
const draft = cloneData(org)
expect(diffRow({ current: draft, original: org }).hasChanges).toBe(false)
draft.b_name = '改了'
expect(diffRow({ current: draft, original: org }).hasChanges).toBe(true)
})
it('stripInternalKeys 递归剔除 _ / v_ 前缀(数组逐元素)', () => {
const stripped = stripInternalKeys([
{ b_id: 'a', _rowKey: 'k', v_count: 3, b_name: 'n' },
])
expect(stripped[0]).toEqual({ b_id: 'a', b_name: 'n' })
})
})
@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import ModuleBindPanel from '@/views/module/menu-management/ModuleBindPanel.vue'
import {
buildMenuModuleRows,
filterBindableModules,
isBindableModule,
moduleBindSignature,
normalizeMenuModulesByType,
} from '@/views/module/menu-management/utils'
/**
* 菜单「绑定模块」(s_menu_module)的纯逻辑与面板行为。
*
* 关键口径:只有页面(page)菜单绑定模块;落库只登记 data / virtual ——
* module 类型只是模块树上的分类节点,不参与绑定。
*/
const MODULE_TREE = [
{
b_id: 'sales',
b_name: '销售',
children: [
{ b_id: 'v_order', b_name: '订单' },
{ b_id: 'v_customer', b_name: '客户' },
],
},
{ b_id: 'v_other', b_name: '其他' },
]
/** 编码 → 类型:sales 是分类,其余是可绑定的数据模块 */
const TYPES: Record<string, string> = {
sales: 'module',
v_order: 'data',
v_customer: 'virtual',
v_other: 'data',
}
/** 挂面板;menuType 决定渲染树还是给一句说明,model 是已绑模块编码 */
function mountPanel(props: Record<string, unknown> = {}, model: string[] = []) {
return mount(ModuleBindPanel, {
props: {
treeData: MODULE_TREE,
moduleTypes: TYPES,
menuType: 'page',
menuId: 'm1',
modelValue: model,
'onUpdate:modelValue': () => {},
...props,
},
})
}
describe('isBindableModule', () => {
it('data / virtual 可绑定,module 不可', () => {
expect(isBindableModule('data')).toBe(true)
expect(isBindableModule('virtual')).toBe(true)
expect(isBindableModule('module')).toBe(false)
})
it('类型未知时按可绑定处理(避免误删历史绑定)', () => {
// 模块清单没加载全时,宁可保留关系行也不当成脏数据删掉
expect(isBindableModule(undefined)).toBe(true)
expect(isBindableModule(null)).toBe(true)
expect(isBindableModule('')).toBe(true)
})
})
describe('filterBindableModules', () => {
it('剔除分类节点,保留可绑定模块与原顺序', () => {
expect(filterBindableModules(['v_order', 'sales', 'v_customer'], TYPES)).toEqual([
'v_order',
'v_customer',
])
})
it('勾父级带回的子树键里,只有可绑定的留下', () => {
// 非严格模式点「销售」会把 sales / v_order / v_customer 一起回传
expect(filterBindableModules(['sales', 'v_order', 'v_customer'], TYPES)).toEqual([
'v_order',
'v_customer',
])
})
})
describe('moduleBindSignature', () => {
it('顺序无关:同集合不同顺序签名相同', () => {
expect(moduleBindSignature(['a', 'b'])).toBe(moduleBindSignature(['b', 'a']))
})
it('集合不同则签名不同', () => {
expect(moduleBindSignature(['a'])).not.toBe(moduleBindSignature(['a', 'b']))
})
it('数字与字符串编码视为同一个', () => {
expect(moduleBindSignature([1, 2])).toBe(moduleBindSignature(['1', '2']))
})
})
describe('buildMenuModuleRows', () => {
it('按勾选顺序生成关系行,b_xh 从 10 起步', () => {
expect(buildMenuModuleRows('m1', ['v_order', 'v_customer'])).toEqual([
{ b_menu_id: 'm1', b_module_id: 'v_order', b_xh: 10, b_canuse: 1 },
{ b_menu_id: 'm1', b_module_id: 'v_customer', b_xh: 20, b_canuse: 1 },
])
})
it('菜单编码为空时返回空数组(不产出悬空关系行)', () => {
expect(buildMenuModuleRows('', ['v_order'])).toEqual([])
})
it('空绑定返回空数组', () => {
expect(buildMenuModuleRows('m1', [])).toEqual([])
})
})
describe('normalizeMenuModulesByType', () => {
it('页面菜单保留绑定', () => {
expect(normalizeMenuModulesByType('page', ['v_order'])).toEqual(['v_order'])
})
it('目录 / 外链一律清空(非页面不绑定模块)', () => {
expect(normalizeMenuModulesByType('directory', ['v_order'])).toEqual([])
expect(normalizeMenuModulesByType('external', ['v_order'])).toEqual([])
})
})
describe('ModuleBindPanel', () => {
it('页面菜单渲染可勾选的模块树', () => {
const wrapper = mountPanel()
expect(wrapper.find('.biz-tree').exists()).toBe(true)
// 树默认收起(只渲染顶层节点),所以这里只断言顶层两个节点各有复选框
const boxes = wrapper.findAllComponents({ name: 'G3Checkbox' })
expect(boxes.length).toBe(2)
expect(wrapper.text()).toContain('销售')
expect(wrapper.text()).toContain('其他')
})
it('目录 / 外链不渲染树,各给一句说明', () => {
const dir = mountPanel({ menuType: 'directory' })
expect(dir.find('.biz-tree').exists()).toBe(false)
expect(dir.text()).toContain('目录不绑定模块')
const ext = mountPanel({ menuType: 'external' })
expect(ext.find('.biz-tree').exists()).toBe(false)
expect(ext.text()).toContain('外链不绑定模块')
})
it('初始已绑模块反映在顶层复选框上', async () => {
// 「其他」是顶层节点:默认收起的树里只有顶层可见,用它验证已绑态回填
const wrapper = mountPanel({}, ['v_other'])
await nextTick()
const boxes = wrapper.findAllComponents({ name: 'G3Checkbox' })
// 顶层顺序与 treeData 一致:sales、v_other
expect(boxes[0].props('modelValue')).toBe(false)
expect(boxes[1].props('modelValue')).toBe(true)
})
it('切换菜单清掉勾选草稿', async () => {
const wrapper = mountPanel({}, ['v_other'])
await nextTick()
expect(wrapper.findAllComponents({ name: 'G3Checkbox' })[1].props('modelValue')).toBe(true)
// 切到另一个菜单:父级尚未回填新绑定时,草稿必须清空而不是留着上一个菜单的勾
await wrapper.setProps({ menuId: 'm2', modelValue: [] })
await nextTick()
expect(wrapper.findAllComponents({ name: 'G3Checkbox' })[1].props('modelValue')).toBe(false)
})
it('勾选后回写模型时剔除分类节点', async () => {
const updates: string[][] = []
const wrapper = mountPanel({
'onUpdate:modelValue': (v: string[]) => updates.push(v),
})
await nextTick()
// 点「销售」分类 → 级联勾选整棵子树(含分类自身)
const boxes = wrapper.findAllComponents({ name: 'G3Checkbox' })
await boxes[0].vm.$emit('change', true)
await nextTick()
const last = updates.at(-1) ?? []
expect(last).not.toContain('sales')
expect(last.toSorted()).toEqual(['v_customer', 'v_order'])
})
it('数量标记只算可绑定的模块', async () => {
const wrapper = mountPanel({}, ['v_order', 'v_customer'])
await nextTick()
expect(wrapper.find('.g3-tag').text()).toBe('2')
})
it('未绑定时不显示数量标记', () => {
const wrapper = mountPanel({}, [])
expect(wrapper.find('.g3-tag').exists()).toBe(false)
})
})