/**
* Тесты чистой математики неба: дуги светил (совпадение с прежней формулой
* setSun — регрессионный замок рефакторинга), луна/фазы, палитра неба,
* яркость звёзд, упаковка тел. Шейдер и композит тестируются пробами
* с WebGL — здесь только математика.
*/
import { describe, expect, it } from 'vitest';
import {
celestialDir, linearRgb, moonK, moonPhase, packSkyBodies, skyColors,
starBrightness, sunDirection, sunElevation, sunTint, SKY_BODY_MAX,
} from '../sky';
describe('дуга светила', () => {
// Замок рефакторинга setSun: та же формула посчитанными числами
it('k=0.5 (полдень) совпадает с прежней формулой setSun', () => {
const az = Math.PI * 0.5;
const elev = Math.sin(0.5 * Math.PI) * (Math.PI / 3) + 0.12;
const d = sunDirection(0.5);
expect(d[0]).toBeCloseTo(Math.cos(az) * Math.cos(elev), 15);
expect(d[1]).toBeCloseTo(Math.sin(elev), 15);
expect(d[2]).toBeCloseTo(Math.sin(az) * Math.cos(elev), 15);
});
it('восход на востоке, закат на западе (симметрия по x/z)', () => {
const rise = sunDirection(0);
const set = sunDirection(1);
// k=0 → az=π (x<0), k=1 → az=0 (x>0); elev=0.12 — солнце не на горизонте
expect(rise[0]).toBeCloseTo(-Math.cos(0.12), 12);
expect(set[0]).toBeCloseTo(Math.cos(0.12), 12);
expect(rise[2]).toBeCloseTo(0, 9);
expect(set[2]).toBeCloseTo(0, 9);
expect(rise[1]).toBeCloseTo(set[1], 15); // одна высота
});
it('высота монотонна на [0, 0.5] и симметрична', () => {
let prev = -1;
for (let i = 0; i <= 10; i++) {
const e = sunElevation(i / 20);
expect(e).toBeGreaterThan(prev);
prev = e;
}
expect(sunElevation(0)).toBeCloseTo(sunElevation(1), 15);
});
it('unit-вектор на всей дуге', () => {
for (let i = 0; i <= 20; i++) {
const [x, y, z] = sunDirection(i / 20);
expect(Math.hypot(x, y, z)).toBeCloseTo(1, 12);
}
});
it('celestialDir без клампа: k=1.5 — светило под горизонтом', () => {
expect(celestialDir(1.5)[1]).toBeLessThan(0);
expect(celestialDir(-0.25)[1]).toBeLessThan(0);
});
});
describe('луна', () => {
it('moonK: зенит в 0:00, встаёт в 18:00, садится в 6:00', () => {
expect(moonK(0)).toBeCloseTo(0.5, 12);
expect(moonK(18)).toBeCloseTo(0, 12);
expect(moonK(6)).toBeCloseTo(0, 12); // mod: садится = снова 0
expect(moonK(22)).toBeCloseTo(((22 - 18) / 12) % 1, 12);
});
it('moonPhase: цикл 8 суток — 0 → 1 → 0', () => {
expect(moonPhase(0)).toBeCloseTo(0, 12); // новолуние
expect(moonPhase(24 * 4)).toBeCloseTo(1, 12); // полнолуние в середине цикла
expect(moonPhase(24 * 8)).toBeCloseTo(0, 12); // снова новолуние
const q = moonPhase(24 * 2); // четверть
expect(q).toBeGreaterThan(0.3);
expect(q).toBeLessThan(0.7);
});
});
describe('sunTint', () => {
it('полдень нейтральный, у горизонта тёплый', () => {
expect(sunTint(0.5)).toEqual([1, 1, 1]);
const [r, g, b] = sunTint(0); // восход
expect(r).toBe(1);
expect(g).toBeLessThan(1);
expect(b).toBeLessThan(g);
});
});
describe('skyColors', () => {
const luma = (c: number): number =>
(((c >> 16) & 0xff) * 0.3 + ((c >> 8) & 0xff) * 0.6 + (c & 0xff) * 0.1) / 255;
it('день светлее ночи (оба цвета)', () => {
const day = skyColors(0, 0);
const night = skyColors(1, 0);
expect(luma(day.zenith)).toBeGreaterThan(luma(night.zenith) * 2);
expect(luma(day.horizon)).toBeGreaterThan(luma(night.horizon) * 2);
});
it('золотой час тёплый у горизонта (r > b)', () => {
const dusk = skyColors(0.45, 0); // DUSK_PEAK
const r = (dusk.horizon >> 16) & 0xff;
const b = dusk.horizon & 0xff;
expect(r).toBeGreaterThan(b);
});
it('ночная палитра синее-тёмная, не чёрная и не серая', () => {
const night = skyColors(1, 0);
expect(night.zenith).not.toBe(0);
expect(luma(night.zenith)).toBeLessThan(0.1);
});
it('облачность обесцвечивает и темнеет (каналы сходятся)', () => {
const clear = skyColors(0, 0);
const cloudy = skyColors(0, 1);
const spread = (c: number): number =>
((c >> 16) & 0xff) - (c & 0xff);
expect(Math.abs(spread(cloudy.zenith))).toBeLessThan(Math.abs(spread(clear.zenith)));
expect(luma(cloudy.zenith)).toBeLessThan(luma(clear.zenith));
});
});
describe('starBrightness', () => {
it('0 днём, 1 в ясную ночь, облачность гасит', () => {
expect(starBrightness(0, 0)).toBe(0);
expect(starBrightness(1, 0)).toBeCloseTo(1, 12);
expect(starBrightness(1, 1)).toBeCloseTo(0.15, 12);
expect(starBrightness(0.5, 0.5)).toBeGreaterThan(0);
expect(starBrightness(0.5, 0.5)).toBeLessThan(1);
});
});
describe('packSkyBodies', () => {
it('нормализует направление и кладёт дефолты (phase 1, intensity 1)', () => {
const p = packSkyBodies([
{ dir: [2, 0, 0], angRadius: 0.05, color: 0xffffff },
]);
expect(p.count).toBe(1);
expect(p.dirs[0][0]).toBe(1);
expect(p.params[0][2]).toBe(1); // intensity
expect(p.params[0][3]).toBe(1); // phase — полный диск
});
it('клампит отрицательную интенсивность и обрезает по SKY_BODY_MAX', () => {
const bodies = Array.from({ length: SKY_BODY_MAX + 2 }, () => ({
dir: [0, 1, 0] as const, angRadius: 0.05, color: 0xffffff,
}));
const p = packSkyBodies(bodies);
expect(p.count).toBe(SKY_BODY_MAX);
const neg = packSkyBodies([{ dir: [0, 1, 0], angRadius: 0.05, color: 0xffffff, intensity: -3 }]);
expect(neg.params[0][2]).toBe(0);
});
it('цвет — линейный RGB (белый остаётся 1, средний серый темнеет)', () => {
expect(linearRgb(0xffffff)).toEqual([1, 1, 1]);
const [r] = linearRgb(0x808080);
expect(r).toBeLessThan(0.5); // sRGB 0.5 → линейный ~0.216
});
});