/** Тесты чистой части молнии: огибающая мерцания, геометрия разряда,
* жизненный цикл вспышки системы (без WebGL — стаб рендера). */
import { describe, expect, it } from 'vitest';
import {
LIGHTNING_DURATION, LIGHTNING_FLASH, LIGHTNING_TOP, LightningSystem,
boltPoints, lightningEnvelope, lightningEnvLight,
} from '../lightning';
describe('lightningEnvLight (подсветка дождя/тумана)', () => {
it('ноль без вспышки, растёт с k и ночью сильнее', () => {
expect(lightningEnvLight(0, 0)).toEqual([0, 0, 0]);
const day = lightningEnvLight(1, 0);
const night = lightningEnvLight(1, 1);
expect(day[2]).toBeCloseTo(LIGHTNING_FLASH * 0.2); // доля вспышки в env
expect(night[2]).toBeCloseTo(day[2] * 2); // ночью ×(1+nightK)
});
});
describe('lightningEnvelope', () => {
it('пик в момент удара, всё в 0..1', () => {
expect(lightningEnvelope(0)).toBeCloseTo(1);
for (let dt = 0; dt < LIGHTNING_DURATION; dt += 0.01) {
const e = lightningEnvelope(dt);
expect(e).toBeGreaterThan(0);
expect(e).toBeLessThanOrEqual(1);
}
});
it('мерцает (рестрайки): не монотонна', () => {
// спад главного разряда, затем рестрайк поднимает огибающую снова
expect(lightningEnvelope(0.18)).toBeGreaterThan(lightningEnvelope(0.12));
});
it('после длительности — ноль', () => {
expect(lightningEnvelope(LIGHTNING_DURATION)).toBe(0);
expect(lightningEnvelope(LIGHTNING_DURATION + 5)).toBe(0);
expect(lightningEnvelope(-1)).toBe(0);
});
});
describe('boltPoints (геометрия разряда)', () => {
const at: [number, number, number] = [10, 2, -5];
it('детерминизм: тот же seed — та же геометрия; seed меняет форму', () => {
expect(boltPoints(7, at)).toEqual(boltPoints(7, at));
expect(boltPoints(7, at)).not.toEqual(boltPoints(8, at));
});
it('канал от земли вверх до LIGHTNING_TOP, снос ограничен', () => {
const pts = boltPoints(42, at);
expect(pts.length).toBeGreaterThanOrEqual(16);
for (const p of pts) {
expect(p.pos[1]).toBeGreaterThanOrEqual(at[1]);
expect(p.pos[1]).toBeLessThanOrEqual(at[1] + LIGHTNING_TOP);
// зигзаг — сумма синусов (ограничен) плюс ветвь — узкий коридор
// вокруг точки удара
expect(Math.abs(p.pos[0] - at[0])).toBeLessThan(LIGHTNING_TOP * 0.12);
expect(Math.abs(p.pos[2] - at[2])).toBeLessThan(LIGHTNING_TOP * 0.12);
}
});
it('есть ветви (2×5 бледных тонких сегментов вниз) и узлы полной яркости', () => {
const pts = boltPoints(42, at);
expect(pts.some((p) => p.alpha === 1)).toBe(true); // канал
// маркер ветви: alpha 0.7 и size 0.6 — ореол канала (0.22) не путает
const br = pts.filter((p) => p.alpha === 0.7 && p.size === 0.6);
expect(br.length).toBe(10); // две ветви по 5 сегментов
const ys = br.map((p) => p.pos[1]).sort((a, b) => b - a);
for (let i = 1; i < ys.length; i++) {
expect(ys[i]).toBeLessThan(ys[i - 1]); // обе ветви спускаются
}
});
});
describe('LightningSystem (без WebGL — стаб рендера)', () => {
/** Стаб рендера: свет и окно записываются, точки слоя копятся. */
function stub() {
const flashes: Array<{ at: readonly [number, number, number] | null; k: number }> = [];
return {
flashes,
renderer: {
addParticles: () => ({ update: () => {}, setTint: () => {}, dispose: () => {} }),
setLightning: (at: readonly [number, number, number] | null, k: number) => flashes.push({ at, k }),
visibleWindow: () => ({ minX: 0, maxX: 48, minZ: 0, maxZ: 32 }),
focusPoint: () => [20, 1, 14] as [number, number, number],
} as never,
};
}
it('strike без точки — случайная точка окна; вспышка стартует сразу', () => {
const s = stub();
const sys = new LightningSystem(s.renderer);
sys.strike(); // первый кадр вспышки анимируется сразу
const info = sys.info();
expect(info.active).toBe(true);
expect(info.strikes).toBe(1);
expect(info.flash).toBeGreaterThan(0);
expect(info.at![0]).toBeGreaterThanOrEqual(0);
expect(info.at![0]).toBeLessThanOrEqual(48);
expect(s.flashes.at(-1)!.k).toBeGreaterThan(0);
});
it('по истечении длительности вспышка гаснет (свет выключен, слой пуст)', () => {
const s = stub();
const sys = new LightningSystem(s.renderer);
sys.strike([5, 0, 5]);
sys.update(LIGHTNING_DURATION + 0.1);
const info = sys.info();
expect(info.active).toBe(false);
expect(info.flash).toBe(0);
expect(s.flashes.at(-1)).toEqual({ at: null, k: 0 });
});
it('детерминизм удара: тот же номер — та же точка; новый удар — другая', () => {
const a = stub();
const b = stub();
const sysA = new LightningSystem(a.renderer);
const sysB = new LightningSystem(b.renderer);
sysA.strike();
sysB.strike();
expect(sysA.info().at).toEqual(sysB.info().at); // номер удара 1 у обоих
const first = sysA.info().at;
sysA.strike(); // удар 2 — реролл точки от номера удара
expect(sysA.info().at).not.toEqual(first);
});
it('dispose посреди вспышки гасит свет и снапшот (свет не замирает)', () => {
const s = stub();
const sys = new LightningSystem(s.renderer);
sys.strike([5, 0, 5]); // вспышка активна
expect(sys.info().active).toBe(true);
sys.dispose();
expect(s.flashes.at(-1)).toEqual({ at: null, k: 0 }); // свет погашен
expect(sys.info()).toMatchObject({ active: false, flash: 0 });
});
it('cancel гасит вспышку немедленно; неактивной системе безвреден', () => {
const s = stub();
const sys = new LightningSystem(s.renderer);
sys.cancel(); // безвреден без удара
sys.strike([5, 0, 5]);
sys.cancel();
expect(sys.info()).toMatchObject({ active: false, flash: 0 });
expect(s.flashes.at(-1)).toEqual({ at: null, k: 0 });
});
});