600 lines
24 KiB
JavaScript
600 lines
24 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, packSize, rollPackContents, packFinishes, packSubstrates } = await import('../src/packOpening.ts')
|
|
const { packStyles, defaultPackStyle, createPackTexture } = await import('../src/packDesigns.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])
|
|
})
|
|
|
|
test('three cached pack designs change all printed surfaces without changing geometry or materials', () => {
|
|
installDOM()
|
|
const wrapper = createFoilWrapper()
|
|
assert.equal(packStyles.length, 3)
|
|
assert.equal(wrapper.style, defaultPackStyle)
|
|
assert.equal(wrapper.textures.length, 6)
|
|
const materials = wrapper.root.children.map(mesh => mesh.material)
|
|
const maps = new Set()
|
|
for (const style of packStyles) {
|
|
wrapper.setStyle(style)
|
|
assert.equal(wrapper.style, style)
|
|
const [front, back, strip] = wrapper.root.children.map(mesh => mesh.material.map)
|
|
assert.equal(front, strip, 'ribbon must use the same printed sheet as the front')
|
|
assert.notEqual(front, back)
|
|
maps.add(front)
|
|
maps.add(back)
|
|
for (const texture of [front, back]) {
|
|
assert.equal(texture.image.width, 1024)
|
|
assert.equal(texture.image.height, 1400)
|
|
assert.equal(texture.colorSpace, THREE.SRGBColorSpace)
|
|
}
|
|
assert.deepEqual(wrapper.root.children.map(mesh => mesh.material), materials)
|
|
wrapperCases.forEach(([tear, detach, mouth, touch, exit], index) => {
|
|
wrapper.deform(tear, detach, mouth, new THREE.Vector3(...touch), exit)
|
|
assert.equal(geometryHash(wrapper), originalHashes[index], `${style}, pose ${index}`)
|
|
})
|
|
wrapper.setStyle(style)
|
|
assert.equal(wrapper.root.children[0].material.map, front, 'selecting the same style reuses its texture')
|
|
}
|
|
assert.equal(maps.size, 6)
|
|
const before = wrapper.root.children.map(mesh => mesh.material.map)
|
|
assert.throws(() => wrapper.setStyle('unknown'), /Unknown pack style/)
|
|
assert.deepEqual(wrapper.root.children.map(mesh => mesh.material.map), before)
|
|
wrapper.dispose()
|
|
})
|
|
|
|
test('pack textures reject invalid styles and unavailable drawing contexts', (t) => {
|
|
installDOM()
|
|
assert.throws(() => createPackTexture('unknown'))
|
|
t.mock.method(TestCanvas.prototype, 'getContext', () => null)
|
|
assert.throws(() => createPackTexture(defaultPackStyle))
|
|
})
|
|
|
|
function setupPack(t, reducedMotion = false, contents = [
|
|
{ fixture: 'David', finish: 'Printed ink', substrate: 'Linen' },
|
|
{ fixture: 'Timothy', finish: 'Foil', substrate: 'Paper' },
|
|
{ fixture: 'David', finish: 'Holographic', substrate: 'Metal' },
|
|
], textures = new Map([
|
|
['David', { artwork: new THREE.Texture(), mask: new THREE.Texture() }],
|
|
['Timothy', { artwork: new THREE.Texture(), mask: new THREE.Texture() }],
|
|
])) {
|
|
installDOM(reducedMotion)
|
|
let now = 1000
|
|
t.mock.method(performance, 'now', () => now)
|
|
const canvas = new TestCanvas()
|
|
const pack = new PackOpening({
|
|
canvas,
|
|
contents,
|
|
textures,
|
|
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('Leather pack cards start at 0.45 detail while other substrates retain 0.14', (t) => {
|
|
const { pack } = setupPack(t, false, [
|
|
{ fixture: 'David', finish: 'Printed ink', substrate: 'Leather' },
|
|
{ fixture: 'David', finish: 'Foil', substrate: 'Paper' },
|
|
{ fixture: 'Timothy', finish: 'Holographic', substrate: 'Metal' },
|
|
])
|
|
assert.deepEqual(pack.cards.map(card => card.material.uniforms.normalStrength.value), [0.45, 0.14, 0.14])
|
|
assert.deepEqual(pack.cards.map(card => card.material.uniforms.finishMode.value), [0, 1, 2])
|
|
assert.ok(pack.cards.every(card => !card.front.visible), 'material defaults must not reveal hidden cards')
|
|
pack.dispose()
|
|
})
|
|
|
|
test('Metal pack cards use vertical grain across wrapper changes and restart', (t) => {
|
|
const { pack } = setupPack(t, false, [
|
|
{ fixture: 'David', finish: 'Printed ink', substrate: 'Metal' },
|
|
{ fixture: 'Timothy', finish: 'Foil', substrate: 'Metal' },
|
|
{ fixture: 'David', finish: 'Holographic', substrate: 'Metal' },
|
|
])
|
|
const check = () => {
|
|
assert.deepEqual(pack.cards.map(card => card.material.uniforms.metalBrushHorizontal.value), [false, false, false])
|
|
assert.ok(pack.cards.every(card => !card.front.visible))
|
|
}
|
|
check()
|
|
pack.setStyle(packStyles.find(style => style !== defaultPackStyle))
|
|
pack.restart()
|
|
check()
|
|
pack.dispose()
|
|
})
|
|
|
|
test('pack optional text masks are shared, GPU-prepared once, and remain tied to their own card', async (t) => {
|
|
const textMask = new THREE.Texture()
|
|
const textures = new Map([
|
|
['David', { artwork: new THREE.Texture(), mask: new THREE.Texture(), textMask }],
|
|
['Timothy', { artwork: new THREE.Texture(), mask: new THREE.Texture() }],
|
|
])
|
|
const { pack } = setupPack(t, false, undefined, textures)
|
|
pack.setActive(false)
|
|
const uploaded = []
|
|
await pack.prepareGPU({ initTexture(texture) { uploaded.push(texture) }, async compileAsync() {} }, new THREE.Scene())
|
|
assert.equal(uploaded.filter(texture => texture === textMask).length, 1)
|
|
assert.deepEqual(pack.cards.map(card => card.material.uniforms.hasTextMask.value), [true, false, true])
|
|
assert.equal(pack.cards[0].material.uniforms.textMask.value, pack.cards[2].material.uniforms.textMask.value)
|
|
assert.ok(pack.cards.every(card => !card.front.visible))
|
|
pack.syncLighting(pack.cards[1].material)
|
|
assert.deepEqual(pack.cards.map(card => card.material.uniforms.hasTextMask.value), [true, false, true])
|
|
const dispose = t.mock.method(textMask, 'dispose')
|
|
pack.dispose()
|
|
assert.equal(dispose.mock.callCount(), 0, 'caller owns optional card textures')
|
|
})
|
|
|
|
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('style switching preserves partial tears, card order, restart selection and retained mode state', (t) => {
|
|
const { pack, canvas, step } = setupPack(t)
|
|
let reveals = 0
|
|
canvas.addEventListener('packreveal', () => { reveals++ })
|
|
pack.primary()
|
|
step(600)
|
|
pack.pointerDown(pointer(1, 0, 0))
|
|
pack.pointerUp(pointer(1, 0, 0))
|
|
const progress = pack.tearProgress
|
|
const geometry = geometryHash(pack.wrapper)
|
|
const cardSpecs = pack.cards.map(card => card.material.uuid)
|
|
for (const style of packStyles) {
|
|
pack.setStyle(style)
|
|
assert.equal(pack.style, style)
|
|
assert.equal(pack.tearProgress, progress)
|
|
assert.equal(pack.paused, true)
|
|
assert.equal(pack.state, 'opening')
|
|
assert.equal(geometryHash(pack.wrapper), geometry)
|
|
assert.deepEqual(pack.cards.map(card => card.material.uuid), cardSpecs)
|
|
assert.equal(reveals, 0)
|
|
}
|
|
pack.setStyle(packStyles[0])
|
|
pack.setActive(false)
|
|
pack.setActive(true)
|
|
assert.equal(pack.style, packStyles[0])
|
|
assert.equal(pack.tearProgress, progress)
|
|
pack.restart()
|
|
assert.equal(pack.style, packStyles[0])
|
|
assert.equal(pack.state, 'sealed')
|
|
assert.equal(pack.tearProgress, 0)
|
|
pack.dispose()
|
|
})
|
|
|
|
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, 13, '4 artwork/mask textures, normal, environment, back and 6 wrapper maps')
|
|
for (const { artwork, mask } of [...pack.options.textures.values()]) {
|
|
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))
|
|
for (const texture of pack.wrapper.textures) assert.ok(uploaded.includes(texture), 'inactive pack styles must also be GPU-ready')
|
|
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,
|
|
...[...pack.options.textures.values()].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, ...pack.wrapper.textures]
|
|
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))
|
|
})
|
|
|
|
|
|
test('current ten-card pool uses available choices and completes every reveal once', (t) => {
|
|
const contents = rollPackContents(['David', 'Timothy'], () => 0.5)
|
|
assert.equal(packSize, 10)
|
|
assert.equal(contents.length, packSize)
|
|
assert.ok(contents.every(card => card.fixture === 'Timothy'
|
|
&& packFinishes.includes(card.finish) && packSubstrates.includes(card.substrate)))
|
|
assert.throws(() => rollPackContents([]))
|
|
assert.throws(() => rollPackContents(['David'], () => 1))
|
|
const { pack, canvas, step } = setupPack(t, false, contents)
|
|
const reveals = []
|
|
let completions = 0
|
|
canvas.addEventListener('packreveal', event => reveals.push(event.detail))
|
|
canvas.addEventListener('packcomplete', () => completions++)
|
|
pack.skip()
|
|
step()
|
|
for (let index = 0; index < packSize; index++) {
|
|
assert.equal(pack.state, 'stackReady')
|
|
pack.skip()
|
|
assert.equal(pack.state, 'lifted')
|
|
pack.skip()
|
|
assert.equal(pack.state, 'inspecting')
|
|
pack.skip()
|
|
}
|
|
assert.equal(pack.state, 'complete')
|
|
assert.equal(completions, 1)
|
|
assert.deepEqual(reveals.map(({ fixture, finish, substrate }) => ({ fixture, finish, substrate })), contents)
|
|
assert.deepEqual(reveals.map(card => card.index), Array.from({ length: packSize }, (_, i) => i + 1))
|
|
pack.skip()
|
|
assert.equal(completions, 1)
|
|
})
|
|
|
|
test('ten pack cards share two geometry buffers and release each exactly once', (t) => {
|
|
const { pack } = setupPack(t, false, rollPackContents(['David'], () => 0))
|
|
const geometries = new Set()
|
|
for (const card of pack.cards) card.object.traverse(object => {
|
|
if (object instanceof THREE.Mesh) geometries.add(object.geometry)
|
|
})
|
|
assert.equal(geometries.size, 2)
|
|
const disposal = [...geometries].map(geometry => t.mock.method(geometry, 'dispose'))
|
|
pack.dispose()
|
|
assert.ok(disposal.every(spy => spy.mock.callCount() === 1))
|
|
})
|
|
|
|
test('pack frames report motion and touch settling but settled and hidden scenes stay idle', (t) => {
|
|
const { pack, step } = setupPack(t)
|
|
step()
|
|
assert.equal(pack.tick(performance.now()), false)
|
|
pack.primary()
|
|
assert.equal(pack.tick(performance.now()), true)
|
|
pack.pause(performance.now())
|
|
pack.tick(performance.now()) // Flush the pose/deformation at the pause point.
|
|
assert.equal(pack.tick(performance.now()), false)
|
|
pack.primary()
|
|
step(3201)
|
|
assert.equal(pack.state, 'stackReady')
|
|
assert.equal(pack.tick(performance.now()), false)
|
|
pack.touchTarget = 0.5
|
|
assert.equal(pack.tick(performance.now()), true)
|
|
for (let frame = 0; frame < 100; frame++) step()
|
|
assert.equal(pack.tick(performance.now()), false)
|
|
pack.wrapperDirty = true
|
|
assert.equal(pack.tick(performance.now()), false)
|
|
pack.setActive(false)
|
|
assert.equal(pack.tick(performance.now()), false)
|
|
})
|
|
|
|
|
|
test('streamed packs acquire current and next fronts and release outgoing leases', async (t) => {
|
|
installDOM()
|
|
let now = 1000
|
|
t.mock.method(performance, 'now', () => now)
|
|
const canvas = new TestCanvas()
|
|
const acquired = []
|
|
const released = []
|
|
const placeholder = { artwork: new THREE.Texture(), mask: new THREE.Texture() }
|
|
const contents = [
|
|
{ fixture: 'one', finish: 'Holographic', substrate: 'Linen' },
|
|
{ fixture: 'two', finish: 'Foil', substrate: 'Metal' },
|
|
{ fixture: 'three', finish: 'Printed ink', substrate: 'Paper' },
|
|
]
|
|
const pack = new PackOpening({
|
|
canvas, contents, placeholderTextures: placeholder,
|
|
async acquireTextures(spec) {
|
|
acquired.push(spec.fixture)
|
|
return { textures: { artwork: new THREE.Texture(), mask: new THREE.Texture() },
|
|
release: () => released.push(spec.fixture) }
|
|
},
|
|
resolveSurfaceDefaults(finish, substrate) {
|
|
return { finishStrength: finish === 'Holographic' ? 0.33 : 1,
|
|
roughness: 0.23, normalStrength: substrate === 'Linen' ? 0.05 : 1,
|
|
metalBrushHorizontal: false }
|
|
},
|
|
backMaterial: new THREE.MeshPhysicalMaterial(), normalMap: new THREE.Texture(),
|
|
environment: new THREE.CubeTexture(), lightPosition: new THREE.Vector3(), onChange() {},
|
|
})
|
|
pack.setActive(true)
|
|
pack.skip()
|
|
now += 3200
|
|
pack.tick(now)
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
assert.deepEqual(acquired, ['one'])
|
|
assert.ok(pack.cards.every(card => !card.front.visible))
|
|
assert.equal(pack.cards[0].material.uniforms.finishStrength.value, 0.33)
|
|
assert.equal(pack.cards[0].material.uniforms.normalStrength.value, 0.05)
|
|
|
|
pack.skip()
|
|
assert.equal(pack.state, 'lifted')
|
|
pack.skip()
|
|
assert.equal(pack.state, 'inspecting')
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
assert.deepEqual(acquired, ['one', 'two'])
|
|
assert.equal(pack.cards[0].front.visible, true)
|
|
|
|
pack.skip()
|
|
assert.equal(pack.state, 'stackReady')
|
|
assert.deepEqual(released, ['one'])
|
|
pack.dispose()
|
|
assert.deepEqual(released, ['one', 'two'])
|
|
})
|