444 lines
19 KiB
TypeScript
444 lines
19 KiB
TypeScript
/**
|
|
* Adversarial Stress & Correctness Verification Suite — Milestone M4
|
|
* Skills: Thunder Storm (#057), Energy Shield (#058), Lightning Mastery (#063)
|
|
* Author: reviewer_lightning_m4 (teamwork_preview_reviewer / critic)
|
|
* Ground Truth: Diablo II v1.13c (D2Game.dll, D2Common.dll, Skills.txt, Missiles.txt)
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
|
|
import { UnitStatList, FIXED_ONE } from '../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../src/game/engine/state-bus.ts'
|
|
import { executeSUnitDmg, type CombatUnitContext } from '../src/game/engine/combat-pipeline.ts'
|
|
import { GameEngine } from '../src/game/engine.ts'
|
|
import {
|
|
calculateThunderStormDuration,
|
|
calculateThunderStormInterval,
|
|
calculateThunderStormDamage,
|
|
calculateEnergyShieldDuration,
|
|
calculateEnergyShieldAbsorbPct,
|
|
calculateEnergyShieldManaRatio,
|
|
calculateLightningMasteryBonusPct,
|
|
calculateChargedBoltDamage,
|
|
calculateNovaDamage,
|
|
calculateLightningDamage,
|
|
calculateChainLightningDamage,
|
|
calculateSkillDamage,
|
|
BATCH1_SKILLS,
|
|
CANONICAL_113C_MISSILES,
|
|
} from '../src/game/skills.ts'
|
|
import { getSkillRange, getSkillMana } from '../src/scene/act-scene.ts'
|
|
|
|
describe('Adversarial Stress Test: Milestone M4 (Skills 57, 58, 63)', () => {
|
|
// =========================================================================
|
|
// 1. Thunder Storm (57) Adversarial Stress
|
|
// =========================================================================
|
|
describe('1. Thunder Storm (57) Stress Testing', () => {
|
|
it('stresses 5-band piecewise progression across 1..99 levels', () => {
|
|
// Band 1: lvl 1..8 (+10/+10)
|
|
expect(calculateThunderStormDamage(1)).toEqual({ min: 1, max: 100 })
|
|
expect(calculateThunderStormDamage(8)).toEqual({ min: 71, max: 170 })
|
|
// Band 2: lvl 9..16 (+10/+10)
|
|
expect(calculateThunderStormDamage(9)).toEqual({ min: 81, max: 180 })
|
|
expect(calculateThunderStormDamage(16)).toEqual({ min: 151, max: 250 })
|
|
// Band 3: lvl 17..22 (+11/+11)
|
|
expect(calculateThunderStormDamage(17)).toEqual({ min: 162, max: 261 })
|
|
expect(calculateThunderStormDamage(22)).toEqual({ min: 217, max: 316 })
|
|
// Band 4: lvl 23..28 (+11/+11)
|
|
expect(calculateThunderStormDamage(23)).toEqual({ min: 228, max: 327 })
|
|
expect(calculateThunderStormDamage(28)).toEqual({ min: 283, max: 382 })
|
|
// Band 5: lvl 29+ (+11/+11)
|
|
expect(calculateThunderStormDamage(29)).toEqual({ min: 294, max: 393 })
|
|
expect(calculateThunderStormDamage(40)).toEqual({ min: 415, max: 514 })
|
|
expect(calculateThunderStormDamage(99)).toEqual({ min: 1064, max: 1163 })
|
|
})
|
|
|
|
it('stresses cadence interval hyperbolic decay and floor clamping at 25 frames', () => {
|
|
// Interval formula: max(25, 125 - floor(110 * slvl / (slvl + 6)))
|
|
expect(calculateThunderStormInterval(1)).toBe(110)
|
|
expect(calculateThunderStormInterval(5)).toBe(75)
|
|
expect(calculateThunderStormInterval(10)).toBe(57)
|
|
expect(calculateThunderStormInterval(20)).toBe(41)
|
|
expect(calculateThunderStormInterval(30)).toBe(34)
|
|
expect(calculateThunderStormInterval(40)).toBe(30)
|
|
expect(calculateThunderStormInterval(50)).toBe(27)
|
|
expect(calculateThunderStormInterval(60)).toBe(25) // exactly reaches 25 floor!
|
|
expect(calculateThunderStormInterval(70)).toBe(25)
|
|
expect(calculateThunderStormInterval(100)).toBe(25)
|
|
expect(calculateThunderStormInterval(999)).toBe(25)
|
|
})
|
|
|
|
it('stresses edge inputs for duration, interval, damage', () => {
|
|
expect(calculateThunderStormDuration(0)).toBe(800)
|
|
expect(calculateThunderStormDuration(-99)).toBe(800)
|
|
expect(calculateThunderStormDuration(NaN as any)).toBe(800)
|
|
|
|
expect(calculateThunderStormInterval(0)).toBe(110)
|
|
expect(calculateThunderStormInterval(-10)).toBe(110)
|
|
expect(calculateThunderStormInterval(NaN as any)).toBe(110)
|
|
|
|
expect(calculateThunderStormDamage(0)).toEqual({ min: 1, max: 100 })
|
|
expect(calculateThunderStormDamage(-10)).toEqual({ min: 1, max: 100 })
|
|
expect(calculateThunderStormDamage(NaN as any)).toEqual({ min: 1, max: 100 })
|
|
})
|
|
|
|
it('stresses runtime GameEngine strike targeting nearest monster within 340px', () => {
|
|
const terrain = {
|
|
widthPx: 1280,
|
|
heightPx: 960,
|
|
overlap: (_x: number, _y: number) => 0,
|
|
isMissileBlocked: () => false,
|
|
} as any
|
|
const engine = new GameEngine(terrain, {
|
|
spawn: { x: 500, y: 500 },
|
|
monsterCount: 0,
|
|
stats: [],
|
|
skills: ['attack'] as any,
|
|
xpTable: [0, 500],
|
|
combatOptions: {
|
|
playerSpeed: 100,
|
|
playerReach: 40,
|
|
playerCooldownTicks: 15,
|
|
playerDamage: 10,
|
|
playerManaPerAttack: 0,
|
|
respawnTicks: 75,
|
|
disableMonsterAggro: true,
|
|
},
|
|
npcRingFallback: false,
|
|
} as any)
|
|
|
|
// Player at (500, 500) with active Thunder Storm (interval 1 tick for testing)
|
|
engine.world.player.x = 500
|
|
engine.world.player.y = 500
|
|
engine.world.player.thunderstormTicks = 100
|
|
engine.world.player.thunderstormInterval = 1
|
|
engine.world.player.thunderstormNextStrike = 1
|
|
engine.world.player.thunderstormDamage = 200
|
|
|
|
// Add three monsters:
|
|
// M0: dead monster at (510, 500) (dist 10) - must be ignored!
|
|
// M1: live monster at (600, 500) (dist 100) - closest valid!
|
|
// M2: live monster at (750, 500) (dist 250) - farther!
|
|
// M3: live monster at (900, 500) (dist 400) - outside 340px!
|
|
engine.world.monsters = [
|
|
{ index: 0, id: 'm0', x: 510, y: 500, hp: 0, maxHp: 500, state: 'dead', stats: { name: 'm0', id: 'm0', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
|
|
{ index: 1, id: 'm1', x: 600, y: 500, hp: 500, maxHp: 500, state: 'idle', stats: { name: 'm1', id: 'm1', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
|
|
{ index: 2, id: 'm2', x: 750, y: 500, hp: 500, maxHp: 500, state: 'idle', stats: { name: 'm2', id: 'm2', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
|
|
{ index: 3, id: 'm3', x: 900, y: 500, hp: 500, maxHp: 500, state: 'idle', stats: { name: 'm3', id: 'm3', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
|
|
]
|
|
|
|
// Tick engine by 1 frame
|
|
engine.tick({
|
|
movement: { x: 0, y: 0 },
|
|
attacking: false,
|
|
picking: false,
|
|
dropping: null,
|
|
digits: [],
|
|
} as any)
|
|
|
|
// M1 (closest alive inside 340px) should take 200 damage!
|
|
expect(engine.world.monsters[1]?.hp).toBe(300)
|
|
// M2 should take 0 damage
|
|
expect(engine.world.monsters[2]?.hp).toBe(500)
|
|
// M3 should take 0 damage
|
|
expect(engine.world.monsters[3]?.hp).toBe(500)
|
|
// Overlay light_cast_2 should spawn on M1
|
|
const ov = engine.overlays.find(o => o.overlayName === 'light_cast_2')
|
|
expect(ov).toBeDefined()
|
|
expect(ov?.x).toBe(600)
|
|
expect(ov?.y).toBe(500)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 2. Energy Shield (58) Adversarial Stress
|
|
// =========================================================================
|
|
describe('2. Energy Shield (58) Stress Testing', () => {
|
|
it('stresses 95% cap across high skill levels', () => {
|
|
expect(calculateEnergyShieldAbsorbPct(40)).toBe(95)
|
|
expect(calculateEnergyShieldAbsorbPct(41)).toBe(95)
|
|
expect(calculateEnergyShieldAbsorbPct(60)).toBe(95)
|
|
expect(calculateEnergyShieldAbsorbPct(99)).toBe(95)
|
|
expect(calculateEnergyShieldAbsorbPct(999)).toBe(95)
|
|
})
|
|
|
|
it('stresses Telekinesis synergy ratio boundaries and soft point immutability', () => {
|
|
// 0 hard points -> 2.0
|
|
expect(calculateEnergyShieldManaRatio(0)).toBe(2.0)
|
|
// 1..20 hard points -> 1/16 less per point
|
|
expect(calculateEnergyShieldManaRatio(1)).toBe(31 / 16)
|
|
expect(calculateEnergyShieldManaRatio(8)).toBe(24 / 16)
|
|
expect(calculateEnergyShieldManaRatio(16)).toBe(1.0)
|
|
expect(calculateEnergyShieldManaRatio(20)).toBe(0.75)
|
|
// 21+ points -> strictly clamped at 0.75
|
|
expect(calculateEnergyShieldManaRatio(21)).toBe(0.75)
|
|
expect(calculateEnergyShieldManaRatio(40)).toBe(0.75)
|
|
expect(calculateEnergyShieldManaRatio(99)).toBe(0.75)
|
|
// Negative points -> strictly clamped at 2.0
|
|
expect(calculateEnergyShieldManaRatio(-1)).toBe(2.0)
|
|
expect(calculateEnergyShieldManaRatio(-50)).toBe(2.0)
|
|
})
|
|
|
|
it('stresses pre-resistance vs post-resistance mathematical difference', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
|
const defenderStats = new UnitStatList(registry, {
|
|
maxhp: 1000 * FIXED_ONE,
|
|
hitpoints: 1000 * FIXED_ONE,
|
|
maxmana: 2000 * FIXED_ONE,
|
|
mana: 2000 * FIXED_ONE,
|
|
lightresist: 75, // 75% Lightning Resistance
|
|
})
|
|
// TK blvl = 16 (1.0 ratio)
|
|
defenderStats.setBaseSkillLevel(43, 16)
|
|
const attackerBus = new StateBus(attackerStats, registry)
|
|
const defenderBus = new StateBus(defenderStats, registry)
|
|
defenderBus.applyState({
|
|
stateNameOrId: 'energyshield',
|
|
sourceSkillId: 58,
|
|
slvl: 20, // 75% absorb
|
|
})
|
|
|
|
const attackerCtx: CombatUnitContext = { id: 'a', name: 'A', statList: attackerStats, stateBus: attackerBus }
|
|
const defenderCtx: CombatUnitContext = { id: 'd', name: 'D', statList: defenderStats, stateBus: defenderBus }
|
|
|
|
// 1000 raw lightning damage
|
|
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
|
|
skillId: 49,
|
|
attackKind: 'spell',
|
|
autoHit: true,
|
|
elemType: 'ltng',
|
|
elemMin256: 1000 * FIXED_ONE,
|
|
elemMax256: 1000 * FIXED_ONE,
|
|
})
|
|
|
|
// Ground truth 1.13c Pre-Resistance order:
|
|
// 1. Raw: 1000 ltng.
|
|
// 2. ES absorbs 75% = 750 damage.
|
|
// 3. Mana drained: 750 * 1.0 = 750 mana.
|
|
// 4. Remaining raw ltng: 250.
|
|
// 5. 75% LightRes mitigates remaining 250 down to 62.5 (250 * 0.25).
|
|
// 6. 256-fixed-point rounding: Math.ceil(16000 / 256) = 63.
|
|
expect(defenderStats.getMana()).toBe(1250) // 2000 - 750
|
|
expect(res.totalDamage).toBe(63)
|
|
expect(defenderStats.getHp()).toBe(938)
|
|
})
|
|
|
|
it('stresses mixed physical and poison damage: physical is absorbed while poison 100% bypasses', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const stats = new UnitStatList(registry, {
|
|
maxhp: 1000 * FIXED_ONE,
|
|
hitpoints: 1000 * FIXED_ONE,
|
|
maxmana: 1000 * FIXED_ONE,
|
|
mana: 1000 * FIXED_ONE,
|
|
})
|
|
stats.setBaseSkillLevel(43, 16) // 1.0 ratio
|
|
const bus = new StateBus(stats, registry)
|
|
bus.applyState({
|
|
stateNameOrId: 'energyshield',
|
|
sourceSkillId: 58,
|
|
slvl: 20, // 75% absorb
|
|
})
|
|
|
|
const out = bus.triggerReactiveEvents({
|
|
event: 'absorbdamage',
|
|
incomingPhys256: 200 * FIXED_ONE,
|
|
incomingElem256: 100 * FIXED_ONE,
|
|
elemType: 'pois',
|
|
isPoison: true,
|
|
})
|
|
|
|
// Physical: 75% of 200 = 150 absorbed, 50 remains
|
|
expect(out.absorbedPhys256).toBe(150 * FIXED_ONE)
|
|
// Poison: 0% absorbed! 100 remains
|
|
expect(out.absorbedElem256).toBe(0)
|
|
// Mana drained: only 150 mana (from physical), 0 from poison!
|
|
expect(out.manaDrained256).toBe(150 * FIXED_ONE)
|
|
expect(stats.getMana()).toBe(850)
|
|
})
|
|
|
|
it('stresses extreme overkill damage and shield collapse', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const stats = new UnitStatList(registry, {
|
|
maxhp: 5000 * FIXED_ONE,
|
|
hitpoints: 5000 * FIXED_ONE,
|
|
maxmana: 100 * FIXED_ONE,
|
|
mana: 10 * FIXED_ONE, // only 10 mana left!
|
|
})
|
|
stats.setBaseSkillLevel(43, 0) // 2.0 ratio: 10 mana can only absorb 5 damage
|
|
const bus = new StateBus(stats, registry)
|
|
bus.applyState({
|
|
stateNameOrId: 'energyshield',
|
|
sourceSkillId: 58,
|
|
slvl: 20, // 75% absorb
|
|
})
|
|
|
|
// Incoming: 50,000 damage
|
|
const out = bus.triggerReactiveEvents({
|
|
event: 'absorbdamage',
|
|
incomingPhys256: 50000 * FIXED_ONE,
|
|
incomingElem256: 0,
|
|
})
|
|
|
|
// 10 mana / 2.0 = 5 damage absorbed
|
|
expect(out.absorbedPhys256).toBe(5 * FIXED_ONE)
|
|
expect(out.manaDrained256).toBe(10 * FIXED_ONE)
|
|
expect(stats.getMana()).toBe(0)
|
|
// Shield collapsed
|
|
expect(bus.hasState('energyshield')).toBe(false)
|
|
expect(bus.hasState(60)).toBe(false)
|
|
})
|
|
|
|
it('stresses 0 mana available: 0 absorbed, shield collapses instantly', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const stats = new UnitStatList(registry, {
|
|
maxhp: 1000 * FIXED_ONE,
|
|
hitpoints: 1000 * FIXED_ONE,
|
|
maxmana: 100 * FIXED_ONE,
|
|
mana: 0, // 0 mana!
|
|
})
|
|
const bus = new StateBus(stats, registry)
|
|
bus.applyState({
|
|
stateNameOrId: 'energyshield',
|
|
sourceSkillId: 58,
|
|
slvl: 20,
|
|
})
|
|
|
|
const out = bus.triggerReactiveEvents({
|
|
event: 'absorbdamage',
|
|
incomingPhys256: 500 * FIXED_ONE,
|
|
incomingElem256: 0,
|
|
})
|
|
|
|
expect(out.absorbedPhys256).toBe(0)
|
|
expect(out.manaDrained256).toBe(0)
|
|
expect(stats.getMana()).toBe(0)
|
|
expect(bus.hasState('energyshield')).toBe(false)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 3. Lightning Mastery (63) Adversarial Stress
|
|
// =========================================================================
|
|
describe('3. Lightning Mastery (63) Stress Testing', () => {
|
|
it('stresses formula across extreme skill levels', () => {
|
|
expect(calculateLightningMasteryBonusPct(1)).toBe(50)
|
|
expect(calculateLightningMasteryBonusPct(10)).toBe(158)
|
|
expect(calculateLightningMasteryBonusPct(20)).toBe(278)
|
|
expect(calculateLightningMasteryBonusPct(40)).toBe(518)
|
|
expect(calculateLightningMasteryBonusPct(99)).toBe(50 + 98 * 12) // 1226%
|
|
expect(calculateLightningMasteryBonusPct(0)).toBe(0)
|
|
expect(calculateLightningMasteryBonusPct(-5)).toBe(0)
|
|
})
|
|
|
|
it('proves multiplicative scaling is strictly greater than additive scaling for all 5 skills', () => {
|
|
// 1. Charged Bolt: Base * (1 + 1.20) * (1 + 2.78) vs Base * (1 + 1.20 + 2.78)
|
|
const cbBase = calculateChargedBoltDamage(20)
|
|
const cbBoth = calculateChargedBoltDamage(20, { lightningPoints: 20, lightningMastery: 20 })
|
|
const cbAdditiveMin = Math.floor(cbBase.min * (1 + 1.20 + 2.78))
|
|
const cbAdditiveMax = Math.floor(cbBase.max * (1 + 1.20 + 2.78))
|
|
expect(cbBoth.min).toBeGreaterThan(cbAdditiveMin)
|
|
expect(cbBoth.max).toBeGreaterThan(cbAdditiveMax)
|
|
|
|
// 2. Lightning: Base * (1 + 1.60) * (1 + 2.78) vs Base * (1 + 1.60 + 2.78)
|
|
const ltBase = calculateLightningDamage(20)
|
|
const ltBoth = calculateLightningDamage(20, { chainLightningPoints: 20, lightningMastery: 20 })
|
|
const ltAdditiveMax = Math.floor(ltBase.max * (1 + 1.60 + 2.78))
|
|
expect(ltBoth.max).toBeGreaterThan(ltAdditiveMax)
|
|
|
|
// 3. Chain Lightning: Base * (1 + 0.80) * (1 + 2.78) vs Base * (1 + 0.80 + 2.78)
|
|
const clBase = calculateChainLightningDamage(20)
|
|
const clBoth = calculateChainLightningDamage(20, { lightningPoints: 20, lightningMastery: 20 })
|
|
const clAdditiveMax = Math.floor(clBase.max * (1 + 0.80 + 2.78))
|
|
expect(clBoth.max).toBeGreaterThan(clAdditiveMax)
|
|
|
|
// 4. Nova: Base * (1 + 2.78)
|
|
const novaBase = calculateNovaDamage(20)
|
|
const novaMastered = calculateNovaDamage(20, { lightningMastery: 20 })
|
|
expect(novaMastered.min).toBe(Math.floor(novaBase.min * 3.78))
|
|
expect(novaMastered.max).toBe(Math.floor(novaBase.max * 3.78))
|
|
|
|
// 5. Thunder Storm: Base * (1 + 2.78)
|
|
const tsBase = calculateThunderStormDamage(20)
|
|
const tsMastered = calculateThunderStormDamage(20, { lightningMastery: 20 })
|
|
expect(tsMastered.min).toBe(Math.floor(tsBase.min * 3.78))
|
|
expect(tsMastered.max).toBe(Math.floor(tsBase.max * 3.78))
|
|
})
|
|
|
|
it('stresses elemental isolation in executeSUnitDmg (Cold, Fire, Poison, Magic untouched)', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
|
const defenderStats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE })
|
|
// Level 40 Lightning Mastery (+518%)
|
|
attackerStats.setBaseSkillLevel(63, 40)
|
|
const attackerBus = new StateBus(attackerStats, registry)
|
|
const defenderBus = new StateBus(defenderStats, registry)
|
|
|
|
const attackerCtx: CombatUnitContext = { id: 'a', name: 'A', statList: attackerStats, stateBus: attackerBus }
|
|
const defenderCtx: CombatUnitContext = { id: 'd', name: 'D', statList: defenderStats, stateBus: defenderBus }
|
|
|
|
// Fire test
|
|
const resFire = executeSUnitDmg(attackerCtx, defenderCtx, {
|
|
skillId: 47,
|
|
attackKind: 'spell',
|
|
autoHit: true,
|
|
elemType: 'fire',
|
|
elemMin256: 100 * FIXED_ONE,
|
|
elemMax256: 100 * FIXED_ONE,
|
|
})
|
|
expect(resFire.totalDamage).toBe(100)
|
|
|
|
// Cold test
|
|
const resCold = executeSUnitDmg(attackerCtx, defenderCtx, {
|
|
skillId: 59,
|
|
attackKind: 'spell',
|
|
autoHit: true,
|
|
elemType: 'cold',
|
|
elemMin256: 100 * FIXED_ONE,
|
|
elemMax256: 100 * FIXED_ONE,
|
|
})
|
|
expect(resCold.totalDamage).toBe(100)
|
|
|
|
// Poison test
|
|
const resPois = executeSUnitDmg(attackerCtx, defenderCtx, {
|
|
skillId: 1,
|
|
attackKind: 'spell',
|
|
autoHit: true,
|
|
elemType: 'pois',
|
|
elemMin256: 100 * FIXED_ONE,
|
|
elemMax256: 100 * FIXED_ONE,
|
|
})
|
|
expect(resPois.totalDamage).toBe(100)
|
|
|
|
// Lightning test -> should be scaled by +518%: 100 * 6.18 = 618!
|
|
const resLtng = executeSUnitDmg(attackerCtx, defenderCtx, {
|
|
skillId: 49,
|
|
attackKind: 'spell',
|
|
autoHit: true,
|
|
elemType: 'ltng',
|
|
elemMin256: 100 * FIXED_ONE,
|
|
elemMax256: 100 * FIXED_ONE,
|
|
})
|
|
expect(resLtng.totalDamage).toBe(618)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 4. Invariants & System Integrity
|
|
// =========================================================================
|
|
describe('4. Invariants & System Integrity', () => {
|
|
it('strictly preserves BATCH1_SKILLS length at 38', () => {
|
|
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
|
})
|
|
|
|
it('verifies thunderstorm1 canonical missile integrity', () => {
|
|
const msl = CANONICAL_113C_MISSILES['thunderstorm1']
|
|
expect(msl).toBeDefined()
|
|
expect(msl?.id).toBe(166)
|
|
expect(msl?.celFile).toBe('LightningboltBig')
|
|
expect(msl?.animLen).toBe(8)
|
|
expect(msl?.trans).toBe(1)
|
|
})
|
|
})
|
|
})
|