/** Тесты чистой части погоды: кламп spec'а, вектор ветра, детерминизм и
* заворачивание в окно у слоёв дождя/снега, обесцвечивание облачности. */
import { describe, expect, it } from 'vitest';
import { cloudDim } from '../daynight';
import {
DEFAULT_WEATHER, WeatherSystem, clampWeather, rainPoints, snowPoints, windVector,
} from '../weather';
const WIN = { minX: 0, maxX: 48, minZ: 0, maxZ: 32 };
/** Моменты респавна головы капли drop (y подпрыгивает с низа к верху слоя —
* обычное падение за шаг скана даёт сдвиг в доли юнита, не прыжок). */
function headWraps(spec: ReturnType<typeof clampWeather>, drop: number, tMax = 8): number[] {
const wraps: number[] = [];
let prev = -1;
for (let t = 0; t <= tMax; t += 0.002) {
const y = rainPoints(spec, t, WIN)[drop * 3]!.pos[1];
if (prev >= 0 && y - prev > 20) wraps.push(t);
prev = y;
}
return wraps;
}
describe('clampWeather', () => {
it('частичный ввод дополняется дефолтом', () => {
const w = clampWeather({ rain: 0.3 });
expect(w.rain).toBe(0.3);
expect(w.snow).toBe(0);
expect(w.windStrength).toBe(0);
expect(w.windDir).toBe(DEFAULT_WEATHER.windDir);
expect(w.cloud).toBe(0);
});
it('выходит за 0..1 — клампится; NaN/не-число — дефолт', () => {
expect(clampWeather({ rain: 5 }).rain).toBe(1);
expect(clampWeather({ snow: -2 }).snow).toBe(0);
expect(clampWeather({ windStrength: NaN }).windStrength).toBe(0);
expect(clampWeather({ cloud: 'x' as unknown as number }).cloud).toBe(0);
});
});
describe('windVector', () => {
it('ноль силы — ноль дрейфа; сила 1 — 6 юнит/с по направлению', () => {
expect(windVector({ ...DEFAULT_WEATHER, windStrength: 0 })).toEqual([0, 0]);
const [x, z] = windVector({ ...DEFAULT_WEATHER, windStrength: 1, windDir: Math.PI / 2 });
expect(x).toBeCloseTo(6);
expect(z).toBeCloseTo(0);
const [x2, z2] = windVector({ ...DEFAULT_WEATHER, windStrength: 0.5, windDir: 0 });
expect(x2).toBeCloseTo(0);
expect(z2).toBeCloseTo(3);
});
});
describe('rain/snow points', () => {
// окно 48×32 = 1536 кв. юнитов; плотность дождя 0.016 → 25 капель,
// каждая — штрих из 3 точек (75); снег 0.007 → 11 хлопьев
it('число точек = плотность × площадь окна × интенсивность; ноль — пусто', () => {
const spec = { ...DEFAULT_WEATHER, rain: 1, snow: 0 };
expect(rainPoints(spec, 0, WIN).length).toBe(75);
expect(snowPoints({ ...spec, snow: 1 }, 0, WIN).length).toBe(11);
expect(rainPoints({ ...spec, rain: 0 }, 0, WIN).length).toBe(0);
expect(snowPoints({ ...spec, snow: 0 }, 0, WIN).length).toBe(0);
});
it('детерминизм: тот же t — те же позы', () => {
const spec = { ...DEFAULT_WEATHER, rain: 0.4, snow: 0.6, windStrength: 0.3 };
const a = rainPoints(spec, 3.7, WIN);
const b = rainPoints(spec, 3.7, WIN);
expect(a).toEqual(b);
expect(snowPoints(spec, 1.2, WIN)).toEqual(snowPoints(spec, 1.2, WIN));
});
it('время идёт — капли падают (y убывает, потом цикл)', () => {
const spec = { ...DEFAULT_WEATHER, rain: 1 };
const t0 = rainPoints(spec, 0, WIN)[0]!.pos[1];
const t1 = rainPoints(spec, 0.5, WIN)[0]!.pos[1];
expect(t1).toBeLessThan(t0);
});
it('позы внутри окна (wrap), альфа в 0..1', () => {
const spec = { ...DEFAULT_WEATHER, snow: 1, windStrength: 1 };
for (const p of snowPoints(spec, 17, WIN)) {
expect(p.pos[0]).toBeGreaterThanOrEqual(WIN.minX);
expect(p.pos[0]).toBeLessThanOrEqual(WIN.maxX);
expect(p.pos[2]).toBeGreaterThanOrEqual(WIN.minZ);
expect(p.pos[2]).toBeLessThanOrEqual(WIN.maxZ);
expect(p.alpha).toBeGreaterThanOrEqual(0);
expect(p.alpha).toBeLessThanOrEqual(1);
}
});
it('высота слоя: капли и снег не ниже земли цикла (0..TOP)', () => {
const spec = { ...DEFAULT_WEATHER, rain: 1, snow: 1 };
for (const p of rainPoints(spec, 13, WIN)) expect(p.pos[1]).toBeGreaterThanOrEqual(0);
for (const p of snowPoints(spec, 13, WIN)) expect(p.pos[1]).toBeGreaterThanOrEqual(0);
});
it('респавн меняет колонку: траектория не повторяется по циклам', () => {
// скорость капли теперь зависит ещё и от глубины — момент полного
// цикла находим по скану (два соседних респавна: та же фаза падения)
const spec = { ...DEFAULT_WEATHER, rain: 1 };
const w = headWraps(spec, 0);
expect(w.length).toBeGreaterThanOrEqual(2);
const a = rainPoints(spec, w[0]!, WIN)[0]!.pos;
const b = rainPoints(spec, w[1]!, WIN)[0]!.pos;
expect(b[1]).toBeCloseTo(a[1], 0); // та же фаза — верх слоя
expect(b[0]).not.toBeCloseTo(a[0], 3); // но новая колонка (реролл)
});
it('поле дождя не квази-статично: через цикл переброшены почти все капли', () => {
// Сдвиг респавна в узкой окрестности якоря оставлял каплю в той же
// полосе — трек «горел» на экране вечно. Полный реролл перемещает
// подавляющее большинство капель (совпадение колоны — редкость).
// 5 с ≥ 2 полных циклов любой капли (слой 40, скорость ≥ ~17) —
// к этому моменту у всех переброшен номер цикла падения.
const spec = { ...DEFAULT_WEATHER, rain: 1 };
const a = rainPoints(spec, 0, WIN);
const b = rainPoints(spec, 5, WIN);
let moved = 0;
for (let i = 0; i < a.length; i += 3) { // головы штрихов
if (Math.abs(a[i]!.pos[0] - b[i]!.pos[0]) > 2) moved++;
}
expect(moved).toBeGreaterThanOrEqual((a.length / 3) * 0.8);
});
it('внутри падения колонка неизменна (капля падает по прямой)', () => {
// от сразу после респавна до середины падения — цикл не сменится
// (длина цикла ≥ ~1 с, взяли 0.5)
const spec = { ...DEFAULT_WEATHER, rain: 1 };
const w = headWraps(spec, 0);
const a = rainPoints(spec, w[0]! + 0.05, WIN)[0]!.pos;
const b = rainPoints(spec, w[0]! + 0.55, WIN)[0]!.pos;
expect(b[0]).toBeCloseTo(a[0]!, 6);
expect(b[2]).toBeCloseTo(a[2]!, 6);
});
it('глубина: в поле есть дальний план (бледнее/мельче) и ближний (крупнее)', () => {
const spec = { ...DEFAULT_WEATHER, rain: 1, snow: 1 };
const drops = rainPoints(spec, 2, WIN);
const sizes = drops.map((p) => p.size ?? 0);
expect(Math.max(...sizes)).toBeGreaterThan(Math.min(...sizes) * 2);
expect(Math.min(...drops.map((p) => p.alpha))).toBeLessThan(Math.max(...drops.map((p) => p.alpha)) * 0.5);
const flakes = snowPoints(spec, 2, WIN);
const fsz = flakes.map((p) => p.size ?? 0);
expect(Math.max(...fsz)).toBeGreaterThan(Math.min(...fsz) * 2);
});
});
describe('cloudDim (облачность)', () => {
it('k=0 — цвет без изменений, k=1 — затемнение на долю dim', () => {
expect(cloudDim(0xf7f4ea, 0, 0.7, 0.25)).toBe(0xf7f4ea);
const c = cloudDim(0xf7f4ea, 1, 0.7, 0.25);
expect(c).toBeLessThan(0xf7f4ea);
});
it('обесцвечивание: каналы сходятся к серому при полной облачности', () => {
// насыщенный красный при sat=1 и dim=0 становится серым той же яркости
const c = cloudDim(0xff0000, 1, 1, 0);
const r = (c >> 16) & 255, g = (c >> 8) & 255, b = c & 255;
expect(r).toBe(g);
expect(g).toBe(b);
});
});
describe('WeatherSystem (без WebGL — только спецификация)', () => {
it('set клампит и current отдаёт нормализованную погоду', () => {
const sys = new WeatherSystem({
addParticles: () => ({ update: () => {}, setTint: () => {}, dispose: () => {} }),
setCloud: () => {},
visibleWindow: () => ({ minX: 0, maxX: 0, minZ: 0, maxZ: 0 }),
} as never);
sys.set({ rain: 5, windStrength: -1, cloud: 0.5 });
expect(sys.current.rain).toBe(1);
expect(sys.current.windStrength).toBe(0);
expect(sys.current.cloud).toBe(0.5);
expect(sys.info()).toEqual({ rainDrops: 0, snowFlakes: 0 }); // счётчики — после update
});
it('update считает фактические капли/снежинки от окна', () => {
const sys = new WeatherSystem({
addParticles: () => ({ update: () => {}, setTint: () => {}, dispose: () => {} }),
setCloud: () => {},
visibleWindow: () => ({ minX: 0, maxX: 48, minZ: 0, maxZ: 32 }),
} as never);
sys.set({ rain: 1 });
sys.update(1);
// окно погоды — след кадра + маржа 16: (48+32)×(32+32) = 5120 кв. юнитов
expect(sys.info()).toEqual({ rainDrops: 82, snowFlakes: 0 });
sys.set({ rain: 0, snow: 0 }); // пустая погода прячет слои и счётчики
expect(sys.info()).toEqual({ rainDrops: 0, snowFlakes: 0 });
});
});