fix: 通用化消息订阅并统一测试目录
This commit is contained in:
parent
096f96143a
commit
9d7f3c272c
@ -8,6 +8,7 @@
|
|||||||
apps/web-antdv-next 后台管理端入口
|
apps/web-antdv-next 后台管理端入口
|
||||||
internal 构建、Vite、Lint 等内部配置包
|
internal 构建、Vite、Lint 等内部配置包
|
||||||
packages Vben 运行时依赖的核心包和组件包
|
packages Vben 运行时依赖的核心包和组件包
|
||||||
|
test 全仓库单元测试,按 api/views/packages/internal 等领域集中管理
|
||||||
deploy/nginx-admin.conf Admin 静态站点和后端 /api 反向代理配置
|
deploy/nginx-admin.conf Admin 静态站点和后端 /api 反向代理配置
|
||||||
Jenkinsfile Jenkins 静态发布流水线
|
Jenkinsfile Jenkins 静态发布流水线
|
||||||
```
|
```
|
||||||
@ -29,6 +30,8 @@ corepack prepare pnpm@10.28.2 --activate
|
|||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run dev
|
pnpm run dev
|
||||||
|
pnpm run test:unit
|
||||||
|
pnpm run test:type
|
||||||
pnpm run verify:commit
|
pnpm run verify:commit
|
||||||
pnpm run build:antdv-next
|
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 操作保持可见且禁用。
|
- 系统管理 / 网络管理使用 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。
|
- 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 / 账号连接页拆分 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/*`。
|
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`。
|
||||||
- 博客管理 / 文章管理提供“预览”行操作,打开隐藏二级路由 `/blog/article/:articleId/preview`;预览页按 NapCat WebUI 的微服务嵌入形态实现,iframe 独占容器,文章标题、状态、预览 Host 和返回/刷新/新窗口操作放在右下角悬浮卡片,不占用 iframe 布局空间。新增隐藏路由和按钮权限需要同步 API `blog-menu.sql` / `vben-admin-init.sql` 中的 `BlogArticlePreview` 与 `BlogArticlePreviewButton`。
|
- 博客管理 / 文章管理提供“预览”行操作,打开隐藏二级路由 `/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/高度模型,避免暗色模式脱节和首次空编辑器出现内部滚动条。
|
- 博客管理 / 文章表单支持 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/高度模型,避免暗色模式脱节和首次空编辑器出现内部滚动条。
|
||||||
|
|||||||
@ -17,8 +17,8 @@ export namespace QqbotMessagePushApi {
|
|||||||
dependsOn?: string;
|
dependsOn?: string;
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
optionCollection: 'ddnsRecords' | 'portForwards';
|
optionCollection: string;
|
||||||
required: true;
|
required: boolean;
|
||||||
type: 'select';
|
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 {
|
export interface MessageSubscriptionView {
|
||||||
createTime: string;
|
createTime: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -63,7 +76,7 @@ export namespace QqbotMessagePushApi {
|
|||||||
invalidReasonCode: null | string;
|
invalidReasonCode: null | string;
|
||||||
name: string;
|
name: string;
|
||||||
remark: null | string;
|
remark: null | string;
|
||||||
sourceConfig: StunMappingPortChangedSubscriptionConfig;
|
sourceConfig: Record<string, string>;
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
sourceName: string;
|
sourceName: string;
|
||||||
sourceSummary: string;
|
sourceSummary: string;
|
||||||
@ -83,7 +96,7 @@ export namespace QqbotMessagePushApi {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
sourceConfig: Record<string, unknown>;
|
sourceConfig: Record<string, string>;
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,6 +206,13 @@ export function getMessagePushSourceDetail(sourceKey: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取指定系统消息源提供的订阅字段候选项。 */
|
||||||
|
export function getMessagePushSourceOptions(sourceKey: string) {
|
||||||
|
return requestClient.get<QqbotMessagePushApi.SystemMessageSourceOptionsResponse>(
|
||||||
|
`/qqbot/message-push/sources/${encodeURIComponent(sourceKey)}/subscription-options`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getStunMappingPortChangedOptions() {
|
export function getStunMappingPortChangedOptions() {
|
||||||
return requestClient.get<QqbotMessagePushApi.StunMappingPortChangedOptionsResponse>(
|
return requestClient.get<QqbotMessagePushApi.StunMappingPortChangedOptionsResponse>(
|
||||||
'/qqbot/message-push/sources/network.stun.mapping-port-changed/options',
|
'/qqbot/message-push/sources/network.stun.mapping-port-changed/options',
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export interface MessagePushTargetPickerProps {
|
|||||||
value: QqbotMessagePushApi.QqbotMessagePublishTargetInput[];
|
value: QqbotMessagePushApi.QqbotMessagePublishTargetInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 判断 QQ 群号或 QQ 号是否满足消息推送目标格式。 */
|
||||||
export function isValidMessagePushTargetId(targetId: string): boolean {
|
export function isValidMessagePushTargetId(targetId: string): boolean {
|
||||||
return /^[1-9]\d{4,19}$/.test(targetId);
|
return /^[1-9]\d{4,19}$/.test(targetId);
|
||||||
}
|
}
|
||||||
@ -81,6 +82,7 @@ export default defineComponent({
|
|||||||
createSelectOptions(props.options, 'private'),
|
createSelectOptions(props.options, 'private'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 规范化指定目标类型的选择值并向父表单提交完整目标列表。 */
|
||||||
function handleValueUpdate(targetType: TargetType, runtimeValue: unknown) {
|
function handleValueUpdate(targetType: TargetType, runtimeValue: unknown) {
|
||||||
const targetIds = normalizeTargetIds(runtimeValue);
|
const targetIds = normalizeTargetIds(runtimeValue);
|
||||||
if (containsRejectedTarget(runtimeValue, targetIds)) {
|
if (containsRejectedTarget(runtimeValue, targetIds)) {
|
||||||
@ -94,7 +96,10 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => (
|
return () => (
|
||||||
<div class="qqbot-message-push-target-picker">
|
<div
|
||||||
|
class="qqbot-message-push-target-picker w-full"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
{props.available ? null : (
|
{props.available ? null : (
|
||||||
<AAlert
|
<AAlert
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
@ -118,6 +123,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
options={groupOptions.value}
|
options={groupOptions.value}
|
||||||
placeholder="选择或输入群号"
|
placeholder="选择或输入群号"
|
||||||
|
style={{ width: '100%' }}
|
||||||
value={groupIds.value}
|
value={groupIds.value}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -136,6 +142,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
options={privateOptions.value}
|
options={privateOptions.value}
|
||||||
placeholder="选择或输入 QQ 号"
|
placeholder="选择或输入 QQ 号"
|
||||||
|
style={{ width: '100%' }}
|
||||||
value={privateIds.value}
|
value={privateIds.value}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -144,6 +151,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 提取指定目标类型的已选 QQ 标识。 */
|
||||||
function targetIdsForType(
|
function targetIdsForType(
|
||||||
targets: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
targets: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
||||||
targetType: TargetType,
|
targetType: TargetType,
|
||||||
@ -153,6 +161,7 @@ function targetIdsForType(
|
|||||||
.map((target) => target.targetId);
|
.map((target) => target.targetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将账号候选项转换成指定目标类型的选择框候选项。 */
|
||||||
function createSelectOptions(
|
function createSelectOptions(
|
||||||
options: QqbotMessagePushApi.QqbotMessagePushTargetOption[],
|
options: QqbotMessagePushApi.QqbotMessagePushTargetOption[],
|
||||||
targetType: TargetType,
|
targetType: TargetType,
|
||||||
@ -166,6 +175,7 @@ function createSelectOptions(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按显示名称、目标标识和值过滤消息推送候选项。 */
|
||||||
function filterTargetOption(
|
function filterTargetOption(
|
||||||
input: string,
|
input: string,
|
||||||
option: Record<string, unknown>,
|
option: Record<string, unknown>,
|
||||||
@ -177,6 +187,7 @@ function filterTargetOption(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 规范化选择框运行时值并移除无效或重复目标。 */
|
||||||
function normalizeTargetIds(runtimeValue: unknown): string[] {
|
function normalizeTargetIds(runtimeValue: unknown): string[] {
|
||||||
if (!Array.isArray(runtimeValue)) return [];
|
if (!Array.isArray(runtimeValue)) return [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@ -191,6 +202,7 @@ function normalizeTargetIds(runtimeValue: unknown): string[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 判断运行时选择值是否包含被规范化规则拒绝的目标。 */
|
||||||
function containsRejectedTarget(
|
function containsRejectedTarget(
|
||||||
runtimeValue: unknown,
|
runtimeValue: unknown,
|
||||||
normalized: string[],
|
normalized: string[],
|
||||||
@ -203,6 +215,7 @@ function containsRejectedTarget(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 合并本次变更与另一目标类型的既有选择。 */
|
||||||
function mergeTargets(
|
function mergeTargets(
|
||||||
current: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
current: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
||||||
options: QqbotMessagePushApi.QqbotMessagePushTargetOption[],
|
options: QqbotMessagePushApi.QqbotMessagePushTargetOption[],
|
||||||
@ -234,6 +247,7 @@ function mergeTargets(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 优先从候选项或既有配置中解析同类型目标名称。 */
|
||||||
function resolveTargetName(
|
function resolveTargetName(
|
||||||
current: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
current: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
||||||
options: QqbotMessagePushApi.QqbotMessagePushTargetOption[],
|
options: QqbotMessagePushApi.QqbotMessagePushTargetOption[],
|
||||||
|
|||||||
@ -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<T>() {
|
|
||||||
let resolve!: (value: T) => void;
|
|
||||||
const promise = new Promise<T>((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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -3,13 +3,14 @@ import type { PropType } from 'vue';
|
|||||||
import type { VbenFormSchema } from '#/adapter/form';
|
import type { VbenFormSchema } from '#/adapter/form';
|
||||||
import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
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 { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
import { useVbenForm, z } from '#/adapter/form';
|
import { useVbenForm, z } from '#/adapter/form';
|
||||||
import {
|
import {
|
||||||
createMessageSubscription,
|
createMessageSubscription,
|
||||||
|
getMessagePushSourceOptions,
|
||||||
updateMessageSubscription,
|
updateMessageSubscription,
|
||||||
} from '#/api/qqbot/message-push';
|
} from '#/api/qqbot/message-push';
|
||||||
|
|
||||||
@ -18,14 +19,15 @@ export interface MessageSubscriptionModalExposed {
|
|||||||
openEdit: (row: QqbotMessagePushApi.MessageSubscriptionView) => void;
|
openEdit: (row: QqbotMessagePushApi.MessageSubscriptionView) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageSubscriptionFormValues {
|
type MessageSubscriptionFormValues = Record<
|
||||||
ddnsRecordId?: string;
|
string,
|
||||||
|
boolean | string | undefined
|
||||||
|
> & {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
portForwardId?: string;
|
|
||||||
remark?: string;
|
remark?: string;
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface MessageSubscriptionModalData {
|
interface MessageSubscriptionModalData {
|
||||||
values: MessageSubscriptionFormValues;
|
values: MessageSubscriptionFormValues;
|
||||||
@ -40,45 +42,70 @@ export default defineComponent({
|
|||||||
QqbotMessagePushApi.SystemMessageSourceDefinition[]
|
QqbotMessagePushApi.SystemMessageSourceDefinition[]
|
||||||
>,
|
>,
|
||||||
},
|
},
|
||||||
stunOptions: {
|
|
||||||
default: undefined,
|
|
||||||
type: Object as PropType<
|
|
||||||
QqbotMessagePushApi.StunMappingPortChangedOptionsResponse | undefined
|
|
||||||
>,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
emits: ['saved'],
|
emits: ['saved'],
|
||||||
setup(props, { emit, expose }) {
|
setup(props, { emit, expose }) {
|
||||||
const editingRow = ref<QqbotMessagePushApi.MessageSubscriptionView>();
|
const editingRow = ref<QqbotMessagePushApi.MessageSubscriptionView>();
|
||||||
const selectedPortForwardId = ref<string>();
|
const selectedSourceKey = ref('');
|
||||||
|
const sourceFieldValues = ref<Record<string, string | undefined>>({});
|
||||||
|
const sourceOptions =
|
||||||
|
ref<QqbotMessagePushApi.SystemMessageSourceOptionsResponse>({});
|
||||||
|
const sourceOptionsLoading = ref(false);
|
||||||
|
let sourceRevision = 0;
|
||||||
let sessionRevision = 0;
|
let sessionRevision = 0;
|
||||||
|
|
||||||
|
/** 处理来源或来源依赖字段变化,并清理不再有效的旧值。 */
|
||||||
|
async function handleValuesChange(
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
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<string, undefined> = {};
|
||||||
|
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({
|
const [SubscriptionForm, formApi] = useVbenForm({
|
||||||
commonConfig: {
|
commonConfig: {
|
||||||
labelClass: 'w-24',
|
labelClass: 'w-24',
|
||||||
},
|
},
|
||||||
async handleValuesChange(values, fieldsChanged) {
|
handleValuesChange,
|
||||||
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 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
layout: 'horizontal',
|
layout: 'horizontal',
|
||||||
schema: createFormSchema(props, selectedPortForwardId),
|
schema: createFormSchema(
|
||||||
|
props,
|
||||||
|
selectedSourceKey,
|
||||||
|
sourceFieldValues,
|
||||||
|
sourceOptions,
|
||||||
|
sourceOptionsLoading,
|
||||||
|
),
|
||||||
showDefaultActions: false,
|
showDefaultActions: false,
|
||||||
wrapperClass: 'grid-cols-1',
|
wrapperClass: 'grid-cols-1',
|
||||||
});
|
});
|
||||||
@ -98,38 +125,49 @@ export default defineComponent({
|
|||||||
async onOpenChange(isOpen: boolean) {
|
async onOpenChange(isOpen: boolean) {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
const { values } = modalApi.getData<MessageSubscriptionModalData>();
|
const { values } = modalApi.getData<MessageSubscriptionModalData>();
|
||||||
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);
|
await resetForm(values);
|
||||||
|
if (values.sourceKey) {
|
||||||
|
await loadSourceOptions(values.sourceKey);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 打开不预选消息源的新建订阅弹窗。 */
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
sessionRevision += 1;
|
sessionRevision += 1;
|
||||||
editingRow.value = undefined;
|
editingRow.value = undefined;
|
||||||
modalApi
|
modalApi
|
||||||
.setData({
|
.setData({
|
||||||
values: {
|
values: {
|
||||||
ddnsRecordId: undefined,
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
name: '',
|
name: '',
|
||||||
portForwardId: undefined,
|
|
||||||
remark: '',
|
remark: '',
|
||||||
sourceKey: props.sources[0]?.sourceKey || '',
|
sourceKey: '',
|
||||||
},
|
},
|
||||||
} satisfies MessageSubscriptionModalData)
|
} satisfies MessageSubscriptionModalData)
|
||||||
.open();
|
.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开编辑弹窗并保留当前消息源的动态配置。 */
|
||||||
function openEdit(row: QqbotMessagePushApi.MessageSubscriptionView) {
|
function openEdit(row: QqbotMessagePushApi.MessageSubscriptionView) {
|
||||||
sessionRevision += 1;
|
sessionRevision += 1;
|
||||||
editingRow.value = row;
|
editingRow.value = row;
|
||||||
modalApi
|
modalApi
|
||||||
.setData({
|
.setData({
|
||||||
values: {
|
values: {
|
||||||
ddnsRecordId: row.sourceConfig.ddnsRecordId,
|
...row.sourceConfig,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
portForwardId: row.sourceConfig.portForwardId,
|
|
||||||
remark: row.remark || '',
|
remark: row.remark || '',
|
||||||
sourceKey: row.sourceKey,
|
sourceKey: row.sourceKey,
|
||||||
},
|
},
|
||||||
@ -137,28 +175,118 @@ export default defineComponent({
|
|||||||
.open();
|
.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 重置表单并写入当前会话值。 */
|
||||||
async function resetForm(values: MessageSubscriptionFormValues) {
|
async function resetForm(values: MessageSubscriptionFormValues) {
|
||||||
await formApi.resetForm();
|
await formApi.resetForm();
|
||||||
await formApi.setValues(values);
|
await formApi.setValues(values, false);
|
||||||
await formApi.resetValidate();
|
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() {
|
async function submit() {
|
||||||
const revision = sessionRevision;
|
const revision = sessionRevision;
|
||||||
const editingId = editingRow.value?.id;
|
const editingId = editingRow.value?.id;
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
if (revision !== sessionRevision) return;
|
if (revision !== sessionRevision || !valid) return;
|
||||||
if (!valid) return;
|
|
||||||
const values = await formApi.getValues<MessageSubscriptionFormValues>();
|
const values = await formApi.getValues<MessageSubscriptionFormValues>();
|
||||||
if (revision !== sessionRevision) return;
|
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 = {
|
const payload: QqbotMessagePushApi.MessageSubscriptionInput = {
|
||||||
enabled: !!values.enabled,
|
enabled: !!values.enabled,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
remark: values.remark?.trim() || '',
|
remark: typeof values.remark === 'string' ? values.remark.trim() : '',
|
||||||
sourceConfig: {
|
sourceConfig,
|
||||||
ddnsRecordId: values.ddnsRecordId,
|
|
||||||
portForwardId: values.portForwardId,
|
|
||||||
},
|
|
||||||
sourceKey: values.sourceKey,
|
sourceKey: values.sourceKey,
|
||||||
};
|
};
|
||||||
if (revision !== sessionRevision) return;
|
if (revision !== sessionRevision) return;
|
||||||
@ -176,6 +304,8 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(() => props.sources, handleSourcesChanged, { deep: true });
|
||||||
|
|
||||||
expose({ openCreate, openEdit } satisfies MessageSubscriptionModalExposed);
|
expose({ openCreate, openEdit } satisfies MessageSubscriptionModalExposed);
|
||||||
|
|
||||||
return () => (
|
return () => (
|
||||||
@ -186,15 +316,32 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 根据消息源目录与当前选中项生成订阅表单。 */
|
||||||
function createFormSchema(
|
function createFormSchema(
|
||||||
props: Readonly<{
|
props: Readonly<{
|
||||||
sources: QqbotMessagePushApi.SystemMessageSourceDefinition[];
|
sources: QqbotMessagePushApi.SystemMessageSourceDefinition[];
|
||||||
stunOptions:
|
|
||||||
| QqbotMessagePushApi.StunMappingPortChangedOptionsResponse
|
|
||||||
| undefined;
|
|
||||||
}>,
|
}>,
|
||||||
selectedPortForwardId: Readonly<{ value: string | undefined }>,
|
selectedSourceKey: Readonly<{ value: string }>,
|
||||||
|
sourceFieldValues: Readonly<{
|
||||||
|
value: Record<string, string | undefined>;
|
||||||
|
}>,
|
||||||
|
sourceOptions: Readonly<{
|
||||||
|
value: QqbotMessagePushApi.SystemMessageSourceOptionsResponse;
|
||||||
|
}>,
|
||||||
|
sourceOptionsLoading: Readonly<{ value: boolean }>,
|
||||||
): VbenFormSchema[] {
|
): VbenFormSchema[] {
|
||||||
|
const definition = findSourceDefinition(
|
||||||
|
props.sources,
|
||||||
|
selectedSourceKey.value,
|
||||||
|
);
|
||||||
|
const dynamicFields = (definition?.subscriptionFields || []).map((field) =>
|
||||||
|
createSourceFieldSchema(
|
||||||
|
field,
|
||||||
|
sourceFieldValues,
|
||||||
|
sourceOptions,
|
||||||
|
sourceOptionsLoading,
|
||||||
|
),
|
||||||
|
);
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
component: 'Input',
|
component: 'Input',
|
||||||
@ -206,6 +353,7 @@ function createFormSchema(
|
|||||||
{
|
{
|
||||||
component: 'Select',
|
component: 'Select',
|
||||||
componentProps: () => ({
|
componentProps: () => ({
|
||||||
|
allowClear: true,
|
||||||
options: props.sources.map((source) => ({
|
options: props.sources.map((source) => ({
|
||||||
label: `${source.displayName} · ${source.sourceKey}`,
|
label: `${source.displayName} · ${source.sourceKey}`,
|
||||||
value: source.sourceKey,
|
value: source.sourceKey,
|
||||||
@ -215,32 +363,7 @@ function createFormSchema(
|
|||||||
label: '消息源',
|
label: '消息源',
|
||||||
rules: z.string().min(1),
|
rules: z.string().min(1),
|
||||||
},
|
},
|
||||||
{
|
...dynamicFields,
|
||||||
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),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
component: 'Switch',
|
component: 'Switch',
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
@ -257,28 +380,77 @@ function createFormSchema(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPortForwardOption(
|
/** 将消息源字段元数据转换成 Vben 选择框定义。 */
|
||||||
option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['portForwards'][number],
|
function createSourceFieldSchema(
|
||||||
) {
|
field: QqbotMessagePushApi.SystemMessageSourceFieldDefinition,
|
||||||
const reason = option.eligible
|
sourceFieldValues: Readonly<{
|
||||||
? ''
|
value: Record<string, string | undefined>;
|
||||||
: ` · ${option.disabledReasonCode || 'unavailable'}`;
|
}>,
|
||||||
|
sourceOptions: Readonly<{
|
||||||
|
value: QqbotMessagePushApi.SystemMessageSourceOptionsResponse;
|
||||||
|
}>,
|
||||||
|
sourceOptionsLoading: Readonly<{ value: boolean }>,
|
||||||
|
): VbenFormSchema {
|
||||||
|
const optionalRule = z.string().optional().or(z.literal(''));
|
||||||
return {
|
return {
|
||||||
disabled: !option.eligible,
|
component: 'Select',
|
||||||
label: `${option.name} · ${option.protocol.toUpperCase()}:${option.externalPort}${reason}`,
|
componentProps: () => ({
|
||||||
value: option.id,
|
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],
|
function getFieldOptions(
|
||||||
) {
|
field: QqbotMessagePushApi.SystemMessageSourceFieldDefinition,
|
||||||
const reason = option.eligible
|
sourceOptions: QqbotMessagePushApi.SystemMessageSourceOptionsResponse,
|
||||||
? ''
|
sourceFieldValues: Record<string, string | undefined>,
|
||||||
: ` · ${option.disabledReasonCode || 'unavailable'}`;
|
): QqbotMessagePushApi.SystemMessageSourceOptionDefinition[] {
|
||||||
return {
|
const options = sourceOptions[field.optionCollection] || [];
|
||||||
disabled: !option.eligible,
|
if (!field.dependsOn) return options;
|
||||||
label: `${option.name} · ${option.fqdn}${reason}`,
|
const dependsOnValue = sourceFieldValues[field.dependsOn];
|
||||||
value: option.id,
|
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<string, unknown>,
|
||||||
|
definition: QqbotMessagePushApi.SystemMessageSourceDefinition,
|
||||||
|
): Record<string, string | undefined> {
|
||||||
|
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<string, string | undefined> {
|
||||||
|
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<string, string | undefined>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import {
|
|||||||
deleteMessageSubscription,
|
deleteMessageSubscription,
|
||||||
getMessagePushSources,
|
getMessagePushSources,
|
||||||
getMessageSubscriptionList,
|
getMessageSubscriptionList,
|
||||||
getStunMappingPortChangedOptions,
|
|
||||||
setMessageSubscriptionEnabled,
|
setMessageSubscriptionEnabled,
|
||||||
} from '#/api/qqbot/message-push';
|
} from '#/api/qqbot/message-push';
|
||||||
import { KtTable, useKtTable } from '#/components/ktTable';
|
import { KtTable, useKtTable } from '#/components/ktTable';
|
||||||
@ -40,8 +39,6 @@ export default defineComponent({
|
|||||||
const sources = ref<QqbotMessagePushApi.SystemMessageSourceDefinition[]>(
|
const sources = ref<QqbotMessagePushApi.SystemMessageSourceDefinition[]>(
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const stunOptions =
|
|
||||||
ref<QqbotMessagePushApi.StunMappingPortChangedOptionsResponse>();
|
|
||||||
const columns: Array<
|
const columns: Array<
|
||||||
TableColumnType<QqbotMessagePushApi.MessageSubscriptionView>
|
TableColumnType<QqbotMessagePushApi.MessageSubscriptionView>
|
||||||
> = [
|
> = [
|
||||||
@ -166,20 +163,24 @@ export default defineComponent({
|
|||||||
tableTitle: '消息订阅',
|
tableTitle: '消息订阅',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 打开新建订阅弹窗。 */
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
modalRef.value?.openCreate();
|
modalRef.value?.openCreate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开指定订阅的编辑弹窗。 */
|
||||||
function openEdit(row: QqbotMessagePushApi.MessageSubscriptionView) {
|
function openEdit(row: QqbotMessagePushApi.MessageSubscriptionView) {
|
||||||
modalRef.value?.openEdit(row);
|
modalRef.value?.openEdit(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 生成删除订阅确认文案。 */
|
||||||
function getDeleteConfirm(
|
function getDeleteConfirm(
|
||||||
row: QqbotMessagePushApi.MessageSubscriptionView,
|
row: QqbotMessagePushApi.MessageSubscriptionView,
|
||||||
) {
|
) {
|
||||||
return `确认删除消息订阅「${row.name}」吗?`;
|
return `确认删除消息订阅「${row.name}」吗?`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 切换订阅启用状态并刷新当前表格上下文。 */
|
||||||
async function handleToggle(
|
async function handleToggle(
|
||||||
row: QqbotMessagePushApi.MessageSubscriptionView,
|
row: QqbotMessagePushApi.MessageSubscriptionView,
|
||||||
context: KtTableContext<QqbotMessagePushApi.MessageSubscriptionView>,
|
context: KtTableContext<QqbotMessagePushApi.MessageSubscriptionView>,
|
||||||
@ -188,6 +189,7 @@ export default defineComponent({
|
|||||||
await context.reload();
|
await context.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 删除订阅并刷新当前表格上下文。 */
|
||||||
async function handleDelete(
|
async function handleDelete(
|
||||||
row: QqbotMessagePushApi.MessageSubscriptionView,
|
row: QqbotMessagePushApi.MessageSubscriptionView,
|
||||||
context: KtTableContext<QqbotMessagePushApi.MessageSubscriptionView>,
|
context: KtTableContext<QqbotMessagePushApi.MessageSubscriptionView>,
|
||||||
@ -196,24 +198,23 @@ export default defineComponent({
|
|||||||
await context.reload();
|
await context.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在弹窗保存成功后刷新订阅列表。 */
|
||||||
async function handleModalSaved() {
|
async function handleModalSaved() {
|
||||||
await tableApi.reload();
|
await tableApi.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMetadata() {
|
/** 加载通用系统消息源目录。 */
|
||||||
const [nextSources, nextStunOptions] = await Promise.all([
|
async function loadSources() {
|
||||||
getMessagePushSources(),
|
sources.value = await getMessagePushSources();
|
||||||
getStunMappingPortChangedOptions(),
|
|
||||||
]);
|
|
||||||
sources.value = nextSources;
|
|
||||||
stunOptions.value = nextStunOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 首次进入页面时并行加载列表与消息源目录。 */
|
||||||
async function activatePage() {
|
async function activatePage() {
|
||||||
if (!canList) return;
|
if (!canList) return;
|
||||||
await Promise.all([tableApi.reload(), loadMetadata()]);
|
await Promise.all([tableApi.reload(), loadSources()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 渲染订阅表格的自定义单元格。 */
|
||||||
function renderBodyCell(slot: {
|
function renderBodyCell(slot: {
|
||||||
column: TableColumnType<QqbotMessagePushApi.MessageSubscriptionView>;
|
column: TableColumnType<QqbotMessagePushApi.MessageSubscriptionView>;
|
||||||
record: QqbotMessagePushApi.MessageSubscriptionView;
|
record: QqbotMessagePushApi.MessageSubscriptionView;
|
||||||
@ -249,7 +250,6 @@ export default defineComponent({
|
|||||||
onSaved={handleModalSaved}
|
onSaved={handleModalSaved}
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
sources={sources.value}
|
sources={sources.value}
|
||||||
stunOptions={stunOptions.value}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
1
internal/tsconfig/types/vben-web/index.d.ts
vendored
1
internal/tsconfig/types/vben-web/index.d.ts
vendored
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable spaced-comment -- TypeScript 三斜线引用指令要求连续斜线。 */
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
declare module '*.vue' {
|
declare module '*.vue' {
|
||||||
|
|||||||
28
package.json
28
package.json
@ -8,15 +8,15 @@
|
|||||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 turbo build",
|
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 turbo build",
|
||||||
"build:analyze": "turbo build:analyze",
|
"build:analyze": "turbo build:analyze",
|
||||||
"build:antdv-next": "pnpm run build --filter=@vben/web-antdv-next",
|
"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": "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: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",
|
"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": "pnpm run dev:antdv-next",
|
||||||
"dev:antdv-next": "pnpm -F @vben/web-antdv-next run dev",
|
"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",
|
"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 --cache && prettier . --ignore-unknown --check --cache && stylelint \"**/*.{vue,css,less,scss}\" --cache",
|
"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",
|
"lint:commit": "eslint apps/web-antdv-next internal/commit/validate-commit-msg.mjs --cache",
|
||||||
"postinstall": "pnpm -r run stub --if-present",
|
"postinstall": "pnpm -r run stub --if-present",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@ -26,13 +26,23 @@
|
|||||||
"verify:staged": "node internal/commit/husky-fast-check.mjs",
|
"verify:staged": "node internal/commit/husky-fast-check.mjs",
|
||||||
"verify:commit": "pnpm run lint:commit && pnpm -F @vben/web-antdv-next run typecheck",
|
"verify:commit": "pnpm run lint:commit && pnpm -F @vben/web-antdv-next run typecheck",
|
||||||
"test:e2e": "turbo run test:e2e",
|
"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": {
|
"devDependencies": {
|
||||||
"@playwright/test": "catalog:",
|
"@playwright/test": "catalog:",
|
||||||
|
"@tiptap/vue-3": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
"@types/sortablejs": "catalog:",
|
||||||
|
"@vben-core/shared": "workspace:*",
|
||||||
|
"@vben/access": "workspace:*",
|
||||||
|
"@vben/common-ui": "workspace:*",
|
||||||
"@vben/eslint-config": "workspace:*",
|
"@vben/eslint-config": "workspace:*",
|
||||||
|
"@vben/icons": "workspace:*",
|
||||||
|
"@vben/locales": "workspace:*",
|
||||||
"@vben/prettier-config": "workspace:*",
|
"@vben/prettier-config": "workspace:*",
|
||||||
|
"@vben/stores": "workspace:*",
|
||||||
"@vben/stylelint-config": "workspace:*",
|
"@vben/stylelint-config": "workspace:*",
|
||||||
"@vben/tailwind-config": "workspace:*",
|
"@vben/tailwind-config": "workspace:*",
|
||||||
"@vben/tsconfig": "workspace:*",
|
"@vben/tsconfig": "workspace:*",
|
||||||
@ -40,12 +50,19 @@
|
|||||||
"@vitejs/plugin-vue": "catalog:",
|
"@vitejs/plugin-vue": "catalog:",
|
||||||
"@vitejs/plugin-vue-jsx": "catalog:",
|
"@vitejs/plugin-vue-jsx": "catalog:",
|
||||||
"@vue/test-utils": "catalog:",
|
"@vue/test-utils": "catalog:",
|
||||||
|
"@vueuse/core": "catalog:",
|
||||||
|
"antdv-next": "catalog:",
|
||||||
"autoprefixer": "catalog:",
|
"autoprefixer": "catalog:",
|
||||||
|
"axios": "catalog:",
|
||||||
|
"axios-mock-adapter": "catalog:",
|
||||||
"cross-env": "catalog:",
|
"cross-env": "catalog:",
|
||||||
|
"dayjs": "catalog:",
|
||||||
"happy-dom": "catalog:",
|
"happy-dom": "catalog:",
|
||||||
"husky": "catalog:",
|
"husky": "catalog:",
|
||||||
|
"pinia": "catalog:",
|
||||||
"playwright": "catalog:",
|
"playwright": "catalog:",
|
||||||
"rimraf": "catalog:",
|
"rimraf": "catalog:",
|
||||||
|
"sortablejs": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"turbo": "catalog:",
|
"turbo": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
@ -53,6 +70,7 @@
|
|||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vitest": "catalog:",
|
"vitest": "catalog:",
|
||||||
"vue": "catalog:",
|
"vue": "catalog:",
|
||||||
|
"vue-router": "catalog:",
|
||||||
"vue-tsc": "catalog:"
|
"vue-tsc": "catalog:"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/tsconfig",
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
"extends": "@vben/tsconfig/web.json",
|
"extends": "@vben/tsconfig/web.json",
|
||||||
"include": ["src", "__tests__"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/tsconfig",
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
"extends": "@vben/tsconfig/web.json",
|
"extends": "@vben/tsconfig/web.json",
|
||||||
"include": ["src", "__tests__"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -485,15 +485,39 @@ importers:
|
|||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.58.2
|
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':
|
'@types/node':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 24.10.12
|
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':
|
'@vben/eslint-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:internal/lint-configs/eslint-config
|
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':
|
'@vben/prettier-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:internal/lint-configs/prettier-config
|
version: link:internal/lint-configs/prettier-config
|
||||||
|
'@vben/stores':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:packages/stores
|
||||||
'@vben/stylelint-config':
|
'@vben/stylelint-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:internal/lint-configs/stylelint-config
|
version: link:internal/lint-configs/stylelint-config
|
||||||
@ -515,24 +539,45 @@ importers:
|
|||||||
'@vue/test-utils':
|
'@vue/test-utils':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 2.4.6
|
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:
|
autoprefixer:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 10.4.24(postcss@8.5.6)
|
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:
|
cross-env:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
|
dayjs:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 1.11.19
|
||||||
happy-dom:
|
happy-dom:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 17.6.3
|
version: 17.6.3
|
||||||
husky:
|
husky:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 9.1.7
|
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:
|
playwright:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.58.2
|
version: 1.58.2
|
||||||
rimraf:
|
rimraf:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 6.1.2
|
version: 6.1.2
|
||||||
|
sortablejs:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 1.15.6
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.4.19(yaml@2.8.2)
|
version: 3.4.19(yaml@2.8.2)
|
||||||
@ -554,6 +599,9 @@ importers:
|
|||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.27
|
specifier: ^3.5.27
|
||||||
version: 3.5.27(typescript@5.9.3)
|
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:
|
vue-tsc:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.2.4(typescript@5.9.3)
|
version: 3.2.4(typescript@5.9.3)
|
||||||
|
|||||||
@ -40,7 +40,7 @@ import { defineOverridesPreferences } from '@vben/preferences';
|
|||||||
|
|
||||||
export const overridesPreferences = defineOverridesPreferences({
|
export const overridesPreferences = defineOverridesPreferences({
|
||||||
app: {
|
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`。
|
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';
|
import { setI18nLanguage } from '@vben/locales';
|
||||||
|
|
||||||
async function loadLocaleMessages(locale: string) {
|
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);
|
setI18nLanguage(locale, messages);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -130,7 +132,7 @@ async function loadLocaleMessages(locale: string) {
|
|||||||
```ts
|
```ts
|
||||||
export const overridesPreferences = defineOverridesPreferences({
|
export const overridesPreferences = defineOverridesPreferences({
|
||||||
widget: {
|
widget: {
|
||||||
languageToggle: true, // 显示语言切换按钮
|
languageToggle: true, // 显示语言切换按钮
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
/* @vitest-environment happy-dom */
|
/* @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 { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { buildKtBlogPreviewUrl, resolveKtBlogWebBaseUrl } from './preview-url';
|
|
||||||
|
|
||||||
describe('blog preview url helpers', () => {
|
describe('blog preview url helpers', () => {
|
||||||
it('builds a KT Blog Web preview URL with encoded slug and trace params', () => {
|
it('builds a KT Blog Web preview URL with encoded slug and trace params', () => {
|
||||||
const url = buildKtBlogPreviewUrl(
|
const url = buildKtBlogPreviewUrl(
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
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 ts from 'typescript';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
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();
|
const menus = await getAllMenusApi();
|
||||||
|
|
||||||
expect(requestClientGet).toHaveBeenCalledWith('/menu/all');
|
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();
|
const menus = await getAllMenusApi();
|
||||||
|
|
||||||
expect(menus.map((menu) => menu.meta?.order)).toEqual([200, 1, 0]);
|
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();
|
const menus = await getAllMenusApi();
|
||||||
|
|
||||||
expect(menus).toEqual([
|
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();
|
const menus = await getAllMenusApi();
|
||||||
|
|
||||||
expect(menus).toEqual([
|
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 menus = await getAllMenusApi();
|
||||||
const qqbot = menus.find((menu) => menu.name === 'QqBot');
|
const qqbot = menus.find((menu) => menu.name === 'QqBot');
|
||||||
const retainedNames = qqbot?.children?.map((child) => child.name) ?? [];
|
const retainedNames = qqbot?.children?.map((child) => child.name) ?? [];
|
||||||
@ -1,9 +1,10 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
const readApiFile = (name: string) =>
|
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', () => {
|
describe('qqbot core API caller boundary', () => {
|
||||||
it('keeps plugin platform and NapCat scan routes out of the core caller', () => {
|
it('keeps plugin platform and NapCat scan routes out of the core caller', () => {
|
||||||
@ -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';
|
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 * as messagePushApi 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';
|
|
||||||
|
|
||||||
import * as messagePushApi from './message-push';
|
|
||||||
import {
|
import {
|
||||||
createAccountMessagePushBinding,
|
createAccountMessagePushBinding,
|
||||||
createMessageSubscription,
|
createMessageSubscription,
|
||||||
@ -19,6 +13,7 @@ import {
|
|||||||
getAccountMessagePushBindings,
|
getAccountMessagePushBindings,
|
||||||
getAccountMessagePushTargets,
|
getAccountMessagePushTargets,
|
||||||
getMessagePushSourceDetail,
|
getMessagePushSourceDetail,
|
||||||
|
getMessagePushSourceOptions,
|
||||||
getMessagePushSources,
|
getMessagePushSources,
|
||||||
getMessageSubscriptionList,
|
getMessageSubscriptionList,
|
||||||
getMessageTemplateList,
|
getMessageTemplateList,
|
||||||
@ -30,7 +25,11 @@ import {
|
|||||||
updateAccountMessagePushBinding,
|
updateAccountMessagePushBinding,
|
||||||
updateMessageSubscription,
|
updateMessageSubscription,
|
||||||
updateMessageTemplate,
|
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<Actual, Expected> =
|
type Equal<Actual, Expected> =
|
||||||
(<Value>() => Value extends Actual ? 1 : 2) extends <
|
(<Value>() => Value extends Actual ? 1 : 2) extends <
|
||||||
@ -53,8 +52,8 @@ type ExpectedSystemMessageSourceFieldDefinition = {
|
|||||||
dependsOn?: string;
|
dependsOn?: string;
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
optionCollection: 'ddnsRecords' | 'portForwards';
|
optionCollection: string;
|
||||||
required: true;
|
required: boolean;
|
||||||
type: 'select';
|
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 = {
|
type ExpectedMessageSubscriptionView = {
|
||||||
createTime: string;
|
createTime: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -94,7 +106,7 @@ type ExpectedMessageSubscriptionView = {
|
|||||||
invalidReasonCode: null | string;
|
invalidReasonCode: null | string;
|
||||||
name: string;
|
name: string;
|
||||||
remark: null | string;
|
remark: null | string;
|
||||||
sourceConfig: { ddnsRecordId: string; portForwardId: string };
|
sourceConfig: Record<string, string>;
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
sourceName: string;
|
sourceName: string;
|
||||||
sourceSummary: string;
|
sourceSummary: string;
|
||||||
@ -114,7 +126,7 @@ type ExpectedMessageSubscriptionInput = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
sourceConfig: Record<string, unknown>;
|
sourceConfig: Record<string, string>;
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -226,6 +238,18 @@ type MessagePushTypeContracts = [
|
|||||||
ExpectedStunMappingPortChangedOptionsResponse
|
ExpectedStunMappingPortChangedOptionsResponse
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.SystemMessageSourceOptionDefinition,
|
||||||
|
ExpectedSystemMessageSourceOptionDefinition
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.SystemMessageSourceOptionsResponse,
|
||||||
|
ExpectedSystemMessageSourceOptionsResponse
|
||||||
|
>
|
||||||
|
>,
|
||||||
Assert<
|
Assert<
|
||||||
Equal<
|
Equal<
|
||||||
QqbotMessagePushApi.MessageSubscriptionView,
|
QqbotMessagePushApi.MessageSubscriptionView,
|
||||||
@ -338,6 +362,15 @@ type MessagePushTypeContracts = [
|
|||||||
Promise<ExpectedSystemMessageSourceDefinition>
|
Promise<ExpectedSystemMessageSourceDefinition>
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<Parameters<typeof getMessagePushSourceOptions>, [sourceKey: string]>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof getMessagePushSourceOptions>,
|
||||||
|
Promise<ExpectedSystemMessageSourceOptionsResponse>
|
||||||
|
>
|
||||||
|
>,
|
||||||
Assert<Equal<Parameters<typeof getStunMappingPortChangedOptions>, []>>,
|
Assert<Equal<Parameters<typeof getStunMappingPortChangedOptions>, []>>,
|
||||||
Assert<
|
Assert<
|
||||||
Equal<
|
Equal<
|
||||||
@ -536,6 +569,7 @@ const messagePushCallerNames = [
|
|||||||
'getAccountMessagePushBindings',
|
'getAccountMessagePushBindings',
|
||||||
'getAccountMessagePushTargets',
|
'getAccountMessagePushTargets',
|
||||||
'getMessagePushSourceDetail',
|
'getMessagePushSourceDetail',
|
||||||
|
'getMessagePushSourceOptions',
|
||||||
'getMessagePushSources',
|
'getMessagePushSources',
|
||||||
'getMessageSubscriptionList',
|
'getMessageSubscriptionList',
|
||||||
'getMessageTemplateList',
|
'getMessageTemplateList',
|
||||||
@ -549,10 +583,11 @@ const messagePushCallerNames = [
|
|||||||
'updateMessageTemplate',
|
'updateMessageTemplate',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 使用工作区 TypeScript 配置检查消息推送公开契约。 */
|
||||||
function getMessagePushContractDiagnostics() {
|
function getMessagePushContractDiagnostics() {
|
||||||
const appRoot = resolve('apps/web-antdv-next');
|
const appRoot = resolve('apps/web-antdv-next');
|
||||||
const configPath = resolve(appRoot, 'tsconfig.json');
|
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 =
|
const virtualRequestModulePath =
|
||||||
'/__qqbot-message-push-contract__/request.ts';
|
'/__qqbot-message-push-contract__/request.ts';
|
||||||
const virtualRequestModuleSource = `
|
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(
|
return ts.resolveModuleName(
|
||||||
moduleName,
|
moduleName,
|
||||||
containingFile,
|
containingFile,
|
||||||
@ -688,6 +732,7 @@ describe('qqbot message push api', () => {
|
|||||||
|
|
||||||
await getMessagePushSources();
|
await getMessagePushSources();
|
||||||
await getMessagePushSourceDetail(sourceKey);
|
await getMessagePushSourceDetail(sourceKey);
|
||||||
|
await getMessagePushSourceOptions(sourceKey);
|
||||||
await getStunMappingPortChangedOptions();
|
await getStunMappingPortChangedOptions();
|
||||||
await getMessageSubscriptionList(subscriptionQuery);
|
await getMessageSubscriptionList(subscriptionQuery);
|
||||||
await createMessageSubscription(subscriptionInput);
|
await createMessageSubscription(subscriptionInput);
|
||||||
@ -714,15 +759,19 @@ describe('qqbot message push api', () => {
|
|||||||
);
|
);
|
||||||
expect(requestClient.get).toHaveBeenNthCalledWith(
|
expect(requestClient.get).toHaveBeenNthCalledWith(
|
||||||
3,
|
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(
|
expect(requestClient.get).toHaveBeenNthCalledWith(
|
||||||
4,
|
4,
|
||||||
|
'/qqbot/message-push/sources/network.stun.mapping-port-changed/options',
|
||||||
|
);
|
||||||
|
expect(requestClient.get).toHaveBeenNthCalledWith(
|
||||||
|
5,
|
||||||
'/qqbot/message-push/subscriptions',
|
'/qqbot/message-push/subscriptions',
|
||||||
{ params: subscriptionQuery },
|
{ params: subscriptionQuery },
|
||||||
);
|
);
|
||||||
expect(requestClient.get).toHaveBeenNthCalledWith(
|
expect(requestClient.get).toHaveBeenNthCalledWith(
|
||||||
5,
|
6,
|
||||||
'/qqbot/message-push/templates',
|
'/qqbot/message-push/templates',
|
||||||
{ params: templateQuery },
|
{ 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(
|
expect(Object.keys(messagePushApi).toSorted()).toEqual(
|
||||||
messagePushCallerNames,
|
messagePushCallerNames,
|
||||||
);
|
);
|
||||||
@ -1,7 +1,3 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { requestClient } from '#/api/request';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cancelQqbotAccountScan,
|
cancelQqbotAccountScan,
|
||||||
createQqbotNapcatWebuiSession,
|
createQqbotNapcatWebuiSession,
|
||||||
@ -19,7 +15,10 @@ import {
|
|||||||
startQqbotAccountScanCreate,
|
startQqbotAccountScanCreate,
|
||||||
startQqbotAccountScanRefresh,
|
startQqbotAccountScanRefresh,
|
||||||
submitQqbotAccountScanCaptcha,
|
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', () => ({
|
vi.mock('#/api/request', () => ({
|
||||||
requestClient: {
|
requestClient: {
|
||||||
@ -1,7 +1,3 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { requestClient } from '#/api/request';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
disableQqbotPluginTask,
|
disableQqbotPluginTask,
|
||||||
enableQqbotPluginTask,
|
enableQqbotPluginTask,
|
||||||
@ -9,7 +5,10 @@ import {
|
|||||||
getQqbotPluginTaskRunPage,
|
getQqbotPluginTaskRunPage,
|
||||||
runQqbotPluginTaskOnce,
|
runQqbotPluginTaskOnce,
|
||||||
updateQqbotPluginTaskCron,
|
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', () => ({
|
vi.mock('#/api/request', () => ({
|
||||||
requestClient: {
|
requestClient: {
|
||||||
@ -1,7 +1,3 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { requestClient } from '#/api/request';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
enableQqbotPluginInstallation,
|
enableQqbotPluginInstallation,
|
||||||
getQqbotPluginAccountBindings,
|
getQqbotPluginAccountBindings,
|
||||||
@ -11,7 +7,10 @@ import {
|
|||||||
installLocalQqbotPluginPackage,
|
installLocalQqbotPluginPackage,
|
||||||
uploadQqbotPluginPackage,
|
uploadQqbotPluginPackage,
|
||||||
validateQqbotPluginManifest,
|
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', () => ({
|
vi.mock('#/api/request', () => ({
|
||||||
requestClient: {
|
requestClient: {
|
||||||
@ -1,12 +1,11 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { requestClient } from '#/api/request';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getEnvironmentDashboard,
|
getEnvironmentDashboard,
|
||||||
getEnvironmentDashboardEventsUrl,
|
getEnvironmentDashboardEventsUrl,
|
||||||
runEnvironmentSelfCheck,
|
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', () => ({
|
vi.mock('#/api/request', () => ({
|
||||||
requestClient: {
|
requestClient: {
|
||||||
@ -1,7 +1,3 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { requestClient } from '#/api/request';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createNetworkDdnsRecord,
|
createNetworkDdnsRecord,
|
||||||
createNetworkPortForward,
|
createNetworkPortForward,
|
||||||
@ -21,7 +17,10 @@ import {
|
|||||||
retryNetworkPortForward,
|
retryNetworkPortForward,
|
||||||
updateNetworkDdnsRecord,
|
updateNetworkDdnsRecord,
|
||||||
updateNetworkPortForward,
|
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', () => ({
|
vi.mock('#/api/request', () => ({
|
||||||
requestClient: {
|
requestClient: {
|
||||||
@ -5,10 +5,9 @@
|
|||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
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 { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { useKtTableActions } from './useKtTableActions';
|
|
||||||
|
|
||||||
vi.mock('antdv-next', () => ({
|
vi.mock('antdv-next', () => ({
|
||||||
Button: defineComponent({
|
Button: defineComponent({
|
||||||
name: 'MockButton',
|
name: 'MockButton',
|
||||||
@ -1,13 +1,15 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
const sourceRoot = path.resolve('apps/web-antdv-next/src/components/markdown');
|
||||||
const source = readFileSync(path.join(dirname, 'KtMilkdownEditor.tsx'), 'utf8');
|
const source = readFileSync(
|
||||||
|
path.join(sourceRoot, 'KtMilkdownEditor.tsx'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
const styleSource = readFileSync(
|
const styleSource = readFileSync(
|
||||||
path.join(dirname, 'KtMilkdownEditor.scss'),
|
path.join(sourceRoot, 'KtMilkdownEditor.scss'),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -6,10 +6,9 @@ import type { ComponentPublicInstance, Ref } from 'vue';
|
|||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import KtTiptapHtmlEditor from './KtTiptapHtmlEditor';
|
|
||||||
|
|
||||||
type FakeEditorOptions = {
|
type FakeEditorOptions = {
|
||||||
content?: string;
|
content?: string;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
57
test/governance/test-location.test.ts
Normal file
57
test/governance/test-location.test.ts
Normal file
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -9,19 +9,18 @@ import {
|
|||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import process from 'node:process';
|
||||||
import { runInNewContext } from 'node:vm';
|
import { runInNewContext } from 'node:vm';
|
||||||
|
|
||||||
import { it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
applyJsdocPolicyFix,
|
applyJsdocPolicyFix,
|
||||||
getCodeTokenSignature,
|
getCodeTokenSignature,
|
||||||
getTrackedCodeFiles,
|
getTrackedCodeFiles,
|
||||||
inspectFileSource,
|
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 = {}) {
|
function runFixtureGit(repository, args, extraEnvironment = {}) {
|
||||||
const environment = Object.fromEntries(
|
const environment = Object.fromEntries(
|
||||||
@ -1,9 +1,8 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
import { generatorContentHash } from '@test-source/internal/node-utils/src/hash';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { generatorContentHash } from '../hash';
|
|
||||||
|
|
||||||
describe('generatorContentHash', () => {
|
describe('generatorContentHash', () => {
|
||||||
it('should generate an MD5 hash for the content', () => {
|
it('should generate an MD5 hash for the content', () => {
|
||||||
const content = 'example content';
|
const content = 'example content';
|
||||||
@ -1,9 +1,8 @@
|
|||||||
// pathUtils.test.ts
|
// pathUtils.test.ts
|
||||||
|
|
||||||
|
import { toPosixPath } from '@test-source/internal/node-utils/src/path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { toPosixPath } from '../path';
|
|
||||||
|
|
||||||
describe('toPosixPath', () => {
|
describe('toPosixPath', () => {
|
||||||
// 测试 Windows 风格路径到 POSIX 风格路径的转换
|
// 测试 Windows 风格路径到 POSIX 风格路径的转换
|
||||||
it('converts Windows-style paths to POSIX paths', () => {
|
it('converts Windows-style paths to POSIX paths', () => {
|
||||||
@ -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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { StorageManager } from '../storage-manager';
|
|
||||||
|
|
||||||
describe('storageManager', () => {
|
describe('storageManager', () => {
|
||||||
let storageManager: StorageManager;
|
let storageManager: StorageManager;
|
||||||
|
|
||||||
@ -1,11 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
convertToHsl,
|
convertToHsl,
|
||||||
convertToHslCssVar,
|
convertToHslCssVar,
|
||||||
convertToRgb,
|
convertToRgb,
|
||||||
isValidColor,
|
isValidColor,
|
||||||
} from '../convert';
|
} from '@test-source/packages/@core/base/shared/src/color/convert';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
describe('color conversion functions', () => {
|
describe('color conversion functions', () => {
|
||||||
it('should correctly convert color to HSL format', () => {
|
it('should correctly convert color to HSL format', () => {
|
||||||
@ -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 {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
@ -11,7 +6,11 @@ import {
|
|||||||
isDate,
|
isDate,
|
||||||
isDayjsObject,
|
isDayjsObject,
|
||||||
setCurrentTimezone,
|
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(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
@ -1,7 +1,6 @@
|
|||||||
|
import { diff } from '@test-source/packages/@core/base/shared/src/utils/diff';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { diff } from '../diff';
|
|
||||||
|
|
||||||
describe('diff function', () => {
|
describe('diff function', () => {
|
||||||
it('should return an empty object when comparing identical objects', () => {
|
it('should return an empty object when comparing identical objects', () => {
|
||||||
const obj1 = { a: 1, b: { c: 2 } };
|
const obj1 = { a: 1, b: { c: 2 } };
|
||||||
@ -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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { getElementVisibleRect } from '../dom';
|
|
||||||
|
|
||||||
describe('getElementVisibleRect', () => {
|
describe('getElementVisibleRect', () => {
|
||||||
// 设置浏览器视口尺寸的 mock
|
// 设置浏览器视口尺寸的 mock
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -1,5 +1,3 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getFirstNonNullOrUndefined,
|
getFirstNonNullOrUndefined,
|
||||||
isBoolean,
|
isBoolean,
|
||||||
@ -8,7 +6,8 @@ import {
|
|||||||
isObject,
|
isObject,
|
||||||
isUndefined,
|
isUndefined,
|
||||||
isWindow,
|
isWindow,
|
||||||
} from '../inference';
|
} from '@test-source/packages/@core/base/shared/src/utils/inference';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
describe('isHttpUrl', () => {
|
describe('isHttpUrl', () => {
|
||||||
it("should return true when given 'http://example.com'", () => {
|
it("should return true when given 'http://example.com'", () => {
|
||||||
@ -1,11 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
capitalizeFirstLetter,
|
capitalizeFirstLetter,
|
||||||
kebabToCamelCase,
|
kebabToCamelCase,
|
||||||
toCamelCase,
|
toCamelCase,
|
||||||
toLowerCaseFirstLetter,
|
toLowerCaseFirstLetter,
|
||||||
} from '../letter';
|
} from '@test-source/packages/@core/base/shared/src/utils/letter';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
describe('capitalizeFirstLetter', () => {
|
describe('capitalizeFirstLetter', () => {
|
||||||
it('should capitalize the first letter of a string', () => {
|
it('should capitalize the first letter of a string', () => {
|
||||||
@ -1,7 +1,6 @@
|
|||||||
|
import { loadScript } from '@test-source/packages/@core/base/shared/src/utils/resources';
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { loadScript } from '../resources';
|
|
||||||
|
|
||||||
const testJsPath =
|
const testJsPath =
|
||||||
'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js';
|
'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js';
|
||||||
|
|
||||||
@ -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 { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { createStack, Stack } from '../stack';
|
|
||||||
|
|
||||||
describe('stack', () => {
|
describe('stack', () => {
|
||||||
let stack: Stack<number>;
|
let stack: Stack<number>;
|
||||||
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
|
import { StateHandler } from '@test-source/packages/@core/base/shared/src/utils/state-handler';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { StateHandler } from '../state-handler';
|
|
||||||
|
|
||||||
describe('stateHandler', () => {
|
describe('stateHandler', () => {
|
||||||
it('should resolve when condition is set to true', async () => {
|
it('should resolve when condition is set to true', async () => {
|
||||||
const handler = new StateHandler();
|
const handler = new StateHandler();
|
||||||
@ -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 { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { filterTree, mapTree, traverseTreeValues } from '../tree';
|
|
||||||
|
|
||||||
describe('traverseTreeValues', () => {
|
describe('traverseTreeValues', () => {
|
||||||
interface Node {
|
interface Node {
|
||||||
children?: Node[];
|
children?: Node[];
|
||||||
@ -1,7 +1,6 @@
|
|||||||
|
import { uniqueByField } from '@test-source/packages/@core/base/shared/src/utils/unique';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { uniqueByField } from '../unique';
|
|
||||||
|
|
||||||
describe('uniqueByField', () => {
|
describe('uniqueByField', () => {
|
||||||
it('should return an array with unique items based on id field', () => {
|
it('should return an array with unique items based on id field', () => {
|
||||||
const items = [
|
const items = [
|
||||||
@ -1,7 +1,6 @@
|
|||||||
|
import { updateCSSVariables } from '@test-source/packages/@core/base/shared/src/utils/update-css-variables';
|
||||||
import { expect, it } from 'vitest';
|
import { expect, it } from 'vitest';
|
||||||
|
|
||||||
import { updateCSSVariables } from '../update-css-variables';
|
|
||||||
|
|
||||||
it('updateCSSVariables should update CSS variables in :root selector', () => {
|
it('updateCSSVariables should update CSS variables in :root selector', () => {
|
||||||
// 模拟初始的内联样式表内容
|
// 模拟初始的内联样式表内容
|
||||||
const initialStyleContent = ':root { --primaryColor: red; }';
|
const initialStyleContent = ':root { --primaryColor: red; }';
|
||||||
@ -1,7 +1,9 @@
|
|||||||
|
import {
|
||||||
|
bindMethods,
|
||||||
|
getNestedValue,
|
||||||
|
} from '@test-source/packages/@core/base/shared/src/utils/util';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { bindMethods, getNestedValue } from '../util';
|
|
||||||
|
|
||||||
class TestClass {
|
class TestClass {
|
||||||
public value: string;
|
public value: string;
|
||||||
|
|
||||||
@ -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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { openWindow } from '../window';
|
|
||||||
|
|
||||||
describe('openWindow', () => {
|
describe('openWindow', () => {
|
||||||
// 保存原始的 window.open 函数
|
// 保存原始的 window.open 函数
|
||||||
let originalOpen: typeof window.open;
|
let originalOpen: typeof window.open;
|
||||||
@ -1,9 +1,8 @@
|
|||||||
import type { SortableOptions } from 'sortablejs';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { useSortable } from '../use-sortable';
|
|
||||||
|
|
||||||
describe('useSortable', () => {
|
describe('useSortable', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mock('sortablejs/modular/sortable.complete.esm.js', () => ({
|
vi.mock('sortablejs/modular/sortable.complete.esm.js', () => ({
|
||||||
@ -1,7 +1,6 @@
|
|||||||
|
import { defaultPreferences } from '@test-source/packages/@core/preferences/src/config';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { defaultPreferences } from '../src/config';
|
|
||||||
|
|
||||||
describe('defaultPreferences immutability test', () => {
|
describe('defaultPreferences immutability test', () => {
|
||||||
// 创建快照,确保默认配置对象不被修改
|
// 创建快照,确保默认配置对象不被修改
|
||||||
it('should not modify the config object', () => {
|
it('should not modify the config object', () => {
|
||||||
@ -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 { 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', () => {
|
describe('preferences', () => {
|
||||||
let preferenceManager: PreferenceManager;
|
let preferenceManager: PreferenceManager;
|
||||||
|
|
||||||
@ -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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { FormApi } from '../src/form-api';
|
|
||||||
|
|
||||||
describe('formApi', () => {
|
describe('formApi', () => {
|
||||||
let formApi: FormApi;
|
let formApi: FormApi;
|
||||||
const componentRefMap = () => new Map<string, unknown>();
|
const componentRefMap = () => new Map<string, unknown>();
|
||||||
@ -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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { DrawerApi } from '../drawer-api';
|
|
||||||
|
|
||||||
// 模拟 Store 类
|
// 模拟 Store 类
|
||||||
vi.mock('@vben-core/shared/store', () => {
|
vi.mock('@vben-core/shared/store', () => {
|
||||||
return {
|
return {
|
||||||
@ -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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ModalApi } from '../modal-api';
|
|
||||||
|
|
||||||
vi.mock('@vben-core/shared/store', () => {
|
vi.mock('@vben-core/shared/store', () => {
|
||||||
return {
|
return {
|
||||||
isFunction: (fn: any) => typeof fn === 'function',
|
isFunction: (fn: any) => typeof fn === 'function',
|
||||||
@ -1,9 +1,8 @@
|
|||||||
import { mount } from '@vue/test-utils';
|
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 { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { Page } from '..';
|
|
||||||
|
|
||||||
describe('page.vue', () => {
|
describe('page.vue', () => {
|
||||||
it('renders title when passed', () => {
|
it('renders title when passed', () => {
|
||||||
const wrapper = mount(Page, {
|
const wrapper = mount(Page, {
|
||||||
@ -1,9 +1,8 @@
|
|||||||
import type { AxiosRequestConfig } from 'axios';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { FileDownloader } from './downloader';
|
|
||||||
|
|
||||||
describe('fileDownloader', () => {
|
describe('fileDownloader', () => {
|
||||||
let fileDownloader: FileDownloader;
|
let fileDownloader: FileDownloader;
|
||||||
const mockAxiosInstance = {
|
const mockAxiosInstance = {
|
||||||
@ -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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { SSE } from './sse';
|
|
||||||
|
|
||||||
// 模拟 TextDecoder
|
// 模拟 TextDecoder
|
||||||
const OriginalTextDecoder = globalThis.TextDecoder;
|
const OriginalTextDecoder = globalThis.TextDecoder;
|
||||||
|
|
||||||
@ -1,9 +1,8 @@
|
|||||||
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { FileUploader } from './uploader';
|
|
||||||
|
|
||||||
describe('fileUploader', () => {
|
describe('fileUploader', () => {
|
||||||
let fileUploader: FileUploader;
|
let fileUploader: FileUploader;
|
||||||
// Mock the AxiosInstance
|
// Mock the AxiosInstance
|
||||||
@ -1,11 +1,10 @@
|
|||||||
import type { AxiosResponse } from 'axios';
|
import type { AxiosResponse } from 'axios';
|
||||||
|
|
||||||
|
import { RequestClient } from '@test-source/packages/effects/request/src/request-client/request-client';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { RequestClient } from './request-client';
|
|
||||||
|
|
||||||
describe('requestClient', () => {
|
describe('requestClient', () => {
|
||||||
let mock: MockAdapter;
|
let mock: MockAdapter;
|
||||||
let requestClient: RequestClient;
|
let requestClient: RequestClient;
|
||||||
@ -1,8 +1,7 @@
|
|||||||
|
import { useAccessStore } from '@test-source/packages/stores/src/modules/access';
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { useAccessStore } from './access';
|
|
||||||
|
|
||||||
describe('useAccessStore', () => {
|
describe('useAccessStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia());
|
setActivePinia(createPinia());
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
|
||||||
|
import { useTabbarStore } from '@test-source/packages/stores/src/modules/tabbar';
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { useTabbarStore } from './tabbar';
|
|
||||||
|
|
||||||
describe('useAccessStore', () => {
|
describe('useAccessStore', () => {
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@ -1,8 +1,7 @@
|
|||||||
|
import { useUserStore } from '@test-source/packages/stores/src/modules/user';
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { useUserStore } from './user';
|
|
||||||
|
|
||||||
describe('useUserStore', () => {
|
describe('useUserStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia());
|
setActivePinia(createPinia());
|
||||||
@ -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 { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { findMenuByPath, findRootMenuByPath } from '../find-menu-by-path';
|
|
||||||
|
|
||||||
// 示例菜单数据
|
// 示例菜单数据
|
||||||
const menus: any[] = [
|
const menus: any[] = [
|
||||||
{ path: '/', children: [] },
|
{ path: '/', children: [] },
|
||||||
@ -2,10 +2,9 @@ import type { Router, RouteRecordRaw } from 'vue-router';
|
|||||||
|
|
||||||
import { createRouter, createWebHistory } 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 { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { generateMenus } from '../generate-menus';
|
|
||||||
|
|
||||||
// Nested route setup to test child inclusion and hideChildrenInMenu functionality
|
// Nested route setup to test child inclusion and hideChildrenInMenu functionality
|
||||||
|
|
||||||
describe('generateMenus', () => {
|
describe('generateMenus', () => {
|
||||||
@ -1,11 +1,10 @@
|
|||||||
import type { RouteRecordRaw } from 'vue-router';
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
generateRoutesByFrontend,
|
generateRoutesByFrontend,
|
||||||
hasAuthority,
|
hasAuthority,
|
||||||
} from '../generate-routes-frontend';
|
} from '@test-source/packages/utils/src/helpers/generate-routes-frontend';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
// Mock 路由数据
|
// Mock 路由数据
|
||||||
const mockRoutes = [
|
const mockRoutes = [
|
||||||
@ -36,17 +35,24 @@ const mockRoutes = [
|
|||||||
},
|
},
|
||||||
] as RouteRecordRaw[];
|
] as RouteRecordRaw[];
|
||||||
|
|
||||||
|
/** 返回指定位置的路由测试数据,不允许测试静默使用越界值。 */
|
||||||
|
function getMockRoute(index: number): RouteRecordRaw {
|
||||||
|
const route = mockRoutes[index];
|
||||||
|
if (!route) throw new Error(`缺少索引为 ${index} 的路由测试数据`);
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
describe('hasAuthority', () => {
|
describe('hasAuthority', () => {
|
||||||
it('should return true if there is no authority defined', () => {
|
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', () => {
|
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', () => {
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -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 { 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 { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { mergeRouteModules } from '../merge-route-modules';
|
|
||||||
|
|
||||||
describe('mergeRouteModules', () => {
|
describe('mergeRouteModules', () => {
|
||||||
it('should merge route modules correctly', () => {
|
it('should merge route modules correctly', () => {
|
||||||
const routeModules: Record<string, RouteModuleType> = {
|
const routeModules: Record<string, RouteModuleType> = {
|
||||||
@ -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 { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { refreshAccessCodes } from './access-codes';
|
|
||||||
|
|
||||||
describe('router access code refresh', () => {
|
describe('router access code refresh', () => {
|
||||||
it('overwrites persisted access codes with the latest backend codes', async () => {
|
it('overwrites persisted access codes with the latest backend codes', async () => {
|
||||||
const setAccessCodes = vi.fn();
|
const setAccessCodes = vi.fn();
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ADMIN_SSO_DEFAULT_REDIRECT,
|
ADMIN_SSO_DEFAULT_REDIRECT,
|
||||||
isAdminSsoRequest,
|
isAdminSsoRequest,
|
||||||
resolveAdminSsoRedirect,
|
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', () => {
|
describe('admin SSO route helpers', () => {
|
||||||
it('recognizes only the explicit SSO bootstrap flag', () => {
|
it('recognizes only the explicit SSO bootstrap flag', () => {
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
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 ts from 'typescript';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import { useAccessStore, useUserStore } from '@vben/stores';
|
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@test-source/apps/web-antdv-next/src/store/auth';
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { useAuthStore } from './auth';
|
|
||||||
|
|
||||||
const apiMocks = vi.hoisted(() => ({
|
const apiMocks = vi.hoisted(() => ({
|
||||||
getAccessCodesApi: vi.fn(),
|
getAccessCodesApi: vi.fn(),
|
||||||
getUserInfoApi: vi.fn(),
|
getUserInfoApi: vi.fn(),
|
||||||
13
test/tsconfig.json
Normal file
13
test/tsconfig.json
Normal file
@ -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/**"]
|
||||||
|
}
|
||||||
@ -1,10 +1,14 @@
|
|||||||
/* @vitest-environment node */
|
/* @vitest-environment node */
|
||||||
|
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('blog article list preview entry', () => {
|
||||||
function getSourceSlice(start: string, end: string) {
|
function getSourceSlice(start: string, end: string) {
|
||||||
@ -4,12 +4,11 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { getArticleDetail } from '#/api/blog';
|
import { getArticleDetail } from '#/api/blog';
|
||||||
|
|
||||||
import BlogArticlePreview from './index';
|
|
||||||
|
|
||||||
const testState = vi.hoisted(() => ({
|
const testState = vi.hoisted(() => ({
|
||||||
pushSpy: vi.fn(),
|
pushSpy: vi.fn(),
|
||||||
route: {
|
route: {
|
||||||
@ -1,7 +1,5 @@
|
|||||||
/* @vitest-environment happy-dom */
|
/* @vitest-environment happy-dom */
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BLOG_ARTICLE_CONTENT_FIELD_CLASS,
|
BLOG_ARTICLE_CONTENT_FIELD_CLASS,
|
||||||
BLOG_ARTICLE_FORM_CLASS,
|
BLOG_ARTICLE_FORM_CLASS,
|
||||||
@ -12,7 +10,8 @@ import {
|
|||||||
getBlogArticleCreateFormDefaults,
|
getBlogArticleCreateFormDefaults,
|
||||||
getBlogArticleEditFormValues,
|
getBlogArticleEditFormValues,
|
||||||
getContentFormatForEditorMode,
|
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', () => {
|
describe('blog article form helpers', () => {
|
||||||
it('edits WordPress Argon HTML articles as raw HTML to preserve codeblocks', () => {
|
it('edits WordPress Argon HTML articles as raw HTML to preserve codeblocks', () => {
|
||||||
@ -6,6 +6,7 @@ import type { EnvironmentDashboardApi } from '#/api/system/environment';
|
|||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h, nextTick } from 'vue';
|
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -13,8 +14,6 @@ import {
|
|||||||
runEnvironmentSelfCheck,
|
runEnvironmentSelfCheck,
|
||||||
} from '#/api/system/environment';
|
} from '#/api/system/environment';
|
||||||
|
|
||||||
import EnvironmentDashboardPage from './index.vue';
|
|
||||||
|
|
||||||
vi.mock('#/api/system/environment', () => ({
|
vi.mock('#/api/system/environment', () => ({
|
||||||
getEnvironmentDashboard: vi.fn(),
|
getEnvironmentDashboard: vi.fn(),
|
||||||
getEnvironmentDashboardEventsUrl: vi.fn((lastEventId?: string) =>
|
getEnvironmentDashboardEventsUrl: vi.fn((lastEventId?: string) =>
|
||||||
@ -7,11 +7,10 @@ import type { QqbotApi } from '#/api/qqbot';
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import AccountConfigPanel from './AccountConfigPanel';
|
|
||||||
import AccountMessagePushPanel from './AccountMessagePushPanel';
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
api: {
|
api: {
|
||||||
bindCommand: vi.fn(),
|
bindCommand: vi.fn(),
|
||||||
@ -103,23 +102,26 @@ vi.mock('antdv-next', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./AccountMessagePushPanel', () => ({
|
vi.mock(
|
||||||
default: defineComponent({
|
'@test-source/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel',
|
||||||
name: 'AccountMessagePushPanel',
|
() => ({
|
||||||
props: {
|
default: defineComponent({
|
||||||
headerControls: Function,
|
name: 'AccountMessagePushPanel',
|
||||||
selfId: String,
|
props: {
|
||||||
title: Function,
|
headerControls: Function,
|
||||||
},
|
selfId: String,
|
||||||
setup(props) {
|
title: Function,
|
||||||
return () =>
|
},
|
||||||
h('section', { 'data-testid': 'message-push-panel' }, [
|
setup(props) {
|
||||||
(props.title as (() => unknown) | undefined)?.(),
|
return () =>
|
||||||
(props.headerControls as (() => unknown) | undefined)?.(),
|
h('section', { 'data-testid': 'message-push-panel' }, [
|
||||||
] as any);
|
(props.title as (() => unknown) | undefined)?.(),
|
||||||
},
|
(props.headerControls as (() => unknown) | undefined)?.(),
|
||||||
|
] as any);
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}));
|
);
|
||||||
|
|
||||||
function createAccount(): QqbotApi.Account {
|
function createAccount(): QqbotApi.Account {
|
||||||
return {
|
return {
|
||||||
@ -7,10 +7,9 @@ import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import AccountMessagePushModal from './AccountMessagePushModal';
|
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
subscriptionId: string;
|
subscriptionId: string;
|
||||||
@ -119,36 +118,39 @@ vi.mock('@vben/common-ui', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./MessagePushTargetPicker', () => ({
|
vi.mock(
|
||||||
default: defineComponent({
|
'@test-source/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker',
|
||||||
name: 'MockMessagePushTargetPicker',
|
() => ({
|
||||||
props: {
|
default: defineComponent({
|
||||||
available: Boolean,
|
name: 'MockMessagePushTargetPicker',
|
||||||
disabled: Boolean,
|
props: {
|
||||||
loading: Boolean,
|
available: Boolean,
|
||||||
options: Array,
|
disabled: Boolean,
|
||||||
reasonCode: String,
|
loading: Boolean,
|
||||||
value: Array,
|
options: Array,
|
||||||
},
|
reasonCode: String,
|
||||||
emits: ['update:value'],
|
value: Array,
|
||||||
setup(_, { emit }) {
|
},
|
||||||
return () =>
|
emits: ['update:value'],
|
||||||
h(
|
setup(_, { emit }) {
|
||||||
'button',
|
return () =>
|
||||||
{
|
h(
|
||||||
'data-testid': 'target-picker',
|
'button',
|
||||||
onClick: () =>
|
{
|
||||||
emit('update:value', [
|
'data-testid': 'target-picker',
|
||||||
{ targetId: '12345', targetType: 'group' },
|
onClick: () =>
|
||||||
]),
|
emit('update:value', [
|
||||||
},
|
{ targetId: '12345', targetType: 'group' },
|
||||||
'target-picker',
|
]),
|
||||||
);
|
},
|
||||||
},
|
'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', () => ({
|
vi.mock('#/api/qqbot/message-push', () => ({
|
||||||
createAccountMessagePushBinding: mocks.create,
|
createAccountMessagePushBinding: mocks.create,
|
||||||
@ -7,10 +7,9 @@ import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import AccountMessagePushPanel from './AccountMessagePushPanel';
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => {
|
const mocks = vi.hoisted(() => {
|
||||||
const state = {
|
const state = {
|
||||||
accessCodes: new Set<string>(),
|
accessCodes: new Set<string>(),
|
||||||
@ -99,34 +98,37 @@ vi.mock('#/components/ktTable', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./AccountMessagePushModal', () => ({
|
vi.mock(
|
||||||
default: defineComponent({
|
'@test-source/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal',
|
||||||
name: 'MockAccountMessagePushModal',
|
() => ({
|
||||||
props: {
|
default: defineComponent({
|
||||||
selfId: String,
|
name: 'MockAccountMessagePushModal',
|
||||||
subscriptions: Array,
|
props: {
|
||||||
targetOptions: Object,
|
selfId: String,
|
||||||
targetOptionsLoading: Boolean,
|
subscriptions: Array,
|
||||||
templates: Array,
|
targetOptions: Object,
|
||||||
},
|
targetOptionsLoading: Boolean,
|
||||||
emits: ['saved'],
|
templates: Array,
|
||||||
setup(_, { emit, expose }) {
|
},
|
||||||
expose({
|
emits: ['saved'],
|
||||||
openCreate: mocks.modalOpenCreate,
|
setup(_, { emit, expose }) {
|
||||||
openEdit: mocks.modalOpenEdit,
|
expose({
|
||||||
});
|
openCreate: mocks.modalOpenCreate,
|
||||||
return () =>
|
openEdit: mocks.modalOpenEdit,
|
||||||
h(
|
});
|
||||||
'button',
|
return () =>
|
||||||
{
|
h(
|
||||||
'data-testid': 'modal-saved',
|
'button',
|
||||||
onClick: () => emit('saved'),
|
{
|
||||||
},
|
'data-testid': 'modal-saved',
|
||||||
'saved',
|
onClick: () => emit('saved'),
|
||||||
);
|
},
|
||||||
},
|
'saved',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}));
|
);
|
||||||
|
|
||||||
vi.mock('#/api/qqbot/message-push', () => ({
|
vi.mock('#/api/qqbot/message-push', () => ({
|
||||||
deleteAccountMessagePushBinding: mocks.api.deleteBinding,
|
deleteAccountMessagePushBinding: mocks.api.deleteBinding,
|
||||||
@ -4,13 +4,13 @@ import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
|||||||
|
|
||||||
import { flushPromises, mount } from '@vue/test-utils';
|
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 Select from 'antdv-next/dist/select/index';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import MessagePushTargetPicker, {
|
/** 创建群聊与私聊候选目标。 */
|
||||||
isValidMessagePushTargetId,
|
|
||||||
} from './MessagePushTargetPicker';
|
|
||||||
|
|
||||||
function createOptions(): QqbotMessagePushApi.QqbotMessagePushTargetOption[] {
|
function createOptions(): QqbotMessagePushApi.QqbotMessagePushTargetOption[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -26,6 +26,7 @@ function createOptions(): QqbotMessagePushApi.QqbotMessagePushTargetOption[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按覆盖参数挂载消息推送目标选择器。 */
|
||||||
function mountPicker(
|
function mountPicker(
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@ -47,6 +48,7 @@ function mountPicker(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取选择器最近一次提交的目标数组。 */
|
||||||
function latestValue(
|
function latestValue(
|
||||||
wrapper: ReturnType<typeof mountPicker>,
|
wrapper: ReturnType<typeof mountPicker>,
|
||||||
): QqbotMessagePushApi.QqbotMessagePublishTargetInput[] {
|
): QqbotMessagePushApi.QqbotMessagePublishTargetInput[] {
|
||||||
@ -72,6 +74,9 @@ describe('message-push target picker', () => {
|
|||||||
const selects = wrapper.findAllComponents(Select);
|
const selects = wrapper.findAllComponents(Select);
|
||||||
|
|
||||||
expect(selects).toHaveLength(2);
|
expect(selects).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
wrapper.get('.qqbot-message-push-target-picker').attributes('style'),
|
||||||
|
).toContain('width: 100%');
|
||||||
expect(selects[0]?.props()).toMatchObject({
|
expect(selects[0]?.props()).toMatchObject({
|
||||||
mode: 'tags',
|
mode: 'tags',
|
||||||
value: [],
|
value: [],
|
||||||
@ -82,6 +87,12 @@ describe('message-push target picker', () => {
|
|||||||
});
|
});
|
||||||
expect(selects[0]?.props('labelInValue')).not.toBe(true);
|
expect(selects[0]?.props('labelInValue')).not.toBe(true);
|
||||||
expect(selects[1]?.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(selects[0]?.props('options')).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
targetId: '10000000000000001',
|
targetId: '10000000000000001',
|
||||||
@ -4,6 +4,7 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -12,8 +13,6 @@ import {
|
|||||||
revokeQqbotNapcatWebuiSession,
|
revokeQqbotNapcatWebuiSession,
|
||||||
} from '#/api/qqbot/napcat';
|
} from '#/api/qqbot/napcat';
|
||||||
|
|
||||||
import QqBotAccountNapcatWebui from './index';
|
|
||||||
|
|
||||||
const testState = vi.hoisted(() => ({
|
const testState = vi.hoisted(() => ({
|
||||||
intervalControls: {
|
intervalControls: {
|
||||||
callback: undefined as (() => unknown) | undefined,
|
callback: undefined as (() => unknown) | undefined,
|
||||||
@ -4,12 +4,11 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { getQqbotNapcatRuntimeDetail } from '#/api/qqbot/napcat';
|
import { getQqbotNapcatRuntimeDetail } from '#/api/qqbot/napcat';
|
||||||
|
|
||||||
import NapcatRuntimeProfileDrawer from './NapcatRuntimeProfileDrawer';
|
|
||||||
|
|
||||||
vi.mock('#/api/qqbot/napcat', () => ({
|
vi.mock('#/api/qqbot/napcat', () => ({
|
||||||
getQqbotNapcatRuntimeDetail: vi.fn(),
|
getQqbotNapcatRuntimeDetail: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@ -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<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((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<string, unknown>) => {
|
||||||
|
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<QqbotMessagePushApi.SystemMessageSourceOptionsResponse>();
|
||||||
|
const genericRequest =
|
||||||
|
createDeferred<QqbotMessagePushApi.SystemMessageSourceOptionsResponse>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -7,17 +7,16 @@ import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import MessageSubscriptionList from './list';
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => {
|
const mocks = vi.hoisted(() => {
|
||||||
const state = {
|
const state = {
|
||||||
accessCodes: new Set<string>(),
|
accessCodes: new Set<string>(),
|
||||||
api: {
|
api: {
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
getList: vi.fn(),
|
getList: vi.fn(),
|
||||||
getOptions: vi.fn(),
|
getSourceOptions: vi.fn(),
|
||||||
getSources: vi.fn(),
|
getSources: vi.fn(),
|
||||||
toggle: vi.fn(),
|
toggle: vi.fn(),
|
||||||
},
|
},
|
||||||
@ -108,36 +107,40 @@ vi.mock('#/components/ktTable', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./components/MessageSubscriptionModal', () => ({
|
vi.mock(
|
||||||
default: defineComponent({
|
'@test-source/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal',
|
||||||
name: 'MockMessageSubscriptionModal',
|
() => ({
|
||||||
emits: ['saved'],
|
default: defineComponent({
|
||||||
setup(_, { emit, expose }) {
|
name: 'MockMessageSubscriptionModal',
|
||||||
expose({
|
emits: ['saved'],
|
||||||
openCreate: mocks.modalOpenCreate,
|
setup(_, { emit, expose }) {
|
||||||
openEdit: mocks.modalOpenEdit,
|
expose({
|
||||||
});
|
openCreate: mocks.modalOpenCreate,
|
||||||
return () =>
|
openEdit: mocks.modalOpenEdit,
|
||||||
h(
|
});
|
||||||
'button',
|
return () =>
|
||||||
{
|
h(
|
||||||
'data-testid': 'modal-saved',
|
'button',
|
||||||
onClick: () => emit('saved'),
|
{
|
||||||
},
|
'data-testid': 'modal-saved',
|
||||||
'saved',
|
onClick: () => emit('saved'),
|
||||||
);
|
},
|
||||||
},
|
'saved',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}));
|
);
|
||||||
|
|
||||||
vi.mock('#/api/qqbot/message-push', () => ({
|
vi.mock('#/api/qqbot/message-push', () => ({
|
||||||
deleteMessageSubscription: mocks.api.delete,
|
deleteMessageSubscription: mocks.api.delete,
|
||||||
|
getMessagePushSourceOptions: mocks.api.getSourceOptions,
|
||||||
getMessagePushSources: mocks.api.getSources,
|
getMessagePushSources: mocks.api.getSources,
|
||||||
getMessageSubscriptionList: mocks.api.getList,
|
getMessageSubscriptionList: mocks.api.getList,
|
||||||
getStunMappingPortChangedOptions: mocks.api.getOptions,
|
|
||||||
setMessageSubscriptionEnabled: mocks.api.toggle,
|
setMessageSubscriptionEnabled: mocks.api.toggle,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** 创建列表测试使用的订阅视图。 */
|
||||||
function createRow(): QqbotMessagePushApi.MessageSubscriptionView {
|
function createRow(): QqbotMessagePushApi.MessageSubscriptionView {
|
||||||
return {
|
return {
|
||||||
createTime: '2026-07-24 10:00:00',
|
createTime: '2026-07-24 10:00:00',
|
||||||
@ -181,10 +184,6 @@ describe('message subscription list', () => {
|
|||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
mocks.api.getOptions.mockResolvedValue({
|
|
||||||
ddnsRecords: [],
|
|
||||||
portForwards: [],
|
|
||||||
});
|
|
||||||
mocks.api.getList.mockResolvedValue({
|
mocks.api.getList.mockResolvedValue({
|
||||||
items: [createRow()],
|
items: [createRow()],
|
||||||
total: 1,
|
total: 1,
|
||||||
@ -253,14 +252,14 @@ describe('message subscription list', () => {
|
|||||||
expect(mocks.registerTable).not.toHaveBeenCalled();
|
expect(mocks.registerTable).not.toHaveBeenCalled();
|
||||||
expect(mocks.api.getList).not.toHaveBeenCalled();
|
expect(mocks.api.getList).not.toHaveBeenCalled();
|
||||||
expect(mocks.api.getSources).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(
|
expect(wrapper.find('[data-testid="subscription-table"]').exists()).toBe(
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
expect(wrapper.find('[data-testid="modal-saved"]').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);
|
mount(MessageSubscriptionList);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
@ -277,7 +276,7 @@ describe('message subscription list', () => {
|
|||||||
total: 1,
|
total: 1,
|
||||||
});
|
});
|
||||||
expect(mocks.api.getSources).toHaveBeenCalledOnce();
|
expect(mocks.api.getSources).toHaveBeenCalledOnce();
|
||||||
expect(mocks.api.getOptions).toHaveBeenCalledOnce();
|
expect(mocks.api.getSourceOptions).not.toHaveBeenCalled();
|
||||||
|
|
||||||
vi.advanceTimersByTime(60_000);
|
vi.advanceTimersByTime(60_000);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
@ -285,7 +284,7 @@ describe('message subscription list', () => {
|
|||||||
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
|
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
|
||||||
expect(mocks.api.getList).toHaveBeenCalledOnce();
|
expect(mocks.api.getList).toHaveBeenCalledOnce();
|
||||||
expect(mocks.api.getSources).toHaveBeenCalledOnce();
|
expect(mocks.api.getSources).toHaveBeenCalledOnce();
|
||||||
expect(mocks.api.getOptions).toHaveBeenCalledOnce();
|
expect(mocks.api.getSourceOptions).not.toHaveBeenCalled();
|
||||||
expect(vi.getTimerCount()).toBe(0);
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -383,7 +382,7 @@ describe('message subscription list', () => {
|
|||||||
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
|
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
|
||||||
expect(mocks.api.getList).toHaveBeenCalledOnce();
|
expect(mocks.api.getList).toHaveBeenCalledOnce();
|
||||||
expect(mocks.api.getSources).toHaveBeenCalledOnce();
|
expect(mocks.api.getSources).toHaveBeenCalledOnce();
|
||||||
expect(mocks.api.getOptions).toHaveBeenCalledOnce();
|
expect(mocks.api.getSourceOptions).not.toHaveBeenCalled();
|
||||||
expect(vi.getTimerCount()).toBe(0);
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -6,11 +6,10 @@ import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
|||||||
|
|
||||||
import { flushPromises, mount } from '@vue/test-utils';
|
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 Mentions from 'antdv-next/dist/mentions/index';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import MessageTemplateMentions from './MessageTemplateMentions';
|
|
||||||
|
|
||||||
function createVariable(
|
function createVariable(
|
||||||
overrides: Partial<QqbotMessagePushApi.SystemMessageSourceVariableDefinition> = {},
|
overrides: Partial<QqbotMessagePushApi.SystemMessageSourceVariableDefinition> = {},
|
||||||
): QqbotMessagePushApi.SystemMessageSourceVariableDefinition {
|
): QqbotMessagePushApi.SystemMessageSourceVariableDefinition {
|
||||||
@ -7,11 +7,10 @@ import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h, nextTick } from 'vue';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import MessageTemplateMentions from './MessageTemplateMentions';
|
|
||||||
import MessageTemplateModal from './MessageTemplateModal';
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => {
|
const mocks = vi.hoisted(() => {
|
||||||
const formValues = {
|
const formValues = {
|
||||||
content: '',
|
content: '',
|
||||||
@ -7,10 +7,9 @@ import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import MessageTemplateList from './list';
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => {
|
const mocks = vi.hoisted(() => {
|
||||||
const registerTable = vi.fn();
|
const registerTable = vi.fn();
|
||||||
const tableApi = {
|
const tableApi = {
|
||||||
@ -118,35 +117,38 @@ vi.mock('#/components/ktTable', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./components/MessageTemplateModal', () => ({
|
vi.mock(
|
||||||
default: defineComponent({
|
'@test-source/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal',
|
||||||
name: 'MockMessageTemplateModal',
|
() => ({
|
||||||
props: {
|
default: defineComponent({
|
||||||
canPreview: Boolean,
|
name: 'MockMessageTemplateModal',
|
||||||
sources: {
|
props: {
|
||||||
default: () => [],
|
canPreview: Boolean,
|
||||||
type: Array,
|
sources: {
|
||||||
|
default: () => [],
|
||||||
|
type: Array,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
emits: ['saved'],
|
||||||
emits: ['saved'],
|
setup(props, { emit, expose }) {
|
||||||
setup(props, { emit, expose }) {
|
expose({
|
||||||
expose({
|
openCreate: mocks.modalOpenCreate,
|
||||||
openCreate: mocks.modalOpenCreate,
|
openEdit: mocks.modalOpenEdit,
|
||||||
openEdit: mocks.modalOpenEdit,
|
});
|
||||||
});
|
return () =>
|
||||||
return () =>
|
h(
|
||||||
h(
|
'button',
|
||||||
'button',
|
{
|
||||||
{
|
'data-can-preview': String(props.canPreview),
|
||||||
'data-can-preview': String(props.canPreview),
|
'data-testid': 'modal-saved',
|
||||||
'data-testid': 'modal-saved',
|
onClick: () => emit('saved'),
|
||||||
onClick: () => emit('saved'),
|
},
|
||||||
},
|
'saved',
|
||||||
'saved',
|
);
|
||||||
);
|
},
|
||||||
},
|
}),
|
||||||
}),
|
}),
|
||||||
}));
|
);
|
||||||
|
|
||||||
function createRow(
|
function createRow(
|
||||||
overrides: Partial<QqbotMessagePushApi.MessageTemplateView> = {},
|
overrides: Partial<QqbotMessagePushApi.MessageTemplateView> = {},
|
||||||
@ -27,7 +27,8 @@ vi.mock('antdv-next', () => ({
|
|||||||
|
|
||||||
describe('qqbot shared action renderer', () => {
|
describe('qqbot shared action renderer', () => {
|
||||||
it('renders link buttons and preserves action props', async () => {
|
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({
|
const TestIcon = defineComponent({
|
||||||
name: 'TestIcon',
|
name: 'TestIcon',
|
||||||
setup: () => () => h('i'),
|
setup: () => () => h('i'),
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getQqbotStatusColor,
|
getQqbotStatusColor,
|
||||||
getQqbotStatusLabel,
|
getQqbotStatusLabel,
|
||||||
qqbotStatusLabels,
|
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', () => {
|
describe('qqbot shared status helpers', () => {
|
||||||
it('maps common QQBot status keys to Chinese labels', () => {
|
it('maps common QQBot status keys to Chinese labels', () => {
|
||||||
@ -5,13 +5,12 @@
|
|||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
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 { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { isSupportedAdminMenuName } from '#/api/core/menu';
|
import { isSupportedAdminMenuName } from '#/api/core/menu';
|
||||||
import routes from '#/router/routes/modules/qqbot';
|
import routes from '#/router/routes/modules/qqbot';
|
||||||
|
|
||||||
import QqBotPluginTaskList from './list';
|
|
||||||
|
|
||||||
vi.mock('#/api/request', () => ({
|
vi.mock('#/api/request', () => ({
|
||||||
requestClient: {
|
requestClient: {
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
@ -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 { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { loadQqbotPluginMetadata } from './metadata';
|
|
||||||
|
|
||||||
describe('qqbot plugin page metadata', () => {
|
describe('qqbot plugin page metadata', () => {
|
||||||
it('keeps plugin page metadata loading from rejecting when plugin list fails', async () => {
|
it('keeps plugin page metadata loading from rejecting when plugin list fails', async () => {
|
||||||
const error = { code: 500, msg: 'plugin list failed' };
|
const error = { code: 500, msg: 'plugin list failed' };
|
||||||
@ -7,12 +7,11 @@ import type { SystemNetworkApi } from '#/api/system/network';
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
import { defineComponent, h } from 'vue';
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import NetworkDdnsRecordModal, {
|
import NetworkDdnsRecordModal, {
|
||||||
isValidDdnsDomain,
|
isValidDdnsDomain,
|
||||||
isValidDdnsSubDomain,
|
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 mocks = vi.hoisted(() => {
|
||||||
const modalApi = {
|
const modalApi = {
|
||||||
@ -7,11 +7,10 @@ import type { SystemNetworkApi } from '#/api/system/network';
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
import { defineComponent, h } from 'vue';
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import NetworkDdnsTable, {
|
import NetworkDdnsTable, {
|
||||||
getDdnsRetryDisabledReason,
|
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(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
api: {
|
api: {
|
||||||
@ -77,18 +76,21 @@ vi.mock('#/components/ktTable', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./NetworkDdnsRecordModal', () => ({
|
vi.mock(
|
||||||
default: defineComponent({
|
'@test-source/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal',
|
||||||
name: 'MockNetworkDdnsRecordModal',
|
() => ({
|
||||||
setup(_, { expose }) {
|
default: defineComponent({
|
||||||
expose({
|
name: 'MockNetworkDdnsRecordModal',
|
||||||
openCreate: mocks.modalOpenCreate,
|
setup(_, { expose }) {
|
||||||
openEdit: mocks.modalOpenEdit,
|
expose({
|
||||||
});
|
openCreate: mocks.modalOpenCreate,
|
||||||
return () => h('div');
|
openEdit: mocks.modalOpenEdit,
|
||||||
},
|
});
|
||||||
|
return () => h('div');
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}));
|
);
|
||||||
|
|
||||||
vi.mock('#/api/system/network', () => ({
|
vi.mock('#/api/system/network', () => ({
|
||||||
deleteNetworkDdnsRecord: mocks.api.deleteRecord,
|
deleteNetworkDdnsRecord: mocks.api.deleteRecord,
|
||||||
@ -7,10 +7,9 @@ import type { SystemNetworkApi } from '#/api/system/network';
|
|||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import NetworkEndpointHistoryDrawer from './NetworkEndpointHistoryDrawer';
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
drawerApi: { open: vi.fn() },
|
drawerApi: { open: vi.fn() },
|
||||||
drawerOptions: undefined as any,
|
drawerOptions: undefined as any,
|
||||||
@ -5,10 +5,9 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import NetworkPortForwardModal from './NetworkPortForwardModal';
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => {
|
const mocks = vi.hoisted(() => {
|
||||||
const modalApi = {
|
const modalApi = {
|
||||||
close: vi.fn(async () => {}),
|
close: vi.fn(async () => {}),
|
||||||
@ -7,15 +7,14 @@ import type { SystemNetworkApi } from '#/api/system/network';
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
import { defineComponent, h } from 'vue';
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import routes from '#/router/routes/modules/system';
|
|
||||||
|
|
||||||
import NetworkList, {
|
import NetworkList, {
|
||||||
getCurrentEndpoint,
|
getCurrentEndpoint,
|
||||||
getKeeperDisabledReason,
|
getKeeperDisabledReason,
|
||||||
isDeleting,
|
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;
|
type FakeEventSourceListener = (event: Event) => void;
|
||||||
|
|
||||||
@ -170,38 +169,47 @@ vi.mock('#/components/ktTable', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./components/NetworkPortForwardModal', () => ({
|
vi.mock(
|
||||||
default: defineComponent({
|
'@test-source/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal',
|
||||||
name: 'MockNetworkPortForwardModal',
|
() => ({
|
||||||
setup(_, { expose }) {
|
default: defineComponent({
|
||||||
expose({
|
name: 'MockNetworkPortForwardModal',
|
||||||
openCreate: mocks.modalOpenCreate,
|
setup(_, { expose }) {
|
||||||
openEdit: mocks.modalOpenEdit,
|
expose({
|
||||||
});
|
openCreate: mocks.modalOpenCreate,
|
||||||
return () => h('div');
|
openEdit: mocks.modalOpenEdit,
|
||||||
},
|
});
|
||||||
|
return () => h('div');
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}));
|
);
|
||||||
|
|
||||||
vi.mock('./components/NetworkDdnsTable', () => ({
|
vi.mock(
|
||||||
default: defineComponent({
|
'@test-source/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable',
|
||||||
name: 'MockNetworkDdnsTable',
|
() => ({
|
||||||
setup(_, { expose }) {
|
default: defineComponent({
|
||||||
expose({ reload: mocks.ddnsReload });
|
name: 'MockNetworkDdnsTable',
|
||||||
return () => h('section', { 'data-testid': 'ddns-table' });
|
setup(_, { expose }) {
|
||||||
},
|
expose({ reload: mocks.ddnsReload });
|
||||||
|
return () => h('section', { 'data-testid': 'ddns-table' });
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}));
|
);
|
||||||
|
|
||||||
vi.mock('./components/NetworkEndpointHistoryDrawer', () => ({
|
vi.mock(
|
||||||
default: defineComponent({
|
'@test-source/apps/web-antdv-next/src/views/system/network/components/NetworkEndpointHistoryDrawer',
|
||||||
name: 'MockNetworkEndpointHistoryDrawer',
|
() => ({
|
||||||
setup(_, { expose }) {
|
default: defineComponent({
|
||||||
expose({ open: vi.fn() });
|
name: 'MockNetworkEndpointHistoryDrawer',
|
||||||
return () => h('div');
|
setup(_, { expose }) {
|
||||||
},
|
expose({ open: vi.fn() });
|
||||||
|
return () => h('div');
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}));
|
);
|
||||||
|
|
||||||
vi.mock('#/api/system/network', () => ({
|
vi.mock('#/api/system/network', () => ({
|
||||||
deleteNetworkPortForward: mocks.api.deleteMapping,
|
deleteNetworkPortForward: mocks.api.deleteMapping,
|
||||||
@ -1,9 +1,17 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
|
||||||
import Vue from '@vitejs/plugin-vue';
|
import Vue from '@vitejs/plugin-vue';
|
||||||
import VueJsx from '@vitejs/plugin-vue-jsx';
|
import VueJsx from '@vitejs/plugin-vue-jsx';
|
||||||
import { configDefaults, defineConfig } from 'vitest/config';
|
import { configDefaults, defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [Vue(), VueJsx()],
|
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: {
|
test: {
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
exclude: [
|
exclude: [
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user