Files
SnowLuma/packages/protocol/tests/element-manifest.test.ts
T
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

204 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
ELEMENT_MANIFEST,
MessageElementValidationError,
assertValidMessageElement,
typesForDirection,
} from '../src/element-manifest';
// 对账测试(protocol 侧)—— 拿 element-manifest 核对「按 element.type 收敛」的
// 两个 protocol 方向:
// D 收·解 rich-body-decoder.ts 产出的 MessageElement 类型集合
// W 发·打包 element-builder.ts 的 switch 分支集合
// onebot 侧的 S/P 由 onebot/tests/element-manifest.test.ts 核对(包边界所限)。
//
// 这是一道架构护栏(fitness function):谁在这两处新增/删除一种元素方向,却没有
//同步更新 element-manifest,测试立即报红——把「漏写一个方向」从运行时兜底静默
// 变成 CI 当场可见。纯读源码、零运行时行为改动。
function readSrc(relFromTestDir: string): string {
const abs = fileURLToPath(new URL(relFromTestDir, import.meta.url));
const raw = readFileSync(abs, 'utf8');
// 剥掉块注释与行注释,避免注释里出现的类型字面量污染扫描结果。
return raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
}
/** 抽出所有匹配 `pattern` 第 1 捕获组的去重、排序类型名。 */
function extractTypes(src: string, pattern: RegExp): string[] {
const found = new Set<string>();
for (const m of src.matchAll(pattern)) {
found.add(m[1]!);
}
return [...found].sort();
}
const sorted = (s: Iterable<string>): string[] => [...s].sort();
describe('element-manifest 对账(protocol 侧:D 收·解 / W 发·打包)', () => {
it('D:rich-body-decoder 产出的元素类型 == 清单声明的 D=yes', () => {
const src = readSrc('../src/msg-push/rich-body-decoder.ts');
// 解码产出形如 `result.push({ type: 'image', ... })` / `{ type: 'reply', ... }`。
// 值域放宽到 `[A-Za-z0-9_]+`(而非仅小写):类型名当前虽全小写,但收紧到
// `[a-z]+` 会让未来的驼峰/下划线类型名被静默漏抓,护栏就形同虚设——正是本
// 重构要消灭的"漏一个方向、走 default 静默"失效模式,不能在护栏自身重演。
const handled = extractTypes(src, /(?<![A-Za-z])type:\s*'([A-Za-z0-9_]+)'/g);
expect(handled).toEqual(sorted(typesForDirection('D')));
});
it('W:element-builder 的 switch 分支 == 清单声明的 W=yes', () => {
const src = readSrc('../src/element-builder.ts');
const handled = extractTypes(src, /case\s*'([A-Za-z0-9_]+)'\s*:/g);
expect(handled).toEqual(sorted(typesForDirection('W')));
});
it('fields 是可执行封闭契约:每个声明字段都有 validator,越界字段会拒绝', () => {
const sampleValue = (field: string): unknown => {
if (field === 'replyElements') {
return [];
}
if (field === 'forwardNews') {
return [{ text: 'preview' }];
}
if (field === 'rows') {
return [{ buttons: [] }];
}
if (field === 'mediaNode') {
return {};
}
if (field === 'emojiId' || field === 'md5Hex') {
return 'ab'.repeat(16);
}
if (field === 'sha1Hex') {
return 'cd'.repeat(20);
}
if (field === 'flash' || field === 'noByteFallback' || field === 'large') {
return true;
}
if (field === 'botAppid') {
return '1';
}
if (
field === 'targetUin' ||
field === 'faceId' ||
field === 'fileSize' ||
field.startsWith('reply') ||
field === 'subType' ||
field === 'duration' ||
field === 'width' ||
field === 'height' ||
field === 'emojiPackageId' ||
field === 'sceneType' ||
field === 'forwardTSum' ||
field.endsWith('Format')
) {
return 1;
}
return 'value';
};
for (const [type, spec] of Object.entries(ELEMENT_MANIFEST)) {
expect(new Set(spec.fields).size, `${type} has duplicate fields`).toBe(
spec.fields.length,
);
for (const required of spec.requiredFields) {
expect(spec.fields).toContain(required);
}
const element: Record<string, unknown> = { type };
for (const field of spec.fields) {
element[field] = sampleValue(field);
}
expect(() => assertValidMessageElement(element), type).not.toThrow();
}
expect(() =>
assertValidMessageElement({ type: 'poke', subType: 1, faceId: 1 }),
).toThrowError(
expect.objectContaining({
name: 'MessageElementValidationError',
code: 'UNEXPECTED_FIELD',
elementType: 'poke',
field: 'faceId',
}),
);
expect(() => assertValidMessageElement({ type: 'reply' })).toThrowError(
expect.objectContaining({ code: 'MISSING_FIELD', field: 'replySeq' }),
);
expect(() => assertValidMessageElement({ type: 'toString' })).toThrowError(
expect.objectContaining({ code: 'UNKNOWN_TYPE', elementType: 'toString' }),
);
expect(() => assertValidMessageElement({ type: 'mface', emojiId: 'zz' }, 'W')).toThrowError(
expect.objectContaining({ code: 'INVALID_FIELD', field: 'emojiId' }),
);
expect(() =>
assertValidMessageElement(
{
type: 'image',
url: 'https://example.com/a.png',
md5Hex: 'not-md5',
},
'W',
),
).toThrowError(expect.objectContaining({ code: 'INVALID_FIELD', field: 'md5Hex' }));
});
it('returns a stable typed validation error for BAD_REQUEST mapping', () => {
try {
assertValidMessageElement({ type: 'unknown-segment' }, 'W');
expect.unreachable('validation should throw');
} catch (error) {
expect(error).toBeInstanceOf(MessageElementValidationError);
expect(error).toMatchObject({ code: 'UNKNOWN_TYPE', elementType: 'unknown-segment' });
}
});
it('rejects invalid wire ranges and incomplete fast-upload fingerprints', () => {
expect(() =>
assertValidMessageElement(
{
type: 'file',
fileId: 'fid',
fileSize: -1,
},
'P',
),
).toThrowError(
expect.objectContaining({
code: 'INVALID_FIELD',
field: 'fileSize',
}),
);
expect(() =>
assertValidMessageElement(
{
type: 'image',
url: 'https://example.com/fallback-must-not-be-used.png',
noByteFallback: true,
},
'W',
),
).toThrowError(
expect.objectContaining({
code: 'MISSING_FIELD',
field: 'md5Hex',
}),
);
});
});