import { test } from 'node:test' import assert from 'node:assert/strict' import * as THREE from 'three' import { installDOM, TestCanvas, geometryHash, wrapperCases } from './test-support.mjs' const { createFoilWrapper } = await import('../src/foilWrapper.ts') const { PackOpening } = await import('../src/packOpening.ts') // Captured from the pre-optimization implementation, including normals, UVs and bounds. const originalHashes = [ 'f20984ff506a2b6ac557e30592b4d3b990efdd5e9f335a2655fe78567e240d1a', '2ae0cf1d2ba9fcc29dde5cd6c5cca0862e22466ed5a8c4257a3d0d0075b9d500', 'f8c912718e192139b6d33843c2c5527e765372f339897033c864909929b59bbe', '1f70db18f24f8a629a0765e21cb1d73f377ffb816e2e7893edaabe9616053441', '64e019c59fa6fe120ac044a01b2ba852ce09e86cd4abe6d286bb18d456bd8700', '140e2ad4fdc339d671a8b19da32c89b74c98e785b05250f4a4c491edf23a460a', 'cacbbd787ff9f47acddf0227d31fe8051adf06d6e401a0f53a69a7d81c3c12fc', '7a76c0eeea7cf22a3f6d888c43df76a2dcdeaad418d91c9cee5e5102a09ebf49', 'e5625218a4d89f58bb8a935d30fa6aee80613b41868bad334079fca6da33e397', 'f20984ff506a2b6ac557e30592b4d3b990efdd5e9f335a2655fe78567e240d1a', 'a6f746d8d729cf77068a05a24269024cd30c1a807828b907c559c140645a3b86', '91fd70b90a7a40976ccfe9ad6727af5f12db6e8f20ac414503f5fe10aaee4d8b', ] test('wrapper geometry exactly matches the original across tear, dent, mouth and restart poses', () => { installDOM() const wrapper = createFoilWrapper() wrapperCases.forEach(([tear, detach, mouth, touch, exit], index) => { wrapper.deform(tear, detach, mouth, new THREE.Vector3(...touch), exit) assert.equal(geometryHash(wrapper), originalHashes[index], `pose ${index}`) }) }) test('only changed surfaces recompute normals, bounds and upload buffers', (t) => { installDOM() const wrapper = createFoilWrapper() const meshes = wrapper.root.children const normalSpies = meshes.map((mesh) => t.mock.method(mesh.geometry, 'computeVertexNormals')) const boundsSpies = meshes.map((mesh) => t.mock.method(mesh.geometry, 'computeBoundingSphere')) const versions = () => meshes.map((mesh) => [ mesh.geometry.getAttribute('position').version, mesh.geometry.getAttribute('normal').version, ]) const before = versions() const touch = new THREE.Vector3() wrapper.deform(0, 0, 0, touch) assert.deepEqual(versions(), before) assert.deepEqual(normalSpies.map((spy) => spy.mock.callCount()), [0, 0, 0]) wrapper.deform(0.5, 0, 0, touch) assert.deepEqual(normalSpies.map((spy) => spy.mock.callCount()), [0, 0, 1]) assert.deepEqual(versions().slice(0, 2), before.slice(0, 2)) wrapper.deform(0.5, 0, 0, touch.set(0.4, 1.7, 0.5)) assert.deepEqual(normalSpies.map((spy) => spy.mock.callCount()), [1, 0, 1]) wrapper.deform(0.5, 0, 0.5, touch) assert.deepEqual(normalSpies.map((spy) => spy.mock.callCount()), [2, 1, 1]) assert.deepEqual(boundsSpies.map((spy) => spy.mock.callCount()), [2, 1, 1]) }) function setupPack(t, reducedMotion = false) { installDOM(reducedMotion) let now = 1000 t.mock.method(performance, 'now', () => now) const canvas = new TestCanvas() const pack = new PackOpening({ canvas, textures: { David: { artwork: new THREE.Texture(), mask: new THREE.Texture() }, Timothy: { artwork: new THREE.Texture(), mask: new THREE.Texture() }, }, backMaterial: new THREE.MeshPhysicalMaterial(), normalMap: new THREE.Texture(), environment: new THREE.CubeTexture(), lightPosition: new THREE.Vector3(2.2, 2.5, 4.4), onChange() {}, }) pack.resize(canvas.width, canvas.height) pack.setActive(true) pack.tick(now) const deform = t.mock.method(pack.wrapper, 'deform') return { pack, canvas, deform, step(ms = 16) { now += ms; pack.tick(now) }, } } function pointer(id, x, y) { return { pointerId: id, clientX: x, clientY: y, button: 0 } } test('a burst of tear events and the touch dent deform at most once per rendered frame', (t) => { const { pack, deform, step } = setupPack(t) const seam = pack.seamScreenBounds() const start = pointer(1, seam.left + 10, seam.y) pack.pointerDown(start) for (let offset = 10; offset <= 100; offset += 10) { pack.pointerMove(pointer(1, start.clientX + offset, start.clientY)) } const progress = pack.tearProgress assert.ok(progress > 0 && progress < 1) assert.equal(deform.mock.callCount(), 0) step() assert.equal(deform.mock.callCount(), 1) assert.equal(deform.mock.calls[0].arguments[0], progress) assert.ok(deform.mock.calls[0].arguments[3].z > 0) pack.pointerMove(pointer(1, start.clientX + 20, start.clientY)) assert.equal(pack.tearProgress, progress, 'pulling backward must not reseal') pack.pointerUp(pointer(1, start.clientX + 20, start.clientY), true) step() assert.equal(pack.tearProgress, progress) assert.equal(pack.paused, true) pack.primary() step(3200) assert.equal(pack.state, 'stackReady') }) test('body dragging batches dent changes without updating the back or ribbon', (t) => { const { pack, deform, step } = setupPack(t) pack.pointerDown(pointer(1, 195, 368)) for (let x = 200; x <= 220; x += 5) pack.pointerMove(pointer(1, x, 368)) assert.equal(deform.mock.callCount(), 0) const [front, back, strip] = pack.wrapper.root.children const backVersion = back.geometry.getAttribute('position').version const stripVersion = strip.geometry.getAttribute('position').version const frontVersion = front.geometry.getAttribute('position').version step() assert.equal(deform.mock.callCount(), 1) assert.ok(front.geometry.getAttribute('position').version > frontVersion) assert.equal(back.geometry.getAttribute('position').version, backVersion) assert.equal(strip.geometry.getAttribute('position').version, stripVersion) }) test('interruption and mode changes preserve progress without deforming in pointer handlers', (t) => { const { pack, deform, step } = setupPack(t) pack.primary() step(600) const calls = deform.mock.callCount() const versions = pack.wrapper.root.children.map((mesh) => mesh.geometry.getAttribute('position').version) pack.pointerDown(pointer(1, 0, 0)) const progress = pack.tearProgress assert.equal(deform.mock.callCount(), calls) assert.equal(pack.paused, true) pack.pointerUp(pointer(1, 0, 0)) step(600) assert.equal(pack.tearProgress, progress) assert.deepEqual( pack.wrapper.root.children.map((mesh) => mesh.geometry.getAttribute('position').version), versions, ) const pausedCalls = deform.mock.callCount() step() assert.equal(deform.mock.callCount(), pausedCalls) pack.setActive(false) step(500) pack.setActive(true) assert.equal(pack.tearProgress, progress) assert.equal(pack.view.action, 'Continue') pack.primary() step(3200) assert.equal(pack.state, 'stackReady') }) test('a second finger cancels seam dragging for pinch without losing the partial tear', (t) => { const { pack, canvas, step } = setupPack(t) const seam = pack.seamScreenBounds() const start = pointer(1, seam.left + 10, seam.y) pack.pointerDown(start) pack.pointerMove(pointer(1, start.clientX + 70, seam.y)) step() const progress = pack.tearProgress pack.pointerDown(pointer(2, start.clientX + 170, seam.y)) pack.pointerMove(pointer(2, start.clientX + 200, seam.y)) assert.equal(pack.tearProgress, progress) assert.ok(pack.zoom < 1) pack.restart() step() assert.equal(canvas.captures.size, 0) assert.equal(pack.tearProgress, 0) assert.equal(geometryHash(pack.wrapper), originalHashes[0]) }) test('full-pull release continues opening and hidden wrappers do no geometry work', (t) => { const { pack, deform, step } = setupPack(t) const seam = pack.seamScreenBounds() pack.pointerDown(pointer(1, seam.left + 10, seam.y)) pack.pointerMove(pointer(1, seam.right + 10, seam.y)) pack.pointerUp(pointer(1, seam.right + 10, seam.y)) assert.equal(pack.tearProgress, 1) step(3200) assert.equal(pack.state, 'stackReady') assert.equal(pack.wrapper.root.visible, false) assert.equal(deform.mock.callCount(), 0) pack.restart() step() assert.equal(pack.wrapper.root.visible, true) assert.equal(geometryHash(pack.wrapper), originalHashes[0]) }) test('skip retains ordered lift/reveal states and emits each reveal and completion once', (t) => { const { pack, canvas, step } = setupPack(t) const reveals = [] let completions = 0 canvas.addEventListener('packreveal', (event) => reveals.push(event.detail.index)) canvas.addEventListener('packcomplete', () => completions++) pack.skip() step() for (let index = 1; index <= 3; index++) { assert.equal(pack.state, 'stackReady') assert.equal(pack.cards.some((card) => card.front.visible), false) pack.skip() assert.equal(pack.state, 'lifted') assert.equal(pack.cards.some((card) => card.front.visible), false) pack.skip() assert.equal(pack.state, 'inspecting') step() pack.skip() } assert.equal(pack.state, 'complete') pack.skip() assert.deepEqual(reveals, [1, 2, 3]) assert.equal(completions, 1) pack.restart() step() assert.equal(pack.state, 'sealed') assert.equal(geometryHash(pack.wrapper), originalHashes[0]) }) test('reduced-motion opening keeps the existing 70 ms transition duration', (t) => { const { pack, step } = setupPack(t, true) pack.primary() step(69) assert.equal(pack.state, 'opening') step(1) assert.equal(pack.state, 'stackReady') }) test('GPU preparation uploads unique textures and compiles hidden fronts and both edge looks', async (t) => { const { pack, canvas } = setupPack(t) pack.setActive(false) const scene = new THREE.Scene() scene.environment = pack.options.environment const backTexture = new THREE.Texture() pack.options.backMaterial.map = backTexture const uploaded = [] const compiledEdges = [] let reveals = 0 canvas.addEventListener('packreveal', () => reveals++) const before = geometryHash(pack.wrapper) let finishCompile const firstCompile = new Promise((resolve) => { finishCompile = resolve }) const renderer = { initTexture(texture) { uploaded.push(texture) }, async compileAsync(root, camera, targetScene) { assert.equal(root, pack.root) assert.equal(camera, pack.camera) assert.equal(targetScene, scene) assert.equal(root.visible, false) assert.equal(pack.state, 'sealed') assert.equal(pack.cards.some((card) => card.front.visible), false) assert.ok(pack.cards.every((card) => root.getObjectById(card.front.id))) compiledEdges.push(pack.cards.map((card) => card.edge.clearcoat)) if (compiledEdges.length === 1) await firstCompile }, } let ready = false const preparation = pack.prepareGPU(renderer, scene).then(() => { ready = true }) await Promise.resolve() assert.equal(ready, false) finishCompile() await preparation assert.equal(uploaded.length, new Set(uploaded).size) assert.equal(uploaded.length, 9, '4 artwork/mask textures, normal, environment, back and 2 wrapper maps') for (const { artwork, mask } of Object.values(pack.options.textures)) { assert.ok(uploaded.includes(artwork)) assert.ok(uploaded.includes(mask)) } assert.ok(uploaded.includes(backTexture)) assert.ok(uploaded.includes(pack.options.normalMap)) assert.ok(uploaded.includes(scene.environment)) assert.deepEqual(compiledEdges, [[0.02, 0.02, 0.02], [0, 0.02, 0.18]]) assert.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02]) assert.equal(geometryHash(pack.wrapper), before) assert.equal(reveals, 0) }) test('failed reveal-material warmup restores neutral edges and can be retried', async (t) => { const { pack } = setupPack(t) pack.setActive(false) let compilations = 0 const failure = new Error('GPU compilation failed') const renderer = { initTexture() {}, async compileAsync() { if (++compilations === 2) throw failure }, } const scene = new THREE.Scene() await assert.rejects(pack.prepareGPU(renderer, scene), (error) => error === failure) assert.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02]) assert.equal(pack.root.visible, false) assert.equal(pack.state, 'sealed') await pack.prepareGPU(renderer, scene) assert.equal(compilations, 4) }) test('GPU upload errors propagate and preparation cannot mutate an active pack', async (t) => { const { pack } = setupPack(t) const failure = new Error('GPU upload failed') let uploads = 0 const renderer = { initTexture() { uploads++; throw failure }, async compileAsync() { assert.fail('Compilation must not run after upload failure') }, } const scene = new THREE.Scene() await assert.rejects(pack.prepareGPU(renderer, scene), /before activating/) assert.equal(uploads, 0) pack.setActive(false) await assert.rejects(pack.prepareGPU(renderer, scene), (error) => error === failure) }) test('pack cleanup disposes owned resources without disposing shared or caller-owned textures', (t) => { const { pack } = setupPack(t) const scene = new THREE.Scene() scene.add(pack.root) const shared = [ pack.options.backMaterial, pack.options.normalMap, pack.options.environment, ...Object.values(pack.options.textures).flatMap(({ artwork, mask }) => [artwork, mask]), ] const sharedSpies = shared.map((resource) => t.mock.method(resource, 'dispose')) const geometries = new Set() const materials = new Set() pack.root.traverse((object) => { if (!(object instanceof THREE.Mesh)) return geometries.add(object.geometry) materials.add(object.material) }) materials.delete(pack.options.backMaterial) const resources = [...geometries, ...materials, ...new Set(pack.wrapper.root.children.map((mesh) => mesh.material.map))] const ownedSpies = resources.map((resource) => t.mock.method(resource, 'dispose')) pack.dispose() assert.equal(pack.root.parent, null) assert.equal(pack.root.visible, false) assert.ok(ownedSpies.every((spy) => spy.mock.callCount() === 1)) assert.ok(sharedSpies.every((spy) => spy.mock.callCount() === 0)) })