- PCHIP для предпросмотра и серверного рендера - пресеты всех каналов в профиле, импорт и экспорт - мерж настроек auth без потери разделов - сохранение вкладки и правок текущего фото при переключении
258 lines
8.3 KiB
TypeScript
258 lines
8.3 KiB
TypeScript
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);
|
|
});
|
|
});
|