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