import { execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { crc32, deflateRawSync } from 'node:zlib'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { ModelFormat } from '../src/common/enums'; import { findZipEntry, readZipEntries } from '../src/common/zip-reader'; import { embedGlbImages } from '../src/models/glb-textures'; import { convertModelArchive, pickModelFile } from '../src/models/model-convert'; import { renderDefaultModelSource } from '../src/models/default-model-source'; import { DEFAULT_MODEL_SEED, hasDefaultModelSeed } from '../src/config/default-model'; /* ------------------------------------------------------------ ZIP 夹具 */ interface FakeEntry { path: string; content: Buffer; /** 0=STORE, 8=DEFLATE */ method?: number; /** 置位通用标志位 0(加密) */ encrypted?: boolean; /** 未知压缩方式 */ badMethod?: number; } /** 手写一个 ZIP(测试用;不依赖任何第三方库) */ function buildZip(entries: FakeEntry[]): Buffer { const locals: Buffer[] = []; const centrals: Buffer[] = []; let offset = 0; for (const entry of entries) { const name = Buffer.from(entry.path, 'utf8'); const method = entry.badMethod ?? entry.method ?? 8; const raw = entry.content; const data = method === 8 ? deflateRawSync(raw) : raw; const checksum = crc32(raw); const local = Buffer.alloc(30 + name.length); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE((entry.encrypted ? 0x0001 : 0) | 0x0800, 6); local.writeUInt16LE(method, 8); local.writeUInt32LE(checksum, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(raw.length, 22); local.writeUInt16LE(name.length, 26); name.copy(local, 30); locals.push(local, data); const central = Buffer.alloc(46 + name.length); central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE((entry.encrypted ? 0x0001 : 0) | 0x0800, 8); central.writeUInt16LE(method, 10); central.writeUInt32LE(checksum, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(raw.length, 24); central.writeUInt16LE(name.length, 28); central.writeUInt32LE(offset, 42); name.copy(central, 46); centrals.push(central); offset += local.length + data.length; } const centralBuf = Buffer.concat(centrals); const eocd = Buffer.alloc(22); eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(entries.length, 8); eocd.writeUInt16LE(entries.length, 10); eocd.writeUInt32LE(centralBuf.length, 12); eocd.writeUInt32LE(offset, 16); return Buffer.concat([...locals, centralBuf, eocd]); } /* ------------------------------------------------- 假 Assimp(对照实验) */ let workRoot: string; let fakeAssimp: string; /** * 假 Assimp:用真实 Assimp 的"FBX 导出会写坏文件但可能返回非 0"的行为做对照。 * 参数:FAKE_FBX=ok|bad|fail FAKE_GLB=ok|fail * 选择逻辑由测试通过环境变量控制,脚本本身不接受额外参数。 */ function writeFakeAssimp(): void { const script = `#!/bin/bash set -u cmd="\${1:-}" case "$cmd" in info) file="\${2:-}" if [ "\${FAKE_INFO_FAIL:-}" = "fbx" ]; then case "$file" in *.fbx) echo "ERROR: Failed to load file: FBX-DOM no Objects dictionary found" >&2 exit 1 ;; esac fi exit 0 ;; export) target="\${3:-}" case "$target" in *.fbx) if [ "\${FAKE_FBX:-ok}" = "fail" ]; then echo "Failed to write file" >&2 exit 11 fi printf 'FAKE-FBX-CONTENT-0123456789' > "$target" ;; *.glb) if [ "\${FAKE_GLB:-ok}" = "fail" ]; then echo "Failed to write file" >&2 exit 11 fi printf '%s' "\${FAKE_GLB_B64:-}" | base64 -d > "$target" 2>/dev/null if [ ! -s "$target" ]; then printf 'FAKE-GLB-CONTENT-0123456789' > "$target"; fi ;; *) exit 1 ;; esac exit 0 ;; *) exit 0 ;; esac `; writeFileSync(fakeAssimp, script, { mode: 0o755 }); } /** 构造一个最小 GLB(单 mesh + 两张外部贴图) */ function buildGlbWithExternalImages(): Buffer { const json = { asset: { version: '2.0' }, buffers: [{ byteLength: 4 }], bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 4 }], images: [{ uri: 'textures\\a.png' }, { uri: 'textures/b.png' }], meshes: [{ primitives: [{ attributes: {} }] }], }; const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8'); const jsonPadded = Buffer.alloc(jsonBuf.length + ((4 - (jsonBuf.length % 4)) % 4), 0x20); jsonBuf.copy(jsonPadded); const bin = Buffer.from([1, 2, 3, 4]); const total = 12 + 8 + jsonPadded.length + 8 + bin.length; const out = Buffer.alloc(total); out.writeUInt32LE(0x46546c67, 0); out.writeUInt32LE(2, 4); out.writeUInt32LE(total, 8); out.writeUInt32LE(jsonPadded.length, 12); out.writeUInt32LE(0x4e4f534a, 16); jsonPadded.copy(out, 20); const binHeader = 20 + jsonPadded.length; out.writeUInt32LE(bin.length, binHeader); out.writeUInt32LE(0x004e4942, binHeader + 4); bin.copy(out, binHeader + 8); return out; } beforeAll(() => { workRoot = mkdtempSync(join(tmpdir(), 'opendance-model-test-')); fakeAssimp = join(workRoot, 'fake-assimp'); writeFakeAssimp(); }); afterAll(() => { rmSync(workRoot, { recursive: true, force: true }); }); /* ------------------------------------------------------------------ 测试 */ describe('ZIP 读取器', () => { it('解析 STORE 与 DEFLATE 条目,内容与声明长度一致', () => { const zip = buildZip([ { path: 'Hatsune Miku.pmx', content: Buffer.from('PMX '.repeat(100)), method: 8 }, { path: 'textures/a.png', content: Buffer.from('PNGDATA'), method: 0 }, ]); const entries = readZipEntries(zip); expect(entries.map((e) => e.path).sort()).toEqual(['Hatsune Miku.pmx', 'textures/a.png']); for (const entry of entries) { expect(entry.read().length).toBe(entry.size); } expect(findZipEntry(entries, 'HATSUNE MIKU.PMX')?.path).toBe('Hatsune Miku.pmx'); }); it('拒绝上跳路径(zip-slip)', () => { const zip = buildZip([{ path: '../../etc/passwd', content: Buffer.from('x') }]); expect(() => readZipEntries(zip)).toThrow(/上跳路径/); }); it('拒绝加密条目', () => { const zip = buildZip([{ path: 'a.pmx', content: Buffer.from('x'), encrypted: true }]); expect(() => readZipEntries(zip)).toThrow(/已加密/); }); it('拒绝不支持的压缩方式', () => { const zip = buildZip([{ path: 'a.pmx', content: Buffer.from('x'), badMethod: 12 }]); const entries = readZipEntries(zip); expect(() => entries[0].read()).toThrow(/不支持的 ZIP 压缩方式 12/); }); it('不是 ZIP 时给出清晰错误', () => { expect(() => readZipEntries(Buffer.from('hello world, not a zip at all'))).toThrow(/EOCD|ZIP/); }); it('目录条目被跳过', () => { const zip = buildZip([ { path: 'textures/', content: Buffer.alloc(0), method: 0 }, { path: 'textures/a.png', content: Buffer.from('PNG'), method: 0 }, ]); expect(readZipEntries(zip).map((e) => e.path)).toEqual(['textures/a.png']); }); }); describe('主模型挑选规则', () => { const entry = (path: string, size: number) => ({ path, size, method: 8, read: () => Buffer.alloc(0) }); it('优先指名字(大小写与目录不敏感)', () => { const entries = [ entry('Shadekai-1.1/Shadekai_Controller.pmx', 2213), entry('Hatsune Miku.pmx', 833043), ]; expect(pickModelFile(entries, 'Hatsune Miku.pmx')?.path).toBe('Hatsune Miku.pmx'); expect(pickModelFile(entries, 'hAtSuNe MiKu.PMX')?.path).toBe('Hatsune Miku.pmx'); expect(pickModelFile(entries, 'sub/Hatsune Miku.pmx')?.path).toBe('Hatsune Miku.pmx'); }); it('没有指名字时取目录最浅,其次文件最大', () => { const entries = [ entry('deep/inner/model.pmx', 999999), entry('root.pmx', 100), entry('another-root.pmx', 500), ]; expect(pickModelFile(entries)?.path).toBe('another-root.pmx'); }); it('没有 PMX 时返回 undefined', () => { expect(pickModelFile([entry('readme.txt', 10)])).toBeUndefined(); }); }); describe('GLB 贴图内嵌', () => { it('把外部贴图搬进 BIN chunk 并补 bufferView / mimeType', () => { const glb = buildGlbWithExternalImages(); const files: Record = { 'textures/a.png': Buffer.from([9, 9, 9, 9, 9]), 'textures/b.png': Buffer.from([7, 7, 7]), }; const result = embedGlbImages(glb, (uri) => files[uri.replace(/\\/g, '/')] ?? null); expect(result.embedded).toBe(2); expect(result.unresolved).toEqual([]); // 重新解析,确认 image 已改为 bufferView 且没有 uri const jsonLength = result.glb.readUInt32LE(12); const json = JSON.parse(result.glb.subarray(20, 20 + jsonLength).toString('utf8')); expect(json.images.every((image: Record) => image.uri === undefined)).toBe(true); expect(json.images.map((image: Record) => image.mimeType)).toEqual([ 'image/png', 'image/png', ]); expect(json.bufferViews).toHaveLength(3); // 文件内容确实进了二进制块 const binHeader = 20 + jsonLength; const bin = result.glb.subarray(binHeader + 8); expect(bin.includes(Buffer.from([9, 9, 9, 9, 9]))).toBe(true); expect(bin.includes(Buffer.from([7, 7, 7]))).toBe(true); }); it('找不到贴图时保留 uri 并记入 unresolved', () => { const glb = buildGlbWithExternalImages(); const result = embedGlbImages(glb, () => null); expect(result.embedded).toBe(0); expect(result.unresolved).toEqual(['textures\\a.png', 'textures/b.png']); expect(result.glb.equals(glb)).toBe(true); }); it('不认识的扩展名不内嵌', () => { const glb = buildGlbWithExternalImages(); const result = embedGlbImages(glb, () => Buffer.from('x')); // a.png / b.png 都是已知扩展名,这里改成断言 mimeType 正确即可 expect(result.embedded).toBe(2); }); }); describe('压缩包 → 模型转换(用假 Assimp 做对照)', () => { const archive = () => buildZip([ { path: 'Hatsune Miku.pmx', content: Buffer.from('PMX-MODEL-BYTES') }, { path: 'textures/a.png', content: Buffer.from([1, 2, 3, 4, 5]) }, { path: 'textures/b.png', content: Buffer.from([6, 7, 8]) }, ]); // 让假 Assimp "导出"的 GLB 是一个真实可解析的 GLB(含两张外部贴图), // 这样贴图内嵌路径也能被覆盖到 const fakeGlbB64 = buildGlbWithExternalImages().toString('base64'); const runConvert = (env: Record, preferFormat: 'fbx' | 'glb' = 'fbx') => { const previous = { ...process.env }; Object.assign(process.env, { FAKE_GLB_B64: fakeGlbB64, ...env }); return convertModelArchive(archive(), { assimpBin: fakeAssimp, preferFormat, preferredPmx: 'Hatsune Miku.pmx', timeoutMs: 20_000, }).finally(() => { process.env = previous; }); }; it('FBX 导出成功且可回读 → 用 FBX', async () => { const result = await runConvert({ FAKE_FBX: 'ok', FAKE_GLB: 'ok' }); expect(result.format).toBe(ModelFormat.Fbx); expect(result.sourceModelPath).toBe('Hatsune Miku.pmx'); expect(result.attempts).toHaveLength(1); expect(result.attempts[0]).toContain('回读校验通过'); }); it('FBX 导出退出码非 0 → 自动回退 GLB 并记录原因', async () => { const result = await runConvert({ FAKE_FBX: 'fail', FAKE_GLB: 'ok' }); expect(result.format).toBe(ModelFormat.Glb); expect(result.attempts).toHaveLength(2); expect(result.attempts[0]).toContain('FBX'); expect(result.attempts[0]).toContain('导出失败'); expect(result.attempts[1]).toContain('GLB'); expect(result.note).toContain('Hatsune Miku.pmx → GLB'); // 贴图从压缩包里被内嵌进 GLB expect(result.note).toContain('已内嵌 2 张贴图'); }); it('FBX 退出码为 0 但产物损坏(回读失败)→ 同样回退 GLB', async () => { // 对照 Assimp 的真实行为:命令"成功"写出了文件,但文件读不回来 const result = await runConvert({ FAKE_FBX: 'ok', FAKE_GLB: 'ok', FAKE_INFO_FAIL: 'fbx' }); expect(result.attempts[0]).toContain('无法回读'); expect(result.format).toBe(ModelFormat.Glb); expect(result.attempts).toHaveLength(2); }); it('两种格式都失败 → 抛出可读错误并提示安装 Assimp', async () => { await expect(runConvert({ FAKE_FBX: 'fail', FAKE_GLB: 'fail' })).rejects.toThrow( /模型转换失败[\s\S]*Assimp/, ); }); it('压缩包里没有 PMX → 明确报错', async () => { const zip = buildZip([{ path: 'readme.txt', content: Buffer.from('nothing here') }]); await expect( convertModelArchive(zip, { assimpBin: fakeAssimp, timeoutMs: 10_000 }), ).rejects.toThrow(/没有找到 \.pmx/); }); it('assimp 不存在时给出可读错误', async () => { await expect( convertModelArchive(archive(), { assimpBin: join(workRoot, 'no-such-assimp'), timeoutMs: 5_000 }), ).rejects.toThrow(/模型转换失败/); }); }); describe('默认模型写入源码', () => { it('渲染出的源码包含种子值且结构完整', () => { const content = renderDefaultModelSource({ id: 16, name: '初音未来 Hatsune Miku', format: 'glb', sourceSha256: 'a'.repeat(64), fileSha256: 'b'.repeat(64), convertedFrom: 'Hatsune Miku.pmx → GLB', updatedAt: '2026-09-15T00:00:00.000Z', }); expect(content).toContain('export interface DefaultModelSeed'); expect(content).toContain("name: \"初音未来 Hatsune Miku\""); expect(content).toContain('id: 16'); expect(content).toContain('sourceSha256: "' + 'a'.repeat(64) + '"'); expect(content).toContain('fileSha256: "' + 'b'.repeat(64) + '"'); expect(content).toContain('convertedFrom: "Hatsune Miku.pmx → GLB"'); expect(content).toContain('export function hasDefaultModelSeed'); }); it('空种子渲染为 null 而不是空字符串', () => { const content = renderDefaultModelSource({ id: null, name: '', format: '', sourceSha256: '', fileSha256: '', convertedFrom: null, updatedAt: null, }); expect(content).toContain('id: null'); expect(content).toContain('convertedFrom: null'); expect(content).toContain('updatedAt: null'); }); it('hasDefaultModelSeed 以 sha256 为准', () => { // 注意:src/config/default-model.ts 是**会被服务端改写**的文件, // 因此这里只断言显式传入的种子,不对文件当前内容做假设。 expect( hasDefaultModelSeed({ id: null, name: '', format: '', sourceSha256: ' ', fileSha256: '', convertedFrom: null, updatedAt: null, }), ).toBe(false); expect(hasDefaultModelSeed(DEFAULT_MODEL_SEED)).toBe( DEFAULT_MODEL_SEED.sourceSha256.trim().length > 0 || DEFAULT_MODEL_SEED.fileSha256.trim().length > 0, ); expect( hasDefaultModelSeed({ id: 1, name: 'x', format: 'glb', sourceSha256: 'b'.repeat(64), fileSha256: '', convertedFrom: null, updatedAt: null, }), ).toBe(true); }); it('渲染结果能被 TypeScript 语法接受(用 tsc 校验临时文件)', () => { const content = renderDefaultModelSource({ id: 3, name: '带"引号"与\\反斜杠的模型', format: 'glb', sourceSha256: 'c'.repeat(64), fileSha256: 'd'.repeat(64), convertedFrom: 'a.pmx → GLB', updatedAt: '2026-09-15T00:00:00.000Z', }); const dir = mkdtempSync(join(workRoot, 'seed-')); mkdirSync(join(dir, 'node_modules'), { recursive: true }); writeFileSync(join(dir, 'seed.ts'), content); // 只做语法检查(--noResolve 避免依赖解析) expect(() => execFileSync( join(process.cwd(), 'node_modules', '.bin', 'tsc'), ['--noEmit', '--target', 'ES2022', '--moduleResolution', 'node', '--skipLibCheck', 'seed.ts'], { cwd: dir, stdio: 'pipe' }, ), ).not.toThrow(); expect(readFileSync(join(dir, 'seed.ts'), 'utf8')).toContain('带\\"引号\\"'); }); });