Files
Sanctification/spikes/card-harness/scripts/pack-performance.test.mjs
2026-09-10 21:11:07 -07:00

345 lines
14 KiB
JavaScript

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))
})