Files
lindyson-ai/apps/api/tests/seedreamModels.test.ts
Juno ChowandClaude Fable 5.1 f665082dad chore: 建立产品仓库,完成开源前的敏感信息清理
前端(Vite + React)、API/worker(Fastify + BullMQ)、api-contracts 契约包、
迁移脚本、测试与自托管 docker-compose 示例。

开源准备:运维脚本与机器信息已拆到私有仓库;公司名、备案号、隐私邮箱改读
publicCompliance.local.json(仓库只保留占位模板);站点地址与客服二维码改为
VITE_ 环境变量;fixtures 中的真实用户标识已匿名化;三个 package.json 标注
Apache-2.0。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 02:10:46 +08:00

267 lines
10 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import type { DbClient } from '../src/db/pool.js';
import {
compileSeedreamPrompt,
getSeedreamOutputSize,
sanitizeSeedreamImageConfig,
} from '../src/domain/seedreamModels.js';
const assetId = '00000000-0000-4000-8000-000000000101';
describe('seedreamModels', () => {
it('按固定比例映射 1K/2K 像素,并允许零参考图文生图', async () => {
expect(getSeedreamOutputSize('1K', '16:9')).toBe('1424x800');
expect(getSeedreamOutputSize('2K', '9:16')).toBe('1584x2816');
await expect(sanitizeSeedreamImageConfig({
client: makeClient([]),
userId: 'user-1',
model: 'Seedream 5.0 Pro',
prompt: '一杯咖啡',
config: { resolution: '1K', aspectRatio: '1:1', optimizePromptMode: 'standard' },
references: [],
annotations: [],
})).resolves.toEqual({
resolution: '1K',
aspectRatio: '1:1',
optimizePromptMode: 'standard',
outputSize: '1024x1024',
referenceAssets: [],
annotations: [],
});
});
it('只按当前用户资产 ID 固化规范参考图,并在 provider 边界编译坐标标签', async () => {
const config = await sanitizeSeedreamImageConfig({
client: makeClient([validAsset()]),
userId: 'user-1',
model: 'Seedream 5.0 Pro',
prompt: '把 @标记1 换成皇冠',
config: { resolution: '2K', aspectRatio: '3:4', optimizePromptMode: 'fast' },
references: [{ assetId, role: 'reference_image' }],
annotations: [{ token: '@标记1', assetId, type: 'bbox', x1: 10, y1: 20, x2: 800, y2: 900 }],
});
expect(config.referenceAssets).toEqual([expect.objectContaining({
id: assetId,
type: 'image',
role: 'reference_image',
objectKey: 'users/user-1/ref.png',
width: 2048,
height: 2048,
})]);
expect(compileSeedreamPrompt('把 @标记1 换成皇冠', config)).toBe(
'把 图 1 <bbox>10 20 800 900</bbox> 换成皇冠',
);
});
it('允许 @图片N 与 @标记N 共存,并只在 provider 边界编译图片序号和坐标', async () => {
const config = await sanitizeSeedreamImageConfig({
client: makeClient([validAsset()]),
userId: 'user-1',
model: 'Seedream 5.0 Pro',
prompt: '保持 @图片1中的主体,把 @标记1 换成皇冠',
config: { resolution: '2K', aspectRatio: '1:1', optimizePromptMode: 'standard' },
references: [{ assetId, role: 'reference_image' }],
annotations: [{ token: '@标记1', assetId, type: 'point', x: 520, y: 460 }],
});
expect(compileSeedreamPrompt('保持 @图片1中的主体,把 @标记1 换成皇冠', config)).toBe(
'保持 图 1中的主体,把 图 1 <point>520 460</point> 换成皇冠',
);
});
it.each([
{ label: '缺少编号', prompt: '参考 @图片 生成' },
{ label: '编号为零', prompt: '参考 @图片0 生成' },
{ label: '非法编号', prompt: '参考 @图片x 生成' },
{ label: '编号含前导零', prompt: '参考 @图片01 生成' },
{ label: '编号后连接字母', prompt: '参考 @图片1abc 生成' },
{ label: '编号为小数', prompt: '参考 @图片1.5 生成' },
{ label: '编号为范围', prompt: '参考 @图片1-2 生成' },
{ label: '编号为分数', prompt: '参考 @图片1/2 生成' },
])('清洗和 provider 编译边界都拒绝$label的图片引用', async ({ prompt }) => {
await expect(sanitizeSeedreamImageConfig({
client: makeClient([validAsset()]),
userId: 'user-1',
model: 'Seedream 5.0 Pro',
prompt,
config: { resolution: '1K', aspectRatio: '1:1', optimizePromptMode: 'standard' },
references: [{ assetId, role: 'reference_image' }],
annotations: [],
})).rejects.toThrow('图片引用格式无效');
expect(() => compileSeedreamPrompt(prompt, {
referenceAssets: [{ id: assetId }],
annotations: [],
})).toThrow('图片引用格式无效');
});
it('清洗和 provider 编译边界都拒绝越界的 @图片N', async () => {
await expect(sanitizeSeedreamImageConfig({
client: makeClient([validAsset()]),
userId: 'user-1',
model: 'Seedream 5.0 Pro',
prompt: '参考 @图片2 生成',
config: { resolution: '1K', aspectRatio: '1:1', optimizePromptMode: 'standard' },
references: [{ assetId, role: 'reference_image' }],
annotations: [],
})).rejects.toThrow('@图片2 没有对应参考图片');
expect(() => compileSeedreamPrompt('参考 @图片2 生成', {
referenceAssets: [{ id: assetId }],
annotations: [],
})).toThrow('@图片2 没有对应参考图片');
});
it('拒绝原始 XML、未声明/重复 token、非法框选和非参考资产标注', async () => {
const base = {
client: makeClient([validAsset()]),
userId: 'user-1',
model: 'Seedream 5.0 Pro',
config: { resolution: '1K', aspectRatio: '1:1', optimizePromptMode: 'standard' },
references: [{ assetId, role: 'reference_image' as const }],
};
await expect(sanitizeSeedreamImageConfig({
...base,
prompt: '把 <point>1 2</point> 换掉',
})).rejects.toThrow('不能直接包含 point 或 bbox XML');
await expect(sanitizeSeedreamImageConfig({
...base,
prompt: '编辑 @标记1 和 @标记1',
annotations: [{ token: '@标记1', assetId, type: 'point', x: 1, y: 2 }],
})).rejects.toThrow('必须在提示词中恰好出现一次');
await expect(sanitizeSeedreamImageConfig({
...base,
prompt: '编辑 @标记2',
annotations: [],
})).rejects.toThrow('没有声明结构化标注');
await expect(sanitizeSeedreamImageConfig({
...base,
prompt: '编辑 @标记1',
annotations: [{ token: '@标记1', assetId, type: 'bbox', x1: 10, y1: 10, x2: 10, y2: 20 }],
})).rejects.toThrow('x1 < x2');
});
it('参考图格式与模型限制 fail closed,明确拒绝 AVIF', async () => {
await expect(sanitizeSeedreamImageConfig({
client: makeClient([validAsset({ name: 'ref.avif', object_key: 'users/user-1/ref.avif' })]),
userId: 'user-1',
model: 'Seedream 5.0 Pro',
prompt: '编辑图片',
config: { resolution: '1K', aspectRatio: '1:1', optimizePromptMode: 'standard' },
references: [{ assetId, role: 'reference_image' }],
})).rejects.toThrow('只支持 JPEG、PNG、WebP、BMP、TIFF、GIF、HEIC 或 HEIF');
});
it.each([
{ label: '文件大小缺失', size_bytes: null },
{ label: '文件大小为零', size_bytes: 0 },
{ label: '文件大小为负数', size_bytes: -1 },
{ label: '文件大小不是整数', size_bytes: 1024.5 },
])('为 $label 返回独立的参考图元数据错误', async ({ size_bytes }) => {
await expect(sanitizeSingleReference({ size_bytes })).rejects.toMatchObject({
statusCode: 400,
code: 'REFERENCE_IMAGE_METADATA_INCOMPLETE',
message: '参考图片缺少可校验的文件大小',
});
});
it.each([
{ label: '宽高都缺失', width: null, height: null },
{ label: '宽度缺失', width: null, height: 1024 },
{ label: '高度缺失', width: 1024, height: null },
{ label: '宽度不是整数', width: 1024.5, height: 1024 },
{ label: '高度不是正数', width: 1024, height: 0 },
])('为 $label 返回独立的参考图元数据错误', async ({ width, height }) => {
await expect(sanitizeSingleReference({ width, height })).rejects.toMatchObject({
statusCode: 400,
code: 'REFERENCE_IMAGE_METADATA_INCOMPLETE',
message: '参考图片缺少完整且有效的宽高元数据',
});
});
it.each([
{ width: 14, height: 15 },
{ width: 15, height: 14 },
])('拒绝宽高不大于 14 像素的真实图片:$width x $height', async ({ width, height }) => {
await expect(sanitizeSingleReference({ width, height })).rejects.toMatchObject({
statusCode: 400,
code: 'INVALID_SEEDREAM_REQUEST',
message: '参考图片宽度和高度必须均大于 14 像素',
});
});
it('接受 15 像素边界及 PostgreSQL 返回的整数字符串', async () => {
await expect(sanitizeSingleReference({ width: 15, height: 15 })).resolves.toEqual(
expect.objectContaining({
referenceAssets: [expect.objectContaining({ width: 15, height: 15 })],
}),
);
await expect(sanitizeSingleReference({
width: '2048',
height: '1024',
size_bytes: '4096',
})).resolves.toEqual(expect.objectContaining({
referenceAssets: [expect.objectContaining({ width: 2048, height: 1024, sizeBytes: 4096 })],
}));
});
it('允许 3600 万总像素边界并拒绝超出边界', async () => {
await expect(sanitizeSingleReference({ width: 6000, height: 6000 })).resolves.toBeDefined();
await expect(sanitizeSingleReference({ width: 6001, height: 6000 }))
.rejects.toThrow('单张参考图片总像素不能超过 3600 万');
});
it.each([
{ width: 100, height: 1600 },
{ width: 1600, height: 100 },
])('允许 1:16 到 16:1 的闭区间边界:$width x $height', async ({ width, height }) => {
await expect(sanitizeSingleReference({ width, height })).resolves.toBeDefined();
});
it.each([
{ width: 99, height: 1600 },
{ width: 1600, height: 99 },
])('拒绝超出 1:16 到 16:1 的宽高比:$width x $height', async ({ width, height }) => {
await expect(sanitizeSingleReference({ width, height }))
.rejects.toThrow('参考图片宽高比必须在 1:16 到 16:1 之间');
});
});
function sanitizeSingleReference(overrides: Record<string, unknown> = {}) {
return sanitizeSeedreamImageConfig({
client: makeClient([validAsset(overrides)]),
userId: 'user-1',
model: 'Seedream 5.0 Pro',
prompt: '编辑图片',
config: { resolution: '1K', aspectRatio: '1:1', optimizePromptMode: 'standard' },
references: [{ assetId, role: 'reference_image' }],
annotations: [],
});
}
function makeClient(rows: Array<Record<string, unknown>>) {
return {
query: vi.fn().mockResolvedValue({ rows }),
} as unknown as DbClient;
}
function validAsset(overrides: Record<string, unknown> = {}) {
return {
id: assetId,
user_id: 'user-1',
name: 'ref.png',
object_key: 'users/user-1/ref.png',
thumbnail_key: null,
thumbnail_status: null,
type: 'image',
duration_seconds: null,
size_bytes: 1024,
width: 2048,
height: 2048,
...overrides,
};
}