SnowLuma v2.4.2 source snapshot
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
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
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,203 @@
|
||||
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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user