import assert from "node:assert/strict"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const manifestPath = path.join(root, "tests/golden/M12-06A/manifest.json"); const reportPath = path.join(root, "tests/golden/M12-06A/desktop-fixtures.json"); const fixtureRoot = path.join(root, "tests/files/web/m12_glb_desktop_v1"); const generator = path.join(root, "tools/web/generate-glb-desktop-fixtures.py"); const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); const fileHash = (file) => sha256(fs.readFileSync(file)); const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); const manifest = readJson(manifestPath); const report = readJson(reportPath); assert.equal(manifest.schemaVersion, 1); assert.equal(manifest.task, "M12-06A"); assert.equal(manifest.parentTask, "M12-05F"); assert.equal(manifest.nextTask, "M12-06B"); assert.equal(report.schemaVersion, 1); assert.equal(report.task, "M12-06A"); assert.equal(report.operation, "DESKTOP_GLB_FIXTURE_GENERATION"); assert.equal(report.nextTask, "M12-06B"); assert.equal(report.fixtureCount, 5); assert.equal(report.maxFixtureBytes, 512 * 1024); for (const artifact of Object.values(manifest.artifacts)) { if (artifact.path === manifestPath) continue; const absolute = path.join(root, artifact.path); assert.ok(fs.existsSync(absolute), `missing artifact ${artifact.path}`); assert.equal(fileHash(absolute), artifact.sha256, `hash mismatch ${artifact.path}`); } const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json")); for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) { assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`); } const expectedIds = ["mesh", "pbr", "uv", "skin", "animation"]; assert.deepEqual(report.fixtures.map((fixture) => fixture.id), expectedIds); for (const fixture of report.fixtures) { assert.ok(fixture.byteLength > 128, `${fixture.id} is unexpectedly empty`); assert.ok(fixture.byteLength <= report.maxFixtureBytes, `${fixture.id} exceeds fixture budget`); const file = path.join(fixtureRoot, fixture.file); assert.equal(fileHash(file), fixture.sha256, `${fixture.id} fixture hash`); assert.equal(fs.statSync(file).size, fixture.byteLength, `${fixture.id} fixture byte length`); assert.equal(fixture.semantic.asset.version, "2.0"); assert.equal(fixture.semantic.extensionsUsed.length, 0, `${fixture.id} unexpectedly uses an extension`); assert.equal(fixture.semantic.extensionsRequired.length, 0, `${fixture.id} unexpectedly requires an extension`); assert.equal(fixture.semantic.meshes.length, 1); assert.equal(fixture.semantic.meshes[0].primitives.length, 1); const primitive = fixture.semantic.meshes[0].primitives[0]; assert.equal(primitive.mode, 4, `${fixture.id} is not triangle geometry`); assert.ok(primitive.attributes.POSITION, `${fixture.id} lacks POSITION`); assert.ok(primitive.indices, `${fixture.id} lacks indexed topology`); if (fixture.id === "mesh") { assert.ok(primitive.attributes.NORMAL); assert.ok(primitive.attributes.COLOR_0); assert.equal(primitive.indices.count, 6); } if (fixture.id === "pbr") { assert.equal(fixture.semantic.materials.length, 1); const pbr = fixture.semantic.materials[0].pbr; assert.deepEqual(pbr.baseColorFactor.map((value) => Number(value.toFixed(3))), [0.31, 0.57, 0.91, 1]); assert.equal(Number(pbr.metallicFactor.toFixed(3)), 0.72); assert.equal(Number(pbr.roughnessFactor.toFixed(3)), 0.28); assert.ok(fixture.semantic.materials[0].emissiveFactor); } if (fixture.id === "uv") { assert.ok(primitive.attributes.TEXCOORD_0); assert.equal(fixture.semantic.textures.length, 1); assert.equal(fixture.semantic.images.length, 1); assert.equal(fixture.semantic.images[0].mimeType, "image/png"); } if (fixture.id === "skin") { assert.ok(primitive.attributes.JOINTS_0); assert.ok(primitive.attributes.WEIGHTS_0); assert.equal(fixture.semantic.skins.length, 1); assert.equal(fixture.semantic.skins[0].joints.length, 2); assert.equal(fixture.semantic.skins[0].inverseBindMatrices.count, 2); } if (fixture.id === "animation") { assert.equal(fixture.semantic.animations.length, 1); assert.equal(fixture.semantic.animations[0].name, "M12 Animation Action"); assert.deepEqual( fixture.semantic.animations[0].channels.map((channel) => channel.target.path).sort(), ["rotation", "translation"], ); assert.equal(fixture.semantic.animations[0].samplers[0].input.count, 25); } } const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-06a-glb-")); try { const regeneratedReport = path.join(temporary, "desktop-fixtures.json"); const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regeneratedReport], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024, }); assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.deepEqual(readJson(regeneratedReport), report, "desktop semantic report is not deterministic"); for (const fixture of report.fixtures) { assert.deepEqual( fs.readFileSync(path.join(temporary, fixture.file)), fs.readFileSync(path.join(fixtureRoot, fixture.file)), `${fixture.id} GLB bytes are not deterministic`, ); } } finally { fs.rmSync(temporary, { recursive: true, force: true }); } process.stdout.write( `glb-desktop-fixtures-ok fixtures=${report.fixtures.length} ` + `bytes=${report.fixtures.reduce((total, fixture) => total + fixture.byteLength, 0)} ` + `features=mesh,pbr,uv,skin,animation deterministic=true next=${manifest.nextTask}\n`, );