From 9d7f3c272ce4cd2a33fa4694e2865d21ebf73ff3 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 25 Jul 2026 14:59:02 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=80=9A=E7=94=A8=E5=8C=96=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=AE=A2=E9=98=85=E5=B9=B6=E7=BB=9F=E4=B8=80=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- .../src/api/qqbot/message-push.ts | 28 +- .../components/MessagePushTargetPicker.tsx | 16 +- .../MessageSubscriptionModal.spec.tsx | 654 ------------------ .../components/MessageSubscriptionModal.tsx | 368 +++++++--- .../views/qqbot/message-subscription/list.tsx | 24 +- internal/tsconfig/types/vben-web/index.d.ts | 1 + package.json | 28 +- packages/@core/preferences/tsconfig.json | 2 +- packages/@core/ui-kit/form-ui/tsconfig.json | 2 +- pnpm-lock.yaml | 48 ++ skills/references/core/locale.md | 10 +- .../src => test}/api/blog/preview-url.spec.ts | 6 +- .../src => test}/api/core/menu.spec.ts | 16 +- .../src => test}/api/qqbot/index.spec.ts | 3 +- .../api/qqbot/message-push.spec.ts | 83 ++- .../src => test}/api/qqbot/napcat.spec.ts | 9 +- .../api/qqbot/plugin-task.spec.ts | 9 +- .../src => test}/api/qqbot/plugin.spec.ts | 9 +- .../api/system/environment.spec.ts | 9 +- .../src => test}/api/system/network.spec.ts | 9 +- .../ktTable/hooks/useKtTableActions.spec.tsx | 3 +- .../markdown/KtMilkdownEditor.spec.ts | 10 +- .../richText/KtTiptapHtmlEditor.spec.tsx | 3 +- test/governance/test-location.test.ts | 57 ++ .../jsdoc-policy/check-jsdoc.test.mjs | 9 +- .../internal/node-utils}/hash.test.ts | 3 +- .../internal/node-utils}/path.test.ts | 3 +- .../shared/cache}/storage-manager.test.ts | 3 +- .../@core/base/shared/color}/convert.test.ts | 5 +- .../@core/base/shared/utils}/date.test.ts | 11 +- .../@core/base/shared/utils}/diff.test.ts | 3 +- .../@core/base/shared/utils}/dom.test.ts | 3 +- .../base/shared/utils}/inference.test.ts | 5 +- .../@core/base/shared/utils}/letter.test.ts | 5 +- .../base/shared/utils}/resources.test.ts | 3 +- .../@core/base/shared/utils}/stack.test.ts | 6 +- .../base/shared/utils}/state-handler.test.ts | 3 +- .../@core/base/shared/utils}/tree.test.ts | 7 +- .../@core/base/shared/utils}/unique.test.ts | 3 +- .../utils}/update-css-variables.test.ts | 3 +- .../@core/base/shared/utils}/util.test.ts | 6 +- .../@core/base/shared/utils}/window.test.ts | 3 +- .../@core/composables}/use-sortable.test.ts | 3 +- .../__snapshots__/config.test.ts.snap | 0 .../@core/preferences}/config.test.ts | 3 +- .../@core/preferences}/preferences.test.ts | 7 +- .../@core/ui-kit/form-ui}/form-api.test.ts | 3 +- .../popup-ui/drawer}/drawer-api.test.ts | 5 +- .../ui-kit/popup-ui/modal}/modal-api.test.ts | 5 +- .../common-ui/components/page}/page.test.ts | 3 +- .../request-client/modules/downloader.test.ts | 3 +- .../request-client/modules/sse.test.ts | 5 +- .../request-client/modules/uploader.test.ts | 3 +- .../request-client/request-client.test.ts | 3 +- .../packages/stores}/modules/access.test.ts | 3 +- .../packages/stores}/modules/tabbar.test.ts | 3 +- .../packages/stores}/modules/user.test.ts | 3 +- .../utils/helpers}/find-menu-by-path.test.ts | 6 +- .../utils/helpers}/generate-menus.test.ts | 3 +- .../helpers}/generate-routes-frontend.test.ts | 18 +- .../helpers}/merge-route-modules.test.ts | 7 +- .../src => test}/router/access-codes.spec.ts | 3 +- .../src => test}/router/admin-sso.spec.ts | 5 +- .../router/routes/modules/qqbot.spec.ts | 1 - .../src => test}/store/auth.spec.ts | 3 +- test/tsconfig.json | 13 + .../views/blog/article/list.spec.ts | 6 +- .../blog/article/preview/preview.spec.tsx | 3 +- .../views/blog/modules/article-form.spec.ts | 5 +- .../analytics/environment-dashboard.spec.tsx | 3 +- .../components/AccountConfigPanel.spec.tsx | 40 +- .../AccountMessagePushModal.spec.tsx | 64 +- .../AccountMessagePushPanel.spec.tsx | 60 +- .../MessagePushTargetPicker.spec.tsx | 19 +- .../qqbot/account/napcat-boundary.spec.ts | 0 .../napcat-webui/napcat-webui.spec.tsx | 3 +- .../NapcatRuntimeProfileDrawer.spec.tsx | 3 +- .../MessageSubscriptionModal.spec.tsx | 538 ++++++++++++++ .../qqbot/message-subscription/list.spec.tsx | 65 +- .../MessageTemplateMentions.spec.tsx | 3 +- .../components/MessageTemplateModal.spec.tsx | 5 +- .../qqbot/message-template/list.spec.tsx | 60 +- .../views/qqbot/modules/actions.spec.tsx | 3 +- .../views/qqbot/modules/status.spec.ts | 5 +- .../qqbot/plugin-task/plugin-task.spec.tsx | 3 +- .../views/qqbot/plugin/metadata.spec.ts | 3 +- .../NetworkDdnsRecordModal.spec.tsx | 5 +- .../components/NetworkDdnsTable.spec.tsx | 30 +- .../NetworkEndpointHistoryDrawer.spec.tsx | 3 +- .../NetworkPortForwardModal.spec.tsx | 3 +- .../views/system/network/list.spec.tsx | 72 +- .../system/network/network-locale.spec.ts | 0 vitest.config.ts | 8 + 94 files changed, 1458 insertions(+), 1164 deletions(-) delete mode 100644 apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx rename {apps/web-antdv-next/src => test}/api/blog/preview-url.spec.ts (88%) rename {apps/web-antdv-next/src => test}/api/core/menu.spec.ts (96%) rename {apps/web-antdv-next/src => test}/api/qqbot/index.spec.ts (89%) rename {apps/web-antdv-next/src => test}/api/qqbot/message-push.spec.ts (91%) rename {apps/web-antdv-next/src => test}/api/qqbot/napcat.spec.ts (99%) rename {apps/web-antdv-next/src => test}/api/qqbot/plugin-task.spec.ts (96%) rename {apps/web-antdv-next/src => test}/api/qqbot/plugin.spec.ts (97%) rename {apps/web-antdv-next/src => test}/api/system/environment.spec.ts (93%) rename {apps/web-antdv-next/src => test}/api/system/network.spec.ts (98%) rename {apps/web-antdv-next/src => test}/components/ktTable/hooks/useKtTableActions.spec.tsx (96%) rename {apps/web-antdv-next/src => test}/components/markdown/KtMilkdownEditor.spec.ts (86%) rename {apps/web-antdv-next/src => test}/components/richText/KtTiptapHtmlEditor.spec.tsx (98%) create mode 100644 test/governance/test-location.test.ts rename {internal => test/internal}/jsdoc-policy/check-jsdoc.test.mjs (99%) rename {internal/node-utils/src/__tests__ => test/internal/node-utils}/hash.test.ts (95%) rename {internal/node-utils/src/__tests__ => test/internal/node-utils}/path.test.ts (96%) rename {packages/@core/base/shared/src/cache/__tests__ => test/packages/@core/base/shared/cache}/storage-manager.test.ts (97%) rename {packages/@core/base/shared/src/color/__tests__ => test/packages/@core/base/shared/color}/convert.test.ts (96%) rename {packages/@core/base/shared/src/utils/__tests__ => test/packages/@core/base/shared/utils}/date.test.ts (98%) rename {packages/@core/base/shared/src/utils/__tests__ => test/packages/@core/base/shared/utils}/diff.test.ts (95%) rename {packages/@core/base/shared/src/utils/__tests__ => test/packages/@core/base/shared/utils}/dom.test.ts (96%) rename {packages/@core/base/shared/src/utils/__tests__ => test/packages/@core/base/shared/utils}/inference.test.ts (98%) rename {packages/@core/base/shared/src/utils/__tests__ => test/packages/@core/base/shared/utils}/letter.test.ts (98%) rename {packages/@core/base/shared/src/utils/__tests__ => test/packages/@core/base/shared/utils}/resources.test.ts (96%) rename {packages/@core/base/shared/src/utils/__tests__ => test/packages/@core/base/shared/utils}/stack.test.ts (95%) rename {packages/@core/base/shared/src/utils/__tests__ => test/packages/@core/base/shared/utils}/state-handler.test.ts (95%) rename {packages/@core/base/shared/src/utils/__tests__ => test/packages/@core/base/shared/utils}/tree.test.ts (97%) rename {packages/@core/base/shared/src/utils/__tests__ => test/packages/@core/base/shared/utils}/unique.test.ts (94%) rename {packages/@core/base/shared/src/utils/__tests__ => test/packages/@core/base/shared/utils}/update-css-variables.test.ts (90%) rename {packages/@core/base/shared/src/utils/__tests__ => test/packages/@core/base/shared/utils}/util.test.ts (97%) rename {packages/@core/base/shared/src/utils/__tests__ => test/packages/@core/base/shared/utils}/window.test.ts (89%) rename {packages/@core/composables/src/__tests__ => test/packages/@core/composables}/use-sortable.test.ts (93%) rename {packages/@core/preferences/__tests__ => test/packages/@core/preferences}/__snapshots__/config.test.ts.snap (100%) rename {packages/@core/preferences/__tests__ => test/packages/@core/preferences}/config.test.ts (75%) rename {packages/@core/preferences/__tests__ => test/packages/@core/preferences}/preferences.test.ts (96%) rename {packages/@core/ui-kit/form-ui/__tests__ => test/packages/@core/ui-kit/form-ui}/form-api.test.ts (98%) rename {packages/@core/ui-kit/popup-ui/src/drawer/__tests__ => test/packages/@core/ui-kit/popup-ui/drawer}/drawer-api.test.ts (94%) rename {packages/@core/ui-kit/popup-ui/src/modal/__tests__ => test/packages/@core/ui-kit/popup-ui/modal}/modal-api.test.ts (94%) rename {packages/effects/common-ui/src/components/page/__tests__ => test/packages/effects/common-ui/components/page}/page.test.ts (96%) rename {packages/effects/request/src => test/packages/effects/request}/request-client/modules/downloader.test.ts (97%) rename {packages/effects/request/src => test/packages/effects/request}/request-client/modules/sse.test.ts (94%) rename {packages/effects/request/src => test/packages/effects/request}/request-client/modules/uploader.test.ts (97%) rename {packages/effects/request/src => test/packages/effects/request}/request-client/request-client.test.ts (96%) rename {packages/stores/src => test/packages/stores}/modules/access.test.ts (94%) rename {packages/stores/src => test/packages/stores}/modules/tabbar.test.ts (98%) rename {packages/stores/src => test/packages/stores}/modules/user.test.ts (93%) rename {packages/utils/src/helpers/__tests__ => test/packages/utils/helpers}/find-menu-by-path.test.ts (95%) rename {packages/utils/src/helpers/__tests__ => test/packages/utils/helpers}/generate-menus.test.ts (98%) rename {packages/utils/src/helpers/__tests__ => test/packages/utils/helpers}/generate-routes-frontend.test.ts (82%) rename {packages/utils/src/helpers/__tests__ => test/packages/utils/helpers}/merge-route-modules.test.ts (89%) rename {apps/web-antdv-next/src => test}/router/access-codes.spec.ts (88%) rename {apps/web-antdv-next/src => test}/router/admin-sso.spec.ts (94%) rename {apps/web-antdv-next/src => test}/router/routes/modules/qqbot.spec.ts (97%) rename {apps/web-antdv-next/src => test}/store/auth.spec.ts (96%) create mode 100644 test/tsconfig.json rename {apps/web-antdv-next/src => test}/views/blog/article/list.spec.ts (93%) rename {apps/web-antdv-next/src => test}/views/blog/article/preview/preview.spec.tsx (98%) rename {apps/web-antdv-next/src => test}/views/blog/modules/article-form.spec.ts (98%) rename {apps/web-antdv-next/src => test}/views/dashboard/analytics/environment-dashboard.spec.tsx (99%) rename {apps/web-antdv-next/src => test}/views/qqbot/account/components/AccountConfigPanel.spec.tsx (88%) rename {apps/web-antdv-next/src => test}/views/qqbot/account/components/AccountMessagePushModal.spec.tsx (95%) rename {apps/web-antdv-next/src => test}/views/qqbot/account/components/AccountMessagePushPanel.spec.tsx (95%) rename {apps/web-antdv-next/src => test}/views/qqbot/account/components/MessagePushTargetPicker.spec.tsx (93%) rename {apps/web-antdv-next/src => test}/views/qqbot/account/napcat-boundary.spec.ts (100%) rename {apps/web-antdv-next/src => test}/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx (98%) rename {apps/web-antdv-next/src => test}/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.spec.tsx (94%) create mode 100644 test/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx rename {apps/web-antdv-next/src => test}/views/qqbot/message-subscription/list.spec.tsx (90%) rename {apps/web-antdv-next/src => test}/views/qqbot/message-template/components/MessageTemplateMentions.spec.tsx (96%) rename {apps/web-antdv-next/src => test}/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx (98%) rename {apps/web-antdv-next/src => test}/views/qqbot/message-template/list.spec.tsx (93%) rename {apps/web-antdv-next/src => test}/views/qqbot/modules/actions.spec.tsx (94%) rename {apps/web-antdv-next/src => test}/views/qqbot/modules/status.spec.ts (93%) rename {apps/web-antdv-next/src => test}/views/qqbot/plugin-task/plugin-task.spec.tsx (96%) rename {apps/web-antdv-next/src => test}/views/qqbot/plugin/metadata.spec.ts (93%) rename {apps/web-antdv-next/src => test}/views/system/network/components/NetworkDdnsRecordModal.spec.tsx (98%) rename {apps/web-antdv-next/src => test}/views/system/network/components/NetworkDdnsTable.spec.tsx (94%) rename {apps/web-antdv-next/src => test}/views/system/network/components/NetworkEndpointHistoryDrawer.spec.tsx (95%) rename {apps/web-antdv-next/src => test}/views/system/network/components/NetworkPortForwardModal.spec.tsx (98%) rename {apps/web-antdv-next/src => test}/views/system/network/list.spec.tsx (94%) rename {apps/web-antdv-next/src => test}/views/system/network/network-locale.spec.ts (100%) diff --git a/README.md b/README.md index 863209d..ce76cea 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ apps/web-antdv-next 后台管理端入口 internal 构建、Vite、Lint 等内部配置包 packages Vben 运行时依赖的核心包和组件包 +test 全仓库单元测试,按 api/views/packages/internal 等领域集中管理 deploy/nginx-admin.conf Admin 静态站点和后端 /api 反向代理配置 Jenkinsfile Jenkins 静态发布流水线 ``` @@ -29,6 +30,8 @@ corepack prepare pnpm@10.28.2 --activate ```bash pnpm install pnpm run dev +pnpm run test:unit +pnpm run test:type pnpm run verify:commit pnpm run build:antdv-next ``` @@ -53,7 +56,10 @@ pnpm run build:antdv-next - 系统管理 / 网络管理使用 TSX、KtTable 与统一 Vben 表单维护 API 持久化的 TCP/UDP 单端口转发期望状态,并通过独立页签管理腾讯云云解析 DNS 的 A/AAAA 自动更新绑定。页面展示 Agent、路由同步、UDP Keeper、公网端点租约和 DDNS 同步状态,支持异步 CRUD、重试、Keeper 启停、立即 STUN 刷新及端点历史;A 只使用合格 UDP Keeper 的公网 IPv4,AAAA 只使用 Agent 全局 IPv6,DNS 值不包含端口。首屏读取一次 HTTP 快照,后续只按 API SSE 的资源来源刷新当前活动页签;心跳和其他页签事件不刷新,不使用定时轮询。`Page autoContentHeight` 中的 Tabs 与 KtTable 必须由满高纵向 flex 外壳承接,活动面板保持 `flex-1 min-h-0`,否则 KtTable 的百分比高度链会塌陷。Admin 不接触路由器、MQTT 或腾讯云凭据;当前已验证切片只执行 UDP 路由器写入,TCP 可保存 CRUD 期望但 Agent 会显示设备协议门禁失败,STUN 操作保持可见且禁用。 - Vue i18n 文案中的普通 `@` 必须写成字面量插值 `{'@'}`,否则生产消息编译器会把它识别为 linked message 语法。网络管理语言包由 `network-locale.spec.ts` 逐条通过实际 i18n runtime 校验,不能只依赖 JSON 解析或组件测试里的 `$t` mock。 - QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API,新设备验证二维码和腾讯验证码分开展示;行操作“运行态”打开只读抽屉,展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。 -- QQBot / 消息订阅与消息模板是两个平级菜单;账号配置第四页签用于为当前 QQBot 选择订阅、模板以及群聊/私聊目标,不提供跨账号选择。模板编辑器输入 `$` 后通过 Mentions 候选精确插入 `${{变量}}`。三个入口只在首次进入、显式刷新或成功写操作后更新列表,不使用后台轮询;菜单、按钮与账号页签分别受 `QqBot:MessageSubscription:*`、`QqBot:MessageTemplate:*` 和 `QqBot:Account:MessagePush:*` 权限控制。 +- QQBot / 消息订阅与消息模板是两个平级菜单;新建订阅不默认选择消息源,选择后才按该来源的 `subscriptionFields` 动态生成字段并加载候选项,不把通用订阅表单绑定到 STUN。账号配置第四页签用于为当前 QQBot 选择订阅、模板以及群聊/私聊目标,不提供跨账号选择,两个目标选择框固定填满横向表单宽度。模板编辑器输入 `$` 后通过 Mentions 候选精确插入 `${{变量}}`。三个入口只在首次进入、显式刷新或成功写操作后更新列表,不使用后台轮询;菜单、按钮与账号页签分别受 `QqBot:MessageSubscription:*`、`QqBot:MessageTemplate:*` 和 `QqBot:Account:MessagePush:*` 权限控制。 + +源码目录禁止同级存放单元测试或 `__tests__`;全部单元测试统一放在根目录单数 `test/`,应用测试直接使用 `test/api`、`test/components`、`test/router`、`test/store`、`test/views`,共享包与内部工具分别使用 `test/packages`、`test/internal`,结构门禁位于 `test/governance`。 + - QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`。 - 博客管理 / 文章管理提供“预览”行操作,打开隐藏二级路由 `/blog/article/:articleId/preview`;预览页按 NapCat WebUI 的微服务嵌入形态实现,iframe 独占容器,文章标题、状态、预览 Host 和返回/刷新/新窗口操作放在右下角悬浮卡片,不占用 iframe 布局空间。新增隐藏路由和按钮权限需要同步 API `blog-menu.sql` / `vben-admin-init.sql` 中的 `BlogArticlePreview` 与 `BlogArticlePreviewButton`。 - 博客管理 / 文章表单支持 Markdown、富文本 HTML、源码 HTML 三种编辑模式:Markdown 继续使用 Milkdown/Crepe 并保存 `contentFormat=markdown`;富文本 HTML 使用 Tiptap 并保存 `contentFormat=html`;源码 HTML 用于保留 WordPress/Argon 运行时 DOM,同样保存 `contentFormat=html`。Milkdown/Crepe 必须先引入 `@milkdown/crepe/theme/common/style.css`,再引入具体主题 CSS,否则生产包只有主题变量没有工具栏/菜单布局样式;组件 SCSS 还必须把 `--crepe-color-*` 映射到 Admin `hsl(var(--...))` 主题 token,并用固定高度外壳、隐藏溢出的 root 和正文 flex 滚动区覆盖 common 默认大 padding/高度模型,避免暗色模式脱节和首次空编辑器出现内部滚动条。 diff --git a/apps/web-antdv-next/src/api/qqbot/message-push.ts b/apps/web-antdv-next/src/api/qqbot/message-push.ts index 11f810c..5c93838 100644 --- a/apps/web-antdv-next/src/api/qqbot/message-push.ts +++ b/apps/web-antdv-next/src/api/qqbot/message-push.ts @@ -17,8 +17,8 @@ export namespace QqbotMessagePushApi { dependsOn?: string; key: string; label: string; - optionCollection: 'ddnsRecords' | 'portForwards'; - required: true; + optionCollection: string; + required: boolean; type: 'select'; } @@ -56,6 +56,19 @@ export namespace QqbotMessagePushApi { }>; } + export interface SystemMessageSourceOptionDefinition { + dependsOnValue?: string; + disabled: boolean; + disabledReasonCode: null | string; + label: string; + value: string; + } + + export type SystemMessageSourceOptionsResponse = Record< + string, + SystemMessageSourceOptionDefinition[] + >; + export interface MessageSubscriptionView { createTime: string; enabled: boolean; @@ -63,7 +76,7 @@ export namespace QqbotMessagePushApi { invalidReasonCode: null | string; name: string; remark: null | string; - sourceConfig: StunMappingPortChangedSubscriptionConfig; + sourceConfig: Record; sourceKey: string; sourceName: string; sourceSummary: string; @@ -83,7 +96,7 @@ export namespace QqbotMessagePushApi { enabled: boolean; name: string; remark?: string; - sourceConfig: Record; + sourceConfig: Record; sourceKey: string; } @@ -193,6 +206,13 @@ export function getMessagePushSourceDetail(sourceKey: string) { ); } +/** 获取指定系统消息源提供的订阅字段候选项。 */ +export function getMessagePushSourceOptions(sourceKey: string) { + return requestClient.get( + `/qqbot/message-push/sources/${encodeURIComponent(sourceKey)}/subscription-options`, + ); +} + export function getStunMappingPortChangedOptions() { return requestClient.get( '/qqbot/message-push/sources/network.stun.mapping-port-changed/options', diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker.tsx index ad72a44..c6cf40c 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker.tsx @@ -27,6 +27,7 @@ export interface MessagePushTargetPickerProps { value: QqbotMessagePushApi.QqbotMessagePublishTargetInput[]; } +/** 判断 QQ 群号或 QQ 号是否满足消息推送目标格式。 */ export function isValidMessagePushTargetId(targetId: string): boolean { return /^[1-9]\d{4,19}$/.test(targetId); } @@ -81,6 +82,7 @@ export default defineComponent({ createSelectOptions(props.options, 'private'), ); + /** 规范化指定目标类型的选择值并向父表单提交完整目标列表。 */ function handleValueUpdate(targetType: TargetType, runtimeValue: unknown) { const targetIds = normalizeTargetIds(runtimeValue); if (containsRejectedTarget(runtimeValue, targetIds)) { @@ -94,7 +96,10 @@ export default defineComponent({ } return () => ( -
+
{props.available ? null : (
@@ -136,6 +142,7 @@ export default defineComponent({ } options={privateOptions.value} placeholder="选择或输入 QQ 号" + style={{ width: '100%' }} value={privateIds.value} />
@@ -144,6 +151,7 @@ export default defineComponent({ }, }); +/** 提取指定目标类型的已选 QQ 标识。 */ function targetIdsForType( targets: QqbotMessagePushApi.QqbotMessagePublishTargetInput[], targetType: TargetType, @@ -153,6 +161,7 @@ function targetIdsForType( .map((target) => target.targetId); } +/** 将账号候选项转换成指定目标类型的选择框候选项。 */ function createSelectOptions( options: QqbotMessagePushApi.QqbotMessagePushTargetOption[], targetType: TargetType, @@ -166,6 +175,7 @@ function createSelectOptions( })); } +/** 按显示名称、目标标识和值过滤消息推送候选项。 */ function filterTargetOption( input: string, option: Record, @@ -177,6 +187,7 @@ function filterTargetOption( ); } +/** 规范化选择框运行时值并移除无效或重复目标。 */ function normalizeTargetIds(runtimeValue: unknown): string[] { if (!Array.isArray(runtimeValue)) return []; const seen = new Set(); @@ -191,6 +202,7 @@ function normalizeTargetIds(runtimeValue: unknown): string[] { return result; } +/** 判断运行时选择值是否包含被规范化规则拒绝的目标。 */ function containsRejectedTarget( runtimeValue: unknown, normalized: string[], @@ -203,6 +215,7 @@ function containsRejectedTarget( ); } +/** 合并本次变更与另一目标类型的既有选择。 */ function mergeTargets( current: QqbotMessagePushApi.QqbotMessagePublishTargetInput[], options: QqbotMessagePushApi.QqbotMessagePushTargetOption[], @@ -234,6 +247,7 @@ function mergeTargets( ); } +/** 优先从候选项或既有配置中解析同类型目标名称。 */ function resolveTargetName( current: QqbotMessagePushApi.QqbotMessagePublishTargetInput[], options: QqbotMessagePushApi.QqbotMessagePushTargetOption[], diff --git a/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx b/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx deleted file mode 100644 index bda4644..0000000 --- a/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx +++ /dev/null @@ -1,654 +0,0 @@ -/* @vitest-environment happy-dom */ - -/* eslint-disable vue/one-component-per-file */ - -import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; - -import { flushPromises, mount } from '@vue/test-utils'; -import { defineComponent, h } from 'vue'; - -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import MessageSubscriptionModal from './MessageSubscriptionModal'; - -type MessageSubscriptionFormPatch = Partial<{ - ddnsRecordId: string | undefined; - enabled: boolean; - name: string; - portForwardId: string | undefined; - remark: string | undefined; - sourceKey: string; -}>; - -const mocks = vi.hoisted(() => { - const modalApi = { - close: vi.fn(async () => {}), - getData: vi.fn(), - lock: vi.fn(), - open: vi.fn(), - setData: vi.fn(), - unlock: vi.fn(), - }; - const formApi = { - getValues: vi.fn(), - resetForm: vi.fn(async () => {}), - resetValidate: vi.fn(async () => {}), - setValues: vi.fn(async (_patch: MessageSubscriptionFormPatch) => {}), - validate: vi.fn(async () => ({ valid: true })), - }; - return { - create: vi.fn(), - formApi, - formOptions: undefined as any, - modalApi, - modalOptions: undefined as any, - update: vi.fn(), - }; -}); - -function createRule(): any { - const rule: any = {}; - for (const method of ['max', 'min', 'optional', 'or', 'trim']) { - rule[method] = vi.fn(() => rule); - } - return rule; -} - -vi.mock('#/adapter/form', () => ({ - useVbenForm: vi.fn((options) => { - mocks.formOptions = options; - const Form = defineComponent({ - name: 'MockForm', - setup() { - return () => h('form', { 'data-testid': 'subscription-form' }); - }, - }); - return [Form, mocks.formApi]; - }), - z: { - literal: vi.fn(createRule), - string: vi.fn(createRule), - }, -})); - -vi.mock('@vben/common-ui', () => ({ - useVbenModal: vi.fn((options) => { - mocks.modalOptions = options; - const Modal = defineComponent({ - name: 'MockModal', - setup(_, { slots }) { - return () => h('section', slots.default?.()); - }, - }); - return [Modal, mocks.modalApi]; - }), -})); - -vi.mock('#/api/qqbot/message-push', () => ({ - createMessageSubscription: mocks.create, - updateMessageSubscription: mocks.update, -})); - -function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] { - return [ - { - description: 'STUN 端口变化', - displayName: 'STUN 映射端口变更', - sourceKey: 'network.stun.mapping-port-changed', - subscriptionFields: [], - variables: [], - version: 1, - }, - ]; -} - -function createStunOptions(): QqbotMessagePushApi.StunMappingPortChangedOptionsResponse { - return { - ddnsRecords: [ - { - disabledReasonCode: null, - eligible: true, - fqdn: 'pal.kwitsukasa.top', - id: '2041700000000000002', - name: 'Pal DDNS', - portForwardId: '2041700000000000001', - }, - { - disabledReasonCode: 'ddns_disabled', - eligible: false, - fqdn: 'disabled.kwitsukasa.top', - id: '2041700000000000003', - name: 'Disabled DDNS', - portForwardId: '2041700000000000001', - }, - { - disabledReasonCode: null, - eligible: true, - fqdn: 'other.kwitsukasa.top', - id: '2041700000000000004', - name: 'Other DDNS', - portForwardId: '2041700000000000005', - }, - ], - portForwards: [ - { - disabledReasonCode: null, - eligible: true, - externalPort: 8213, - id: '2041700000000000001', - internalPort: 8213, - name: 'Pal UDP', - protocol: 'udp', - }, - { - disabledReasonCode: 'keeper_disabled', - eligible: false, - externalPort: 8214, - id: '2041700000000000005', - internalPort: 8214, - name: 'Disabled UDP', - protocol: 'udp', - }, - ], - }; -} - -function createRow(): QqbotMessagePushApi.MessageSubscriptionView { - return { - createTime: '2026-07-24 10:00:00', - enabled: false, - id: '10000000000000001', - invalidReasonCode: null, - name: '旧订阅', - remark: '旧备注', - sourceConfig: { - ddnsRecordId: '2041700000000000002', - portForwardId: '2041700000000000001', - }, - sourceKey: 'network.stun.mapping-port-changed', - sourceName: 'STUN 映射端口变更', - sourceSummary: 'Pal UDP · pal.kwitsukasa.top', - updateTime: '2026-07-24 10:00:00', - valid: true, - }; -} - -function createDeferred() { - let resolve!: (value: T) => void; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; -} - -function mountModal() { - return mount(MessageSubscriptionModal, { - props: { - sources: createSources(), - stunOptions: createStunOptions(), - }, - }); -} - -describe('message subscription modal', () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.modalApi.getData.mockImplementation( - () => mocks.modalApi.setData.mock.calls.at(-1)?.[0] || {}, - ); - mocks.modalApi.setData.mockImplementation(() => mocks.modalApi); - mocks.modalApi.open.mockImplementation(() => { - void mocks.modalOptions.onOpenChange?.(true); - return mocks.modalApi; - }); - mocks.create.mockResolvedValue({}); - mocks.update.mockResolvedValue({}); - mocks.formApi.getValues.mockResolvedValue({ - ddnsRecordId: '2041700000000000002', - enabled: true, - name: ' 帕鲁端口变更 ', - portForwardId: '2041700000000000001', - remark: ' managed ', - sourceKey: 'network.stun.mapping-port-changed', - }); - }); - - it('contains the exact source-only fields in their locked order', () => { - const wrapper = mountModal(); - - const fields = mocks.formOptions.schema.map( - (field: any) => field.fieldName, - ); - expect(fields).toEqual([ - 'name', - 'sourceKey', - 'portForwardId', - 'ddnsRecordId', - 'enabled', - 'remark', - ]); - expect(JSON.stringify(mocks.formOptions.schema)).not.toMatch( - /account|selfId|template|target|group|private|publish|delivery|event|worker|queue/i, - ); - expect( - wrapper.get('section').find('[data-testid="subscription-form"]').exists(), - ).toBe(true); - }); - - it('opens before reset/set/reset-validate and clears edit state for create', async () => { - const wrapper = mountModal(); - mocks.formApi.resetForm.mockImplementation(async () => { - expect(mocks.modalApi.open).toHaveBeenCalled(); - }); - - (wrapper.vm as any).openEdit(createRow()); - await flushPromises(); - (wrapper.vm as any).openCreate(); - await flushPromises(); - - expect(mocks.formApi.resetForm).toHaveBeenCalledTimes(2); - expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({ - ddnsRecordId: undefined, - enabled: true, - name: '', - portForwardId: undefined, - remark: '', - sourceKey: 'network.stun.mapping-port-changed', - }); - expect(mocks.formApi.resetValidate).toHaveBeenCalledTimes(2); - const resetOrder = - mocks.formApi.resetForm.mock.invocationCallOrder.at(-1) || 0; - const setOrder = - mocks.formApi.setValues.mock.invocationCallOrder.at(-1) || 0; - const validateOrder = - mocks.formApi.resetValidate.mock.invocationCallOrder.at(-1) || 0; - expect(resetOrder).toBeLessThan(setOrder); - expect(setOrder).toBeLessThan(validateOrder); - }); - - it('discards an old create confirmation before a new edit session submits its own row', async () => { - const validation = createDeferred<{ valid: boolean }>(); - mocks.formApi.validate.mockReturnValueOnce(validation.promise); - const wrapper = mountModal(); - (wrapper.vm as any).openCreate(); - await flushPromises(); - - const staleConfirmation = mocks.modalOptions.onConfirm(); - await flushPromises(); - const editRow = { - ...createRow(), - enabled: false, - id: '10000000000000002', - name: '新编辑订阅', - remark: '新编辑备注', - }; - (wrapper.vm as any).openEdit(editRow); - await flushPromises(); - const editValues = { - ddnsRecordId: '2041700000000000002', - enabled: false, - name: ' 新编辑订阅 ', - portForwardId: '2041700000000000001', - remark: ' 新编辑备注 ', - sourceKey: 'network.stun.mapping-port-changed', - }; - mocks.formApi.getValues.mockResolvedValue(editValues); - const closeCountBeforeResume = mocks.modalApi.close.mock.calls.length; - - validation.resolve({ valid: true }); - await staleConfirmation; - await flushPromises(); - - expect(mocks.create).not.toHaveBeenCalled(); - expect(mocks.update).not.toHaveBeenCalled(); - expect(mocks.modalApi.lock).not.toHaveBeenCalled(); - expect(mocks.modalApi.unlock).not.toHaveBeenCalled(); - expect(mocks.modalApi.close).toHaveBeenCalledTimes(closeCountBeforeResume); - expect(wrapper.emitted('saved')).toBeUndefined(); - expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({ - ddnsRecordId: '2041700000000000002', - enabled: false, - name: '新编辑订阅', - portForwardId: '2041700000000000001', - remark: '新编辑备注', - sourceKey: 'network.stun.mapping-port-changed', - }); - - await mocks.modalOptions.onConfirm(); - - expect(mocks.create).not.toHaveBeenCalled(); - expect(mocks.update).toHaveBeenCalledOnce(); - expect(mocks.update).toHaveBeenCalledWith('10000000000000002', { - enabled: false, - name: '新编辑订阅', - remark: '新编辑备注', - sourceConfig: { - ddnsRecordId: '2041700000000000002', - portForwardId: '2041700000000000001', - }, - sourceKey: 'network.stun.mapping-port-changed', - }); - expect(mocks.modalApi.close).toHaveBeenCalledTimes( - closeCountBeforeResume + 1, - ); - expect(wrapper.emitted('saved')).toHaveLength(1); - }); - - it('discards an old edit confirmation before a new create session submits its own payload', async () => { - const values = createDeferred<{ - ddnsRecordId: string; - enabled: boolean; - name: string; - portForwardId: string; - remark: string; - sourceKey: string; - }>(); - mocks.formApi.getValues.mockReturnValueOnce(values.promise); - const wrapper = mountModal(); - (wrapper.vm as any).openEdit(createRow()); - await flushPromises(); - - const staleConfirmation = mocks.modalOptions.onConfirm(); - await flushPromises(); - expect(mocks.formApi.getValues).toHaveBeenCalledOnce(); - - (wrapper.vm as any).openCreate(); - await flushPromises(); - const createValues = { - ddnsRecordId: '2041700000000000002', - enabled: true, - name: ' 新创建订阅 ', - portForwardId: '2041700000000000001', - remark: ' 新创建备注 ', - sourceKey: 'network.stun.mapping-port-changed', - }; - mocks.formApi.getValues.mockResolvedValue(createValues); - const closeCountBeforeResume = mocks.modalApi.close.mock.calls.length; - - values.resolve({ - ddnsRecordId: '2041700000000002', - enabled: false, - name: '旧编辑订阅', - portForwardId: '2041700000000001', - remark: '旧编辑备注', - sourceKey: 'network.stun.mapping-port-changed', - }); - await staleConfirmation; - await flushPromises(); - - expect(mocks.create).not.toHaveBeenCalled(); - expect(mocks.update).not.toHaveBeenCalled(); - expect(mocks.modalApi.lock).not.toHaveBeenCalled(); - expect(mocks.modalApi.unlock).not.toHaveBeenCalled(); - expect(mocks.modalApi.close).toHaveBeenCalledTimes(closeCountBeforeResume); - expect(wrapper.emitted('saved')).toBeUndefined(); - expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({ - ddnsRecordId: undefined, - enabled: true, - name: '', - portForwardId: undefined, - remark: '', - sourceKey: 'network.stun.mapping-port-changed', - }); - - await mocks.modalOptions.onConfirm(); - - expect(mocks.update).not.toHaveBeenCalled(); - expect(mocks.create).toHaveBeenCalledOnce(); - expect(mocks.create).toHaveBeenCalledWith({ - enabled: true, - name: '新创建订阅', - remark: '新创建备注', - sourceConfig: { - ddnsRecordId: '2041700000000000002', - portForwardId: '2041700000000000001', - }, - sourceKey: 'network.stun.mapping-port-changed', - }); - expect(mocks.modalApi.close).toHaveBeenCalledTimes( - closeCountBeforeResume + 1, - ); - expect(wrapper.emitted('saved')).toHaveLength(1); - }); - - it('preserves string IDs and exposes disabled reasons on matching options', async () => { - const wrapper = mountModal(); - (wrapper.vm as any).openEdit(createRow()); - await flushPromises(); - const portField = mocks.formOptions.schema.find( - (field: any) => field.fieldName === 'portForwardId', - ); - const ddnsField = mocks.formOptions.schema.find( - (field: any) => field.fieldName === 'ddnsRecordId', - ); - const portOptions = portField.componentProps().options; - const ddnsOptions = ddnsField.componentProps().options; - - expect(portOptions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - disabled: true, - value: '2041700000000000005', - }), - ]), - ); - expect(portOptions[1].label).toContain('keeper_disabled'); - expect(ddnsOptions).toHaveLength(2); - expect(ddnsOptions[1]).toMatchObject({ - disabled: true, - value: '2041700000000000003', - }); - expect(ddnsOptions[1].label).toContain('ddns_disabled'); - expect(typeof portOptions[0].value).toBe('string'); - expect(typeof ddnsOptions[0].value).toBe('string'); - }); - - it('clears only an incompatible DDNS when the selected port changes', async () => { - mountModal(); - - await mocks.formOptions.handleValuesChange( - { - ddnsRecordId: '2041700000000000002', - portForwardId: '2041700000000000001', - }, - ['portForwardId'], - ); - expect(mocks.formApi.setValues).not.toHaveBeenCalledWith({ - ddnsRecordId: undefined, - }); - - await mocks.formOptions.handleValuesChange( - { - ddnsRecordId: '2041700000000000002', - portForwardId: '2041700000000000005', - }, - ['portForwardId'], - ); - expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({ - ddnsRecordId: undefined, - }); - const ddnsField = mocks.formOptions.schema.find( - (field: any) => field.fieldName === 'ddnsRecordId', - ); - expect(ddnsField.componentProps().options).toEqual([ - expect.objectContaining({ value: '2041700000000000004' }), - ]); - }); - - it('clears a selected DDNS that is absent from the loaded options', async () => { - mountModal(); - - await mocks.formOptions.handleValuesChange( - { - ddnsRecordId: '2041700000000000099', - portForwardId: '2041700000000000001', - }, - ['portForwardId'], - ); - - expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({ - ddnsRecordId: undefined, - }); - }); - - it.each([ - ['loading', undefined], - ['empty', { ddnsRecords: [], portForwards: [] }], - ])( - 'clears and does not submit a stale DDNS while options are %s', - async (_, stunOptions) => { - const values = { - ddnsRecordId: '2041700000000000099', - enabled: true, - name: '帕鲁端口变更', - portForwardId: '2041700000000000001', - remark: '', - sourceKey: 'network.stun.mapping-port-changed', - }; - mocks.formApi.setValues.mockImplementation(async (patch) => { - Object.assign(values, patch); - }); - mocks.formApi.getValues.mockImplementation(async () => values); - mount(MessageSubscriptionModal, { - props: { - sources: createSources(), - stunOptions, - }, - }); - - await mocks.formOptions.handleValuesChange(values, ['portForwardId']); - await mocks.modalOptions.onConfirm(); - - expect(mocks.create).toHaveBeenCalledWith( - expect.objectContaining({ - sourceConfig: { - ddnsRecordId: undefined, - portForwardId: '2041700000000000001', - }, - }), - ); - expect(JSON.stringify(mocks.create.mock.calls[0]?.[0])).not.toContain( - '2041700000000000099', - ); - }, - ); - - it('submits the exact trimmed source-only create payload', async () => { - const wrapper = mountModal(); - (wrapper.vm as any).openCreate(); - await flushPromises(); - - await mocks.modalOptions.onConfirm(); - - expect(mocks.create).toHaveBeenCalledWith({ - enabled: true, - name: '帕鲁端口变更', - remark: 'managed', - sourceConfig: { - ddnsRecordId: '2041700000000000002', - portForwardId: '2041700000000000001', - }, - sourceKey: 'network.stun.mapping-port-changed', - }); - expect(JSON.stringify(mocks.create.mock.calls[0]?.[0])).not.toMatch( - /account|selfId|template|target|group|private|publish|delivery|event|worker|queue/i, - ); - }); - - it('updates with the original unsafe-integer string ID unchanged', async () => { - const wrapper = mountModal(); - (wrapper.vm as any).openEdit(createRow()); - await flushPromises(); - - await mocks.modalOptions.onConfirm(); - - expect(mocks.update).toHaveBeenCalledWith( - '10000000000000001', - expect.any(Object), - ); - }); - - it('does not persist or lock when normal form validation fails', async () => { - mocks.formApi.validate.mockResolvedValueOnce({ valid: false }); - const wrapper = mountModal(); - (wrapper.vm as any).openCreate(); - await flushPromises(); - - await expect(mocks.modalOptions.onConfirm()).resolves.toBeUndefined(); - - expect(mocks.create).not.toHaveBeenCalled(); - expect(mocks.update).not.toHaveBeenCalled(); - expect(mocks.modalApi.lock).not.toHaveBeenCalled(); - expect(mocks.modalApi.unlock).not.toHaveBeenCalled(); - }); - - it('locks, closes, emits once, and unlocks only after successful persistence', async () => { - const wrapper = mountModal(); - (wrapper.vm as any).openCreate(); - await flushPromises(); - - await mocks.modalOptions.onConfirm(); - - expect(mocks.modalApi.lock).toHaveBeenCalledOnce(); - expect(mocks.modalApi.close).toHaveBeenCalledOnce(); - expect(wrapper.emitted('saved')).toHaveLength(1); - expect(mocks.modalApi.unlock).toHaveBeenCalledOnce(); - const lockOrder = mocks.modalApi.lock.mock.invocationCallOrder[0]; - const createOrder = mocks.create.mock.invocationCallOrder[0]; - expect(lockOrder).toBeDefined(); - expect(createOrder).toBeDefined(); - if (lockOrder === undefined || createOrder === undefined) { - throw new Error('expected lock and create invocation order values'); - } - expect(lockOrder).toBeLessThan(createOrder); - }); - - it('contains a rejected mutation and remains usable for retry', async () => { - mocks.create.mockRejectedValueOnce(new Error('save failed')); - const wrapper = mountModal(); - (wrapper.vm as any).openCreate(); - await flushPromises(); - const expectedPayload = { - enabled: true, - name: '帕鲁端口变更', - remark: 'managed', - sourceConfig: { - ddnsRecordId: '2041700000000000002', - portForwardId: '2041700000000000001', - }, - sourceKey: 'network.stun.mapping-port-changed', - }; - - await expect(mocks.modalOptions.onConfirm()).resolves.toBeUndefined(); - - expect(mocks.modalApi.lock).toHaveBeenCalledOnce(); - expect(mocks.modalApi.close).not.toHaveBeenCalled(); - expect(wrapper.emitted('saved')).toBeUndefined(); - expect(mocks.modalApi.unlock).toHaveBeenCalledOnce(); - expect(mocks.create).toHaveBeenNthCalledWith(1, expectedPayload); - - mocks.create.mockResolvedValue({}); - await mocks.modalOptions.onConfirm(); - - expect(mocks.modalApi.close).toHaveBeenCalledOnce(); - expect(wrapper.emitted('saved')).toHaveLength(1); - expect(mocks.modalApi.unlock).toHaveBeenCalledTimes(2); - expect(mocks.create).toHaveBeenNthCalledWith(2, expectedPayload); - }); - - it('contains persistence failure when the real modal owner discards the callback promise', async () => { - mocks.create.mockRejectedValueOnce(new Error('discarded save failed')); - const wrapper = mountModal(); - (wrapper.vm as any).openCreate(); - await flushPromises(); - - void mocks.modalOptions.onConfirm(); - await vi.waitFor(() => { - expect(mocks.modalApi.unlock).toHaveBeenCalledOnce(); - }); - - expect(mocks.modalApi.close).not.toHaveBeenCalled(); - expect(wrapper.emitted('saved')).toBeUndefined(); - expect(mocks.create).toHaveBeenCalledOnce(); - }); -}); diff --git a/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx b/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx index dcc7e11..80945e8 100644 --- a/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx +++ b/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx @@ -3,13 +3,14 @@ import type { PropType } from 'vue'; import type { VbenFormSchema } from '#/adapter/form'; import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; -import { computed, defineComponent, ref } from 'vue'; +import { computed, defineComponent, ref, watch } from 'vue'; import { useVbenModal } from '@vben/common-ui'; import { useVbenForm, z } from '#/adapter/form'; import { createMessageSubscription, + getMessagePushSourceOptions, updateMessageSubscription, } from '#/api/qqbot/message-push'; @@ -18,14 +19,15 @@ export interface MessageSubscriptionModalExposed { openEdit: (row: QqbotMessagePushApi.MessageSubscriptionView) => void; } -interface MessageSubscriptionFormValues { - ddnsRecordId?: string; +type MessageSubscriptionFormValues = Record< + string, + boolean | string | undefined +> & { enabled: boolean; name: string; - portForwardId?: string; remark?: string; sourceKey: string; -} +}; interface MessageSubscriptionModalData { values: MessageSubscriptionFormValues; @@ -40,45 +42,70 @@ export default defineComponent({ QqbotMessagePushApi.SystemMessageSourceDefinition[] >, }, - stunOptions: { - default: undefined, - type: Object as PropType< - QqbotMessagePushApi.StunMappingPortChangedOptionsResponse | undefined - >, - }, }, emits: ['saved'], setup(props, { emit, expose }) { const editingRow = ref(); - const selectedPortForwardId = ref(); + const selectedSourceKey = ref(''); + const sourceFieldValues = ref>({}); + const sourceOptions = + ref({}); + const sourceOptionsLoading = ref(false); + let sourceRevision = 0; let sessionRevision = 0; + + /** 处理来源或来源依赖字段变化,并清理不再有效的旧值。 */ + async function handleValuesChange( + values: Record, + fieldsChanged: string[], + ) { + if (fieldsChanged.includes('sourceKey')) { + const sourceKey = + typeof values.sourceKey === 'string' ? values.sourceKey : ''; + await selectSource(sourceKey); + return; + } + + const definition = findSourceDefinition( + props.sources, + selectedSourceKey.value, + ); + if (!definition) return; + const nextValues = pickSourceFieldValues(values, definition); + const clearPatch: Record = {}; + for (const field of definition.subscriptionFields) { + if (!field.dependsOn || !fieldsChanged.includes(field.dependsOn)) { + continue; + } + const currentValue = nextValues[field.key]; + const options = getFieldOptions(field, sourceOptions.value, nextValues); + if ( + currentValue && + !options.some((option) => option.value === currentValue) + ) { + clearPatch[field.key] = undefined; + nextValues[field.key] = undefined; + } + } + sourceFieldValues.value = nextValues; + if (Object.keys(clearPatch).length > 0) { + await formApi.setValues(clearPatch); + } + } + const [SubscriptionForm, formApi] = useVbenForm({ commonConfig: { labelClass: 'w-24', }, - async handleValuesChange(values, fieldsChanged) { - if (!fieldsChanged.includes('portForwardId')) return; - selectedPortForwardId.value = - typeof values.portForwardId === 'string' - ? values.portForwardId - : undefined; - const currentDdnsId = - typeof values.ddnsRecordId === 'string' - ? values.ddnsRecordId - : undefined; - if (!currentDdnsId) return; - const currentDdns = props.stunOptions?.ddnsRecords.find( - (option) => option.id === currentDdnsId, - ); - if ( - !currentDdns || - currentDdns.portForwardId !== selectedPortForwardId.value - ) { - await formApi.setValues({ ddnsRecordId: undefined }); - } - }, + handleValuesChange, layout: 'horizontal', - schema: createFormSchema(props, selectedPortForwardId), + schema: createFormSchema( + props, + selectedSourceKey, + sourceFieldValues, + sourceOptions, + sourceOptionsLoading, + ), showDefaultActions: false, wrapperClass: 'grid-cols-1', }); @@ -98,38 +125,49 @@ export default defineComponent({ async onOpenChange(isOpen: boolean) { if (!isOpen) return; const { values } = modalApi.getData(); - selectedPortForwardId.value = values.portForwardId; + resetSourceRequest(); + selectedSourceKey.value = values.sourceKey; + const definition = findSourceDefinition( + props.sources, + values.sourceKey, + ); + sourceFieldValues.value = definition + ? pickSourceFieldValues(values, definition) + : pickUnknownSourceValues(values); + rebuildSchema(); await resetForm(values); + if (values.sourceKey) { + await loadSourceOptions(values.sourceKey); + } }, }); + /** 打开不预选消息源的新建订阅弹窗。 */ function openCreate() { sessionRevision += 1; editingRow.value = undefined; modalApi .setData({ values: { - ddnsRecordId: undefined, enabled: true, name: '', - portForwardId: undefined, remark: '', - sourceKey: props.sources[0]?.sourceKey || '', + sourceKey: '', }, } satisfies MessageSubscriptionModalData) .open(); } + /** 打开编辑弹窗并保留当前消息源的动态配置。 */ function openEdit(row: QqbotMessagePushApi.MessageSubscriptionView) { sessionRevision += 1; editingRow.value = row; modalApi .setData({ values: { - ddnsRecordId: row.sourceConfig.ddnsRecordId, + ...row.sourceConfig, enabled: row.enabled, name: row.name, - portForwardId: row.sourceConfig.portForwardId, remark: row.remark || '', sourceKey: row.sourceKey, }, @@ -137,28 +175,118 @@ export default defineComponent({ .open(); } + /** 重置表单并写入当前会话值。 */ async function resetForm(values: MessageSubscriptionFormValues) { await formApi.resetForm(); - await formApi.setValues(values); + await formApi.setValues(values, false); await formApi.resetValidate(); } + /** 使旧候选项请求失效并清空来源运行态。 */ + function resetSourceRequest() { + sourceRevision += 1; + sourceOptions.value = {}; + sourceOptionsLoading.value = false; + } + + /** 用当前消息源元数据重建动态表单结构。 */ + function rebuildSchema() { + formApi.setState({ + schema: createFormSchema( + props, + selectedSourceKey, + sourceFieldValues, + sourceOptions, + sourceOptionsLoading, + ), + }); + } + + /** 切换消息源,移除上一来源字段并按需加载新候选项。 */ + async function selectSource(sourceKey: string) { + const previousDefinition = findSourceDefinition( + props.sources, + selectedSourceKey.value, + ); + const staleFieldKeys = new Set([ + ...(previousDefinition?.subscriptionFields.map((field) => field.key) || + []), + ...Object.keys(sourceFieldValues.value), + ]); + if (staleFieldKeys.size > 0) { + await formApi.setValues( + Object.fromEntries( + [...staleFieldKeys].map((fieldName) => [fieldName, undefined]), + ), + false, + ); + } + + resetSourceRequest(); + selectedSourceKey.value = sourceKey; + sourceFieldValues.value = {}; + rebuildSchema(); + if (sourceKey) { + await loadSourceOptions(sourceKey); + } + } + + /** 加载当前消息源候选项,并忽略快速切换产生的迟到响应。 */ + async function loadSourceOptions(sourceKey: string) { + const revision = ++sourceRevision; + sourceOptions.value = {}; + sourceOptionsLoading.value = true; + rebuildSchema(); + try { + const result = await getMessagePushSourceOptions(sourceKey); + if ( + revision === sourceRevision && + selectedSourceKey.value === sourceKey + ) { + sourceOptions.value = result; + } + } catch { + // The request layer owns user-facing errors; an empty catalog remains usable. + } finally { + if ( + revision === sourceRevision && + selectedSourceKey.value === sourceKey + ) { + sourceOptionsLoading.value = false; + rebuildSchema(); + } + } + } + + /** 在消息源目录迟到时补建当前编辑会话的动态字段。 */ + function handleSourcesChanged() { + rebuildSchema(); + } + + /** 校验并提交当前来源声明范围内的订阅配置。 */ async function submit() { const revision = sessionRevision; const editingId = editingRow.value?.id; const { valid } = await formApi.validate(); - if (revision !== sessionRevision) return; - if (!valid) return; + if (revision !== sessionRevision || !valid) return; const values = await formApi.getValues(); if (revision !== sessionRevision) return; + const definition = findSourceDefinition(props.sources, values.sourceKey); + const sourceConfig = definition + ? Object.fromEntries( + definition.subscriptionFields.flatMap((field) => { + const value = values[field.key]; + return typeof value === 'string' && value + ? [[field.key, value]] + : []; + }), + ) + : {}; const payload: QqbotMessagePushApi.MessageSubscriptionInput = { enabled: !!values.enabled, name: values.name.trim(), - remark: values.remark?.trim() || '', - sourceConfig: { - ddnsRecordId: values.ddnsRecordId, - portForwardId: values.portForwardId, - }, + remark: typeof values.remark === 'string' ? values.remark.trim() : '', + sourceConfig, sourceKey: values.sourceKey, }; if (revision !== sessionRevision) return; @@ -176,6 +304,8 @@ export default defineComponent({ } } + watch(() => props.sources, handleSourcesChanged, { deep: true }); + expose({ openCreate, openEdit } satisfies MessageSubscriptionModalExposed); return () => ( @@ -186,15 +316,32 @@ export default defineComponent({ }, }); +/** 根据消息源目录与当前选中项生成订阅表单。 */ function createFormSchema( props: Readonly<{ sources: QqbotMessagePushApi.SystemMessageSourceDefinition[]; - stunOptions: - | QqbotMessagePushApi.StunMappingPortChangedOptionsResponse - | undefined; }>, - selectedPortForwardId: Readonly<{ value: string | undefined }>, + selectedSourceKey: Readonly<{ value: string }>, + sourceFieldValues: Readonly<{ + value: Record; + }>, + sourceOptions: Readonly<{ + value: QqbotMessagePushApi.SystemMessageSourceOptionsResponse; + }>, + sourceOptionsLoading: Readonly<{ value: boolean }>, ): VbenFormSchema[] { + const definition = findSourceDefinition( + props.sources, + selectedSourceKey.value, + ); + const dynamicFields = (definition?.subscriptionFields || []).map((field) => + createSourceFieldSchema( + field, + sourceFieldValues, + sourceOptions, + sourceOptionsLoading, + ), + ); return [ { component: 'Input', @@ -206,6 +353,7 @@ function createFormSchema( { component: 'Select', componentProps: () => ({ + allowClear: true, options: props.sources.map((source) => ({ label: `${source.displayName} · ${source.sourceKey}`, value: source.sourceKey, @@ -215,32 +363,7 @@ function createFormSchema( label: '消息源', rules: z.string().min(1), }, - { - component: 'Select', - componentProps: () => ({ - allowClear: true, - options: (props.stunOptions?.portForwards || []).map((option) => - formatPortForwardOption(option), - ), - }), - fieldName: 'portForwardId', - label: '端口转发', - rules: z.string().min(1), - }, - { - component: 'Select', - componentProps: () => ({ - allowClear: true, - options: (props.stunOptions?.ddnsRecords || []) - .filter( - (option) => option.portForwardId === selectedPortForwardId.value, - ) - .map((option) => formatDdnsOption(option)), - }), - fieldName: 'ddnsRecordId', - label: 'IPv4 DDNS', - rules: z.string().min(1), - }, + ...dynamicFields, { component: 'Switch', defaultValue: true, @@ -257,28 +380,77 @@ function createFormSchema( ]; } -function formatPortForwardOption( - option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['portForwards'][number], -) { - const reason = option.eligible - ? '' - : ` · ${option.disabledReasonCode || 'unavailable'}`; +/** 将消息源字段元数据转换成 Vben 选择框定义。 */ +function createSourceFieldSchema( + field: QqbotMessagePushApi.SystemMessageSourceFieldDefinition, + sourceFieldValues: Readonly<{ + value: Record; + }>, + sourceOptions: Readonly<{ + value: QqbotMessagePushApi.SystemMessageSourceOptionsResponse; + }>, + sourceOptionsLoading: Readonly<{ value: boolean }>, +): VbenFormSchema { + const optionalRule = z.string().optional().or(z.literal('')); return { - disabled: !option.eligible, - label: `${option.name} · ${option.protocol.toUpperCase()}:${option.externalPort}${reason}`, - value: option.id, + component: 'Select', + componentProps: () => ({ + allowClear: true, + loading: sourceOptionsLoading.value, + options: getFieldOptions( + field, + sourceOptions.value, + sourceFieldValues.value, + ), + }), + fieldName: field.key, + label: field.label, + rules: field.required ? z.string().min(1) : optionalRule, }; } -function formatDdnsOption( - option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['ddnsRecords'][number], -) { - const reason = option.eligible - ? '' - : ` · ${option.disabledReasonCode || 'unavailable'}`; - return { - disabled: !option.eligible, - label: `${option.name} · ${option.fqdn}${reason}`, - value: option.id, - }; +/** 返回消息源字段可用且满足依赖关系的候选项。 */ +function getFieldOptions( + field: QqbotMessagePushApi.SystemMessageSourceFieldDefinition, + sourceOptions: QqbotMessagePushApi.SystemMessageSourceOptionsResponse, + sourceFieldValues: Record, +): QqbotMessagePushApi.SystemMessageSourceOptionDefinition[] { + const options = sourceOptions[field.optionCollection] || []; + if (!field.dependsOn) return options; + const dependsOnValue = sourceFieldValues[field.dependsOn]; + if (!dependsOnValue) return []; + return options.filter((option) => option.dependsOnValue === dependsOnValue); +} + +/** 按 sourceKey 查找消息源元数据。 */ +function findSourceDefinition( + sources: QqbotMessagePushApi.SystemMessageSourceDefinition[], + sourceKey: string, +): QqbotMessagePushApi.SystemMessageSourceDefinition | undefined { + return sources.find((source) => source.sourceKey === sourceKey); +} + +/** 从表单值中提取当前消息源公开声明的字符串字段。 */ +function pickSourceFieldValues( + values: Record, + definition: QqbotMessagePushApi.SystemMessageSourceDefinition, +): Record { + return Object.fromEntries( + definition.subscriptionFields.map((field): [string, string | undefined] => { + const value = values[field.key]; + return [field.key, typeof value === 'string' ? value : undefined]; + }), + ); +} + +/** 在元数据尚未返回时保留编辑会话中的未知来源配置。 */ +function pickUnknownSourceValues( + values: MessageSubscriptionFormValues, +): Record { + const commonFields = new Set(['enabled', 'name', 'remark', 'sourceKey']); + return Object.fromEntries( + Object.entries(values).flatMap(([key, value]) => + !commonFields.has(key) && typeof value === 'string' ? [[key, value]] : [], + ), + ) as Record; } diff --git a/apps/web-antdv-next/src/views/qqbot/message-subscription/list.tsx b/apps/web-antdv-next/src/views/qqbot/message-subscription/list.tsx index ce121e9..df8d57a 100644 --- a/apps/web-antdv-next/src/views/qqbot/message-subscription/list.tsx +++ b/apps/web-antdv-next/src/views/qqbot/message-subscription/list.tsx @@ -22,7 +22,6 @@ import { deleteMessageSubscription, getMessagePushSources, getMessageSubscriptionList, - getStunMappingPortChangedOptions, setMessageSubscriptionEnabled, } from '#/api/qqbot/message-push'; import { KtTable, useKtTable } from '#/components/ktTable'; @@ -40,8 +39,6 @@ export default defineComponent({ const sources = ref( [], ); - const stunOptions = - ref(); const columns: Array< TableColumnType > = [ @@ -166,20 +163,24 @@ export default defineComponent({ tableTitle: '消息订阅', }); + /** 打开新建订阅弹窗。 */ function openCreate() { modalRef.value?.openCreate(); } + /** 打开指定订阅的编辑弹窗。 */ function openEdit(row: QqbotMessagePushApi.MessageSubscriptionView) { modalRef.value?.openEdit(row); } + /** 生成删除订阅确认文案。 */ function getDeleteConfirm( row: QqbotMessagePushApi.MessageSubscriptionView, ) { return `确认删除消息订阅「${row.name}」吗?`; } + /** 切换订阅启用状态并刷新当前表格上下文。 */ async function handleToggle( row: QqbotMessagePushApi.MessageSubscriptionView, context: KtTableContext, @@ -188,6 +189,7 @@ export default defineComponent({ await context.reload(); } + /** 删除订阅并刷新当前表格上下文。 */ async function handleDelete( row: QqbotMessagePushApi.MessageSubscriptionView, context: KtTableContext, @@ -196,24 +198,23 @@ export default defineComponent({ await context.reload(); } + /** 在弹窗保存成功后刷新订阅列表。 */ async function handleModalSaved() { await tableApi.reload(); } - async function loadMetadata() { - const [nextSources, nextStunOptions] = await Promise.all([ - getMessagePushSources(), - getStunMappingPortChangedOptions(), - ]); - sources.value = nextSources; - stunOptions.value = nextStunOptions; + /** 加载通用系统消息源目录。 */ + async function loadSources() { + sources.value = await getMessagePushSources(); } + /** 首次进入页面时并行加载列表与消息源目录。 */ async function activatePage() { if (!canList) return; - await Promise.all([tableApi.reload(), loadMetadata()]); + await Promise.all([tableApi.reload(), loadSources()]); } + /** 渲染订阅表格的自定义单元格。 */ function renderBodyCell(slot: { column: TableColumnType; record: QqbotMessagePushApi.MessageSubscriptionView; @@ -249,7 +250,6 @@ export default defineComponent({ onSaved={handleModalSaved} ref={modalRef} sources={sources.value} - stunOptions={stunOptions.value} /> ) : null} diff --git a/internal/tsconfig/types/vben-web/index.d.ts b/internal/tsconfig/types/vben-web/index.d.ts index 772c04a..1d94316 100644 --- a/internal/tsconfig/types/vben-web/index.d.ts +++ b/internal/tsconfig/types/vben-web/index.d.ts @@ -1,3 +1,4 @@ +/* eslint-disable spaced-comment -- TypeScript 三斜线引用指令要求连续斜线。 */ /// declare module '*.vue' { diff --git a/package.json b/package.json index 5732d2b..6b827ab 100644 --- a/package.json +++ b/package.json @@ -8,15 +8,15 @@ "build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 turbo build", "build:analyze": "turbo build:analyze", "build:antdv-next": "pnpm run build --filter=@vben/web-antdv-next", - "check": "pnpm run check:type", + "check": "pnpm run test:unit && pnpm run check:type", "check:jsdoc": "pnpm run test:jsdoc && node internal/jsdoc-policy/check-jsdoc.mjs", "check:jsdoc:staged": "pnpm run test:jsdoc && node internal/jsdoc-policy/check-jsdoc.mjs --staged", - "check:type": "turbo run typecheck", + "check:type": "turbo run typecheck && pnpm run test:type", "clean": "rimraf .turbo apps/*/dist apps/*/dist.zip packages/**/dist packages/**/dist.zip internal/**/dist internal/**/dist.zip", "dev": "pnpm run dev:antdv-next", "dev:antdv-next": "pnpm -F @vben/web-antdv-next run dev", - "format": "eslint apps/web-antdv-next internal --cache --fix && prettier . --write --cache --log-level warn && stylelint \"**/*.{vue,css,less,scss}\" --cache --fix", - "lint": "pnpm run check:jsdoc && eslint apps/web-antdv-next internal --cache && prettier . --ignore-unknown --check --cache && stylelint \"**/*.{vue,css,less,scss}\" --cache", + "format": "eslint apps/web-antdv-next internal test --cache --fix && prettier . --write --cache --log-level warn && stylelint \"**/*.{vue,css,less,scss}\" --cache --fix", + "lint": "pnpm run check:jsdoc && eslint apps/web-antdv-next internal test --cache && prettier . --ignore-unknown --check --cache && stylelint \"**/*.{vue,css,less,scss}\" --cache", "lint:commit": "eslint apps/web-antdv-next internal/commit/validate-commit-msg.mjs --cache", "postinstall": "pnpm -r run stub --if-present", "prepare": "husky", @@ -26,13 +26,23 @@ "verify:staged": "node internal/commit/husky-fast-check.mjs", "verify:commit": "pnpm run lint:commit && pnpm -F @vben/web-antdv-next run typecheck", "test:e2e": "turbo run test:e2e", - "test:jsdoc": "vitest run internal/jsdoc-policy/check-jsdoc.test.mjs --environment node --reporter=dot" + "test:jsdoc": "vitest run test/internal/jsdoc-policy/check-jsdoc.test.mjs --environment node --reporter=dot", + "test:type": "vue-tsc --noEmit --skipLibCheck -p test/tsconfig.json", + "test:unit": "vitest run test --reporter=dot" }, "devDependencies": { "@playwright/test": "catalog:", + "@tiptap/vue-3": "catalog:", "@types/node": "catalog:", + "@types/sortablejs": "catalog:", + "@vben-core/shared": "workspace:*", + "@vben/access": "workspace:*", + "@vben/common-ui": "workspace:*", "@vben/eslint-config": "workspace:*", + "@vben/icons": "workspace:*", + "@vben/locales": "workspace:*", "@vben/prettier-config": "workspace:*", + "@vben/stores": "workspace:*", "@vben/stylelint-config": "workspace:*", "@vben/tailwind-config": "workspace:*", "@vben/tsconfig": "workspace:*", @@ -40,12 +50,19 @@ "@vitejs/plugin-vue": "catalog:", "@vitejs/plugin-vue-jsx": "catalog:", "@vue/test-utils": "catalog:", + "@vueuse/core": "catalog:", + "antdv-next": "catalog:", "autoprefixer": "catalog:", + "axios": "catalog:", + "axios-mock-adapter": "catalog:", "cross-env": "catalog:", + "dayjs": "catalog:", "happy-dom": "catalog:", "husky": "catalog:", + "pinia": "catalog:", "playwright": "catalog:", "rimraf": "catalog:", + "sortablejs": "catalog:", "tailwindcss": "catalog:", "turbo": "catalog:", "typescript": "catalog:", @@ -53,6 +70,7 @@ "vite": "catalog:", "vitest": "catalog:", "vue": "catalog:", + "vue-router": "catalog:", "vue-tsc": "catalog:" }, "engines": { diff --git a/packages/@core/preferences/tsconfig.json b/packages/@core/preferences/tsconfig.json index bbb8050..ce1a891 100644 --- a/packages/@core/preferences/tsconfig.json +++ b/packages/@core/preferences/tsconfig.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "@vben/tsconfig/web.json", - "include": ["src", "__tests__"], + "include": ["src"], "exclude": ["node_modules"] } diff --git a/packages/@core/ui-kit/form-ui/tsconfig.json b/packages/@core/ui-kit/form-ui/tsconfig.json index bbb8050..ce1a891 100644 --- a/packages/@core/ui-kit/form-ui/tsconfig.json +++ b/packages/@core/ui-kit/form-ui/tsconfig.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "@vben/tsconfig/web.json", - "include": ["src", "__tests__"], + "include": ["src"], "exclude": ["node_modules"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26c618e..3b2d6b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -485,15 +485,39 @@ importers: '@playwright/test': specifier: 'catalog:' version: 1.58.2 + '@tiptap/vue-3': + specifier: 'catalog:' + version: 3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(vue@3.5.27(typescript@5.9.3)) '@types/node': specifier: 'catalog:' version: 24.10.12 + '@types/sortablejs': + specifier: 'catalog:' + version: 1.15.9 + '@vben-core/shared': + specifier: workspace:* + version: link:packages/@core/base/shared + '@vben/access': + specifier: workspace:* + version: link:packages/effects/access + '@vben/common-ui': + specifier: workspace:* + version: link:packages/effects/common-ui '@vben/eslint-config': specifier: workspace:* version: link:internal/lint-configs/eslint-config + '@vben/icons': + specifier: workspace:* + version: link:packages/icons + '@vben/locales': + specifier: workspace:* + version: link:packages/locales '@vben/prettier-config': specifier: workspace:* version: link:internal/lint-configs/prettier-config + '@vben/stores': + specifier: workspace:* + version: link:packages/stores '@vben/stylelint-config': specifier: workspace:* version: link:internal/lint-configs/stylelint-config @@ -515,24 +539,45 @@ importers: '@vue/test-utils': specifier: 'catalog:' version: 2.4.6 + '@vueuse/core': + specifier: 'catalog:' + version: 14.2.0(vue@3.5.27(typescript@5.9.3)) + antdv-next: + specifier: 'catalog:' + version: 1.0.2(date-fns@4.1.0)(vue@3.5.27(typescript@5.9.3)) autoprefixer: specifier: 'catalog:' version: 10.4.24(postcss@8.5.6) + axios: + specifier: 'catalog:' + version: 1.13.5 + axios-mock-adapter: + specifier: 'catalog:' + version: 2.1.0(axios@1.13.5) cross-env: specifier: 'catalog:' version: 10.1.0 + dayjs: + specifier: 'catalog:' + version: 1.11.19 happy-dom: specifier: 'catalog:' version: 17.6.3 husky: specifier: 'catalog:' version: 9.1.7 + pinia: + specifier: ^3.0.4 + version: 3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)) playwright: specifier: 'catalog:' version: 1.58.2 rimraf: specifier: 'catalog:' version: 6.1.2 + sortablejs: + specifier: 'catalog:' + version: 1.15.6 tailwindcss: specifier: 'catalog:' version: 3.4.19(yaml@2.8.2) @@ -554,6 +599,9 @@ importers: vue: specifier: ^3.5.27 version: 3.5.27(typescript@5.9.3) + vue-router: + specifier: 'catalog:' + version: 4.6.4(vue@3.5.27(typescript@5.9.3)) vue-tsc: specifier: 'catalog:' version: 3.2.4(typescript@5.9.3) diff --git a/skills/references/core/locale.md b/skills/references/core/locale.md index bc894ad..ee55370 100644 --- a/skills/references/core/locale.md +++ b/skills/references/core/locale.md @@ -40,7 +40,7 @@ import { defineOverridesPreferences } from '@vben/preferences'; export const overridesPreferences = defineOverridesPreferences({ app: { - locale: 'zh-CN', // 'en-US' | 'zh-CN' | ... + locale: 'zh-CN', // 'en-US' | 'zh-CN' | ... }, }); ``` @@ -109,7 +109,7 @@ packages/locales/ Vue i18n 会把 `@` 解析为 linked message 起始符。文案需要显示普通 `@` 时必须使用字面量插值 `{'@'}`,不能直接写裸 `@`,否则生产消息编译器可能抛出 `SyntaxError: 14`。 -新增或修改业务语言包后,应通过实际 i18n runtime 逐条调用对应 namespace,并断言没有 message compilation error。网络管理的回归入口为 `apps/web-antdv-next/src/views/system/network/network-locale.spec.ts`。 +新增或修改业务语言包后,应通过实际 i18n runtime 逐条调用对应 namespace,并断言没有 message compilation error。网络管理的回归入口为 `test/views/system/network/network-locale.spec.ts`。 ## 远程加载语言包 @@ -118,7 +118,9 @@ Vue i18n 会把 `@` 解析为 linked message 起始符。文案需要显示普 import { setI18nLanguage } from '@vben/locales'; async function loadLocaleMessages(locale: string) { - const messages = await fetch(`/locales/${locale}.json`).then(res => res.json()); + const messages = await fetch(`/locales/${locale}.json`).then((res) => + res.json(), + ); setI18nLanguage(locale, messages); } ``` @@ -130,7 +132,7 @@ async function loadLocaleMessages(locale: string) { ```ts export const overridesPreferences = defineOverridesPreferences({ widget: { - languageToggle: true, // 显示语言切换按钮 + languageToggle: true, // 显示语言切换按钮 }, }); ``` diff --git a/apps/web-antdv-next/src/api/blog/preview-url.spec.ts b/test/api/blog/preview-url.spec.ts similarity index 88% rename from apps/web-antdv-next/src/api/blog/preview-url.spec.ts rename to test/api/blog/preview-url.spec.ts index 4e3705f..066b458 100644 --- a/apps/web-antdv-next/src/api/blog/preview-url.spec.ts +++ b/test/api/blog/preview-url.spec.ts @@ -1,9 +1,11 @@ /* @vitest-environment happy-dom */ +import { + buildKtBlogPreviewUrl, + resolveKtBlogWebBaseUrl, +} from '@test-source/apps/web-antdv-next/src/api/blog/preview-url'; import { describe, expect, it } from 'vitest'; -import { buildKtBlogPreviewUrl, resolveKtBlogWebBaseUrl } from './preview-url'; - describe('blog preview url helpers', () => { it('builds a KT Blog Web preview URL with encoded slug and trace params', () => { const url = buildKtBlogPreviewUrl( diff --git a/apps/web-antdv-next/src/api/core/menu.spec.ts b/test/api/core/menu.spec.ts similarity index 96% rename from apps/web-antdv-next/src/api/core/menu.spec.ts rename to test/api/core/menu.spec.ts index b1ed446..4487bf6 100644 --- a/apps/web-antdv-next/src/api/core/menu.spec.ts +++ b/test/api/core/menu.spec.ts @@ -1,7 +1,6 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -// eslint-disable-next-line n/no-extraneous-import -- TypeScript is the workspace compiler used for source-level contract checks. import ts from 'typescript'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -121,7 +120,8 @@ describe('core menu api', () => { }, ]); - const { getAllMenusApi } = await import('./menu'); + const { getAllMenusApi } = + await import('@test-source/apps/web-antdv-next/src/api/core/menu'); const menus = await getAllMenusApi(); expect(requestClientGet).toHaveBeenCalledWith('/menu/all'); @@ -210,7 +210,8 @@ describe('core menu api', () => { }, ]); - const { getAllMenusApi } = await import('./menu'); + const { getAllMenusApi } = + await import('@test-source/apps/web-antdv-next/src/api/core/menu'); const menus = await getAllMenusApi(); expect(menus.map((menu) => menu.meta?.order)).toEqual([200, 1, 0]); @@ -249,7 +250,8 @@ describe('core menu api', () => { }, ]); - const { getAllMenusApi } = await import('./menu'); + const { getAllMenusApi } = + await import('@test-source/apps/web-antdv-next/src/api/core/menu'); const menus = await getAllMenusApi(); expect(menus).toEqual([ @@ -366,7 +368,8 @@ describe('core menu api', () => { }, ]); - const { getAllMenusApi } = await import('./menu'); + const { getAllMenusApi } = + await import('@test-source/apps/web-antdv-next/src/api/core/menu'); const menus = await getAllMenusApi(); expect(menus).toEqual([ @@ -474,7 +477,8 @@ describe('core menu api', () => { }, ]); - const { getAllMenusApi } = await import('./menu'); + const { getAllMenusApi } = + await import('@test-source/apps/web-antdv-next/src/api/core/menu'); const menus = await getAllMenusApi(); const qqbot = menus.find((menu) => menu.name === 'QqBot'); const retainedNames = qqbot?.children?.map((child) => child.name) ?? []; diff --git a/apps/web-antdv-next/src/api/qqbot/index.spec.ts b/test/api/qqbot/index.spec.ts similarity index 89% rename from apps/web-antdv-next/src/api/qqbot/index.spec.ts rename to test/api/qqbot/index.spec.ts index 7a7b750..503068e 100644 --- a/apps/web-antdv-next/src/api/qqbot/index.spec.ts +++ b/test/api/qqbot/index.spec.ts @@ -1,9 +1,10 @@ import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; const readApiFile = (name: string) => - readFileSync(new URL(name, import.meta.url), 'utf8'); + readFileSync(resolve('apps/web-antdv-next/src/api/qqbot', name), 'utf8'); describe('qqbot core API caller boundary', () => { it('keeps plugin platform and NapCat scan routes out of the core caller', () => { diff --git a/apps/web-antdv-next/src/api/qqbot/message-push.spec.ts b/test/api/qqbot/message-push.spec.ts similarity index 91% rename from apps/web-antdv-next/src/api/qqbot/message-push.spec.ts rename to test/api/qqbot/message-push.spec.ts index 377c463..0ccca3e 100644 --- a/apps/web-antdv-next/src/api/qqbot/message-push.spec.ts +++ b/test/api/qqbot/message-push.spec.ts @@ -1,14 +1,8 @@ -import type { QqbotMessagePushApi } from './message-push'; +import type { QqbotMessagePushApi } from '@test-source/apps/web-antdv-next/src/api/qqbot/message-push'; import { resolve } from 'node:path'; -// eslint-disable-next-line n/no-extraneous-import -- TypeScript is the workspace compiler used for source-level contract checks. -import ts from 'typescript'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { requestClient } from '#/api/request'; - -import * as messagePushApi from './message-push'; +import * as messagePushApi from '@test-source/apps/web-antdv-next/src/api/qqbot/message-push'; import { createAccountMessagePushBinding, createMessageSubscription, @@ -19,6 +13,7 @@ import { getAccountMessagePushBindings, getAccountMessagePushTargets, getMessagePushSourceDetail, + getMessagePushSourceOptions, getMessagePushSources, getMessageSubscriptionList, getMessageTemplateList, @@ -30,7 +25,11 @@ import { updateAccountMessagePushBinding, updateMessageSubscription, updateMessageTemplate, -} from './message-push'; +} from '@test-source/apps/web-antdv-next/src/api/qqbot/message-push'; +import ts from 'typescript'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { requestClient } from '#/api/request'; type Equal = (() => Value extends Actual ? 1 : 2) extends < @@ -53,8 +52,8 @@ type ExpectedSystemMessageSourceFieldDefinition = { dependsOn?: string; key: string; label: string; - optionCollection: 'ddnsRecords' | 'portForwards'; - required: true; + optionCollection: string; + required: boolean; type: 'select'; }; @@ -87,6 +86,19 @@ type ExpectedStunMappingPortChangedOptionsResponse = { }>; }; +type ExpectedSystemMessageSourceOptionDefinition = { + dependsOnValue?: string; + disabled: boolean; + disabledReasonCode: null | string; + label: string; + value: string; +}; + +type ExpectedSystemMessageSourceOptionsResponse = Record< + string, + ExpectedSystemMessageSourceOptionDefinition[] +>; + type ExpectedMessageSubscriptionView = { createTime: string; enabled: boolean; @@ -94,7 +106,7 @@ type ExpectedMessageSubscriptionView = { invalidReasonCode: null | string; name: string; remark: null | string; - sourceConfig: { ddnsRecordId: string; portForwardId: string }; + sourceConfig: Record; sourceKey: string; sourceName: string; sourceSummary: string; @@ -114,7 +126,7 @@ type ExpectedMessageSubscriptionInput = { enabled: boolean; name: string; remark?: string; - sourceConfig: Record; + sourceConfig: Record; sourceKey: string; }; @@ -226,6 +238,18 @@ type MessagePushTypeContracts = [ ExpectedStunMappingPortChangedOptionsResponse > >, + Assert< + Equal< + QqbotMessagePushApi.SystemMessageSourceOptionDefinition, + ExpectedSystemMessageSourceOptionDefinition + > + >, + Assert< + Equal< + QqbotMessagePushApi.SystemMessageSourceOptionsResponse, + ExpectedSystemMessageSourceOptionsResponse + > + >, Assert< Equal< QqbotMessagePushApi.MessageSubscriptionView, @@ -338,6 +362,15 @@ type MessagePushTypeContracts = [ Promise > >, + Assert< + Equal, [sourceKey: string]> + >, + Assert< + Equal< + ReturnType, + Promise + > + >, Assert, []>>, Assert< Equal< @@ -536,6 +569,7 @@ const messagePushCallerNames = [ 'getAccountMessagePushBindings', 'getAccountMessagePushTargets', 'getMessagePushSourceDetail', + 'getMessagePushSourceOptions', 'getMessagePushSources', 'getMessageSubscriptionList', 'getMessageTemplateList', @@ -549,10 +583,11 @@ const messagePushCallerNames = [ 'updateMessageTemplate', ]; +/** 使用工作区 TypeScript 配置检查消息推送公开契约。 */ function getMessagePushContractDiagnostics() { const appRoot = resolve('apps/web-antdv-next'); const configPath = resolve(appRoot, 'tsconfig.json'); - const testFilePath = resolve(appRoot, 'src/api/qqbot/message-push.spec.ts'); + const testFilePath = resolve('test/api/qqbot/message-push.spec.ts'); const virtualRequestModulePath = '/__qqbot-message-push-contract__/request.ts'; const virtualRequestModuleSource = ` @@ -616,6 +651,15 @@ export declare const requestClient: { }; } + if (moduleName.startsWith('@test-source/')) { + return ts.resolveModuleName( + resolve(moduleName.slice('@test-source/'.length)), + containingFile, + parsed.options, + baseCompilerHost, + ).resolvedModule; + } + return ts.resolveModuleName( moduleName, containingFile, @@ -688,6 +732,7 @@ describe('qqbot message push api', () => { await getMessagePushSources(); await getMessagePushSourceDetail(sourceKey); + await getMessagePushSourceOptions(sourceKey); await getStunMappingPortChangedOptions(); await getMessageSubscriptionList(subscriptionQuery); await createMessageSubscription(subscriptionInput); @@ -714,15 +759,19 @@ describe('qqbot message push api', () => { ); expect(requestClient.get).toHaveBeenNthCalledWith( 3, - '/qqbot/message-push/sources/network.stun.mapping-port-changed/options', + '/qqbot/message-push/sources/network.stun%2Fa%20b/subscription-options', ); expect(requestClient.get).toHaveBeenNthCalledWith( 4, + '/qqbot/message-push/sources/network.stun.mapping-port-changed/options', + ); + expect(requestClient.get).toHaveBeenNthCalledWith( + 5, '/qqbot/message-push/subscriptions', { params: subscriptionQuery }, ); expect(requestClient.get).toHaveBeenNthCalledWith( - 5, + 6, '/qqbot/message-push/templates', { params: templateQuery }, ); @@ -826,7 +875,7 @@ describe('qqbot message push api', () => { ); }); - it('exports exactly the twenty approved callers', () => { + it('exports exactly the twenty-one approved callers', () => { expect(Object.keys(messagePushApi).toSorted()).toEqual( messagePushCallerNames, ); diff --git a/apps/web-antdv-next/src/api/qqbot/napcat.spec.ts b/test/api/qqbot/napcat.spec.ts similarity index 99% rename from apps/web-antdv-next/src/api/qqbot/napcat.spec.ts rename to test/api/qqbot/napcat.spec.ts index 091e7c2..241d705 100644 --- a/apps/web-antdv-next/src/api/qqbot/napcat.spec.ts +++ b/test/api/qqbot/napcat.spec.ts @@ -1,7 +1,3 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { requestClient } from '#/api/request'; - import { cancelQqbotAccountScan, createQqbotNapcatWebuiSession, @@ -19,7 +15,10 @@ import { startQqbotAccountScanCreate, startQqbotAccountScanRefresh, submitQqbotAccountScanCaptcha, -} from './napcat'; +} from '@test-source/apps/web-antdv-next/src/api/qqbot/napcat'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { requestClient } from '#/api/request'; vi.mock('#/api/request', () => ({ requestClient: { diff --git a/apps/web-antdv-next/src/api/qqbot/plugin-task.spec.ts b/test/api/qqbot/plugin-task.spec.ts similarity index 96% rename from apps/web-antdv-next/src/api/qqbot/plugin-task.spec.ts rename to test/api/qqbot/plugin-task.spec.ts index 76a566c..4a064bb 100644 --- a/apps/web-antdv-next/src/api/qqbot/plugin-task.spec.ts +++ b/test/api/qqbot/plugin-task.spec.ts @@ -1,7 +1,3 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { requestClient } from '#/api/request'; - import { disableQqbotPluginTask, enableQqbotPluginTask, @@ -9,7 +5,10 @@ import { getQqbotPluginTaskRunPage, runQqbotPluginTaskOnce, updateQqbotPluginTaskCron, -} from './plugin-task'; +} from '@test-source/apps/web-antdv-next/src/api/qqbot/plugin-task'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { requestClient } from '#/api/request'; vi.mock('#/api/request', () => ({ requestClient: { diff --git a/apps/web-antdv-next/src/api/qqbot/plugin.spec.ts b/test/api/qqbot/plugin.spec.ts similarity index 97% rename from apps/web-antdv-next/src/api/qqbot/plugin.spec.ts rename to test/api/qqbot/plugin.spec.ts index 13d5186..a2af26c 100644 --- a/apps/web-antdv-next/src/api/qqbot/plugin.spec.ts +++ b/test/api/qqbot/plugin.spec.ts @@ -1,7 +1,3 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { requestClient } from '#/api/request'; - import { enableQqbotPluginInstallation, getQqbotPluginAccountBindings, @@ -11,7 +7,10 @@ import { installLocalQqbotPluginPackage, uploadQqbotPluginPackage, validateQqbotPluginManifest, -} from './plugin'; +} from '@test-source/apps/web-antdv-next/src/api/qqbot/plugin'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { requestClient } from '#/api/request'; vi.mock('#/api/request', () => ({ requestClient: { diff --git a/apps/web-antdv-next/src/api/system/environment.spec.ts b/test/api/system/environment.spec.ts similarity index 93% rename from apps/web-antdv-next/src/api/system/environment.spec.ts rename to test/api/system/environment.spec.ts index 9fd4960..bdb0f07 100644 --- a/apps/web-antdv-next/src/api/system/environment.spec.ts +++ b/test/api/system/environment.spec.ts @@ -1,12 +1,11 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { requestClient } from '#/api/request'; - import { getEnvironmentDashboard, getEnvironmentDashboardEventsUrl, runEnvironmentSelfCheck, -} from './environment'; +} from '@test-source/apps/web-antdv-next/src/api/system/environment'; +import { describe, expect, it, vi } from 'vitest'; + +import { requestClient } from '#/api/request'; vi.mock('#/api/request', () => ({ requestClient: { diff --git a/apps/web-antdv-next/src/api/system/network.spec.ts b/test/api/system/network.spec.ts similarity index 98% rename from apps/web-antdv-next/src/api/system/network.spec.ts rename to test/api/system/network.spec.ts index a471219..aa8005a 100644 --- a/apps/web-antdv-next/src/api/system/network.spec.ts +++ b/test/api/system/network.spec.ts @@ -1,7 +1,3 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { requestClient } from '#/api/request'; - import { createNetworkDdnsRecord, createNetworkPortForward, @@ -21,7 +17,10 @@ import { retryNetworkPortForward, updateNetworkDdnsRecord, updateNetworkPortForward, -} from './network'; +} from '@test-source/apps/web-antdv-next/src/api/system/network'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { requestClient } from '#/api/request'; vi.mock('#/api/request', () => ({ requestClient: { diff --git a/apps/web-antdv-next/src/components/ktTable/hooks/useKtTableActions.spec.tsx b/test/components/ktTable/hooks/useKtTableActions.spec.tsx similarity index 96% rename from apps/web-antdv-next/src/components/ktTable/hooks/useKtTableActions.spec.tsx rename to test/components/ktTable/hooks/useKtTableActions.spec.tsx index 94c0723..566fbbd 100644 --- a/apps/web-antdv-next/src/components/ktTable/hooks/useKtTableActions.spec.tsx +++ b/test/components/ktTable/hooks/useKtTableActions.spec.tsx @@ -5,10 +5,9 @@ import { mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; +import { useKtTableActions } from '@test-source/apps/web-antdv-next/src/components/ktTable/hooks/useKtTableActions'; import { describe, expect, it, vi } from 'vitest'; -import { useKtTableActions } from './useKtTableActions'; - vi.mock('antdv-next', () => ({ Button: defineComponent({ name: 'MockButton', diff --git a/apps/web-antdv-next/src/components/markdown/KtMilkdownEditor.spec.ts b/test/components/markdown/KtMilkdownEditor.spec.ts similarity index 86% rename from apps/web-antdv-next/src/components/markdown/KtMilkdownEditor.spec.ts rename to test/components/markdown/KtMilkdownEditor.spec.ts index f658377..f8effc3 100644 --- a/apps/web-antdv-next/src/components/markdown/KtMilkdownEditor.spec.ts +++ b/test/components/markdown/KtMilkdownEditor.spec.ts @@ -1,13 +1,15 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -const dirname = path.dirname(fileURLToPath(import.meta.url)); -const source = readFileSync(path.join(dirname, 'KtMilkdownEditor.tsx'), 'utf8'); +const sourceRoot = path.resolve('apps/web-antdv-next/src/components/markdown'); +const source = readFileSync( + path.join(sourceRoot, 'KtMilkdownEditor.tsx'), + 'utf8', +); const styleSource = readFileSync( - path.join(dirname, 'KtMilkdownEditor.scss'), + path.join(sourceRoot, 'KtMilkdownEditor.scss'), 'utf8', ); diff --git a/apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.spec.tsx b/test/components/richText/KtTiptapHtmlEditor.spec.tsx similarity index 98% rename from apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.spec.tsx rename to test/components/richText/KtTiptapHtmlEditor.spec.tsx index 931c0fa..6927fc2 100644 --- a/apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.spec.tsx +++ b/test/components/richText/KtTiptapHtmlEditor.spec.tsx @@ -6,10 +6,9 @@ import type { ComponentPublicInstance, Ref } from 'vue'; import { mount } from '@vue/test-utils'; import { defineComponent, h, nextTick, ref } from 'vue'; +import KtTiptapHtmlEditor from '@test-source/apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import KtTiptapHtmlEditor from './KtTiptapHtmlEditor'; - type FakeEditorOptions = { content?: string; editable?: boolean; diff --git a/test/governance/test-location.test.ts b/test/governance/test-location.test.ts new file mode 100644 index 0000000..ac9419e --- /dev/null +++ b/test/governance/test-location.test.ts @@ -0,0 +1,57 @@ +import { execFileSync } from 'node:child_process'; + +import { describe, expect, it } from 'vitest'; + +const UNIT_TEST_FILE_PATTERN = + /\.(?:spec|test)\.(?:[cm]?[jt]sx?|vue)$|\/__(?:snapshots|tests)__\//; +const ALLOWED_TEST_ROOTS = new Set([ + 'api', + 'components', + 'governance', + 'internal', + 'packages', + 'router', + 'store', + 'views', +]); +const ALLOWED_TEST_ROOT_FILES = new Set(['test/tsconfig.json']); + +describe('单元测试目录约束', () => { + it('只允许在仓库根目录 test 下存放单元测试', () => { + const repositoryFiles = execFileSync( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard'], + { encoding: 'utf8' }, + ) + .split('\n') + .filter(Boolean); + const scatteredUnitTests = repositoryFiles.filter( + (file) => + !file.startsWith('test/') && UNIT_TEST_FILE_PATTERN.test(`/${file}`), + ); + + expect(scatteredUnitTests).toEqual([]); + }); + + it('禁止在 test 下复制源码目录或保留旧测试目录层级', () => { + const testFiles = execFileSync( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard', 'test'], + { encoding: 'utf8' }, + ) + .split('\n') + .filter(Boolean); + const invalidTestFiles = testFiles.filter((file) => { + if (ALLOWED_TEST_ROOT_FILES.has(file)) return false; + const [, root] = file.split('/'); + return ( + !root || + !ALLOWED_TEST_ROOTS.has(root) || + file.includes('/src/') || + file.includes('/__tests__/') + ); + }); + + expect(invalidTestFiles).toEqual([]); + }); +}); diff --git a/internal/jsdoc-policy/check-jsdoc.test.mjs b/test/internal/jsdoc-policy/check-jsdoc.test.mjs similarity index 99% rename from internal/jsdoc-policy/check-jsdoc.test.mjs rename to test/internal/jsdoc-policy/check-jsdoc.test.mjs index cc3e863..7ff6457 100644 --- a/internal/jsdoc-policy/check-jsdoc.test.mjs +++ b/test/internal/jsdoc-policy/check-jsdoc.test.mjs @@ -9,19 +9,18 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import process from 'node:process'; import { runInNewContext } from 'node:vm'; -import { it } from 'vitest'; - import { applyJsdocPolicyFix, getCodeTokenSignature, getTrackedCodeFiles, inspectFileSource, -} from './check-jsdoc.mjs'; +} from '@test-source/internal/jsdoc-policy/check-jsdoc.mjs'; +import { it } from 'vitest'; -const CHECKER_PATH = fileURLToPath(new URL('check-jsdoc.mjs', import.meta.url)); +const CHECKER_PATH = path.resolve('internal/jsdoc-policy/check-jsdoc.mjs'); function runFixtureGit(repository, args, extraEnvironment = {}) { const environment = Object.fromEntries( diff --git a/internal/node-utils/src/__tests__/hash.test.ts b/test/internal/node-utils/hash.test.ts similarity index 95% rename from internal/node-utils/src/__tests__/hash.test.ts rename to test/internal/node-utils/hash.test.ts index 3851306..9b6c018 100644 --- a/internal/node-utils/src/__tests__/hash.test.ts +++ b/test/internal/node-utils/hash.test.ts @@ -1,9 +1,8 @@ import { createHash } from 'node:crypto'; +import { generatorContentHash } from '@test-source/internal/node-utils/src/hash'; import { describe, expect, it } from 'vitest'; -import { generatorContentHash } from '../hash'; - describe('generatorContentHash', () => { it('should generate an MD5 hash for the content', () => { const content = 'example content'; diff --git a/internal/node-utils/src/__tests__/path.test.ts b/test/internal/node-utils/path.test.ts similarity index 96% rename from internal/node-utils/src/__tests__/path.test.ts rename to test/internal/node-utils/path.test.ts index 3bab5a1..96c91c7 100644 --- a/internal/node-utils/src/__tests__/path.test.ts +++ b/test/internal/node-utils/path.test.ts @@ -1,9 +1,8 @@ // pathUtils.test.ts +import { toPosixPath } from '@test-source/internal/node-utils/src/path'; import { describe, expect, it } from 'vitest'; -import { toPosixPath } from '../path'; - describe('toPosixPath', () => { // 测试 Windows 风格路径到 POSIX 风格路径的转换 it('converts Windows-style paths to POSIX paths', () => { diff --git a/packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts b/test/packages/@core/base/shared/cache/storage-manager.test.ts similarity index 97% rename from packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts rename to test/packages/@core/base/shared/cache/storage-manager.test.ts index a5abe5a..40b7ebb 100644 --- a/packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts +++ b/test/packages/@core/base/shared/cache/storage-manager.test.ts @@ -1,7 +1,6 @@ +import { StorageManager } from '@test-source/packages/@core/base/shared/src/cache/storage-manager'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { StorageManager } from '../storage-manager'; - describe('storageManager', () => { let storageManager: StorageManager; diff --git a/packages/@core/base/shared/src/color/__tests__/convert.test.ts b/test/packages/@core/base/shared/color/convert.test.ts similarity index 96% rename from packages/@core/base/shared/src/color/__tests__/convert.test.ts rename to test/packages/@core/base/shared/color/convert.test.ts index fc4256c..fa0cdd2 100644 --- a/packages/@core/base/shared/src/color/__tests__/convert.test.ts +++ b/test/packages/@core/base/shared/color/convert.test.ts @@ -1,11 +1,10 @@ -import { describe, expect, it } from 'vitest'; - import { convertToHsl, convertToHslCssVar, convertToRgb, isValidColor, -} from '../convert'; +} from '@test-source/packages/@core/base/shared/src/color/convert'; +import { describe, expect, it } from 'vitest'; describe('color conversion functions', () => { it('should correctly convert color to HSL format', () => { diff --git a/packages/@core/base/shared/src/utils/__tests__/date.test.ts b/test/packages/@core/base/shared/utils/date.test.ts similarity index 98% rename from packages/@core/base/shared/src/utils/__tests__/date.test.ts rename to test/packages/@core/base/shared/utils/date.test.ts index 46a27c5..584adb8 100644 --- a/packages/@core/base/shared/src/utils/__tests__/date.test.ts +++ b/test/packages/@core/base/shared/utils/date.test.ts @@ -1,8 +1,3 @@ -import dayjs from 'dayjs'; -import timezone from 'dayjs/plugin/timezone'; -import utc from 'dayjs/plugin/utc'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - import { formatDate, formatDateTime, @@ -11,7 +6,11 @@ import { isDate, isDayjsObject, setCurrentTimezone, -} from '../date'; +} from '@test-source/packages/@core/base/shared/src/utils/date'; +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; dayjs.extend(utc); dayjs.extend(timezone); diff --git a/packages/@core/base/shared/src/utils/__tests__/diff.test.ts b/test/packages/@core/base/shared/utils/diff.test.ts similarity index 95% rename from packages/@core/base/shared/src/utils/__tests__/diff.test.ts rename to test/packages/@core/base/shared/utils/diff.test.ts index cb3227b..8069f82 100644 --- a/packages/@core/base/shared/src/utils/__tests__/diff.test.ts +++ b/test/packages/@core/base/shared/utils/diff.test.ts @@ -1,7 +1,6 @@ +import { diff } from '@test-source/packages/@core/base/shared/src/utils/diff'; import { describe, expect, it } from 'vitest'; -import { diff } from '../diff'; - describe('diff function', () => { it('should return an empty object when comparing identical objects', () => { const obj1 = { a: 1, b: { c: 2 } }; diff --git a/packages/@core/base/shared/src/utils/__tests__/dom.test.ts b/test/packages/@core/base/shared/utils/dom.test.ts similarity index 96% rename from packages/@core/base/shared/src/utils/__tests__/dom.test.ts rename to test/packages/@core/base/shared/utils/dom.test.ts index ffc5b49..29975dc 100644 --- a/packages/@core/base/shared/src/utils/__tests__/dom.test.ts +++ b/test/packages/@core/base/shared/utils/dom.test.ts @@ -1,7 +1,6 @@ +import { getElementVisibleRect } from '@test-source/packages/@core/base/shared/src/utils/dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getElementVisibleRect } from '../dom'; - describe('getElementVisibleRect', () => { // 设置浏览器视口尺寸的 mock beforeEach(() => { diff --git a/packages/@core/base/shared/src/utils/__tests__/inference.test.ts b/test/packages/@core/base/shared/utils/inference.test.ts similarity index 98% rename from packages/@core/base/shared/src/utils/__tests__/inference.test.ts rename to test/packages/@core/base/shared/utils/inference.test.ts index 7fda465..b8fb601 100644 --- a/packages/@core/base/shared/src/utils/__tests__/inference.test.ts +++ b/test/packages/@core/base/shared/utils/inference.test.ts @@ -1,5 +1,3 @@ -import { describe, expect, it } from 'vitest'; - import { getFirstNonNullOrUndefined, isBoolean, @@ -8,7 +6,8 @@ import { isObject, isUndefined, isWindow, -} from '../inference'; +} from '@test-source/packages/@core/base/shared/src/utils/inference'; +import { describe, expect, it } from 'vitest'; describe('isHttpUrl', () => { it("should return true when given 'http://example.com'", () => { diff --git a/packages/@core/base/shared/src/utils/__tests__/letter.test.ts b/test/packages/@core/base/shared/utils/letter.test.ts similarity index 98% rename from packages/@core/base/shared/src/utils/__tests__/letter.test.ts rename to test/packages/@core/base/shared/utils/letter.test.ts index a4aebaf..f788e36 100644 --- a/packages/@core/base/shared/src/utils/__tests__/letter.test.ts +++ b/test/packages/@core/base/shared/utils/letter.test.ts @@ -1,11 +1,10 @@ -import { describe, expect, it } from 'vitest'; - import { capitalizeFirstLetter, kebabToCamelCase, toCamelCase, toLowerCaseFirstLetter, -} from '../letter'; +} from '@test-source/packages/@core/base/shared/src/utils/letter'; +import { describe, expect, it } from 'vitest'; describe('capitalizeFirstLetter', () => { it('should capitalize the first letter of a string', () => { diff --git a/packages/@core/base/shared/src/utils/__tests__/resources.test.ts b/test/packages/@core/base/shared/utils/resources.test.ts similarity index 96% rename from packages/@core/base/shared/src/utils/__tests__/resources.test.ts rename to test/packages/@core/base/shared/utils/resources.test.ts index f14ff89..613390d 100644 --- a/packages/@core/base/shared/src/utils/__tests__/resources.test.ts +++ b/test/packages/@core/base/shared/utils/resources.test.ts @@ -1,7 +1,6 @@ +import { loadScript } from '@test-source/packages/@core/base/shared/src/utils/resources'; import { beforeEach, describe, expect, it } from 'vitest'; -import { loadScript } from '../resources'; - const testJsPath = 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js'; diff --git a/packages/@core/base/shared/src/utils/__tests__/stack.test.ts b/test/packages/@core/base/shared/utils/stack.test.ts similarity index 95% rename from packages/@core/base/shared/src/utils/__tests__/stack.test.ts rename to test/packages/@core/base/shared/utils/stack.test.ts index 2803ef2..7b7b401 100644 --- a/packages/@core/base/shared/src/utils/__tests__/stack.test.ts +++ b/test/packages/@core/base/shared/utils/stack.test.ts @@ -1,7 +1,9 @@ +import { + createStack, + Stack, +} from '@test-source/packages/@core/base/shared/src/utils/stack'; import { beforeEach, describe, expect, it } from 'vitest'; -import { createStack, Stack } from '../stack'; - describe('stack', () => { let stack: Stack; diff --git a/packages/@core/base/shared/src/utils/__tests__/state-handler.test.ts b/test/packages/@core/base/shared/utils/state-handler.test.ts similarity index 95% rename from packages/@core/base/shared/src/utils/__tests__/state-handler.test.ts rename to test/packages/@core/base/shared/utils/state-handler.test.ts index d11be06..d2169b1 100644 --- a/packages/@core/base/shared/src/utils/__tests__/state-handler.test.ts +++ b/test/packages/@core/base/shared/utils/state-handler.test.ts @@ -1,7 +1,6 @@ +import { StateHandler } from '@test-source/packages/@core/base/shared/src/utils/state-handler'; import { describe, expect, it } from 'vitest'; -import { StateHandler } from '../state-handler'; - describe('stateHandler', () => { it('should resolve when condition is set to true', async () => { const handler = new StateHandler(); diff --git a/packages/@core/base/shared/src/utils/__tests__/tree.test.ts b/test/packages/@core/base/shared/utils/tree.test.ts similarity index 97% rename from packages/@core/base/shared/src/utils/__tests__/tree.test.ts rename to test/packages/@core/base/shared/utils/tree.test.ts index afe43cc..db0c42f 100644 --- a/packages/@core/base/shared/src/utils/__tests__/tree.test.ts +++ b/test/packages/@core/base/shared/utils/tree.test.ts @@ -1,7 +1,10 @@ +import { + filterTree, + mapTree, + traverseTreeValues, +} from '@test-source/packages/@core/base/shared/src/utils/tree'; import { describe, expect, it } from 'vitest'; -import { filterTree, mapTree, traverseTreeValues } from '../tree'; - describe('traverseTreeValues', () => { interface Node { children?: Node[]; diff --git a/packages/@core/base/shared/src/utils/__tests__/unique.test.ts b/test/packages/@core/base/shared/utils/unique.test.ts similarity index 94% rename from packages/@core/base/shared/src/utils/__tests__/unique.test.ts rename to test/packages/@core/base/shared/utils/unique.test.ts index 0aa9d61..b6c975f 100644 --- a/packages/@core/base/shared/src/utils/__tests__/unique.test.ts +++ b/test/packages/@core/base/shared/utils/unique.test.ts @@ -1,7 +1,6 @@ +import { uniqueByField } from '@test-source/packages/@core/base/shared/src/utils/unique'; import { describe, expect, it } from 'vitest'; -import { uniqueByField } from '../unique'; - describe('uniqueByField', () => { it('should return an array with unique items based on id field', () => { const items = [ diff --git a/packages/@core/base/shared/src/utils/__tests__/update-css-variables.test.ts b/test/packages/@core/base/shared/utils/update-css-variables.test.ts similarity index 90% rename from packages/@core/base/shared/src/utils/__tests__/update-css-variables.test.ts rename to test/packages/@core/base/shared/utils/update-css-variables.test.ts index 4a9cdad..b42cd20 100644 --- a/packages/@core/base/shared/src/utils/__tests__/update-css-variables.test.ts +++ b/test/packages/@core/base/shared/utils/update-css-variables.test.ts @@ -1,7 +1,6 @@ +import { updateCSSVariables } from '@test-source/packages/@core/base/shared/src/utils/update-css-variables'; import { expect, it } from 'vitest'; -import { updateCSSVariables } from '../update-css-variables'; - it('updateCSSVariables should update CSS variables in :root selector', () => { // 模拟初始的内联样式表内容 const initialStyleContent = ':root { --primaryColor: red; }'; diff --git a/packages/@core/base/shared/src/utils/__tests__/util.test.ts b/test/packages/@core/base/shared/utils/util.test.ts similarity index 97% rename from packages/@core/base/shared/src/utils/__tests__/util.test.ts rename to test/packages/@core/base/shared/utils/util.test.ts index dd58465..3a330b4 100644 --- a/packages/@core/base/shared/src/utils/__tests__/util.test.ts +++ b/test/packages/@core/base/shared/utils/util.test.ts @@ -1,7 +1,9 @@ +import { + bindMethods, + getNestedValue, +} from '@test-source/packages/@core/base/shared/src/utils/util'; import { describe, expect, it } from 'vitest'; -import { bindMethods, getNestedValue } from '../util'; - class TestClass { public value: string; diff --git a/packages/@core/base/shared/src/utils/__tests__/window.test.ts b/test/packages/@core/base/shared/utils/window.test.ts similarity index 89% rename from packages/@core/base/shared/src/utils/__tests__/window.test.ts rename to test/packages/@core/base/shared/utils/window.test.ts index ebb04bb..8b45c6b 100644 --- a/packages/@core/base/shared/src/utils/__tests__/window.test.ts +++ b/test/packages/@core/base/shared/utils/window.test.ts @@ -1,7 +1,6 @@ +import { openWindow } from '@test-source/packages/@core/base/shared/src/utils/window'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { openWindow } from '../window'; - describe('openWindow', () => { // 保存原始的 window.open 函数 let originalOpen: typeof window.open; diff --git a/packages/@core/composables/src/__tests__/use-sortable.test.ts b/test/packages/@core/composables/use-sortable.test.ts similarity index 93% rename from packages/@core/composables/src/__tests__/use-sortable.test.ts rename to test/packages/@core/composables/use-sortable.test.ts index 3524143..2e6d2d9 100644 --- a/packages/@core/composables/src/__tests__/use-sortable.test.ts +++ b/test/packages/@core/composables/use-sortable.test.ts @@ -1,9 +1,8 @@ import type { SortableOptions } from 'sortablejs'; +import { useSortable } from '@test-source/packages/@core/composables/src/use-sortable'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useSortable } from '../use-sortable'; - describe('useSortable', () => { beforeEach(() => { vi.mock('sortablejs/modular/sortable.complete.esm.js', () => ({ diff --git a/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap b/test/packages/@core/preferences/__snapshots__/config.test.ts.snap similarity index 100% rename from packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap rename to test/packages/@core/preferences/__snapshots__/config.test.ts.snap diff --git a/packages/@core/preferences/__tests__/config.test.ts b/test/packages/@core/preferences/config.test.ts similarity index 75% rename from packages/@core/preferences/__tests__/config.test.ts rename to test/packages/@core/preferences/config.test.ts index f7c9bb3..bab0ada 100644 --- a/packages/@core/preferences/__tests__/config.test.ts +++ b/test/packages/@core/preferences/config.test.ts @@ -1,7 +1,6 @@ +import { defaultPreferences } from '@test-source/packages/@core/preferences/src/config'; import { describe, expect, it } from 'vitest'; -import { defaultPreferences } from '../src/config'; - describe('defaultPreferences immutability test', () => { // 创建快照,确保默认配置对象不被修改 it('should not modify the config object', () => { diff --git a/packages/@core/preferences/__tests__/preferences.test.ts b/test/packages/@core/preferences/preferences.test.ts similarity index 96% rename from packages/@core/preferences/__tests__/preferences.test.ts rename to test/packages/@core/preferences/preferences.test.ts index 37657d8..e95bbdc 100644 --- a/packages/@core/preferences/__tests__/preferences.test.ts +++ b/test/packages/@core/preferences/preferences.test.ts @@ -1,9 +1,8 @@ +import { defaultPreferences } from '@test-source/packages/@core/preferences/src/config'; +import { PreferenceManager } from '@test-source/packages/@core/preferences/src/preferences'; +import { isDarkTheme } from '@test-source/packages/@core/preferences/src/update-css-variables'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { defaultPreferences } from '../src/config'; -import { PreferenceManager } from '../src/preferences'; -import { isDarkTheme } from '../src/update-css-variables'; - describe('preferences', () => { let preferenceManager: PreferenceManager; diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts b/test/packages/@core/ui-kit/form-ui/form-api.test.ts similarity index 98% rename from packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts rename to test/packages/@core/ui-kit/form-ui/form-api.test.ts index a47227f..2651c20 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts +++ b/test/packages/@core/ui-kit/form-ui/form-api.test.ts @@ -1,7 +1,6 @@ +import { FormApi } from '@test-source/packages/@core/ui-kit/form-ui/src/form-api'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { FormApi } from '../src/form-api'; - describe('formApi', () => { let formApi: FormApi; const componentRefMap = () => new Map(); diff --git a/packages/@core/ui-kit/popup-ui/src/drawer/__tests__/drawer-api.test.ts b/test/packages/@core/ui-kit/popup-ui/drawer/drawer-api.test.ts similarity index 94% rename from packages/@core/ui-kit/popup-ui/src/drawer/__tests__/drawer-api.test.ts rename to test/packages/@core/ui-kit/popup-ui/drawer/drawer-api.test.ts index 365a2e4..bc8d523 100644 --- a/packages/@core/ui-kit/popup-ui/src/drawer/__tests__/drawer-api.test.ts +++ b/test/packages/@core/ui-kit/popup-ui/drawer/drawer-api.test.ts @@ -1,9 +1,8 @@ -import type { DrawerState } from '../drawer'; +import type { DrawerState } from '@test-source/packages/@core/ui-kit/popup-ui/src/drawer/drawer'; +import { DrawerApi } from '@test-source/packages/@core/ui-kit/popup-ui/src/drawer/drawer-api'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { DrawerApi } from '../drawer-api'; - // 模拟 Store 类 vi.mock('@vben-core/shared/store', () => { return { diff --git a/packages/@core/ui-kit/popup-ui/src/modal/__tests__/modal-api.test.ts b/test/packages/@core/ui-kit/popup-ui/modal/modal-api.test.ts similarity index 94% rename from packages/@core/ui-kit/popup-ui/src/modal/__tests__/modal-api.test.ts rename to test/packages/@core/ui-kit/popup-ui/modal/modal-api.test.ts index 1edd477..fcac8cc 100644 --- a/packages/@core/ui-kit/popup-ui/src/modal/__tests__/modal-api.test.ts +++ b/test/packages/@core/ui-kit/popup-ui/modal/modal-api.test.ts @@ -1,9 +1,8 @@ -import type { ModalState } from '../modal'; +import type { ModalState } from '@test-source/packages/@core/ui-kit/popup-ui/src/modal/modal'; +import { ModalApi } from '@test-source/packages/@core/ui-kit/popup-ui/src/modal/modal-api'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ModalApi } from '../modal-api'; - vi.mock('@vben-core/shared/store', () => { return { isFunction: (fn: any) => typeof fn === 'function', diff --git a/packages/effects/common-ui/src/components/page/__tests__/page.test.ts b/test/packages/effects/common-ui/components/page/page.test.ts similarity index 96% rename from packages/effects/common-ui/src/components/page/__tests__/page.test.ts rename to test/packages/effects/common-ui/components/page/page.test.ts index a89061f..7a1b601 100644 --- a/packages/effects/common-ui/src/components/page/__tests__/page.test.ts +++ b/test/packages/effects/common-ui/components/page/page.test.ts @@ -1,9 +1,8 @@ import { mount } from '@vue/test-utils'; +import { Page } from '@test-source/packages/effects/common-ui/src/components/page'; import { describe, expect, it } from 'vitest'; -import { Page } from '..'; - describe('page.vue', () => { it('renders title when passed', () => { const wrapper = mount(Page, { diff --git a/packages/effects/request/src/request-client/modules/downloader.test.ts b/test/packages/effects/request/request-client/modules/downloader.test.ts similarity index 97% rename from packages/effects/request/src/request-client/modules/downloader.test.ts rename to test/packages/effects/request/request-client/modules/downloader.test.ts index 27e8a30..28a3206 100644 --- a/packages/effects/request/src/request-client/modules/downloader.test.ts +++ b/test/packages/effects/request/request-client/modules/downloader.test.ts @@ -1,9 +1,8 @@ import type { AxiosRequestConfig } from 'axios'; +import { FileDownloader } from '@test-source/packages/effects/request/src/request-client/modules/downloader'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { FileDownloader } from './downloader'; - describe('fileDownloader', () => { let fileDownloader: FileDownloader; const mockAxiosInstance = { diff --git a/packages/effects/request/src/request-client/modules/sse.test.ts b/test/packages/effects/request/request-client/modules/sse.test.ts similarity index 94% rename from packages/effects/request/src/request-client/modules/sse.test.ts rename to test/packages/effects/request/request-client/modules/sse.test.ts index 4e8c6a9..57bf3fb 100644 --- a/packages/effects/request/src/request-client/modules/sse.test.ts +++ b/test/packages/effects/request/request-client/modules/sse.test.ts @@ -1,9 +1,8 @@ -import type { RequestClient } from '../request-client'; +import type { RequestClient } from '@test-source/packages/effects/request/src/request-client/request-client'; +import { SSE } from '@test-source/packages/effects/request/src/request-client/modules/sse'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { SSE } from './sse'; - // 模拟 TextDecoder const OriginalTextDecoder = globalThis.TextDecoder; diff --git a/packages/effects/request/src/request-client/modules/uploader.test.ts b/test/packages/effects/request/request-client/modules/uploader.test.ts similarity index 97% rename from packages/effects/request/src/request-client/modules/uploader.test.ts rename to test/packages/effects/request/request-client/modules/uploader.test.ts index 8306656..746d9e9 100644 --- a/packages/effects/request/src/request-client/modules/uploader.test.ts +++ b/test/packages/effects/request/request-client/modules/uploader.test.ts @@ -1,9 +1,8 @@ import type { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { FileUploader } from '@test-source/packages/effects/request/src/request-client/modules/uploader'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { FileUploader } from './uploader'; - describe('fileUploader', () => { let fileUploader: FileUploader; // Mock the AxiosInstance diff --git a/packages/effects/request/src/request-client/request-client.test.ts b/test/packages/effects/request/request-client/request-client.test.ts similarity index 96% rename from packages/effects/request/src/request-client/request-client.test.ts rename to test/packages/effects/request/request-client/request-client.test.ts index a742a68..a39b0b0 100644 --- a/packages/effects/request/src/request-client/request-client.test.ts +++ b/test/packages/effects/request/request-client/request-client.test.ts @@ -1,11 +1,10 @@ import type { AxiosResponse } from 'axios'; +import { RequestClient } from '@test-source/packages/effects/request/src/request-client/request-client'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { RequestClient } from './request-client'; - describe('requestClient', () => { let mock: MockAdapter; let requestClient: RequestClient; diff --git a/packages/stores/src/modules/access.test.ts b/test/packages/stores/modules/access.test.ts similarity index 94% rename from packages/stores/src/modules/access.test.ts rename to test/packages/stores/modules/access.test.ts index 211e256..1880a51 100644 --- a/packages/stores/src/modules/access.test.ts +++ b/test/packages/stores/modules/access.test.ts @@ -1,8 +1,7 @@ +import { useAccessStore } from '@test-source/packages/stores/src/modules/access'; import { createPinia, setActivePinia } from 'pinia'; import { beforeEach, describe, expect, it } from 'vitest'; -import { useAccessStore } from './access'; - describe('useAccessStore', () => { beforeEach(() => { setActivePinia(createPinia()); diff --git a/packages/stores/src/modules/tabbar.test.ts b/test/packages/stores/modules/tabbar.test.ts similarity index 98% rename from packages/stores/src/modules/tabbar.test.ts rename to test/packages/stores/modules/tabbar.test.ts index 7c0fe7a..50ea685 100644 --- a/packages/stores/src/modules/tabbar.test.ts +++ b/test/packages/stores/modules/tabbar.test.ts @@ -1,10 +1,9 @@ import { createRouter, createWebHistory } from 'vue-router'; +import { useTabbarStore } from '@test-source/packages/stores/src/modules/tabbar'; import { createPinia, setActivePinia } from 'pinia'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useTabbarStore } from './tabbar'; - describe('useAccessStore', () => { const router = createRouter({ history: createWebHistory(), diff --git a/packages/stores/src/modules/user.test.ts b/test/packages/stores/modules/user.test.ts similarity index 93% rename from packages/stores/src/modules/user.test.ts rename to test/packages/stores/modules/user.test.ts index 3d8a22c..dab393f 100644 --- a/packages/stores/src/modules/user.test.ts +++ b/test/packages/stores/modules/user.test.ts @@ -1,8 +1,7 @@ +import { useUserStore } from '@test-source/packages/stores/src/modules/user'; import { createPinia, setActivePinia } from 'pinia'; import { beforeEach, describe, expect, it } from 'vitest'; -import { useUserStore } from './user'; - describe('useUserStore', () => { beforeEach(() => { setActivePinia(createPinia()); diff --git a/packages/utils/src/helpers/__tests__/find-menu-by-path.test.ts b/test/packages/utils/helpers/find-menu-by-path.test.ts similarity index 95% rename from packages/utils/src/helpers/__tests__/find-menu-by-path.test.ts rename to test/packages/utils/helpers/find-menu-by-path.test.ts index fc0d76b..5a5a958 100644 --- a/packages/utils/src/helpers/__tests__/find-menu-by-path.test.ts +++ b/test/packages/utils/helpers/find-menu-by-path.test.ts @@ -1,7 +1,9 @@ +import { + findMenuByPath, + findRootMenuByPath, +} from '@test-source/packages/utils/src/helpers/find-menu-by-path'; import { describe, expect, it } from 'vitest'; -import { findMenuByPath, findRootMenuByPath } from '../find-menu-by-path'; - // 示例菜单数据 const menus: any[] = [ { path: '/', children: [] }, diff --git a/packages/utils/src/helpers/__tests__/generate-menus.test.ts b/test/packages/utils/helpers/generate-menus.test.ts similarity index 98% rename from packages/utils/src/helpers/__tests__/generate-menus.test.ts rename to test/packages/utils/helpers/generate-menus.test.ts index c02cc9d..0141c7d 100644 --- a/packages/utils/src/helpers/__tests__/generate-menus.test.ts +++ b/test/packages/utils/helpers/generate-menus.test.ts @@ -2,10 +2,9 @@ import type { Router, RouteRecordRaw } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router'; +import { generateMenus } from '@test-source/packages/utils/src/helpers/generate-menus'; import { describe, expect, it, vi } from 'vitest'; -import { generateMenus } from '../generate-menus'; - // Nested route setup to test child inclusion and hideChildrenInMenu functionality describe('generateMenus', () => { diff --git a/packages/utils/src/helpers/__tests__/generate-routes-frontend.test.ts b/test/packages/utils/helpers/generate-routes-frontend.test.ts similarity index 82% rename from packages/utils/src/helpers/__tests__/generate-routes-frontend.test.ts rename to test/packages/utils/helpers/generate-routes-frontend.test.ts index 801e9e7..c6a1e56 100644 --- a/packages/utils/src/helpers/__tests__/generate-routes-frontend.test.ts +++ b/test/packages/utils/helpers/generate-routes-frontend.test.ts @@ -1,11 +1,10 @@ import type { RouteRecordRaw } from 'vue-router'; -import { describe, expect, it } from 'vitest'; - import { generateRoutesByFrontend, hasAuthority, -} from '../generate-routes-frontend'; +} from '@test-source/packages/utils/src/helpers/generate-routes-frontend'; +import { describe, expect, it } from 'vitest'; // Mock 路由数据 const mockRoutes = [ @@ -36,17 +35,24 @@ const mockRoutes = [ }, ] as RouteRecordRaw[]; +/** 返回指定位置的路由测试数据,不允许测试静默使用越界值。 */ +function getMockRoute(index: number): RouteRecordRaw { + const route = mockRoutes[index]; + if (!route) throw new Error(`缺少索引为 ${index} 的路由测试数据`); + return route; +} + describe('hasAuthority', () => { it('should return true if there is no authority defined', () => { - expect(hasAuthority(mockRoutes[2]!, ['admin'])).toBe(true); + expect(hasAuthority(getMockRoute(2), ['admin'])).toBe(true); }); it('should return true if the user has the required authority', () => { - expect(hasAuthority(mockRoutes[0]!, ['admin'])).toBe(true); + expect(hasAuthority(getMockRoute(0), ['admin'])).toBe(true); }); it('should return false if the user does not have the required authority', () => { - expect(hasAuthority(mockRoutes[1]!, ['user'])).toBe(false); + expect(hasAuthority(getMockRoute(1), ['user'])).toBe(false); }); }); diff --git a/packages/utils/src/helpers/__tests__/merge-route-modules.test.ts b/test/packages/utils/helpers/merge-route-modules.test.ts similarity index 89% rename from packages/utils/src/helpers/__tests__/merge-route-modules.test.ts rename to test/packages/utils/helpers/merge-route-modules.test.ts index 3615556..c59de3d 100644 --- a/packages/utils/src/helpers/__tests__/merge-route-modules.test.ts +++ b/test/packages/utils/helpers/merge-route-modules.test.ts @@ -1,11 +1,10 @@ +import type { RouteModuleType } from '@test-source/packages/utils/src/helpers/merge-route-modules'; + import type { RouteRecordRaw } from 'vue-router'; -import type { RouteModuleType } from '../merge-route-modules'; - +import { mergeRouteModules } from '@test-source/packages/utils/src/helpers/merge-route-modules'; import { describe, expect, it } from 'vitest'; -import { mergeRouteModules } from '../merge-route-modules'; - describe('mergeRouteModules', () => { it('should merge route modules correctly', () => { const routeModules: Record = { diff --git a/apps/web-antdv-next/src/router/access-codes.spec.ts b/test/router/access-codes.spec.ts similarity index 88% rename from apps/web-antdv-next/src/router/access-codes.spec.ts rename to test/router/access-codes.spec.ts index 6b7fa60..a965c14 100644 --- a/apps/web-antdv-next/src/router/access-codes.spec.ts +++ b/test/router/access-codes.spec.ts @@ -1,7 +1,6 @@ +import { refreshAccessCodes } from '@test-source/apps/web-antdv-next/src/router/access-codes'; import { describe, expect, it, vi } from 'vitest'; -import { refreshAccessCodes } from './access-codes'; - describe('router access code refresh', () => { it('overwrites persisted access codes with the latest backend codes', async () => { const setAccessCodes = vi.fn(); diff --git a/apps/web-antdv-next/src/router/admin-sso.spec.ts b/test/router/admin-sso.spec.ts similarity index 94% rename from apps/web-antdv-next/src/router/admin-sso.spec.ts rename to test/router/admin-sso.spec.ts index 6412a77..0478fa4 100644 --- a/apps/web-antdv-next/src/router/admin-sso.spec.ts +++ b/test/router/admin-sso.spec.ts @@ -1,10 +1,9 @@ -import { describe, expect, it } from 'vitest'; - import { ADMIN_SSO_DEFAULT_REDIRECT, isAdminSsoRequest, resolveAdminSsoRedirect, -} from './admin-sso'; +} from '@test-source/apps/web-antdv-next/src/router/admin-sso'; +import { describe, expect, it } from 'vitest'; describe('admin SSO route helpers', () => { it('recognizes only the explicit SSO bootstrap flag', () => { diff --git a/apps/web-antdv-next/src/router/routes/modules/qqbot.spec.ts b/test/router/routes/modules/qqbot.spec.ts similarity index 97% rename from apps/web-antdv-next/src/router/routes/modules/qqbot.spec.ts rename to test/router/routes/modules/qqbot.spec.ts index eddbb1f..b6361a0 100644 --- a/apps/web-antdv-next/src/router/routes/modules/qqbot.spec.ts +++ b/test/router/routes/modules/qqbot.spec.ts @@ -1,7 +1,6 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -// eslint-disable-next-line n/no-extraneous-import -- TypeScript is the workspace compiler used for source-level contract checks. import ts from 'typescript'; import { describe, expect, it } from 'vitest'; diff --git a/apps/web-antdv-next/src/store/auth.spec.ts b/test/store/auth.spec.ts similarity index 96% rename from apps/web-antdv-next/src/store/auth.spec.ts rename to test/store/auth.spec.ts index 80ccf19..196ebaf 100644 --- a/apps/web-antdv-next/src/store/auth.spec.ts +++ b/test/store/auth.spec.ts @@ -1,10 +1,9 @@ import { useAccessStore, useUserStore } from '@vben/stores'; +import { useAuthStore } from '@test-source/apps/web-antdv-next/src/store/auth'; import { createPinia, setActivePinia } from 'pinia'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useAuthStore } from './auth'; - const apiMocks = vi.hoisted(() => ({ getAccessCodesApi: vi.fn(), getUserInfoApi: vi.fn(), diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..2e3c8e0 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@vben/tsconfig/web.json", + "compilerOptions": { + "paths": { + "#/*": ["../apps/web-antdv-next/src/*"], + "@test-source/*": ["../*"] + }, + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": ["./**/*.ts", "./**/*.tsx"], + "exclude": ["./e2e/**"] +} diff --git a/apps/web-antdv-next/src/views/blog/article/list.spec.ts b/test/views/blog/article/list.spec.ts similarity index 93% rename from apps/web-antdv-next/src/views/blog/article/list.spec.ts rename to test/views/blog/article/list.spec.ts index 5ce2ca0..53600aa 100644 --- a/apps/web-antdv-next/src/views/blog/article/list.spec.ts +++ b/test/views/blog/article/list.spec.ts @@ -1,10 +1,14 @@ /* @vitest-environment node */ import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -const source = readFileSync(new URL('list.tsx', import.meta.url), 'utf8'); +const source = readFileSync( + resolve('apps/web-antdv-next/src/views/blog/article/list.tsx'), + 'utf8', +); describe('blog article list preview entry', () => { function getSourceSlice(start: string, end: string) { diff --git a/apps/web-antdv-next/src/views/blog/article/preview/preview.spec.tsx b/test/views/blog/article/preview/preview.spec.tsx similarity index 98% rename from apps/web-antdv-next/src/views/blog/article/preview/preview.spec.tsx rename to test/views/blog/article/preview/preview.spec.tsx index 39a57bf..0050c93 100644 --- a/apps/web-antdv-next/src/views/blog/article/preview/preview.spec.tsx +++ b/test/views/blog/article/preview/preview.spec.tsx @@ -4,12 +4,11 @@ import { flushPromises, mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; +import BlogArticlePreview from '@test-source/apps/web-antdv-next/src/views/blog/article/preview/index'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getArticleDetail } from '#/api/blog'; -import BlogArticlePreview from './index'; - const testState = vi.hoisted(() => ({ pushSpy: vi.fn(), route: { diff --git a/apps/web-antdv-next/src/views/blog/modules/article-form.spec.ts b/test/views/blog/modules/article-form.spec.ts similarity index 98% rename from apps/web-antdv-next/src/views/blog/modules/article-form.spec.ts rename to test/views/blog/modules/article-form.spec.ts index fe37d39..fa5a398 100644 --- a/apps/web-antdv-next/src/views/blog/modules/article-form.spec.ts +++ b/test/views/blog/modules/article-form.spec.ts @@ -1,7 +1,5 @@ /* @vitest-environment happy-dom */ -import { describe, expect, it } from 'vitest'; - import { BLOG_ARTICLE_CONTENT_FIELD_CLASS, BLOG_ARTICLE_FORM_CLASS, @@ -12,7 +10,8 @@ import { getBlogArticleCreateFormDefaults, getBlogArticleEditFormValues, getContentFormatForEditorMode, -} from './article-form'; +} from '@test-source/apps/web-antdv-next/src/views/blog/modules/article-form'; +import { describe, expect, it } from 'vitest'; describe('blog article form helpers', () => { it('edits WordPress Argon HTML articles as raw HTML to preserve codeblocks', () => { diff --git a/apps/web-antdv-next/src/views/dashboard/analytics/environment-dashboard.spec.tsx b/test/views/dashboard/analytics/environment-dashboard.spec.tsx similarity index 99% rename from apps/web-antdv-next/src/views/dashboard/analytics/environment-dashboard.spec.tsx rename to test/views/dashboard/analytics/environment-dashboard.spec.tsx index 5c88437..6063e23 100644 --- a/apps/web-antdv-next/src/views/dashboard/analytics/environment-dashboard.spec.tsx +++ b/test/views/dashboard/analytics/environment-dashboard.spec.tsx @@ -6,6 +6,7 @@ import type { EnvironmentDashboardApi } from '#/api/system/environment'; import { mount } from '@vue/test-utils'; import { defineComponent, h, nextTick } from 'vue'; +import EnvironmentDashboardPage from '@test-source/apps/web-antdv-next/src/views/dashboard/analytics/index.vue'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -13,8 +14,6 @@ import { runEnvironmentSelfCheck, } from '#/api/system/environment'; -import EnvironmentDashboardPage from './index.vue'; - vi.mock('#/api/system/environment', () => ({ getEnvironmentDashboard: vi.fn(), getEnvironmentDashboardEventsUrl: vi.fn((lastEventId?: string) => diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel.spec.tsx b/test/views/qqbot/account/components/AccountConfigPanel.spec.tsx similarity index 88% rename from apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel.spec.tsx rename to test/views/qqbot/account/components/AccountConfigPanel.spec.tsx index 5c15e11..4ac175f 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel.spec.tsx +++ b/test/views/qqbot/account/components/AccountConfigPanel.spec.tsx @@ -7,11 +7,10 @@ import type { QqbotApi } from '#/api/qqbot'; import { flushPromises, mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; +import AccountConfigPanel from '@test-source/apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel'; +import AccountMessagePushPanel from '@test-source/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import AccountConfigPanel from './AccountConfigPanel'; -import AccountMessagePushPanel from './AccountMessagePushPanel'; - const mocks = vi.hoisted(() => ({ api: { bindCommand: vi.fn(), @@ -103,23 +102,26 @@ vi.mock('antdv-next', () => ({ }), })); -vi.mock('./AccountMessagePushPanel', () => ({ - default: defineComponent({ - name: 'AccountMessagePushPanel', - props: { - headerControls: Function, - selfId: String, - title: Function, - }, - setup(props) { - return () => - h('section', { 'data-testid': 'message-push-panel' }, [ - (props.title as (() => unknown) | undefined)?.(), - (props.headerControls as (() => unknown) | undefined)?.(), - ] as any); - }, +vi.mock( + '@test-source/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel', + () => ({ + default: defineComponent({ + name: 'AccountMessagePushPanel', + props: { + headerControls: Function, + selfId: String, + title: Function, + }, + setup(props) { + return () => + h('section', { 'data-testid': 'message-push-panel' }, [ + (props.title as (() => unknown) | undefined)?.(), + (props.headerControls as (() => unknown) | undefined)?.(), + ] as any); + }, + }), }), -})); +); function createAccount(): QqbotApi.Account { return { diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.spec.tsx b/test/views/qqbot/account/components/AccountMessagePushModal.spec.tsx similarity index 95% rename from apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.spec.tsx rename to test/views/qqbot/account/components/AccountMessagePushModal.spec.tsx index b8a72fe..a83b574 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.spec.tsx +++ b/test/views/qqbot/account/components/AccountMessagePushModal.spec.tsx @@ -7,10 +7,9 @@ import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; import { flushPromises, mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; +import AccountMessagePushModal from '@test-source/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import AccountMessagePushModal from './AccountMessagePushModal'; - interface FormValues { enabled: boolean; subscriptionId: string; @@ -119,36 +118,39 @@ vi.mock('@vben/common-ui', () => ({ }), })); -vi.mock('./MessagePushTargetPicker', () => ({ - default: defineComponent({ - name: 'MockMessagePushTargetPicker', - props: { - available: Boolean, - disabled: Boolean, - loading: Boolean, - options: Array, - reasonCode: String, - value: Array, - }, - emits: ['update:value'], - setup(_, { emit }) { - return () => - h( - 'button', - { - 'data-testid': 'target-picker', - onClick: () => - emit('update:value', [ - { targetId: '12345', targetType: 'group' }, - ]), - }, - 'target-picker', - ); - }, +vi.mock( + '@test-source/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker', + () => ({ + default: defineComponent({ + name: 'MockMessagePushTargetPicker', + props: { + available: Boolean, + disabled: Boolean, + loading: Boolean, + options: Array, + reasonCode: String, + value: Array, + }, + emits: ['update:value'], + setup(_, { emit }) { + return () => + h( + 'button', + { + 'data-testid': 'target-picker', + onClick: () => + emit('update:value', [ + { targetId: '12345', targetType: 'group' }, + ]), + }, + 'target-picker', + ); + }, + }), + isValidMessagePushTargetId: (targetId: string) => + /^[1-9]\d{4,19}$/.test(targetId), }), - isValidMessagePushTargetId: (targetId: string) => - /^[1-9]\d{4,19}$/.test(targetId), -})); +); vi.mock('#/api/qqbot/message-push', () => ({ createAccountMessagePushBinding: mocks.create, diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.spec.tsx b/test/views/qqbot/account/components/AccountMessagePushPanel.spec.tsx similarity index 95% rename from apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.spec.tsx rename to test/views/qqbot/account/components/AccountMessagePushPanel.spec.tsx index a745ca1..47011d3 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.spec.tsx +++ b/test/views/qqbot/account/components/AccountMessagePushPanel.spec.tsx @@ -7,10 +7,9 @@ import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; import { flushPromises, mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; +import AccountMessagePushPanel from '@test-source/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import AccountMessagePushPanel from './AccountMessagePushPanel'; - const mocks = vi.hoisted(() => { const state = { accessCodes: new Set(), @@ -99,34 +98,37 @@ vi.mock('#/components/ktTable', () => ({ }), })); -vi.mock('./AccountMessagePushModal', () => ({ - default: defineComponent({ - name: 'MockAccountMessagePushModal', - props: { - selfId: String, - subscriptions: Array, - targetOptions: Object, - targetOptionsLoading: Boolean, - templates: Array, - }, - emits: ['saved'], - setup(_, { emit, expose }) { - expose({ - openCreate: mocks.modalOpenCreate, - openEdit: mocks.modalOpenEdit, - }); - return () => - h( - 'button', - { - 'data-testid': 'modal-saved', - onClick: () => emit('saved'), - }, - 'saved', - ); - }, +vi.mock( + '@test-source/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal', + () => ({ + default: defineComponent({ + name: 'MockAccountMessagePushModal', + props: { + selfId: String, + subscriptions: Array, + targetOptions: Object, + targetOptionsLoading: Boolean, + templates: Array, + }, + emits: ['saved'], + setup(_, { emit, expose }) { + expose({ + openCreate: mocks.modalOpenCreate, + openEdit: mocks.modalOpenEdit, + }); + return () => + h( + 'button', + { + 'data-testid': 'modal-saved', + onClick: () => emit('saved'), + }, + 'saved', + ); + }, + }), }), -})); +); vi.mock('#/api/qqbot/message-push', () => ({ deleteAccountMessagePushBinding: mocks.api.deleteBinding, diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker.spec.tsx b/test/views/qqbot/account/components/MessagePushTargetPicker.spec.tsx similarity index 93% rename from apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker.spec.tsx rename to test/views/qqbot/account/components/MessagePushTargetPicker.spec.tsx index d6fb0b9..daafc0a 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker.spec.tsx +++ b/test/views/qqbot/account/components/MessagePushTargetPicker.spec.tsx @@ -4,13 +4,13 @@ import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; import { flushPromises, mount } from '@vue/test-utils'; +import MessagePushTargetPicker, { + isValidMessagePushTargetId, +} from '@test-source/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker'; import Select from 'antdv-next/dist/select/index'; import { describe, expect, it } from 'vitest'; -import MessagePushTargetPicker, { - isValidMessagePushTargetId, -} from './MessagePushTargetPicker'; - +/** 创建群聊与私聊候选目标。 */ function createOptions(): QqbotMessagePushApi.QqbotMessagePushTargetOption[] { return [ { @@ -26,6 +26,7 @@ function createOptions(): QqbotMessagePushApi.QqbotMessagePushTargetOption[] { ]; } +/** 按覆盖参数挂载消息推送目标选择器。 */ function mountPicker( overrides: Partial<{ available: boolean; @@ -47,6 +48,7 @@ function mountPicker( }); } +/** 读取选择器最近一次提交的目标数组。 */ function latestValue( wrapper: ReturnType, ): QqbotMessagePushApi.QqbotMessagePublishTargetInput[] { @@ -72,6 +74,9 @@ describe('message-push target picker', () => { const selects = wrapper.findAllComponents(Select); expect(selects).toHaveLength(2); + expect( + wrapper.get('.qqbot-message-push-target-picker').attributes('style'), + ).toContain('width: 100%'); expect(selects[0]?.props()).toMatchObject({ mode: 'tags', value: [], @@ -82,6 +87,12 @@ describe('message-push target picker', () => { }); expect(selects[0]?.props('labelInValue')).not.toBe(true); expect(selects[1]?.props('labelInValue')).not.toBe(true); + expect(selects[0]?.vm.$.vnode.props?.style).toMatchObject({ + width: '100%', + }); + expect(selects[1]?.vm.$.vnode.props?.style).toMatchObject({ + width: '100%', + }); expect(selects[0]?.props('options')).toEqual([ expect.objectContaining({ targetId: '10000000000000001', diff --git a/apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts b/test/views/qqbot/account/napcat-boundary.spec.ts similarity index 100% rename from apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts rename to test/views/qqbot/account/napcat-boundary.spec.ts diff --git a/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx b/test/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx similarity index 98% rename from apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx rename to test/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx index a15b13a..d22d79d 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx +++ b/test/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx @@ -4,6 +4,7 @@ import { flushPromises, mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; +import QqBotAccountNapcatWebui from '@test-source/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -12,8 +13,6 @@ import { revokeQqbotNapcatWebuiSession, } from '#/api/qqbot/napcat'; -import QqBotAccountNapcatWebui from './index'; - const testState = vi.hoisted(() => ({ intervalControls: { callback: undefined as (() => unknown) | undefined, diff --git a/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.spec.tsx b/test/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.spec.tsx similarity index 94% rename from apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.spec.tsx rename to test/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.spec.tsx index d8d029b..dab68bf 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.spec.tsx +++ b/test/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.spec.tsx @@ -4,12 +4,11 @@ import { flushPromises, mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; +import NapcatRuntimeProfileDrawer from '@test-source/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getQqbotNapcatRuntimeDetail } from '#/api/qqbot/napcat'; -import NapcatRuntimeProfileDrawer from './NapcatRuntimeProfileDrawer'; - vi.mock('#/api/qqbot/napcat', () => ({ getQqbotNapcatRuntimeDetail: vi.fn(), })); diff --git a/test/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx b/test/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx new file mode 100644 index 0000000..7157868 --- /dev/null +++ b/test/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx @@ -0,0 +1,538 @@ +/* @vitest-environment happy-dom */ + +/* eslint-disable vue/one-component-per-file */ + +import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; + +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h } from 'vue'; + +import MessageSubscriptionModal from '@test-source/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const STUN_SOURCE_KEY = 'network.stun.mapping-port-changed'; +const GENERIC_SOURCE_KEY = 'system.health.changed'; + +const mocks = vi.hoisted(() => { + const state: any = { + create: vi.fn(), + formApi: { + getValues: vi.fn(async () => state.values), + resetForm: vi.fn(async () => {}), + resetValidate: vi.fn(async () => {}), + setState: vi.fn((patch) => { + if (patch.schema) state.formOptions.schema = patch.schema; + }), + setValues: vi.fn(async (patch) => { + Object.assign(state.values, patch); + }), + validate: vi.fn(async () => ({ valid: true })), + }, + formOptions: undefined, + getOptions: vi.fn(), + modalApi: { + close: vi.fn(async () => {}), + getData: vi.fn(), + lock: vi.fn(), + open: vi.fn(), + setData: vi.fn(), + unlock: vi.fn(), + }, + modalOptions: undefined, + update: vi.fn(), + values: {}, + }; + return state; +}); + +/** 创建可链式调用的表单校验规则替身。 */ +function createRule(): any { + const rule: any = {}; + for (const method of ['max', 'min', 'optional', 'or', 'trim']) { + rule[method] = vi.fn(() => rule); + } + return rule; +} + +vi.mock('#/adapter/form', () => ({ + useVbenForm: vi.fn((options) => { + mocks.formOptions = options; + const Form = defineComponent({ + name: 'MockForm', + setup() { + return () => h('form', { 'data-testid': 'subscription-form' }); + }, + }); + return [Form, mocks.formApi]; + }), + z: { + literal: vi.fn(createRule), + string: vi.fn(createRule), + }, +})); + +vi.mock('@vben/common-ui', () => ({ + useVbenModal: vi.fn((options) => { + mocks.modalOptions = options; + const Modal = defineComponent({ + name: 'MockModal', + setup(_, { slots }) { + return () => h('section', slots.default?.()); + }, + }); + return [Modal, mocks.modalApi]; + }), +})); + +vi.mock('#/api/qqbot/message-push', () => ({ + createMessageSubscription: mocks.create, + getMessagePushSourceOptions: mocks.getOptions, + updateMessageSubscription: mocks.update, +})); + +/** 创建覆盖依赖字段与任意消息源字段的目录元数据。 */ +function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] { + return [ + { + description: 'STUN 端口变化', + displayName: 'STUN 映射端口变更', + sourceKey: STUN_SOURCE_KEY, + subscriptionFields: [ + { + key: 'portForwardId', + label: 'STUN 端口转发', + optionCollection: 'portForwards', + required: true, + type: 'select', + }, + { + dependsOn: 'portForwardId', + key: 'ddnsRecordId', + label: 'IPv4 DDNS 记录', + optionCollection: 'ddnsRecords', + required: true, + type: 'select', + }, + ], + variables: [], + version: 1, + }, + { + description: '系统健康状态变化', + displayName: '系统健康状态变化', + sourceKey: GENERIC_SOURCE_KEY, + subscriptionFields: [ + { + key: 'serviceId', + label: '服务', + optionCollection: 'services', + required: true, + type: 'select', + }, + { + dependsOn: 'serviceId', + key: 'channelId', + label: '通道', + optionCollection: 'channels', + required: false, + type: 'select', + }, + ], + variables: [], + version: 1, + }, + ]; +} + +/** 创建 STUN 消息源的标准候选项响应。 */ +function createStunOptions(): QqbotMessagePushApi.SystemMessageSourceOptionsResponse { + return { + ddnsRecords: [ + { + dependsOnValue: '2041700000000000001', + disabled: false, + disabledReasonCode: null, + label: 'Pal DDNS · pal.kwitsukasa.top', + value: '2041700000000000002', + }, + { + dependsOnValue: '2041700000000000001', + disabled: true, + disabledReasonCode: 'ddns_disabled', + label: 'Disabled DDNS · ddns_disabled', + value: '2041700000000000003', + }, + { + dependsOnValue: '2041700000000000005', + disabled: false, + disabledReasonCode: null, + label: 'Other DDNS', + value: '2041700000000000004', + }, + ], + portForwards: [ + { + disabled: false, + disabledReasonCode: null, + label: 'Pal UDP · UDP:8213', + value: '2041700000000000001', + }, + { + disabled: true, + disabledReasonCode: 'keeper_disabled', + label: 'Disabled UDP · keeper_disabled', + value: '2041700000000000005', + }, + ], + }; +} + +/** 创建非 STUN 消息源的标准候选项响应。 */ +function createGenericOptions(): QqbotMessagePushApi.SystemMessageSourceOptionsResponse { + return { + channels: [ + { + dependsOnValue: 'service-1', + disabled: false, + disabledReasonCode: null, + label: '告警通道', + value: 'channel-1', + }, + ], + services: [ + { + disabled: false, + disabledReasonCode: null, + label: 'API 服务', + value: 'service-1', + }, + ], + }; +} + +/** 创建现有通用订阅视图。 */ +function createRow( + sourceKey = GENERIC_SOURCE_KEY, +): QqbotMessagePushApi.MessageSubscriptionView { + return { + createTime: '2026-07-24 10:00:00', + enabled: false, + id: '10000000000000001', + invalidReasonCode: null, + name: '旧订阅', + remark: '旧备注', + sourceConfig: + sourceKey === STUN_SOURCE_KEY + ? { + ddnsRecordId: '2041700000000000002', + portForwardId: '2041700000000000001', + } + : { channelId: 'channel-1', serviceId: 'service-1' }, + sourceKey, + sourceName: '系统消息源', + sourceSummary: '现有来源摘要', + updateTime: '2026-07-24 10:00:00', + valid: true, + }; +} + +/** 创建手动控制完成时机的 Promise。 */ +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +/** 挂载消息订阅弹窗。 */ +function mountModal( + sources: QqbotMessagePushApi.SystemMessageSourceDefinition[] = createSources(), +) { + return mount(MessageSubscriptionModal, { + props: { sources }, + }); +} + +/** 返回当前表单字段顺序。 */ +function currentFields(): string[] { + return mocks.formOptions.schema.map((field: any) => field.fieldName); +} + +describe('message subscription modal', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.values = {}; + mocks.modalApi.getData.mockImplementation( + () => mocks.modalApi.setData.mock.calls.at(-1)?.[0] || {}, + ); + mocks.modalApi.setData.mockImplementation(() => mocks.modalApi); + mocks.modalApi.open.mockImplementation(() => { + void mocks.modalOptions.onOpenChange?.(true); + return mocks.modalApi; + }); + mocks.formApi.getValues.mockImplementation(async () => mocks.values); + mocks.formApi.setValues.mockImplementation( + async (patch: Record) => { + Object.assign(mocks.values, patch); + }, + ); + mocks.formApi.validate.mockResolvedValue({ valid: true }); + mocks.create.mockResolvedValue({}); + mocks.update.mockResolvedValue({}); + mocks.getOptions.mockImplementation(async (sourceKey: string) => + sourceKey === STUN_SOURCE_KEY + ? createStunOptions() + : createGenericOptions(), + ); + }); + + it('新建订阅不默认选择消息源且不预取任何来源候选项', async () => { + const wrapper = mountModal(); + + (wrapper.vm as any).openCreate(); + await flushPromises(); + + expect(currentFields()).toEqual(['name', 'sourceKey', 'enabled', 'remark']); + expect(mocks.formApi.setValues).toHaveBeenLastCalledWith( + { + enabled: true, + name: '', + remark: '', + sourceKey: '', + }, + false, + ); + expect(mocks.getOptions).not.toHaveBeenCalled(); + }); + + it('选择消息源后按元数据生成字段并过滤依赖候选项', async () => { + mountModal(); + + await mocks.formOptions.handleValuesChange({ sourceKey: STUN_SOURCE_KEY }, [ + 'sourceKey', + ]); + + expect(mocks.getOptions).toHaveBeenCalledWith(STUN_SOURCE_KEY); + expect(currentFields()).toEqual([ + 'name', + 'sourceKey', + 'portForwardId', + 'ddnsRecordId', + 'enabled', + 'remark', + ]); + const portField = mocks.formOptions.schema.find( + (field: any) => field.fieldName === 'portForwardId', + ); + expect(portField.componentProps().options[1]).toMatchObject({ + disabled: true, + disabledReasonCode: 'keeper_disabled', + value: '2041700000000000005', + }); + + await mocks.formOptions.handleValuesChange( + { + ddnsRecordId: '2041700000000000002', + portForwardId: '2041700000000000001', + sourceKey: STUN_SOURCE_KEY, + }, + ['portForwardId'], + ); + const ddnsField = mocks.formOptions.schema.find( + (field: any) => field.fieldName === 'ddnsRecordId', + ); + expect(ddnsField.componentProps().options).toEqual([ + expect.objectContaining({ value: '2041700000000000002' }), + expect.objectContaining({ + disabled: true, + value: '2041700000000000003', + }), + ]); + }); + + it('切换消息源时清理旧字段并只显示新来源声明字段', async () => { + mountModal(); + await mocks.formOptions.handleValuesChange({ sourceKey: STUN_SOURCE_KEY }, [ + 'sourceKey', + ]); + await mocks.formOptions.handleValuesChange( + { + ddnsRecordId: '2041700000000000002', + portForwardId: '2041700000000000001', + }, + ['portForwardId'], + ); + + await mocks.formOptions.handleValuesChange( + { sourceKey: GENERIC_SOURCE_KEY }, + ['sourceKey'], + ); + + expect(mocks.formApi.setValues).toHaveBeenCalledWith( + { + ddnsRecordId: undefined, + portForwardId: undefined, + }, + false, + ); + expect(currentFields()).toEqual([ + 'name', + 'sourceKey', + 'serviceId', + 'channelId', + 'enabled', + 'remark', + ]); + expect(mocks.getOptions).toHaveBeenLastCalledWith(GENERIC_SOURCE_KEY); + }); + + it('快速切换消息源时忽略旧来源的迟到响应', async () => { + const stunRequest = + createDeferred(); + const genericRequest = + createDeferred(); + mocks.getOptions.mockImplementation((sourceKey: string) => + sourceKey === STUN_SOURCE_KEY + ? stunRequest.promise + : genericRequest.promise, + ); + mountModal(); + + const staleSelection = mocks.formOptions.handleValuesChange( + { sourceKey: STUN_SOURCE_KEY }, + ['sourceKey'], + ); + await flushPromises(); + const latestSelection = mocks.formOptions.handleValuesChange( + { sourceKey: GENERIC_SOURCE_KEY }, + ['sourceKey'], + ); + genericRequest.resolve(createGenericOptions()); + await latestSelection; + stunRequest.resolve(createStunOptions()); + await staleSelection; + + expect(currentFields()).toContain('serviceId'); + expect(currentFields()).not.toContain('portForwardId'); + const serviceField = mocks.formOptions.schema.find( + (field: any) => field.fieldName === 'serviceId', + ); + expect(serviceField.componentProps().options).toEqual([ + expect.objectContaining({ value: 'service-1' }), + ]); + }); + + it('编辑时恢复任意消息源配置并按当前 sourceKey 加载候选项', async () => { + const wrapper = mountModal(); + const row = createRow(); + + (wrapper.vm as any).openEdit(row); + await flushPromises(); + + expect(mocks.formApi.setValues).toHaveBeenCalledWith( + { + channelId: 'channel-1', + enabled: false, + name: '旧订阅', + remark: '旧备注', + serviceId: 'service-1', + sourceKey: GENERIC_SOURCE_KEY, + }, + false, + ); + expect(mocks.getOptions).toHaveBeenCalledWith(GENERIC_SOURCE_KEY); + expect(currentFields()).toContain('serviceId'); + expect(currentFields()).toContain('channelId'); + }); + + it('弹窗先打开且消息源目录后返回时补建动态字段', async () => { + const wrapper = mountModal([]); + + (wrapper.vm as any).openEdit(createRow(STUN_SOURCE_KEY)); + await flushPromises(); + expect(currentFields()).not.toContain('portForwardId'); + + await wrapper.setProps({ sources: createSources() }); + await flushPromises(); + + expect(currentFields()).toContain('portForwardId'); + expect(currentFields()).toContain('ddnsRecordId'); + }); + + it('提交时只投影所选消息源声明的配置字段', async () => { + const wrapper = mountModal(); + (wrapper.vm as any).openCreate(); + await flushPromises(); + await mocks.formOptions.handleValuesChange( + { sourceKey: GENERIC_SOURCE_KEY }, + ['sourceKey'], + ); + mocks.values = { + channelId: 'channel-1', + enabled: true, + name: ' 通用订阅 ', + portForwardId: 'stale-port', + remark: ' managed ', + serviceId: 'service-1', + sourceKey: GENERIC_SOURCE_KEY, + }; + + await mocks.modalOptions.onConfirm(); + + expect(mocks.create).toHaveBeenCalledWith({ + enabled: true, + name: '通用订阅', + remark: 'managed', + sourceConfig: { + channelId: 'channel-1', + serviceId: 'service-1', + }, + sourceKey: GENERIC_SOURCE_KEY, + }); + expect(mocks.modalApi.close).toHaveBeenCalledOnce(); + expect(wrapper.emitted('saved')).toHaveLength(1); + }); + + it('普通表单校验失败时不持久化也不锁定弹窗', async () => { + mocks.formApi.validate.mockResolvedValueOnce({ valid: false }); + const wrapper = mountModal(); + (wrapper.vm as any).openCreate(); + await flushPromises(); + + await mocks.modalOptions.onConfirm(); + + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.update).not.toHaveBeenCalled(); + expect(mocks.modalApi.lock).not.toHaveBeenCalled(); + }); + + it('持久化失败后保持弹窗可重试并成对解锁', async () => { + mocks.create.mockRejectedValueOnce(new Error('save failed')); + const wrapper = mountModal(); + (wrapper.vm as any).openCreate(); + await flushPromises(); + await mocks.formOptions.handleValuesChange( + { sourceKey: GENERIC_SOURCE_KEY }, + ['sourceKey'], + ); + mocks.values = { + enabled: true, + name: '通用订阅', + remark: '', + serviceId: 'service-1', + sourceKey: GENERIC_SOURCE_KEY, + }; + + await expect(mocks.modalOptions.onConfirm()).resolves.toBeUndefined(); + expect(mocks.modalApi.close).not.toHaveBeenCalled(); + expect(mocks.modalApi.unlock).toHaveBeenCalledOnce(); + + mocks.create.mockResolvedValue({}); + await mocks.modalOptions.onConfirm(); + + expect(mocks.modalApi.close).toHaveBeenCalledOnce(); + expect(mocks.modalApi.unlock).toHaveBeenCalledTimes(2); + expect(wrapper.emitted('saved')).toHaveLength(1); + }); +}); diff --git a/apps/web-antdv-next/src/views/qqbot/message-subscription/list.spec.tsx b/test/views/qqbot/message-subscription/list.spec.tsx similarity index 90% rename from apps/web-antdv-next/src/views/qqbot/message-subscription/list.spec.tsx rename to test/views/qqbot/message-subscription/list.spec.tsx index fadf965..4a4bf09 100644 --- a/apps/web-antdv-next/src/views/qqbot/message-subscription/list.spec.tsx +++ b/test/views/qqbot/message-subscription/list.spec.tsx @@ -7,17 +7,16 @@ import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; import { flushPromises, mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; +import MessageSubscriptionList from '@test-source/apps/web-antdv-next/src/views/qqbot/message-subscription/list'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import MessageSubscriptionList from './list'; - const mocks = vi.hoisted(() => { const state = { accessCodes: new Set(), api: { delete: vi.fn(), getList: vi.fn(), - getOptions: vi.fn(), + getSourceOptions: vi.fn(), getSources: vi.fn(), toggle: vi.fn(), }, @@ -108,36 +107,40 @@ vi.mock('#/components/ktTable', () => ({ }), })); -vi.mock('./components/MessageSubscriptionModal', () => ({ - default: defineComponent({ - name: 'MockMessageSubscriptionModal', - emits: ['saved'], - setup(_, { emit, expose }) { - expose({ - openCreate: mocks.modalOpenCreate, - openEdit: mocks.modalOpenEdit, - }); - return () => - h( - 'button', - { - 'data-testid': 'modal-saved', - onClick: () => emit('saved'), - }, - 'saved', - ); - }, +vi.mock( + '@test-source/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal', + () => ({ + default: defineComponent({ + name: 'MockMessageSubscriptionModal', + emits: ['saved'], + setup(_, { emit, expose }) { + expose({ + openCreate: mocks.modalOpenCreate, + openEdit: mocks.modalOpenEdit, + }); + return () => + h( + 'button', + { + 'data-testid': 'modal-saved', + onClick: () => emit('saved'), + }, + 'saved', + ); + }, + }), }), -})); +); vi.mock('#/api/qqbot/message-push', () => ({ deleteMessageSubscription: mocks.api.delete, + getMessagePushSourceOptions: mocks.api.getSourceOptions, getMessagePushSources: mocks.api.getSources, getMessageSubscriptionList: mocks.api.getList, - getStunMappingPortChangedOptions: mocks.api.getOptions, setMessageSubscriptionEnabled: mocks.api.toggle, })); +/** 创建列表测试使用的订阅视图。 */ function createRow(): QqbotMessagePushApi.MessageSubscriptionView { return { createTime: '2026-07-24 10:00:00', @@ -181,10 +184,6 @@ describe('message subscription list', () => { version: 1, }, ]); - mocks.api.getOptions.mockResolvedValue({ - ddnsRecords: [], - portForwards: [], - }); mocks.api.getList.mockResolvedValue({ items: [createRow()], total: 1, @@ -253,14 +252,14 @@ describe('message subscription list', () => { expect(mocks.registerTable).not.toHaveBeenCalled(); expect(mocks.api.getList).not.toHaveBeenCalled(); expect(mocks.api.getSources).not.toHaveBeenCalled(); - expect(mocks.api.getOptions).not.toHaveBeenCalled(); + expect(mocks.api.getSourceOptions).not.toHaveBeenCalled(); expect(wrapper.find('[data-testid="subscription-table"]').exists()).toBe( false, ); expect(wrapper.find('[data-testid="modal-saved"]').exists()).toBe(false); }); - it('loads list and metadata once, then remains timer-free for 60 seconds', async () => { + it('loads list and source catalog once without prefetching source options', async () => { mount(MessageSubscriptionList); await flushPromises(); @@ -277,7 +276,7 @@ describe('message subscription list', () => { total: 1, }); expect(mocks.api.getSources).toHaveBeenCalledOnce(); - expect(mocks.api.getOptions).toHaveBeenCalledOnce(); + expect(mocks.api.getSourceOptions).not.toHaveBeenCalled(); vi.advanceTimersByTime(60_000); await flushPromises(); @@ -285,7 +284,7 @@ describe('message subscription list', () => { expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); expect(mocks.api.getList).toHaveBeenCalledOnce(); expect(mocks.api.getSources).toHaveBeenCalledOnce(); - expect(mocks.api.getOptions).toHaveBeenCalledOnce(); + expect(mocks.api.getSourceOptions).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }); @@ -383,7 +382,7 @@ describe('message subscription list', () => { expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); expect(mocks.api.getList).toHaveBeenCalledOnce(); expect(mocks.api.getSources).toHaveBeenCalledOnce(); - expect(mocks.api.getOptions).toHaveBeenCalledOnce(); + expect(mocks.api.getSourceOptions).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }); }); diff --git a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateMentions.spec.tsx b/test/views/qqbot/message-template/components/MessageTemplateMentions.spec.tsx similarity index 96% rename from apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateMentions.spec.tsx rename to test/views/qqbot/message-template/components/MessageTemplateMentions.spec.tsx index f93d0fc..535c5e6 100644 --- a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateMentions.spec.tsx +++ b/test/views/qqbot/message-template/components/MessageTemplateMentions.spec.tsx @@ -6,11 +6,10 @@ import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; import { flushPromises, mount } from '@vue/test-utils'; +import MessageTemplateMentions from '@test-source/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateMentions'; import Mentions from 'antdv-next/dist/mentions/index'; import { describe, expect, it } from 'vitest'; -import MessageTemplateMentions from './MessageTemplateMentions'; - function createVariable( overrides: Partial = {}, ): QqbotMessagePushApi.SystemMessageSourceVariableDefinition { diff --git a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx b/test/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx similarity index 98% rename from apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx rename to test/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx index 12e5754..cb6b251 100644 --- a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx +++ b/test/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx @@ -7,11 +7,10 @@ import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; import { flushPromises, mount } from '@vue/test-utils'; import { defineComponent, h, nextTick } from 'vue'; +import MessageTemplateMentions from '@test-source/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateMentions'; +import MessageTemplateModal from '@test-source/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import MessageTemplateMentions from './MessageTemplateMentions'; -import MessageTemplateModal from './MessageTemplateModal'; - const mocks = vi.hoisted(() => { const formValues = { content: '', diff --git a/apps/web-antdv-next/src/views/qqbot/message-template/list.spec.tsx b/test/views/qqbot/message-template/list.spec.tsx similarity index 93% rename from apps/web-antdv-next/src/views/qqbot/message-template/list.spec.tsx rename to test/views/qqbot/message-template/list.spec.tsx index 8f4d5f8..77579b4 100644 --- a/apps/web-antdv-next/src/views/qqbot/message-template/list.spec.tsx +++ b/test/views/qqbot/message-template/list.spec.tsx @@ -7,10 +7,9 @@ import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; import { flushPromises, mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; +import MessageTemplateList from '@test-source/apps/web-antdv-next/src/views/qqbot/message-template/list'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import MessageTemplateList from './list'; - const mocks = vi.hoisted(() => { const registerTable = vi.fn(); const tableApi = { @@ -118,35 +117,38 @@ vi.mock('#/components/ktTable', () => ({ }), })); -vi.mock('./components/MessageTemplateModal', () => ({ - default: defineComponent({ - name: 'MockMessageTemplateModal', - props: { - canPreview: Boolean, - sources: { - default: () => [], - type: Array, +vi.mock( + '@test-source/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal', + () => ({ + default: defineComponent({ + name: 'MockMessageTemplateModal', + props: { + canPreview: Boolean, + sources: { + default: () => [], + type: Array, + }, }, - }, - emits: ['saved'], - setup(props, { emit, expose }) { - expose({ - openCreate: mocks.modalOpenCreate, - openEdit: mocks.modalOpenEdit, - }); - return () => - h( - 'button', - { - 'data-can-preview': String(props.canPreview), - 'data-testid': 'modal-saved', - onClick: () => emit('saved'), - }, - 'saved', - ); - }, + emits: ['saved'], + setup(props, { emit, expose }) { + expose({ + openCreate: mocks.modalOpenCreate, + openEdit: mocks.modalOpenEdit, + }); + return () => + h( + 'button', + { + 'data-can-preview': String(props.canPreview), + 'data-testid': 'modal-saved', + onClick: () => emit('saved'), + }, + 'saved', + ); + }, + }), }), -})); +); function createRow( overrides: Partial = {}, diff --git a/apps/web-antdv-next/src/views/qqbot/modules/actions.spec.tsx b/test/views/qqbot/modules/actions.spec.tsx similarity index 94% rename from apps/web-antdv-next/src/views/qqbot/modules/actions.spec.tsx rename to test/views/qqbot/modules/actions.spec.tsx index a945790..2feb775 100644 --- a/apps/web-antdv-next/src/views/qqbot/modules/actions.spec.tsx +++ b/test/views/qqbot/modules/actions.spec.tsx @@ -27,7 +27,8 @@ vi.mock('antdv-next', () => ({ describe('qqbot shared action renderer', () => { it('renders link buttons and preserves action props', async () => { - const { renderQqbotActions } = await import('./actions'); + const { renderQqbotActions } = + await import('@test-source/apps/web-antdv-next/src/views/qqbot/modules/actions'); const TestIcon = defineComponent({ name: 'TestIcon', setup: () => () => h('i'), diff --git a/apps/web-antdv-next/src/views/qqbot/modules/status.spec.ts b/test/views/qqbot/modules/status.spec.ts similarity index 93% rename from apps/web-antdv-next/src/views/qqbot/modules/status.spec.ts rename to test/views/qqbot/modules/status.spec.ts index ba71ac1..8411336 100644 --- a/apps/web-antdv-next/src/views/qqbot/modules/status.spec.ts +++ b/test/views/qqbot/modules/status.spec.ts @@ -1,10 +1,9 @@ -import { describe, expect, it } from 'vitest'; - import { getQqbotStatusColor, getQqbotStatusLabel, qqbotStatusLabels, -} from './status'; +} from '@test-source/apps/web-antdv-next/src/views/qqbot/modules/status'; +import { describe, expect, it } from 'vitest'; describe('qqbot shared status helpers', () => { it('maps common QQBot status keys to Chinese labels', () => { diff --git a/apps/web-antdv-next/src/views/qqbot/plugin-task/plugin-task.spec.tsx b/test/views/qqbot/plugin-task/plugin-task.spec.tsx similarity index 96% rename from apps/web-antdv-next/src/views/qqbot/plugin-task/plugin-task.spec.tsx rename to test/views/qqbot/plugin-task/plugin-task.spec.tsx index 16037d2..cc15e53 100644 --- a/apps/web-antdv-next/src/views/qqbot/plugin-task/plugin-task.spec.tsx +++ b/test/views/qqbot/plugin-task/plugin-task.spec.tsx @@ -5,13 +5,12 @@ import { mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; +import QqBotPluginTaskList from '@test-source/apps/web-antdv-next/src/views/qqbot/plugin-task/list'; import { describe, expect, it, vi } from 'vitest'; import { isSupportedAdminMenuName } from '#/api/core/menu'; import routes from '#/router/routes/modules/qqbot'; -import QqBotPluginTaskList from './list'; - vi.mock('#/api/request', () => ({ requestClient: { get: vi.fn(), diff --git a/apps/web-antdv-next/src/views/qqbot/plugin/metadata.spec.ts b/test/views/qqbot/plugin/metadata.spec.ts similarity index 93% rename from apps/web-antdv-next/src/views/qqbot/plugin/metadata.spec.ts rename to test/views/qqbot/plugin/metadata.spec.ts index 5119d75..f824fe1 100644 --- a/apps/web-antdv-next/src/views/qqbot/plugin/metadata.spec.ts +++ b/test/views/qqbot/plugin/metadata.spec.ts @@ -1,7 +1,6 @@ +import { loadQqbotPluginMetadata } from '@test-source/apps/web-antdv-next/src/views/qqbot/plugin/metadata'; import { describe, expect, it, vi } from 'vitest'; -import { loadQqbotPluginMetadata } from './metadata'; - describe('qqbot plugin page metadata', () => { it('keeps plugin page metadata loading from rejecting when plugin list fails', async () => { const error = { code: 500, msg: 'plugin list failed' }; diff --git a/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal.spec.tsx b/test/views/system/network/components/NetworkDdnsRecordModal.spec.tsx similarity index 98% rename from apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal.spec.tsx rename to test/views/system/network/components/NetworkDdnsRecordModal.spec.tsx index 80bed27..e8f4fe8 100644 --- a/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal.spec.tsx +++ b/test/views/system/network/components/NetworkDdnsRecordModal.spec.tsx @@ -7,12 +7,11 @@ import type { SystemNetworkApi } from '#/api/system/network'; import { flushPromises, mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - import NetworkDdnsRecordModal, { isValidDdnsDomain, isValidDdnsSubDomain, -} from './NetworkDdnsRecordModal'; +} from '@test-source/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => { const modalApi = { diff --git a/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable.spec.tsx b/test/views/system/network/components/NetworkDdnsTable.spec.tsx similarity index 94% rename from apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable.spec.tsx rename to test/views/system/network/components/NetworkDdnsTable.spec.tsx index 8559c55..94b9e03 100644 --- a/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable.spec.tsx +++ b/test/views/system/network/components/NetworkDdnsTable.spec.tsx @@ -7,11 +7,10 @@ import type { SystemNetworkApi } from '#/api/system/network'; import { flushPromises, mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - import NetworkDdnsTable, { getDdnsRetryDisabledReason, -} from './NetworkDdnsTable'; +} from '@test-source/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ api: { @@ -77,18 +76,21 @@ vi.mock('#/components/ktTable', () => ({ }), })); -vi.mock('./NetworkDdnsRecordModal', () => ({ - default: defineComponent({ - name: 'MockNetworkDdnsRecordModal', - setup(_, { expose }) { - expose({ - openCreate: mocks.modalOpenCreate, - openEdit: mocks.modalOpenEdit, - }); - return () => h('div'); - }, +vi.mock( + '@test-source/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal', + () => ({ + default: defineComponent({ + name: 'MockNetworkDdnsRecordModal', + setup(_, { expose }) { + expose({ + openCreate: mocks.modalOpenCreate, + openEdit: mocks.modalOpenEdit, + }); + return () => h('div'); + }, + }), }), -})); +); vi.mock('#/api/system/network', () => ({ deleteNetworkDdnsRecord: mocks.api.deleteRecord, diff --git a/apps/web-antdv-next/src/views/system/network/components/NetworkEndpointHistoryDrawer.spec.tsx b/test/views/system/network/components/NetworkEndpointHistoryDrawer.spec.tsx similarity index 95% rename from apps/web-antdv-next/src/views/system/network/components/NetworkEndpointHistoryDrawer.spec.tsx rename to test/views/system/network/components/NetworkEndpointHistoryDrawer.spec.tsx index a33c581..20712d3 100644 --- a/apps/web-antdv-next/src/views/system/network/components/NetworkEndpointHistoryDrawer.spec.tsx +++ b/test/views/system/network/components/NetworkEndpointHistoryDrawer.spec.tsx @@ -7,10 +7,9 @@ import type { SystemNetworkApi } from '#/api/system/network'; import { mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; +import NetworkEndpointHistoryDrawer from '@test-source/apps/web-antdv-next/src/views/system/network/components/NetworkEndpointHistoryDrawer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import NetworkEndpointHistoryDrawer from './NetworkEndpointHistoryDrawer'; - const mocks = vi.hoisted(() => ({ drawerApi: { open: vi.fn() }, drawerOptions: undefined as any, diff --git a/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.spec.tsx b/test/views/system/network/components/NetworkPortForwardModal.spec.tsx similarity index 98% rename from apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.spec.tsx rename to test/views/system/network/components/NetworkPortForwardModal.spec.tsx index 391d627..904d2de 100644 --- a/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.spec.tsx +++ b/test/views/system/network/components/NetworkPortForwardModal.spec.tsx @@ -5,10 +5,9 @@ import { flushPromises, mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; +import NetworkPortForwardModal from '@test-source/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import NetworkPortForwardModal from './NetworkPortForwardModal'; - const mocks = vi.hoisted(() => { const modalApi = { close: vi.fn(async () => {}), diff --git a/apps/web-antdv-next/src/views/system/network/list.spec.tsx b/test/views/system/network/list.spec.tsx similarity index 94% rename from apps/web-antdv-next/src/views/system/network/list.spec.tsx rename to test/views/system/network/list.spec.tsx index b9d8719..46b697d 100644 --- a/apps/web-antdv-next/src/views/system/network/list.spec.tsx +++ b/test/views/system/network/list.spec.tsx @@ -7,15 +7,14 @@ import type { SystemNetworkApi } from '#/api/system/network'; import { flushPromises, mount } from '@vue/test-utils'; import { defineComponent, h } from 'vue'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import routes from '#/router/routes/modules/system'; - import NetworkList, { getCurrentEndpoint, getKeeperDisabledReason, isDeleting, -} from './list'; +} from '@test-source/apps/web-antdv-next/src/views/system/network/list'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import routes from '#/router/routes/modules/system'; type FakeEventSourceListener = (event: Event) => void; @@ -170,38 +169,47 @@ vi.mock('#/components/ktTable', () => ({ }), })); -vi.mock('./components/NetworkPortForwardModal', () => ({ - default: defineComponent({ - name: 'MockNetworkPortForwardModal', - setup(_, { expose }) { - expose({ - openCreate: mocks.modalOpenCreate, - openEdit: mocks.modalOpenEdit, - }); - return () => h('div'); - }, +vi.mock( + '@test-source/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal', + () => ({ + default: defineComponent({ + name: 'MockNetworkPortForwardModal', + setup(_, { expose }) { + expose({ + openCreate: mocks.modalOpenCreate, + openEdit: mocks.modalOpenEdit, + }); + return () => h('div'); + }, + }), }), -})); +); -vi.mock('./components/NetworkDdnsTable', () => ({ - default: defineComponent({ - name: 'MockNetworkDdnsTable', - setup(_, { expose }) { - expose({ reload: mocks.ddnsReload }); - return () => h('section', { 'data-testid': 'ddns-table' }); - }, +vi.mock( + '@test-source/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable', + () => ({ + default: defineComponent({ + name: 'MockNetworkDdnsTable', + setup(_, { expose }) { + expose({ reload: mocks.ddnsReload }); + return () => h('section', { 'data-testid': 'ddns-table' }); + }, + }), }), -})); +); -vi.mock('./components/NetworkEndpointHistoryDrawer', () => ({ - default: defineComponent({ - name: 'MockNetworkEndpointHistoryDrawer', - setup(_, { expose }) { - expose({ open: vi.fn() }); - return () => h('div'); - }, +vi.mock( + '@test-source/apps/web-antdv-next/src/views/system/network/components/NetworkEndpointHistoryDrawer', + () => ({ + default: defineComponent({ + name: 'MockNetworkEndpointHistoryDrawer', + setup(_, { expose }) { + expose({ open: vi.fn() }); + return () => h('div'); + }, + }), }), -})); +); vi.mock('#/api/system/network', () => ({ deleteNetworkPortForward: mocks.api.deleteMapping, diff --git a/apps/web-antdv-next/src/views/system/network/network-locale.spec.ts b/test/views/system/network/network-locale.spec.ts similarity index 100% rename from apps/web-antdv-next/src/views/system/network/network-locale.spec.ts rename to test/views/system/network/network-locale.spec.ts diff --git a/vitest.config.ts b/vitest.config.ts index a5ecd3d..f27336d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,17 @@ +import { fileURLToPath, URL } from 'node:url'; + import Vue from '@vitejs/plugin-vue'; import VueJsx from '@vitejs/plugin-vue-jsx'; import { configDefaults, defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [Vue(), VueJsx()], + resolve: { + alias: { + '#': fileURLToPath(new URL('apps/web-antdv-next/src', import.meta.url)), + '@test-source': fileURLToPath(new URL('.', import.meta.url)), + }, + }, test: { environment: 'happy-dom', exclude: [