From c072d53554f740366cd26d5771817f79581668b5 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 24 Jul 2026 15:18:34 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=94=B6=E7=B4=A7=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E5=89=8D=E7=AB=AF=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antdv-next/src/api/core/menu.spec.ts | 97 ++- .../src/api/qqbot/message-push.spec.ts | 631 +++++++++++++++++- .../src/router/routes/modules/qqbot.spec.ts | 229 ++++++- 3 files changed, 905 insertions(+), 52 deletions(-) diff --git a/apps/web-antdv-next/src/api/core/menu.spec.ts b/apps/web-antdv-next/src/api/core/menu.spec.ts index 330f5b0..e0dd75b 100644 --- a/apps/web-antdv-next/src/api/core/menu.spec.ts +++ b/apps/web-antdv-next/src/api/core/menu.spec.ts @@ -1,7 +1,70 @@ +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'; const requestClientGet = vi.fn(); +const messagePushMenuNames = [ + 'QqBotMessageSubscription', + 'QqBotMessageTemplate', + 'QqBotMessageSubscriptionList', + 'QqBotMessageSubscriptionCreate', + 'QqBotMessageSubscriptionUpdate', + 'QqBotMessageSubscriptionDelete', + 'QqBotMessageSubscriptionToggle', + 'QqBotMessageTemplateList', + 'QqBotMessageTemplateCreate', + 'QqBotMessageTemplateUpdate', + 'QqBotMessageTemplateDelete', + 'QqBotMessageTemplateToggle', + 'QqBotMessageTemplatePreview', + 'QqBotAccountMessagePushList', + 'QqBotAccountMessagePushCreate', + 'QqBotAccountMessagePushUpdate', + 'QqBotAccountMessagePushDelete', + 'QqBotAccountMessagePushToggle', +]; + +/** + * Reads the private menu whitelist's literal source array without exporting it. + * @returns Every string literal used to initialize `SUPPORTED_ADMIN_MENU_NAMES`. + */ +function getSupportedAdminMenuNameLiterals() { + const sourceFile = ts.createSourceFile( + 'menu.ts', + readFileSync(resolve('apps/web-antdv-next/src/api/core/menu.ts'), 'utf8'), + ts.ScriptTarget.Latest, + true, + ); + const declaration = sourceFile.statements + .filter((statement) => ts.isVariableStatement(statement)) + .flatMap((statement) => statement.declarationList.declarations) + .find( + (candidate) => + ts.isIdentifier(candidate.name) && + candidate.name.text === 'SUPPORTED_ADMIN_MENU_NAMES', + ); + + expect(declaration?.initializer).toSatisfy(ts.isNewExpression); + + const initializer = declaration?.initializer as ts.NewExpression; + expect(initializer.expression).toSatisfy( + (expression: ts.Expression) => + ts.isIdentifier(expression) && expression.text === 'Set', + ); + expect(initializer.arguments).toHaveLength(1); + expect(initializer.arguments?.[0]).toSatisfy(ts.isArrayLiteralExpression); + + const elements = (initializer.arguments?.[0] as ts.ArrayLiteralExpression) + .elements; + expect(elements.every((element) => ts.isStringLiteral(element))).toBe(true); + + return elements.map((element) => (element as ts.StringLiteral).text); +} + vi.mock('#/api/request', () => ({ requestClient: { get: requestClientGet, @@ -393,26 +456,6 @@ describe('core menu api', () => { }); it('keeps every supported message-push menu name once while filtering unknown nodes', async () => { - const messagePushNames = [ - 'QqBotMessageSubscription', - 'QqBotMessageTemplate', - 'QqBotMessageSubscriptionList', - 'QqBotMessageSubscriptionCreate', - 'QqBotMessageSubscriptionUpdate', - 'QqBotMessageSubscriptionDelete', - 'QqBotMessageSubscriptionToggle', - 'QqBotMessageTemplateList', - 'QqBotMessageTemplateCreate', - 'QqBotMessageTemplateUpdate', - 'QqBotMessageTemplateDelete', - 'QqBotMessageTemplateToggle', - 'QqBotMessageTemplatePreview', - 'QqBotAccountMessagePushList', - 'QqBotAccountMessagePushCreate', - 'QqBotAccountMessagePushUpdate', - 'QqBotAccountMessagePushDelete', - 'QqBotAccountMessagePushToggle', - ]; requestClientGet.mockResolvedValue([ { name: 'QqBot', @@ -422,7 +465,7 @@ describe('core menu api', () => { name: 'QqBotAccount', path: '/qqbot/account', }, - ...messagePushNames.map((name) => ({ + ...messagePushMenuNames.map((name) => ({ authCode: `QqBot:${name}`, name, type: 'button', @@ -440,8 +483,18 @@ describe('core menu api', () => { const qqbot = menus.find((menu) => menu.name === 'QqBot'); const retainedNames = qqbot?.children?.map((child) => child.name) ?? []; - expect(retainedNames).toEqual(['QqBotAccount', ...messagePushNames]); + expect(retainedNames).toEqual(['QqBotAccount', ...messagePushMenuNames]); expect(new Set(retainedNames).size).toBe(retainedNames.length); expect(retainedNames).not.toContain('UnsupportedMessagePushNode'); }); + + it('declares every locked message-push menu literal exactly once', () => { + const literals = getSupportedAdminMenuNameLiterals(); + + expect(new Set(literals).size).toBe(literals.length); + + for (const name of messagePushMenuNames) { + expect(literals.filter((literal) => literal === name)).toHaveLength(1); + } + }); }); diff --git a/apps/web-antdv-next/src/api/qqbot/message-push.spec.ts b/apps/web-antdv-next/src/api/qqbot/message-push.spec.ts index b65a928..be0860a 100644 --- a/apps/web-antdv-next/src/api/qqbot/message-push.spec.ts +++ b/apps/web-antdv-next/src/api/qqbot/message-push.spec.ts @@ -1,3 +1,9 @@ +import type { QqbotMessagePushApi } from './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'; @@ -26,6 +32,616 @@ import { updateMessageTemplate, } from './message-push'; +type Equal = + (() => Value extends Actual ? 1 : 2) extends < + Value, + >() => Value extends Expected ? 1 : 2 + ? true + : false; + +type Assert = Condition; + +type ExpectedSystemMessageSourceVariableDefinition = { + description: string; + example: string; + key: string; + label: string; + type: 'boolean' | 'number' | 'string'; +}; + +type ExpectedSystemMessageSourceFieldDefinition = { + dependsOn?: string; + key: string; + label: string; + optionCollection: 'ddnsRecords' | 'portForwards'; + required: true; + type: 'select'; +}; + +type ExpectedSystemMessageSourceDefinition = { + description: string; + displayName: string; + sourceKey: string; + subscriptionFields: ExpectedSystemMessageSourceFieldDefinition[]; + variables: ExpectedSystemMessageSourceVariableDefinition[]; + version: 1; +}; + +type ExpectedStunMappingPortChangedOptionsResponse = { + ddnsRecords: Array<{ + disabledReasonCode: null | string; + eligible: boolean; + fqdn: string; + id: string; + name: string; + portForwardId: string; + }>; + portForwards: Array<{ + disabledReasonCode: null | string; + eligible: boolean; + externalPort: number; + id: string; + internalPort: number; + name: string; + protocol: 'tcp' | 'udp'; + }>; +}; + +type ExpectedMessageSubscriptionView = { + createTime: string; + enabled: boolean; + id: string; + invalidReasonCode: null | string; + name: string; + remark: null | string; + sourceConfig: { ddnsRecordId: string; portForwardId: string }; + sourceKey: string; + sourceName: string; + sourceSummary: string; + updateTime: string; + valid: boolean; +}; + +type ExpectedMessageSubscriptionListQuery = { + enabled?: boolean; + name?: string; + pageNo?: number; + pageSize?: number; + sourceKey?: string; +}; + +type ExpectedMessageSubscriptionInput = { + enabled: boolean; + name: string; + remark?: string; + sourceConfig: Record; + sourceKey: string; +}; + +type ExpectedMessageTemplateView = { + content: string; + createTime: string; + enabled: boolean; + id: string; + name: string; + referenceCount: number; + remark: null | string; + sourceKey: string; + sourceName: string; + updateTime: string; +}; + +type ExpectedMessageTemplateListQuery = ExpectedMessageSubscriptionListQuery; + +type ExpectedMessageTemplateInput = { + content: string; + enabled: boolean; + name: string; + remark?: string; + sourceKey: string; +}; + +type ExpectedMessageTemplatePreview = { + renderedMessage: string; + variables: Record; +}; + +type ExpectedQqbotMessagePublishBindingInput = { + enabled: boolean; + subscriptionId: string; + targets: Array<{ + targetId: string; + targetName?: string; + targetType: 'group' | 'private'; + }>; + templateId: string; +}; + +type ExpectedQqbotMessagePushTargetOptionsResponse = { + available: boolean; + options: Array<{ + label: string; + targetId: string; + targetType: 'group' | 'private'; + }>; + reasonCode: null | string; +}; + +type ExpectedQqbotMessagePublishBindingView = { + available: boolean; + createTime: string; + enabled: boolean; + id: string; + invalidReasonCode: null | string; + sourceKey: string; + sourceName: string; + subscriptionId: string; + subscriptionName: string; + targets: Array<{ + enabled: boolean; + id: string; + targetId: string; + targetName: null | string; + targetType: 'group' | 'private'; + }>; + templateId: string; + templateName: string; + updateTime: string; +}; + +type MessagePushTypeContracts = [ + Assert< + Equal< + QqbotMessagePushApi.SystemMessageScalar, + boolean | null | number | string + > + >, + Assert< + Equal< + QqbotMessagePushApi.SystemMessageSourceVariableDefinition, + ExpectedSystemMessageSourceVariableDefinition + > + >, + Assert< + Equal< + QqbotMessagePushApi.SystemMessageSourceFieldDefinition, + ExpectedSystemMessageSourceFieldDefinition + > + >, + Assert< + Equal< + QqbotMessagePushApi.SystemMessageSourceDefinition, + ExpectedSystemMessageSourceDefinition + > + >, + Assert< + Equal< + QqbotMessagePushApi.StunMappingPortChangedSubscriptionConfig, + { ddnsRecordId: string; portForwardId: string } + > + >, + Assert< + Equal< + QqbotMessagePushApi.StunMappingPortChangedOptionsResponse, + ExpectedStunMappingPortChangedOptionsResponse + > + >, + Assert< + Equal< + QqbotMessagePushApi.MessageSubscriptionView, + ExpectedMessageSubscriptionView + > + >, + Assert< + Equal< + QqbotMessagePushApi.MessageSubscriptionListQuery, + ExpectedMessageSubscriptionListQuery + > + >, + Assert< + Equal< + QqbotMessagePushApi.MessageSubscriptionInput, + ExpectedMessageSubscriptionInput + > + >, + Assert< + Equal + >, + Assert< + Equal< + QqbotMessagePushApi.MessageTemplateListQuery, + ExpectedMessageTemplateListQuery + > + >, + Assert< + Equal< + QqbotMessagePushApi.MessageTemplateInput, + ExpectedMessageTemplateInput + > + >, + Assert< + Equal< + QqbotMessagePushApi.MessageTemplatePreviewInput, + { content: string; sourceKey: string } + > + >, + Assert< + Equal< + QqbotMessagePushApi.MessageTemplatePreview, + ExpectedMessageTemplatePreview + > + >, + Assert< + Equal + >, + Assert< + Equal< + QqbotMessagePushApi.QqbotMessagePublishTargetInput, + { targetId: string; targetName?: string; targetType: 'group' | 'private' } + > + >, + Assert< + Equal< + QqbotMessagePushApi.QqbotMessagePublishBindingInput, + ExpectedQqbotMessagePublishBindingInput + > + >, + Assert< + Equal< + QqbotMessagePushApi.QqbotMessagePushTargetOption, + { label: string; targetId: string; targetType: 'group' | 'private' } + > + >, + Assert< + Equal< + QqbotMessagePushApi.QqbotMessagePushTargetOptionsResponse, + ExpectedQqbotMessagePushTargetOptionsResponse + > + >, + Assert< + Equal< + QqbotMessagePushApi.QqbotMessagePublishTargetView, + { + enabled: boolean; + id: string; + targetId: string; + targetName: null | string; + targetType: 'group' | 'private'; + } + > + >, + Assert< + Equal< + QqbotMessagePushApi.QqbotMessagePublishBindingView, + ExpectedQqbotMessagePublishBindingView + > + >, + Assert< + Equal< + QqbotMessagePushApi.PageResult, + { items: ExpectedMessageSubscriptionView[]; total: number } + > + >, + Assert, []>>, + Assert< + Equal< + ReturnType, + Promise + > + >, + Assert< + Equal, [sourceKey: string]> + >, + Assert< + Equal< + ReturnType, + Promise + > + >, + Assert, []>>, + Assert< + Equal< + ReturnType, + Promise + > + >, + Assert< + Equal< + Parameters, + [params: ExpectedMessageSubscriptionListQuery] + > + >, + Assert< + Equal< + ReturnType, + Promise<{ items: ExpectedMessageSubscriptionView[]; total: number }> + > + >, + Assert< + Equal< + Parameters, + [data: ExpectedMessageSubscriptionInput] + > + >, + Assert< + Equal< + ReturnType, + Promise + > + >, + Assert< + Equal< + Parameters, + [id: string, data: ExpectedMessageSubscriptionInput] + > + >, + Assert< + Equal< + ReturnType, + Promise + > + >, + Assert< + Equal< + Parameters, + [id: string, enabled: boolean] + > + >, + Assert< + Equal< + ReturnType, + Promise + > + >, + Assert, [id: string]>>, + Assert, Promise>>, + Assert< + Equal< + Parameters, + [params: ExpectedMessageTemplateListQuery] + > + >, + Assert< + Equal< + ReturnType, + Promise<{ items: ExpectedMessageTemplateView[]; total: number }> + > + >, + Assert< + Equal< + Parameters, + [data: ExpectedMessageTemplateInput] + > + >, + Assert< + Equal< + ReturnType, + Promise + > + >, + Assert< + Equal< + Parameters, + [id: string, data: ExpectedMessageTemplateInput] + > + >, + Assert< + Equal< + ReturnType, + Promise + > + >, + Assert< + Equal< + Parameters, + [id: string, enabled: boolean] + > + >, + Assert< + Equal< + ReturnType, + Promise + > + >, + Assert, [id: string]>>, + Assert, Promise>>, + Assert< + Equal< + Parameters, + [data: { content: string; sourceKey: string }] + > + >, + Assert< + Equal< + ReturnType, + Promise + > + >, + Assert< + Equal, [selfId: string]> + >, + Assert< + Equal< + ReturnType, + Promise + > + >, + Assert< + Equal< + Parameters, + [selfId: string, data: ExpectedQqbotMessagePublishBindingInput] + > + >, + Assert< + Equal< + ReturnType, + Promise + > + >, + Assert< + Equal< + Parameters, + [ + selfId: string, + id: string, + data: ExpectedQqbotMessagePublishBindingInput, + ] + > + >, + Assert< + Equal< + ReturnType, + Promise + > + >, + Assert< + Equal< + Parameters, + [selfId: string, id: string, enabled: boolean] + > + >, + Assert< + Equal< + ReturnType, + Promise + > + >, + Assert< + Equal< + Parameters, + [selfId: string, id: string] + > + >, + Assert< + Equal, Promise> + >, + Assert< + Equal, [selfId: string]> + >, + Assert< + Equal< + ReturnType, + Promise + > + >, +]; + +const messagePushCallerNames = [ + 'createAccountMessagePushBinding', + 'createMessageSubscription', + 'createMessageTemplate', + 'deleteAccountMessagePushBinding', + 'deleteMessageSubscription', + 'deleteMessageTemplate', + 'getAccountMessagePushBindings', + 'getAccountMessagePushTargets', + 'getMessagePushSourceDetail', + 'getMessagePushSources', + 'getMessageSubscriptionList', + 'getMessageTemplateList', + 'getStunMappingPortChangedOptions', + 'previewMessageTemplate', + 'setAccountMessagePushBindingEnabled', + 'setMessageSubscriptionEnabled', + 'setMessageTemplateEnabled', + 'updateAccountMessagePushBinding', + 'updateMessageSubscription', + 'updateMessageTemplate', +]; + +/** + * Type-checks this focused contract spec with a virtual request client boundary. + * @returns Deterministic TypeScript diagnostics for only this spec and `message-push.ts`. + */ +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 virtualRequestModulePath = + '/__qqbot-message-push-contract__/request.ts'; + const virtualRequestModuleSource = ` +export declare const requestClient: { + delete(url: string, config?: unknown): Promise; + get(url: string, config?: unknown): Promise; + post(url: string, data?: unknown, config?: unknown): Promise; + put(url: string, data?: unknown, config?: unknown): Promise; +};`; + const config = ts.readConfigFile(configPath, ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + appRoot, + { incremental: false, noEmit: true }, + configPath, + ); + const baseCompilerHost = ts.createCompilerHost(parsed.options, true); + const compilerHost: ts.CompilerHost = { + ...baseCompilerHost, + fileExists(fileName) { + return ( + fileName === virtualRequestModulePath || + baseCompilerHost.fileExists(fileName) + ); + }, + getSourceFile( + fileName, + languageVersion, + onError, + shouldCreateNewSourceFile, + ) { + if (fileName === virtualRequestModulePath) { + return ts.createSourceFile( + fileName, + virtualRequestModuleSource, + languageVersion, + true, + ); + } + + return baseCompilerHost.getSourceFile( + fileName, + languageVersion, + onError, + shouldCreateNewSourceFile, + ); + }, + readFile(fileName) { + return fileName === virtualRequestModulePath + ? virtualRequestModuleSource + : baseCompilerHost.readFile(fileName); + }, + resolveModuleNames(moduleNames, containingFile) { + return moduleNames.map((moduleName) => { + if (moduleName === '#/api/request') { + return { + extension: ts.Extension.Ts, + isExternalLibraryImport: false, + resolvedFileName: virtualRequestModulePath, + }; + } + + return ts.resolveModuleName( + moduleName, + containingFile, + parsed.options, + baseCompilerHost, + ).resolvedModule; + }); + }, + }; + const program = ts.createProgram({ + host: compilerHost, + options: parsed.options, + rootNames: [testFilePath], + }); + + return ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), + ); +} + vi.mock('#/api/request', () => ({ requestClient: { delete: vi.fn(), @@ -214,11 +830,16 @@ describe('qqbot message push api', () => { ); }); - it('does not expose internal publish, event, delivery, or worker callers', () => { - expect(Object.keys(messagePushApi)).not.toEqual( - expect.arrayContaining([ - expect.stringMatching(/delivery|event|publish|worker/i), - ]), + it('exports exactly the twenty approved callers', () => { + expect(Object.keys(messagePushApi).toSorted()).toEqual( + messagePushCallerNames, ); }); + + it('type-checks the complete public namespace and caller contract', () => { + const typeContracts: MessagePushTypeContracts = [] as never; + + void typeContracts; + expect(getMessagePushContractDiagnostics()).toEqual([]); + }); }); diff --git a/apps/web-antdv-next/src/router/routes/modules/qqbot.spec.ts b/apps/web-antdv-next/src/router/routes/modules/qqbot.spec.ts index 5cd7baa..4819ffc 100644 --- a/apps/web-antdv-next/src/router/routes/modules/qqbot.spec.ts +++ b/apps/web-antdv-next/src/router/routes/modules/qqbot.spec.ts @@ -1,33 +1,212 @@ 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'; -describe('qqbot routes', () => { - it('adds two flat message-push route children without loading future pages', () => { - const source = readFileSync( - 'apps/web-antdv-next/src/router/routes/modules/qqbot.ts', - 'utf8', - ); +type ExpectedMessagePushRoute = { + componentPath: string; + name: string; + path: string; + title: string; +}; - for (const [name, path, componentPath, title] of [ - [ - 'QqBotMessageSubscription', - '/qqbot/message-subscription', - '#/views/qqbot/message-subscription/list', - '消息订阅', - ], - [ - 'QqBotMessageTemplate', - '/qqbot/message-template', - '#/views/qqbot/message-template/list', - '消息模板', - ], - ] as const) { - expect(source).toMatch( - new RegExp( - String.raw`\{\s*component: \(\) => import\('${componentPath}'\),\s*meta: \{\s*icon: 'lucide:[^']+',\s*title: '${title}',\s*\},\s*name: '${name}',\s*path: '${path}',\s*\}`, - 's', - ), +const messagePushRoutes: ExpectedMessagePushRoute[] = [ + { + componentPath: '#/views/qqbot/message-subscription/list', + name: 'QqBotMessageSubscription', + path: '/qqbot/message-subscription', + title: '消息订阅', + }, + { + componentPath: '#/views/qqbot/message-template/list', + name: 'QqBotMessageTemplate', + path: '/qqbot/message-template', + title: '消息模板', + }, +]; + +/** + * Returns the text key of a non-computed object-literal property. + * @param property - Candidate route object property from the parsed source. + * @returns The literal property key, or `undefined` when it cannot be read statically. + */ +function getPropertyName(property: ts.ObjectLiteralElementLike) { + if (!ts.isPropertyAssignment(property) || property.name === undefined) { + return undefined; + } + + return ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) + ? property.name.text + : undefined; +} + +/** + * Finds one directly declared property on a route object. + * @param object - Route object literal to inspect. + * @param name - Required literal property key. + * @returns The matching property assignment, if present. + */ +function getProperty(object: ts.ObjectLiteralExpression, name: string) { + return object.properties.find( + (property): property is ts.PropertyAssignment => + getPropertyName(property) === name && ts.isPropertyAssignment(property), + ); +} + +/** + * Reads a required string-literal property from a route object. + * @param object - Route object literal to inspect. + * @param name - Required literal property key. + * @returns The declared string value, or `undefined` when it is not a string literal. + */ +function getStringProperty(object: ts.ObjectLiteralExpression, name: string) { + const property = getProperty(object, name); + + return property && ts.isStringLiteral(property.initializer) + ? property.initializer.text + : undefined; +} + +/** + * Reads the literal target from a route's lazy dynamic import without invoking it. + * @param object - Route object literal to inspect. + * @returns The module path passed to `import()`. + */ +function getLazyComponentPath(object: ts.ObjectLiteralExpression) { + const component = getProperty(object, 'component'); + + expect(component, 'route must declare a lazy component').toBeDefined(); + expect(component?.initializer).toSatisfy(ts.isArrowFunction); + + const body = (component?.initializer as ts.ArrowFunction).body; + expect(body).toSatisfy(ts.isCallExpression); + + const importCall = body as ts.CallExpression; + expect(importCall.expression.kind).toBe(ts.SyntaxKind.ImportKeyword); + expect(importCall.arguments).toHaveLength(1); + expect(importCall.arguments[0]).toSatisfy(ts.isStringLiteral); + + return (importCall.arguments[0] as ts.StringLiteral).text; +} + +/** + * Locates the direct `QqBot.children` array in the route initializer. + * @param sourceFile - Parsed QQBot route module source. + * @returns The direct child route objects of the `QqBot` parent. + */ +function getQqBotChildren(sourceFile: ts.SourceFile) { + const routesDeclaration = sourceFile.statements.find( + (statement): statement is ts.VariableStatement => { + if (!ts.isVariableStatement(statement)) { + return false; + } + + return statement.declarationList.declarations.some( + (declaration) => + ts.isIdentifier(declaration.name) && + declaration.name.text === 'routes', + ); + }, + ); + const routesInitializer = + routesDeclaration?.declarationList.declarations.find( + (declaration) => + ts.isIdentifier(declaration.name) && declaration.name.text === 'routes', + )?.initializer; + + expect(routesInitializer).toSatisfy(ts.isArrayLiteralExpression); + + const qqbotRoute = ( + routesInitializer as ts.ArrayLiteralExpression + ).elements.find( + (element): element is ts.ObjectLiteralExpression => + ts.isObjectLiteralExpression(element) && + getStringProperty(element, 'name') === 'QqBot', + ); + + expect(qqbotRoute, 'routes must contain a direct QqBot parent').toBeDefined(); + + const children = getProperty( + qqbotRoute as ts.ObjectLiteralExpression, + 'children', + ); + expect(children?.initializer).toSatisfy(ts.isArrayLiteralExpression); + + return (children?.initializer as ts.ArrayLiteralExpression).elements.filter( + (element) => ts.isObjectLiteralExpression(element), + ); +} + +/** + * Collects every route object that declares one of the locked message-push names. + * @param sourceFile - Parsed QQBot route module source. + * @returns All matching objects, including accidental nested or duplicate routes. + */ +function getAllMessagePushRoutes(sourceFile: ts.SourceFile) { + const routeNames = new Set(messagePushRoutes.map((route) => route.name)); + const matches: ts.ObjectLiteralExpression[] = []; + + /** Visits route objects so nested copies cannot evade the direct-child assertion. */ + function visit(node: ts.Node): void { + if ( + ts.isObjectLiteralExpression(node) && + routeNames.has(getStringProperty(node, 'name')) + ) { + matches.push(node); + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return matches; +} + +describe('qqbot routes', () => { + it('adds exactly two flat message-push route children without loading future pages', () => { + const sourceFile = ts.createSourceFile( + 'qqbot.ts', + readFileSync( + resolve('apps/web-antdv-next/src/router/routes/modules/qqbot.ts'), + 'utf8', + ), + ts.ScriptTarget.Latest, + true, + ); + const directChildren = getQqBotChildren(sourceFile); + const allMessagePushRouteNodes = getAllMessagePushRoutes(sourceFile); + + expect(allMessagePushRouteNodes).toHaveLength(messagePushRoutes.length); + + for (const expectedRoute of messagePushRoutes) { + const matchingDirectChildren = directChildren.filter( + (route) => getStringProperty(route, 'name') === expectedRoute.name, + ); + + expect(matchingDirectChildren).toHaveLength(1); + + const route = matchingDirectChildren[0]; + if (!route) { + return; + } + + expect(getStringProperty(route, 'path')).toBe(expectedRoute.path); + expect(getLazyComponentPath(route)).toBe(expectedRoute.componentPath); + expect(getProperty(route, 'children')).toBeUndefined(); + + const meta = getProperty(route, 'meta'); + expect(meta?.initializer).toSatisfy(ts.isObjectLiteralExpression); + if (!meta || !ts.isObjectLiteralExpression(meta.initializer)) { + return; + } + + expect(getStringProperty(meta.initializer, 'title')).toBe( + expectedRoute.title, + ); + expect(getStringProperty(meta.initializer, 'icon')).toMatch( + /^lucide:\S+$/, ); } });