import { describe, expect, it } from 'vitest'; import { applyColorToPixels, buildCurveLUT, curveSvgPath, effectiveCurvePoints, emptyColor, emptyEditParams, isEmptyColor, isEmptyEditParams, normalizeEditParams, readEditParams, whiteBalanceFromRGB } from './edit'; import type { Photo } from './types'; describe('параметры правок', () => { it('зажимает значения в допустимые диапазоны', () => { const params = normalizeEditParams({ light: { ...emptyEditParams().light, exposure: 99, contrast: -500 } }); expect(params.light.exposure).toBe(5); expect(params.light.contrast).toBe(-100); }); it('считает пустыми нулевые параметры и линейную кривую', () => { expect(isEmptyEditParams(emptyEditParams())).toBe(true); const linear = normalizeEditParams({ light: { ...emptyEditParams().light, curve: { rgb: [{ x: 0, y: 0 }, { x: 1, y: 1 }] } } }); expect(isEmptyEditParams(linear)).toBe(true); const bent = normalizeEditParams({ light: { ...emptyEditParams().light, curve: { rgb: [{ x: 0, y: 0 }, { x: 1, y: 0.5 }] } } }); expect(isEmptyEditParams(bent)).toBe(false); }); it('читает параметры из метаданных фото', () => { const photo = { edit_params: { light: { exposure: 0.5, curve: { rgb: [{ x: 0, y: 0 }] } } } } as unknown as Photo; const params = readEditParams(photo); expect(params.light.exposure).toBe(0.5); expect(params.light.contrast).toBe(0); }); it('сортирует точки кривой и убирает дубликаты', () => { const params = normalizeEditParams({ light: { ...emptyEditParams().light, curve: { rgb: [{ x: 0.8, y: 0.9 }, { x: 0.2, y: 0.1 }, { x: 0.8, y: 0.7 }] } } }); const points = params.light.curve.rgb ?? []; expect(points.length).toBe(2); expect(points[0].x).toBeCloseTo(0.2); expect(points[1].x).toBeCloseTo(0.8); }); }); describe('цветовые параметры', () => { it('зажимает значения и оттенки колёс', () => { const params = normalizeEditParams({ light: emptyEditParams().light, color: { ...emptyColor(), temperature: 200, tint: -300, saturation: -500, grading: { ...emptyColor().grading, shadows: { hue: 400, saturation: 150, luminance: 0 }, balance: -200 } } }); expect(params.color.temperature).toBe(100); expect(params.color.tint).toBe(-100); expect(params.color.saturation).toBe(-100); expect(params.color.grading.shadows.hue).toBe(360); expect(params.color.grading.shadows.saturation).toBe(100); expect(params.color.grading.balance).toBe(-100); }); it('считает пустыми нулевые цветовые параметры', () => { expect(isEmptyColor(emptyColor())).toBe(true); expect(isEmptyColor({ ...emptyColor(), vibrance: 1 })).toBe(false); const hueOnly = emptyColor(); hueOnly.grading.shadows.hue = 120; expect(isEmptyColor(hueOnly)).toBe(true); const grade = emptyColor(); grade.grading.highlights.saturation = 10; expect(isEmptyColor(grade)).toBe(false); }); it('читает цвет из метаданных и не теряет свет', () => { const photo = { edit_params: { light: { exposure: 0.5 }, color: { temperature: 20, vibrance: 10 } } } as unknown as Photo; const params = readEditParams(photo); expect(params.light.exposure).toBe(0.5); expect(params.color.temperature).toBe(20); expect(params.color.vibrance).toBe(10); }); it('осветляет тёплым балансом белого', () => { const pixels = new Uint8ClampedArray([100, 100, 100, 255]); applyColorToPixels(pixels, { ...emptyColor(), temperature: 100 }); expect(pixels[0]).toBeGreaterThan(pixels[1]); expect(pixels[1]).toBeGreaterThan(pixels[2]); }); it('обеспечивает серый цвет при насыщенности -100', () => { const pixels = new Uint8ClampedArray([200, 50, 50, 255]); applyColorToPixels(pixels, { ...emptyColor(), saturation: -100 }); expect(pixels[0]).toBe(pixels[1]); expect(pixels[1]).toBe(pixels[2]); }); it('тонирует тени синим колесом', () => { const color = emptyColor(); color.grading.shadows = { hue: 240, saturation: 100, luminance: 0 }; const pixels = new Uint8ClampedArray([40, 40, 40, 255]); applyColorToPixels(pixels, color); expect(pixels[2]).toBeGreaterThan(pixels[0]); }); it('повышает яркость теней', () => { const color = emptyColor(); color.grading.shadows = { hue: 0, saturation: 0, luminance: 100 }; const pixels = new Uint8ClampedArray([40, 40, 40, 255]); applyColorToPixels(pixels, color); expect(pixels[0]).toBeGreaterThan(40); }); it('одинаково обрабатывает все строки однородного изображения', () => { const color = emptyColor(); color.grading.shadows = { hue: 240, saturation: 100, luminance: 20 }; const pixels = new Uint8ClampedArray(64 * 64 * 4); for (let i = 0; i < pixels.length; i += 4) { pixels[i] = 40; pixels[i + 1] = 40; pixels[i + 2] = 40; pixels[i + 3] = 255; } applyColorToPixels(pixels, color); for (let i = 4; i < pixels.length; i += 4) { expect(pixels[i]).toBe(pixels[0]); expect(pixels[i + 1]).toBe(pixels[1]); expect(pixels[i + 2]).toBe(pixels[2]); } }); it('не трогает прозрачные пиксели', () => { const pixels = new Uint8ClampedArray([10, 20, 30, 0]); applyColorToPixels(pixels, { ...emptyColor(), temperature: 100, saturation: 100 }); expect(Array.from(pixels)).toEqual([10, 20, 30, 0]); }); it('подбирает баланс белого по нейтральной точке', () => { const neutral = whiteBalanceFromRGB(128, 128, 128); expect(neutral.temperature).toBeCloseTo(0, 5); expect(neutral.tint).toBeCloseTo(0, 5); const wb = whiteBalanceFromRGB(150, 140, 120); const temperature = wb.temperature / 100; const tint = wb.tint / 100; const r = 150 * (1 + 0.2 * temperature + 0.1 * tint); const g = 140 * (1 - 0.2 * tint); const b = 120 * (1 - 0.2 * temperature + 0.1 * tint); expect(r).toBeCloseTo(g, 5); expect(b).toBeCloseTo(g, 5); }); }); describe('кривая', () => { const medium = [ { x: 0, y: 0 }, { x: 0.25, y: 0.2 }, { x: 0.5, y: 0.5 }, { x: 0.75, y: 0.8 }, { x: 1, y: 1 } ]; const strong = [ { x: 0, y: 0 }, { x: 0.2, y: 0.12 }, { x: 0.5, y: 0.5 }, { x: 0.8, y: 0.88 }, { x: 1, y: 1 } ]; const inner = [ { x: 0.1, y: 0.05 }, { x: 0.4, y: 0.3 }, { x: 0.9, y: 0.95 } ]; const samples = [0, 32, 64, 128, 192, 224, 255]; it('показывает крайние точки у пустого канала', () => { expect(effectiveCurvePoints(undefined)).toEqual([ { x: 0, y: 0 }, { x: 1, y: 1 } ]); expect(effectiveCurvePoints([{ x: 0.5, y: 0.4 }])).toEqual([ { x: 0, y: 0 }, { x: 1, y: 1 } ]); expect(effectiveCurvePoints(inner)).toEqual(inner); }); it('совпадает с серверной PCHIP-кривой', () => { const values = (points: { x: number; y: number }[]) => { const lut = buildCurveLUT(points); return samples.map((i) => lut[i]); }; expect(values([{ x: 0, y: 0 }, { x: 1, y: 1 }])).toEqual([ 0, 32, 64, 128, 192, 224, 255 ]); expect(values(medium)).toEqual([0, 23, 51, 128, 205, 233, 255]); expect(values(strong)).toEqual([0, 17, 43, 128, 213, 239, 255]); expect(values(inner)).toEqual([13, 17, 42, 104, 186, 234, 242]); }); it('остаётся монотонной между точками', () => { for (const points of [medium, strong]) { const lut = buildCurveLUT(points); for (let i = 1; i < lut.length; i++) { expect(lut[i]).toBeGreaterThanOrEqual(lut[i - 1]); } } }); it('делает плоские полки вне крайних точек', () => { const lut = buildCurveLUT(inner); for (let i = 0; i <= Math.floor(0.1 * 255); i++) expect(lut[i]).toBe(13); for (let i = Math.ceil(0.9 * 255); i < 256; i++) expect(lut[i]).toBe(242); }); it('рисует плавный SVG-путь с полками', () => { const path = curveSvgPath(inner); expect(path.startsWith('M0.0000,95.0000 L10.0000,95.0000')).toBe(true); expect(path).toContain('C'); expect(path.endsWith('L100.0000,5.0000')).toBe(true); }); });