553 lines
21 KiB
TypeScript
553 lines
21 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import zlib from 'node:zlib'
|
|
import { execSync } from 'node:child_process'
|
|
import { MISSILE_METAS } from '../../src/render/missiles-meta.ts'
|
|
import { OVERLAY_METAS } from '../../src/render/overlays-meta.ts'
|
|
import { SpriteRenderer } from '../../src/render/renderer.ts'
|
|
|
|
// Helper function to decode 32-bit RGBA PNG
|
|
function decodeRgbaPng(buf: Buffer) {
|
|
const width = buf.readUInt32BE(16)
|
|
const height = buf.readUInt32BE(20)
|
|
const bitDepth = buf[24]!
|
|
const colorType = buf[25]!
|
|
|
|
const idatParts: Buffer[] = []
|
|
let offset = 8
|
|
while (offset < buf.length) {
|
|
const len = buf.readUInt32BE(offset)
|
|
const type = buf.subarray(offset + 4, offset + 8).toString('ascii')
|
|
if (type === 'IDAT') {
|
|
idatParts.push(buf.subarray(offset + 8, offset + 8 + len))
|
|
}
|
|
offset += 12 + len
|
|
}
|
|
const idat = Buffer.concat(idatParts)
|
|
const raw = zlib.inflateSync(idat)
|
|
|
|
const bpp = 4
|
|
const rowBytes = width * bpp
|
|
const pixels = new Uint8Array(width * height * 4)
|
|
let srcAt = 0
|
|
let dstAt = 0
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
const filter = raw[srcAt++]
|
|
const row = raw.subarray(srcAt, srcAt + rowBytes)
|
|
srcAt += rowBytes
|
|
|
|
const prevRow = y > 0 ? pixels.subarray((y - 1) * rowBytes, y * rowBytes) : null
|
|
const curRow = pixels.subarray(dstAt, dstAt + rowBytes)
|
|
|
|
for (let x = 0; x < rowBytes; x++) {
|
|
const left = x >= bpp ? curRow[x - bpp]! : 0
|
|
const up = prevRow ? prevRow[x]! : 0
|
|
const upLeft = prevRow && x >= bpp ? prevRow[x - bpp]! : 0
|
|
|
|
let val = row[x]!
|
|
if (filter === 0) {
|
|
// None
|
|
} else if (filter === 1) {
|
|
// Sub
|
|
val = (val + left) & 0xff
|
|
} else if (filter === 2) {
|
|
// Up
|
|
val = (val + up) & 0xff
|
|
} else if (filter === 3) {
|
|
// Average
|
|
val = (val + Math.floor((left + up) / 2)) & 0xff
|
|
} else if (filter === 4) {
|
|
// Paeth
|
|
const p = left + up - upLeft
|
|
const pa = Math.abs(p - left)
|
|
const pb = Math.abs(p - up)
|
|
const pc = Math.abs(p - upLeft)
|
|
let pr = upLeft
|
|
if (pa <= pb && pa <= pc) pr = left
|
|
else if (pb <= pc) pr = up
|
|
val = (val + pr) & 0xff
|
|
}
|
|
curRow[x] = val
|
|
}
|
|
dstAt += rowBytes
|
|
}
|
|
|
|
return { width, height, bitDepth, colorType, pixels }
|
|
}
|
|
|
|
describe('Milestone M0 — Asset Thresholding & Zero-MPQ Invariants', () => {
|
|
const publicMissilesDir = path.resolve(__dirname, '../../public/missiles')
|
|
const packMissilesDir = path.resolve(__dirname, '../../samples/d2-packs/missiles')
|
|
const publicOverlaysDir = path.resolve(__dirname, '../../public/overlays')
|
|
const packOverlaysDir = path.resolve(__dirname, '../../samples/d2-packs/overlays')
|
|
|
|
const MISSILE_NAMES = [
|
|
// 35 Base Missiles
|
|
'firebolt', 'fireball', 'fireexplode', 'icebolt', 'iceblast', 'glacialspike',
|
|
'iceexplode', 'freezeexplode', 'frozenorb', 'frozenorbexplode',
|
|
'blizzard1', 'blizzard2', 'blizzard3', 'blizzardexplode1', 'blizzardexplode2',
|
|
'chargedbolt', 'teeth', 'teethexplode', 'bonespear', 'bonespirit', 'bonespiritexplode',
|
|
'poisonnova', 'holybolt', 'blessedhammer', 'magicarrow', 'firearrow', 'icearrow',
|
|
'arrow', 'lightningjavelin', 'meteor', 'meteortail', 'meteorcenter', 'meteorexplode',
|
|
'meteorfire', 'firesmall',
|
|
// 9 New M0 Missiles
|
|
'nova', 'lightningstrike', 'thunderstorm1', 'sparkle', 'freezingarrowexp2',
|
|
'firewall', 'infernoflame1', 'infernoflame2', 'frostnova',
|
|
// Aliases
|
|
'blizzard4', 'blizzardexplode3', 'meteortargetindicator', 'firemedium',
|
|
'electricnova', 'chainlightning', 'lightninghit', 'thunderstorm', 'lightningboltbig',
|
|
'gleam', 'freezeexplodeejecta', 'glacialspikeejecta', 'blaze', 'firewallmaker',
|
|
'groundfirebig', 'flamethrower', 'flamethrower2', 'coldunique', 'mephfrostnova',
|
|
]
|
|
|
|
const OVERLAY_NAMES = [
|
|
// 8 Cast Overlays
|
|
'fire_cast_1', 'fire_cast_2', 'ice_cast_1', 'ice_cast_2', 'ice_cast_3',
|
|
'light_cast_1', 'light_cast_2', 'teleport',
|
|
// 9 New M0 Overlays
|
|
'lightning', 'frozenarmor', 'frozenarmor_hit', 'energyshield', 'energyshieldhit0',
|
|
'thunderstormcast', 'thunderstormback', 'enchant', 'ice_explode',
|
|
]
|
|
|
|
describe('Suite 1: Asset File Existence & Mirroring Invariants', () => {
|
|
it('1.1 Verifies all missile PNG and JSON files exist in public/ and samples/d2-packs/', () => {
|
|
for (const name of MISSILE_NAMES) {
|
|
for (const dir of [publicMissilesDir, packMissilesDir]) {
|
|
const pngPath = path.join(dir, `${name}.png`)
|
|
const jsonPath = path.join(dir, `${name}.json`)
|
|
expect(fs.existsSync(pngPath), `Missing missile PNG: ${pngPath}`).toBe(true)
|
|
expect(fs.existsSync(jsonPath), `Missing missile JSON: ${jsonPath}`).toBe(true)
|
|
|
|
const stat = fs.statSync(pngPath)
|
|
expect(stat.size).toBeGreaterThan(1000)
|
|
|
|
const buf = fs.readFileSync(pngPath)
|
|
expect(buf[0]).toBe(0x89)
|
|
expect(buf[1]).toBe(0x50) // 'P'
|
|
expect(buf[2]).toBe(0x4e) // 'N'
|
|
expect(buf[3]).toBe(0x47) // 'G'
|
|
}
|
|
}
|
|
})
|
|
|
|
it('1.2 Verifies all overlay PNG and JSON files exist in public/ and samples/d2-packs/', () => {
|
|
for (const name of OVERLAY_NAMES) {
|
|
for (const dir of [publicOverlaysDir, packOverlaysDir]) {
|
|
const pngPath = path.join(dir, `${name}.png`)
|
|
const jsonPath = path.join(dir, `${name}.json`)
|
|
expect(fs.existsSync(pngPath), `Missing overlay PNG: ${pngPath}`).toBe(true)
|
|
expect(fs.existsSync(jsonPath), `Missing overlay JSON: ${jsonPath}`).toBe(true)
|
|
|
|
const stat = fs.statSync(pngPath)
|
|
expect(stat.size).toBeGreaterThan(1000)
|
|
|
|
const buf = fs.readFileSync(pngPath)
|
|
expect(buf[0]).toBe(0x89)
|
|
expect(buf[1]).toBe(0x50)
|
|
expect(buf[2]).toBe(0x4e)
|
|
expect(buf[3]).toBe(0x47)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Suite 2: Metadata Registry & Schema Typing Invariants', () => {
|
|
it('2.1 Verifies MISSILE_METAS contains complete, valid entries for all missiles', () => {
|
|
for (const name of MISSILE_NAMES) {
|
|
const meta = MISSILE_METAS[name]
|
|
expect(meta, `MISSILE_METAS missing entry: ${name}`).toBeDefined()
|
|
expect(meta!.name).toBe(name)
|
|
expect(meta!.celFile.length).toBeGreaterThan(0)
|
|
expect(meta!.width).toBeGreaterThan(0)
|
|
expect(meta!.height).toBeGreaterThan(0)
|
|
expect(meta!.directions).toBeGreaterThan(0)
|
|
expect(meta!.framesPerDirection).toBeGreaterThan(0)
|
|
expect(meta!.animSpeed).toBeGreaterThan(0)
|
|
expect(meta!.groups.length).toBe(meta!.directions)
|
|
|
|
for (const group of meta!.groups) {
|
|
expect(group.length).toBe(meta!.framesPerDirection)
|
|
for (const [x, y, w, h, ax, ay] of group) {
|
|
expect(x).toBeGreaterThanOrEqual(0)
|
|
expect(y).toBeGreaterThanOrEqual(0)
|
|
expect(x + w).toBeLessThanOrEqual(meta!.width)
|
|
expect(y + h).toBeLessThanOrEqual(meta!.height)
|
|
expect(w).toBeGreaterThan(0)
|
|
expect(h).toBeGreaterThan(0)
|
|
expect(typeof ax).toBe('number')
|
|
expect(typeof ay).toBe('number')
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('2.2 Verifies OVERLAY_METAS contains complete, valid entries for all overlays', () => {
|
|
for (const name of OVERLAY_NAMES) {
|
|
const meta = OVERLAY_METAS[name]
|
|
expect(meta, `OVERLAY_METAS missing entry: ${name}`).toBeDefined()
|
|
expect(meta!.name).toBe(name)
|
|
expect(meta!.celFile.length).toBeGreaterThan(0)
|
|
expect(meta!.width).toBeGreaterThan(0)
|
|
expect(meta!.height).toBeGreaterThan(0)
|
|
expect(meta!.directions).toBeGreaterThan(0)
|
|
expect(meta!.frames).toBeGreaterThan(0)
|
|
expect(meta!.framesPerDirection).toBeGreaterThan(0)
|
|
expect(meta!.animRate).toBeGreaterThan(0)
|
|
expect(meta!.groups.length).toBe(meta!.directions)
|
|
expect(meta!.groups[0]?.length).toBe(meta!.frames)
|
|
}
|
|
})
|
|
|
|
it('2.3 Verifies CelFile casing lookups for MISSILE_METAS (e.g. ElectricNova !== undefined)', () => {
|
|
const CEL_CASING_CHECKS = [
|
|
// 9 New M0 Missiles & Aliases
|
|
'ElectricNova',
|
|
'LightningStrike',
|
|
'LightningboltBig',
|
|
'Gleam',
|
|
'FreezeExplodeEjecta',
|
|
'groundFireBig',
|
|
'Flamethrower',
|
|
'Flamethrower2',
|
|
'FrostNova',
|
|
// Primary Class Missiles
|
|
'ChargedBolt',
|
|
'Firebolt',
|
|
'Fireball',
|
|
'FireArrowExplode2',
|
|
'Icebolt',
|
|
'IceBlast',
|
|
'GlacialSpike',
|
|
'IceOrb',
|
|
'Blizzard',
|
|
'BoneSpear',
|
|
'BoneSpirit',
|
|
'HolyBoltMissile',
|
|
'SafeArrow',
|
|
]
|
|
|
|
for (const celFile of CEL_CASING_CHECKS) {
|
|
const meta = MISSILE_METAS[celFile]
|
|
expect(meta, `MISSILE_METAS['${celFile}'] must not be undefined`).toBeDefined()
|
|
expect(meta!.width, `${celFile} width > 0`).toBeGreaterThan(0)
|
|
expect(meta!.height, `${celFile} height > 0`).toBeGreaterThan(0)
|
|
expect(meta!.groups.length, `${celFile} groups > 0`).toBeGreaterThan(0)
|
|
|
|
// Also assert lowercased variant resolves
|
|
const lowerMeta = MISSILE_METAS[celFile.toLowerCase()]
|
|
expect(lowerMeta, `MISSILE_METAS['${celFile.toLowerCase()}'] must not be undefined`).toBeDefined()
|
|
}
|
|
})
|
|
|
|
it('2.4 Verifies CelFile casing lookups for OVERLAY_METAS', () => {
|
|
const OVERLAY_CEL_CHECKS = [
|
|
'ShockHitSm',
|
|
'FrozenArmor',
|
|
'AuraResistColdCast',
|
|
'ManaShieldBall',
|
|
'ManaShieldDir00',
|
|
'ThunderstormCast',
|
|
'ThunderstormCast_operate',
|
|
'FireEnchant',
|
|
'IceExplode',
|
|
'FireCast_for_Sorceress',
|
|
'FireCast2',
|
|
'IceCastNew01',
|
|
'LightningCast',
|
|
'Teleport',
|
|
]
|
|
|
|
for (const celFile of OVERLAY_CEL_CHECKS) {
|
|
const meta = OVERLAY_METAS[celFile]
|
|
expect(meta, `OVERLAY_METAS['${celFile}'] must not be undefined`).toBeDefined()
|
|
expect(meta!.width, `${celFile} width > 0`).toBeGreaterThan(0)
|
|
expect(meta!.height, `${celFile} height > 0`).toBeGreaterThan(0)
|
|
expect(meta!.frames, `${celFile} frames > 0`).toBeGreaterThan(0)
|
|
|
|
const lowerMeta = OVERLAY_METAS[celFile.toLowerCase()]
|
|
expect(lowerMeta, `OVERLAY_METAS['${celFile.toLowerCase()}'] must not be undefined`).toBeDefined()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Suite 3: 32-bit RGBA Decoding, Boundary & Zero Black Border Thresholding Invariants', () => {
|
|
const MAGICAL_MISSILES = [
|
|
'nova', 'lightningstrike', 'thunderstorm1', 'sparkle', 'freezingarrowexp2',
|
|
'firewall', 'infernoflame1', 'infernoflame2', 'frostnova',
|
|
'firebolt', 'fireball', 'icebolt', 'chargedbolt',
|
|
]
|
|
|
|
it('3.1 Verifies magical missiles have 32-bit RGBA color type and zero opaque black pixels', () => {
|
|
for (const name of MAGICAL_MISSILES) {
|
|
const pngPath = path.join(publicMissilesDir, `${name}.png`)
|
|
const buf = fs.readFileSync(pngPath)
|
|
const decoded = decodeRgbaPng(buf)
|
|
|
|
expect(decoded.colorType).toBe(6) // 32-bit RGBA
|
|
expect(decoded.bitDepth).toBe(8)
|
|
|
|
let blackOpaqueCount = 0
|
|
let visibleSpellCount = 0
|
|
let edgeGradientCount = 0
|
|
|
|
for (let i = 0; i < decoded.pixels.length; i += 4) {
|
|
const r = decoded.pixels[i]!
|
|
const g = decoded.pixels[i + 1]!
|
|
const b = decoded.pixels[i + 2]!
|
|
const a = decoded.pixels[i + 3]!
|
|
const m = Math.max(r, g, b)
|
|
|
|
if (m <= 4 && a > 0) {
|
|
blackOpaqueCount++
|
|
}
|
|
if (m >= 30 && a > 0) {
|
|
visibleSpellCount++
|
|
}
|
|
if (m > 4 && m < 60 && a > 0 && a < 255) {
|
|
edgeGradientCount++
|
|
}
|
|
}
|
|
|
|
expect(blackOpaqueCount, `${name}.png contains ${blackOpaqueCount} opaque black pixels!`).toBe(0)
|
|
expect(visibleSpellCount, `${name}.png must contain visible core spell pixels`).toBeGreaterThan(0)
|
|
expect(edgeGradientCount, `${name}.png must contain smoothed edge gradient pixels`).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('3.2 Verifies physical arrow preserves opaque black outline pixels (trans: 0)', () => {
|
|
const arrowPath = path.join(publicMissilesDir, 'arrow.png')
|
|
const buf = fs.readFileSync(arrowPath)
|
|
const decoded = decodeRgbaPng(buf)
|
|
|
|
expect(decoded.colorType).toBe(6)
|
|
expect(decoded.bitDepth).toBe(8)
|
|
|
|
let blackOpaqueCount = 0
|
|
for (let i = 0; i < decoded.pixels.length; i += 4) {
|
|
const r = decoded.pixels[i]!
|
|
const g = decoded.pixels[i + 1]!
|
|
const b = decoded.pixels[i + 2]!
|
|
const a = decoded.pixels[i + 3]!
|
|
const m = Math.max(r, g, b)
|
|
if (m <= 4 && a === 255) {
|
|
blackOpaqueCount++
|
|
}
|
|
}
|
|
|
|
// arrow has physical black outline pixels per trans: 0
|
|
expect(blackOpaqueCount, 'arrow.png must retain physical black outline pixels').toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('Suite 4: Zero Runtime MPQ Invariant (Static Code Scan)', () => {
|
|
it('4.1 Verifies metadata modules contain zero MPQ, DLL or fs imports', () => {
|
|
const missilesMetaPath = path.resolve(__dirname, '../../src/render/missiles-meta.ts')
|
|
const overlaysMetaPath = path.resolve(__dirname, '../../src/render/overlays-meta.ts')
|
|
|
|
for (const p of [missilesMetaPath, overlaysMetaPath]) {
|
|
const content = fs.readFileSync(p, 'utf-8')
|
|
expect(content).not.toContain('node:fs')
|
|
expect(content).not.toContain('src/mpq')
|
|
expect(content).not.toContain('.mpq')
|
|
expect(content).not.toContain('.dll')
|
|
}
|
|
})
|
|
|
|
it('4.2 Verifies core missile engine contains zero MPQ imports', () => {
|
|
const enginePath = path.resolve(__dirname, '../../src/game/engine/missile-engine.ts')
|
|
if (fs.existsSync(enginePath)) {
|
|
const content = fs.readFileSync(enginePath, 'utf-8')
|
|
expect(content).not.toContain('src/mpq')
|
|
expect(content).not.toContain('.mpq')
|
|
}
|
|
})
|
|
|
|
it('4.3 Verifies public/ assets are self-contained JSON and PNG with no external MPQ dependencies', () => {
|
|
for (const name of ['nova', 'lightning', 'firewall', 'energyshield']) {
|
|
const jsonPath = fs.existsSync(path.join(publicMissilesDir, `${name}.json`))
|
|
? path.join(publicMissilesDir, `${name}.json`)
|
|
: path.join(publicOverlaysDir, `${name}.json`)
|
|
const content = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
|
expect(content.name).toBe(name)
|
|
expect(content.groups.length).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('4.4 Verifies package.json scripts contain pack:missiles and pack:overlays', () => {
|
|
const pkgPath = path.resolve(__dirname, '../../package.json')
|
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
|
|
expect(pkg.scripts, 'package.json missing scripts').toBeDefined()
|
|
expect(pkg.scripts['pack:missiles'], 'package.json missing pack:missiles').toBe('tsx scripts/pack-missiles.ts')
|
|
expect(pkg.scripts['pack:overlays'], 'package.json missing pack:overlays').toBe('tsx scripts/pack-overlays.ts')
|
|
|
|
// Verify physical script files exist on disk
|
|
const missilesScript = path.resolve(__dirname, '../../scripts/pack-missiles.ts')
|
|
const overlaysScript = path.resolve(__dirname, '../../scripts/pack-overlays.ts')
|
|
expect(fs.existsSync(missilesScript), 'scripts/pack-missiles.ts missing').toBe(true)
|
|
expect(fs.existsSync(overlaysScript), 'scripts/pack-overlays.ts missing').toBe(true)
|
|
})
|
|
|
|
it('4.5 Verifies npm run pack:overlays executes cleanly with zero exit code', () => {
|
|
const projectRoot = path.resolve(__dirname, '../..')
|
|
const output = execSync('npm run pack:overlays', {
|
|
cwd: projectRoot,
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
})
|
|
expect(output).toContain('[Overlay Packer] Baked')
|
|
expect(output).toContain('overlays-meta.ts')
|
|
})
|
|
})
|
|
|
|
describe('Suite 5: WebGL Additive Blending & Renderer State Isolation', () => {
|
|
function createMockRenderer() {
|
|
let currentBlendMode = 'normal'
|
|
let flushCount = 0
|
|
let blendFuncCalls: [number, number][] = []
|
|
|
|
const gl: any = {
|
|
FLOAT: 5126,
|
|
UNSIGNED_SHORT: 5123,
|
|
TRIANGLES: 4,
|
|
ARRAY_BUFFER: 34962,
|
|
ELEMENT_ARRAY_BUFFER: 34963,
|
|
STATIC_DRAW: 35044,
|
|
DYNAMIC_DRAW: 35048,
|
|
RGBA: 6408,
|
|
UNSIGNED_BYTE: 5121,
|
|
TEXTURE_2D: 3553,
|
|
LINEAR: 9729,
|
|
TEXTURE_MIN_FILTER: 10241,
|
|
TEXTURE_MAG_FILTER: 10240,
|
|
TEXTURE_WRAP_S: 10242,
|
|
TEXTURE_WRAP_T: 10243,
|
|
CLAMP_TO_EDGE: 33071,
|
|
NEAREST: 9728,
|
|
UNPACK_ALIGNMENT: 3317,
|
|
BLEND: 3042,
|
|
SRC_ALPHA: 770,
|
|
ONE: 1,
|
|
ONE_MINUS_SRC_ALPHA: 771,
|
|
DEPTH_TEST: 2929,
|
|
COLOR_BUFFER_BIT: 16384,
|
|
TEXTURE0: 33984,
|
|
drawingBufferWidth: 800,
|
|
drawingBufferHeight: 600,
|
|
isContextLost: () => false,
|
|
createProgram: () => ({ id: 1, type: 'program' }),
|
|
deleteProgram: () => {},
|
|
attachShader: () => {},
|
|
detachShader: () => {},
|
|
linkProgram: () => {},
|
|
getProgramParameter: () => true,
|
|
getProgramInfoLog: () => '',
|
|
useProgram: () => {},
|
|
createShader: (type: number) => ({ id: 1, type }),
|
|
shaderSource: () => {},
|
|
compileShader: () => {},
|
|
getShaderParameter: () => true,
|
|
getShaderInfoLog: () => '',
|
|
deleteShader: () => {},
|
|
createVertexArray: () => ({ id: 1, type: 'vao' }),
|
|
deleteVertexArray: () => {},
|
|
bindVertexArray: () => {},
|
|
createBuffer: () => ({ id: 1, type: 'buffer' }),
|
|
deleteBuffer: () => {},
|
|
bindBuffer: () => {},
|
|
bufferData: () => {},
|
|
bufferSubData: () => {},
|
|
getAttribLocation: () => 0,
|
|
enableVertexAttribArray: () => {},
|
|
vertexAttribPointer: () => {},
|
|
createTexture: () => ({ id: 1, type: 'texture' }),
|
|
deleteTexture: () => {},
|
|
bindTexture: () => {},
|
|
texParameteri: () => {},
|
|
pixelStorei: () => {},
|
|
texImage2D: () => {},
|
|
getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0),
|
|
getUniformLocation: (_p: any, name: string) => ({ name }),
|
|
uniform2f: () => {},
|
|
uniform1f: () => {},
|
|
uniform1i: () => {},
|
|
uniform1iv: () => {},
|
|
enable: () => {},
|
|
disable: () => {},
|
|
blendFunc: (s: number, d: number) => {
|
|
blendFuncCalls.push([s, d])
|
|
},
|
|
viewport: () => {},
|
|
clearColor: () => {},
|
|
clear: () => {},
|
|
activeTexture: () => {},
|
|
drawArrays: () => {},
|
|
drawElements: () => {},
|
|
}
|
|
const canvas: any = {
|
|
width: 800,
|
|
height: 600,
|
|
clientWidth: 800,
|
|
clientHeight: 600,
|
|
getContext: (type: string) => (type === 'webgl2' ? gl : null),
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
}
|
|
const renderer = new SpriteRenderer(canvas as HTMLCanvasElement)
|
|
const origFlush = renderer.flush.bind(renderer)
|
|
renderer.flush = () => {
|
|
flushCount++
|
|
origFlush()
|
|
}
|
|
return { renderer, gl, getBlendCalls: () => blendFuncCalls, getFlushCount: () => flushCount }
|
|
}
|
|
|
|
it('5.1 Verifies setBlendMode switches blendFunc between additive and normal with batch flush', () => {
|
|
const { renderer, gl, getBlendCalls, getFlushCount } = createMockRenderer()
|
|
|
|
// Default is 'normal'
|
|
expect(renderer.blendMode).toBe('normal')
|
|
|
|
// Switch to 'additive'
|
|
renderer.setBlendMode('additive')
|
|
expect(renderer.blendMode).toBe('additive')
|
|
const callsAfterAdd = getBlendCalls()
|
|
expect(callsAfterAdd.length).toBeGreaterThan(0)
|
|
const lastAddCall = callsAfterAdd[callsAfterAdd.length - 1]!
|
|
expect(lastAddCall[0]).toBe(gl.SRC_ALPHA)
|
|
expect(lastAddCall[1]).toBe(gl.ONE)
|
|
expect(getFlushCount()).toBeGreaterThan(0)
|
|
|
|
// Redundant setBlendMode('additive') must not flush again or re-invoke blendFunc
|
|
const flushesBeforeNoop = getFlushCount()
|
|
const callsBeforeNoop = callsAfterAdd.length
|
|
renderer.setBlendMode('additive')
|
|
expect(getFlushCount()).toBe(flushesBeforeNoop)
|
|
expect(getBlendCalls().length).toBe(callsBeforeNoop)
|
|
|
|
// Switch back to 'normal'
|
|
renderer.setBlendMode('normal')
|
|
expect(renderer.blendMode).toBe('normal')
|
|
const callsAfterNorm = getBlendCalls()
|
|
const lastNormCall = callsAfterNorm[callsAfterNorm.length - 1]!
|
|
expect(lastNormCall[0]).toBe(gl.SRC_ALPHA)
|
|
expect(lastNormCall[1]).toBe(gl.ONE_MINUS_SRC_ALPHA)
|
|
expect(getFlushCount()).toBeGreaterThan(flushesBeforeNoop)
|
|
})
|
|
|
|
it('5.2 Verifies renderer disposal safely inhibits blendMode state mutations', () => {
|
|
const { renderer, getBlendCalls } = createMockRenderer()
|
|
renderer.setBlendMode('additive')
|
|
const callsCount = getBlendCalls().length
|
|
|
|
renderer.dispose()
|
|
expect(renderer.isDisposed).toBe(true)
|
|
|
|
// Calls after disposal must safely no-op
|
|
renderer.setBlendMode('normal')
|
|
expect(getBlendCalls().length).toBe(callsCount)
|
|
})
|
|
})
|
|
})
|