fix: 通用化消息订阅并统一测试目录

This commit is contained in:
sunlei 2026-07-25 14:59:02 +08:00
parent 096f96143a
commit 9d7f3c272c
94 changed files with 1458 additions and 1164 deletions

View File

@ -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 的公网 IPv4AAAA 只使用 Agent 全局 IPv6DNS 值不包含端口。首屏读取一次 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 的公网 IPv4AAAA 只使用 Agent 全局 IPv6DNS 值不包含端口。首屏读取一次 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/高度模型,避免暗色模式脱节和首次空编辑器出现内部滚动条。

View File

@ -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',

View File

@ -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[],

View File

@ -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();
});
});

View File

@ -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>;
} }

View File

@ -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}

View File

@ -1,3 +1,4 @@
/* eslint-disable spaced-comment -- TypeScript 三斜线引用指令要求连续斜线。 */
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare module '*.vue' { declare module '*.vue' {

View File

@ -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": {

View File

@ -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"]
} }

View File

@ -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"]
} }

View File

@ -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)

View File

@ -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, // 显示语言切换按钮
}, },
}); });
``` ```

View File

@ -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(

View File

@ -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) ?? [];

View File

@ -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', () => {

View File

@ -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,
); );

View File

@ -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: {

View File

@ -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: {

View File

@ -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: {

View File

@ -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: {

View File

@ -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: {

View File

@ -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',

View File

@ -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',
); );

View File

@ -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;

View 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([]);
});
});

View File

@ -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(

View File

@ -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';

View File

@ -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', () => {

View File

@ -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;

View File

@ -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', () => {

View File

@ -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);

View File

@ -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 } };

View File

@ -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(() => {

View File

@ -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'", () => {

View File

@ -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', () => {

View File

@ -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';

View File

@ -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>;

View File

@ -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();

View File

@ -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[];

View File

@ -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 = [

View File

@ -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; }';

View File

@ -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;

View File

@ -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;

View File

@ -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', () => ({

View File

@ -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', () => {

View File

@ -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;

View File

@ -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>();

View File

@ -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 {

View File

@ -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',

View File

@ -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, {

View File

@ -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 = {

View File

@ -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;

View File

@ -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

View File

@ -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;

View File

@ -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());

View File

@ -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(),

View File

@ -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());

View File

@ -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: [] },

View File

@ -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', () => {

View File

@ -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);
}); });
}); });

View File

@ -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> = {

View File

@ -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();

View File

@ -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', () => {

View File

@ -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';

View File

@ -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
View 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/**"]
}

View File

@ -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) {

View File

@ -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: {

View File

@ -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', () => {

View File

@ -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) =>

View File

@ -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 {

View File

@ -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,

View File

@ -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,

View File

@ -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',

View File

@ -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,

View File

@ -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(),
})); }));

View File

@ -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);
});
});

View File

@ -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);
}); });
}); });

View File

@ -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 {

View File

@ -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: '',

View File

@ -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> = {},

View File

@ -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'),

View File

@ -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', () => {

View File

@ -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(),

View File

@ -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' };

View File

@ -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 = {

View File

@ -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,

View File

@ -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,

View File

@ -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 () => {}),

View File

@ -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,

View File

@ -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: [