Files
zijunchiang 53bf182476 feat(models): 发布 v0.2.0 —— GLB/VRM 主力格式、自动缩略图与分析、模型审核与骨骼预览
需求 1:系统确定以 GLB / VRM 为模型主力格式
- 服务端新增支持库:
  - model-inspector.ts:解析 GLB/VRM 的面数、顶点、材质、贴图、形变目标、
    **骨骼树(名称/层级/是否蒙皮/humanoid 映射/子节点数)**、humanoid 覆盖率与可驱动性
  - model-thumbnail.ts:**纯 TS 软件光栅化**(z-buffer + Lambert + 完整蒙皮矩阵)
    生成 512×512 PNG 缩略图,零原生依赖、不装 headless-gl
  - vrm-writer.ts:GLB → VRM 1.0(写入 VRMC_vrm 扩展与 humanoid 映射)
  - gltf-packer.ts:glTF(.gltf + .bin + 贴图)→ 单文件 GLB
- humanoid 候选表与 Godot 客户端 humanoid_map.gd **逐字对齐**,并有单测断言两边一致
  (服务端判定的"可驱动" = 客户端实际能否驱动)

需求 2:模型管理
- .glb / .vrm / .gltf **直接导入**(不重编码,GLB 含 VRM 扩展会自动识别为 VRM)
- .pmx / .fbx 服务端转换:assimp → .glb →(失败)→ .gltf 后自行打包成 GLB
  →(MODEL_CONVERT_FORMAT=vrm 时)补 VRMC_vrm 产出 VRM 1.0;每步 assimp info 回读校验
- .zip **自动搜索包内可用模型**:.vrm → .glb → .gltf(直接导入)优先,
  其次 .pmx → .fbx(转换);同优先级内按"指名字 → 目录最浅 → 文件最大"
- 入库后自动分析(models.stats)与生成缩略图(models.thumbnail_asset_id),
  失败均不阻断入库;管理端列表显示小缩略图、预览放开到 vrm/glb/gltf

需求 3:审核功能
- 上传的模型状态**一律 pending**(不再自动发布、不再自动设默认)
- 审核界面新增模型面板:体积、面数、顶点、材质、贴图、骨骼、形变目标等指标,
  humanoid 覆盖进度、可驱动性结论(缺哪些必需骨骼)、warnings,以及
  **骨骼详细预览**(层级缩进、蒙皮/Humanoid 徽章、子节点数、搜索保留祖先链、
  仅看蒙皮、限高滚动 + 只渲染前 400 行)
- 审核通过可带 setPrimary=true 一并设为默认模型(此时才写源码种子);
  POST /api/models/:id/primary 仅允许 published(否则 409)
- scripts/import-default-model.mjs 改为"上传 → 审核通过 + 设默认"流程

其它:
- models 新增 stats 列(迁移 AddModelStats1789460000000,已在真实库执行)
- 契约升至 rest-api/data-models/model-formats 0.4.0、database 0.3.0(另两库已同步)
- 版本号 0.1.0 → 0.2.0(package.json ×2 + lockfile + project.yaml)
- 文档:README.txt / DSH.md / project.yaml / tasks 005·007·010 全面更新;
  顶层开发书更新为 v1.4

验证:168 项单测通过(10 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实端到端:MMD 压缩包 → GLB + 分析(面数 9071/骨骼 238/humanoid 0-22) + 缩略图(15KB PNG);
GLB 直接导入 sha256 与源文件一致;审核通过后 defaultModelId 由 VRM 变为该模型
2026-09-15 20:37:53 +08:00

526 lines
19 KiB
TypeScript
Raw Permalink 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 { 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
if [ "\${FAKE_INFO_FAIL:-}" = "all" ]; then
echo "ERROR: Failed to load file" >&2; exit 1
fi
exit 0
;;
export)
target="\${3:-}"
case "$target" in
*.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
;;
*.gltf)
if [ "\${FAKE_GLTF:-fail}" = "fail" ]; then
echo "Failed to write file" >&2
exit 11
fi
# 写出 glTF JSON 与配套 .bin(打包器要能把它们合成 GLB)
bin="\${target%.gltf}.bin"
printf '\x01\x02\x03\x04\x05\x06\x07\x08' > "$bin"
printf '%s' "\${FAKE_GLTF_JSON:-}" > "$target"
;;
*) 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;
}
/** 构造一个节点名能映射到 16 根 humanoid 必需骨骼的最小 GLB */
function buildHumanoidGlb(): Buffer {
const names = [
'Hips', 'Spine', 'Neck', 'Head',
'LeftUpperArm', 'LeftLowerArm', 'LeftHand',
'RightUpperArm', 'RightLowerArm', 'RightHand',
'LeftUpperLeg', 'LeftLowerLeg', 'LeftFoot',
'RightUpperLeg', 'RightLowerLeg', 'RightFoot',
];
const json = {
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: names.map((name, index) => ({
name,
...(index + 1 < names.length ? { children: [index + 1] } : {}),
})),
};
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8');
const jsonPadded = Buffer.alloc(jsonBuf.length + ((4 - (jsonBuf.length % 4)) % 4), 0x20);
jsonBuf.copy(jsonPadded);
const total = 12 + 8 + jsonPadded.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);
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<string, Buffer> = {
'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<string, unknown>) => image.uri === undefined)).toBe(true);
expect(json.images.map((image: Record<string, unknown>) => 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 做对照)', () => {
/** 一个含 PMX 的最小模型包 */
const pmxArchive = () =>
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]) },
]);
const fakeGlbB64 = buildGlbWithExternalImages().toString('base64');
const runConvert = (
archive: Buffer,
env: Record<string, string> = {},
preferFormat: 'glb' | 'vrm' = 'glb',
) => {
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('GLB 直出成功 → 交付 GLB 并内嵌贴图', async () => {
const result = await runConvert(pmxArchive(), { FAKE_GLB: 'ok' });
expect(result.format).toBe(ModelFormat.Glb);
expect(result.direct).toBe(false);
expect(result.sourceModelPath).toBe('Hatsune Miku.pmx');
expect(result.note).toContain('已内嵌 2 张贴图');
expect(result.attempts[0]).toContain('回读校验通过');
});
it('GLB 直出失败 → 退回 glTF 导出再自行打包成 GLB', async () => {
const gltfJson = JSON.stringify({
asset: { version: '2.0' },
buffers: [{ byteLength: 8, uri: 'converted.bin' }],
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 8 }],
images: [{ uri: 'textures/a.png' }],
meshes: [{ primitives: [{ attributes: {} }] }],
});
const result = await runConvert(pmxArchive(), {
FAKE_GLB: 'fail',
FAKE_GLTF: 'ok',
FAKE_GLTF_JSON: gltfJson,
});
expect(result.format).toBe(ModelFormat.Glb);
expect(result.attempts.some((line) => line.includes('GLB 直出:导出失败'))).toBe(true);
expect(result.attempts.some((line) => line.includes('GLB 打包:由 glTF 打包成功'))).toBe(true);
// 打包产物是合法 GLB(长度自洽)
expect(result.buffer.readUInt32LE(0)).toBe(0x46546c67);
expect(result.buffer.readUInt32LE(8)).toBe(result.buffer.length);
});
it('两种路径都失败 → 抛出可读错误并提示安装 Assimp', async () => {
await expect(
runConvert(pmxArchive(), { FAKE_GLB: 'fail', FAKE_GLTF: 'fail' }),
).rejects.toThrow(/模型转换失败[\s\S]*Assimp/);
});
it('preferFormat=vrm 且 humanoid 齐全 → 交付 VRM;不全 → 回退 GLB 并说明', async () => {
// 含 16 根 humanoid 骨骼的 GLB
const humanoidB64 = buildHumanoidGlb().toString('base64');
const wrapped = await runConvert(pmxArchive(), { FAKE_GLB_B64: humanoidB64 }, 'vrm');
expect(wrapped.format).toBe(ModelFormat.Vrm);
expect(wrapped.note).toContain('VRM');
// 骨骼名对不上的 GLB → 回退 GLB
const fallback = await runConvert(pmxArchive(), { FAKE_GLB_B64: fakeGlbB64 }, 'vrm');
expect(fallback.format).toBe(ModelFormat.Glb);
expect(fallback.attempts.some((line) => line.includes('VRM 封装:失败'))).toBe(true);
});
it('包内是 .vrm / .glb 时直接导入,不做重编码', async () => {
const vrmArchive = buildZip([
{ path: 'sub/model.vrm', content: buildHumanoidGlb() },
{ path: 'model.pmx', content: Buffer.from('PMX-BYTES') },
]);
const result = await runConvert(vrmArchive, { FAKE_GLB: 'fail', FAKE_GLTF: 'fail' });
// .vrm 优先级高于 .pmx:即使用 assimp 会失败也应当成功(因为根本不调用它)
expect(result.direct).toBe(true);
expect(result.format).toBe(ModelFormat.Vrm);
expect(result.sourceModelPath).toBe('sub/model.vrm');
expect(result.note).toContain('直接导入');
});
it('包内只有 .glb 时直接导入为 GLB', async () => {
const glbArchive = buildZip([{ path: 'a.glb', content: buildGlbWithExternalImages() }]);
const result = await runConvert(glbArchive);
expect(result.direct).toBe(true);
expect(result.format).toBe(ModelFormat.Glb);
});
it('压缩包里没有可用模型 → 明确报错', async () => {
const zip = buildZip([{ path: 'readme.txt', content: Buffer.from('nothing here') }]);
await expect(convertModelArchive(zip, { assimpBin: fakeAssimp })).rejects.toThrow(
/没有找到可用的模型文件/,
);
});
it('assimp 不存在时给出可读错误', async () => {
await expect(
convertModelArchive(pmxArchive(), {
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('带\\"引号\\"');
});
});