package photos import ( "bytes" "image" "image/png" "math" "testing" ) func TestParseEditParamsClampsValues(t *testing.T) { raw := []byte(`{"light":{"exposure":99,"contrast":-500,"highlights":10,"curve":{"rgb":[{"x":2,"y":-1},{"x":0.5,"y":0.5}]}}}`) params, err := ParseEditParams(raw) if err != nil { t.Fatalf("parse: %v", err) } if params.Light.Exposure != MaxExposureStops { t.Fatalf("exposure = %v, want %v", params.Light.Exposure, MaxExposureStops) } if params.Light.Contrast != -MaxPercent { t.Fatalf("contrast = %v, want %v", params.Light.Contrast, -MaxPercent) } if len(params.Light.Curve.RGB) != 2 { t.Fatalf("curve points = %d, want 2", len(params.Light.Curve.RGB)) } // Точки сортируются по X, а значения зажимаются в диапазон 0..1. first := params.Light.Curve.RGB[0] if first.X != 0.5 || first.Y != 0.5 { t.Fatalf("first curve point = %+v, want {0.5 0.5}", first) } second := params.Light.Curve.RGB[1] if second.X != 1 || second.Y != 0 { t.Fatalf("second curve point = %+v, want {1 0}", second) } } func TestEditRolesCoverTheEditedVersion(t *testing.T) { roles := EditRoles() if len(roles) != 4 { t.Fatalf("edit roles = %v, want four", roles) } for _, role := range roles { if !IsEditRole(role) { t.Fatalf("role %q must be an edit role", role) } } for _, role := range []string{RoleOriginal, RoleThumb, RoleMedium, RolePreview, RolePlayback} { if IsEditRole(role) { t.Fatalf("role %q must not be an edit role", role) } } } func TestEditParamsIsEmpty(t *testing.T) { var zero EditParams if !zero.IsEmpty() { t.Fatal("zero params must be empty") } zero.Light.Exposure = 0.1 if zero.IsEmpty() { t.Fatal("params with exposure must not be empty") } curved := EditParams{} curved.Light.Curve.RGB = []CurvePoint{{X: 0, Y: 0}, {X: 1, Y: 0.5}} if curved.IsEmpty() { t.Fatal("params with a bent curve must not be empty") } linear := EditParams{} linear.Light.Curve.RGB = []CurvePoint{{X: 0, Y: 0}, {X: 1, Y: 1}} if !linear.IsEmpty() { t.Fatal("a linear curve must count as empty") } } func TestCurveLUTIdentity(t *testing.T) { lut := curveLUT(nil) for i := 0; i < 256; i++ { if int(lut[i]) != i { t.Fatalf("lut[%d] = %d, want %d", i, lut[i], i) } } } func TestCurveLUTSteepens(t *testing.T) { lut := curveLUT([]CurvePoint{{X: 0, Y: 0}, {X: 0.5, Y: 0.25}, {X: 1, Y: 1}}) if lut[0] != 0 || lut[255] != 255 { t.Fatalf("curve ends = %d..%d, want 0..255", lut[0], lut[255]) } if lut[128] >= 128 { t.Fatalf("lut[128] = %d, want a darker midtone", lut[128]) } } func TestApplyLightBrightensAndKeepsSource(t *testing.T) { src := image.NewNRGBA(image.Rect(0, 0, 2, 2)) for i := range src.Pix { src.Pix[i] = 100 } src.Pix[3] = 255 before := make([]byte, len(src.Pix)) copy(before, src.Pix) edited := applyLight(src, LightParams{Exposure: 1}) if edited.Pix[0] <= before[0] { t.Fatalf("exposure did not brighten: %d <= %d", edited.Pix[0], before[0]) } for i := range src.Pix { if src.Pix[i] != before[i] { t.Fatalf("source pixel %d changed: %d != %d", i, src.Pix[i], before[i]) } } } func TestApplyLightContrast(t *testing.T) { src := image.NewNRGBA(image.Rect(0, 0, 1, 1)) for i := range src.Pix { src.Pix[i] = 200 } src.Pix[3] = 255 edited := applyLight(src, LightParams{Contrast: 50}) if edited.Pix[0] <= 200 { t.Fatalf("contrast did not push the bright tone up: %d", edited.Pix[0]) } // Контраст работает вокруг средней точки: тёмные тона уходят вниз. dark := image.NewNRGBA(image.Rect(0, 0, 1, 1)) for i := range dark.Pix { dark.Pix[i] = 60 } dark.Pix[3] = 255 editedDark := applyLight(dark, LightParams{Contrast: 50}) if editedDark.Pix[0] >= 60 { t.Fatalf("contrast did not push the dark tone down: %d", editedDark.Pix[0]) } } func TestBuildEditedVariants(t *testing.T) { src := image.NewNRGBA(image.Rect(0, 0, 400, 300)) for y := 0; y < 300; y++ { for x := 0; x < 400; x++ { i := y*src.Stride + x*4 src.Pix[i] = uint8(x % 256) src.Pix[i+1] = uint8(y % 256) src.Pix[i+2] = 128 src.Pix[i+3] = 255 } } edited, err := decodeAndAdjust(mustEncodePNG(t, src), EditParams{Light: LightParams{Exposure: 0.5}}) if err != nil { t.Fatalf("decode and adjust: %v", err) } thumb, medium, preview, err := encodeEditedVariants(edited) if err != nil { t.Fatalf("encode variants: %v", err) } full, err := encodeEdited(edited) if err != nil { t.Fatalf("encode full: %v", err) } if len(thumb) == 0 || len(medium) == 0 || len(preview) == 0 || len(full) == 0 { t.Fatalf("empty variants: %d %d %d %d", len(thumb), len(medium), len(preview), len(full)) } } func TestAdjustChannelClamps(t *testing.T) { lut := curveLUT(nil) if v := adjustChannel(255, LightParams{Exposure: 5}, &lut, &lut); v != 255 { t.Fatalf("value = %v, want 255", v) } if v := adjustChannel(0, LightParams{Exposure: -5}, &lut, &lut); v != 0 { t.Fatalf("value = %v, want 0", v) } if v := adjustChannel(128, LightParams{Contrast: 0}, &lut, &lut); math.Abs(v-128) > 0.001 { t.Fatalf("value = %v, want 128", v) } } func mustEncodePNG(t *testing.T, img image.Image) []byte { t.Helper() var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { t.Fatalf("encode png: %v", err) } return buf.Bytes() } func TestParseEditParamsClampsColor(t *testing.T) { raw := []byte(`{"color":{"temperature":200,"tint":-300,"vibrance":50,"saturation":-500,"mixer":{"red":{"hue":150,"saturation":-200,"luminance":10}},"grading":{"shadows":{"hue":400,"saturation":150},"balance":-200},"correction":{"red":120}}}`) params, err := ParseEditParams(raw) if err != nil { t.Fatalf("parse: %v", err) } c := params.Color if c.Temperature != MaxPercent || c.Tint != -MaxPercent { t.Fatalf("wb = %v/%v, want clamped", c.Temperature, c.Tint) } if c.Saturation != -MaxPercent { t.Fatalf("saturation = %v, want %v", c.Saturation, -MaxPercent) } if c.Mixer.Red.Hue != MaxPercent || c.Mixer.Red.Saturation != -MaxPercent { t.Fatalf("mixer red = %+v, want clamped", c.Mixer.Red) } if c.Grading.Shadows.Hue != 360 || c.Grading.Shadows.Saturation != MaxPercent { t.Fatalf("grading shadows = %+v, want clamped", c.Grading.Shadows) } if c.Grading.Balance != -MaxPercent || c.Correction.Red != MaxPercent { t.Fatalf("grading/correction not clamped: %+v %+v", c.Grading, c.Correction) } } func TestColorParamsIsEmpty(t *testing.T) { var zero ColorParams if !zero.IsEmpty() { t.Fatal("zero color params must be empty") } vibrance := ColorParams{Vibrance: 1} if vibrance.IsEmpty() { t.Fatal("vibrance must not be empty") } mixer := ColorParams{} mixer.Mixer.Blue.Luminance = -10 if mixer.IsEmpty() { t.Fatal("mixer band must not be empty") } // Оттенок колеса без силы не влияет на картинку. hueOnly := ColorParams{} hueOnly.Grading.Shadows.Hue = 120 if !hueOnly.IsEmpty() { t.Fatal("grading hue without strength must count as empty") } grade := ColorParams{} grade.Grading.Highlights.Saturation = 10 if grade.IsEmpty() { t.Fatal("grading strength must not be empty") } } func TestApplyColorWhiteBalance(t *testing.T) { src := image.NewNRGBA(image.Rect(0, 0, 1, 1)) src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 100, 100, 100, 255 out := applyColor(src, ColorParams{Temperature: 100}) if !(out.Pix[0] > out.Pix[1] && out.Pix[1] > out.Pix[2]) { t.Fatalf("warm white balance = %d/%d/%d, want r > g > b", out.Pix[0], out.Pix[1], out.Pix[2]) } } func TestApplyColorSaturationMinus100IsGray(t *testing.T) { src := image.NewNRGBA(image.Rect(0, 0, 1, 1)) src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 200, 50, 50, 255 out := applyColor(src, ColorParams{Saturation: -100}) if out.Pix[0] != out.Pix[1] || out.Pix[1] != out.Pix[2] { t.Fatalf("desaturated pixel = %d/%d/%d, want gray", out.Pix[0], out.Pix[1], out.Pix[2]) } } func TestApplyColorSaturationPlus100(t *testing.T) { src := image.NewNRGBA(image.Rect(0, 0, 1, 1)) src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 150, 120, 120, 255 before := src.Pix[0] - src.Pix[1] out := applyColor(src, ColorParams{Saturation: 100}) if out.Pix[0]-out.Pix[1] <= before { t.Fatalf("saturation did not spread channels: %d/%d/%d", out.Pix[0], out.Pix[1], out.Pix[2]) } } func TestApplyColorGradingTintsShadows(t *testing.T) { src := image.NewNRGBA(image.Rect(0, 0, 1, 1)) src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 40, 40, 40, 255 p := ColorParams{} p.Grading.Blending = 50 p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100} out := applyColor(src, p) if out.Pix[2] <= out.Pix[0] { t.Fatalf("blue shadow grading = %d/%d/%d, want b > r", out.Pix[0], out.Pix[1], out.Pix[2]) } } func TestApplyColorGradingLuminance(t *testing.T) { src := image.NewNRGBA(image.Rect(0, 0, 1, 1)) src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 40, 40, 40, 255 p := ColorParams{} p.Grading.Shadows = ColorWheel{Luminance: 100} out := applyColor(src, p) if out.Pix[0] <= 40 { t.Fatalf("shadow luminance did not brighten: %d", out.Pix[0]) } } func TestApplyColorUniformRows(t *testing.T) { src := image.NewNRGBA(image.Rect(0, 0, 64, 64)) for i := 0; i < len(src.Pix); i += 4 { src.Pix[i], src.Pix[i+1], src.Pix[i+2], src.Pix[i+3] = 40, 40, 40, 255 } p := ColorParams{} p.Grading.Blending = 50 p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100} out := applyColor(src, p) wantR, wantG, wantB := out.Pix[0], out.Pix[1], out.Pix[2] for y := 0; y < 64; y++ { for x := 0; x < 64; x++ { i := y*out.Stride + x*4 if out.Pix[i] != wantR || out.Pix[i+1] != wantG || out.Pix[i+2] != wantB { t.Fatalf("pixel (%d,%d) = %d/%d/%d, want %d/%d/%d", x, y, out.Pix[i], out.Pix[i+1], out.Pix[i+2], wantR, wantG, wantB) } } } } func TestApplyColorParallelMatchesSingle(t *testing.T) { makeImage := func() *image.NRGBA { img := image.NewNRGBA(image.Rect(0, 0, 32, 64)) for y := 0; y < 64; y++ { for x := 0; x < 32; x++ { i := y*img.Stride + x*4 img.Pix[i] = uint8(20 + y*3) img.Pix[i+1] = uint8(20 + y*2) img.Pix[i+2] = uint8(30 + y) img.Pix[i+3] = 255 } } return img } p := ColorParams{} p.Grading.Blending = 50 p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100} parallel := applyColor(makeImage(), p) single := makeImage() applyColorRows(single, 0, single.Rect.Dy(), newColorAdjuster(p)) for i := range parallel.Pix { if parallel.Pix[i] != single.Pix[i] { t.Fatalf("parallel differs at byte %d: %d != %d", i, parallel.Pix[i], single.Pix[i]) } } } func TestApplyColorKeepsTransparentPixels(t *testing.T) { src := image.NewNRGBA(image.Rect(0, 0, 1, 1)) src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 10, 20, 30, 0 out := applyColor(src, ColorParams{Saturation: 100, Temperature: 100}) if out.Pix[0] != 10 || out.Pix[1] != 20 || out.Pix[2] != 30 || out.Pix[3] != 0 { t.Fatalf("transparent pixel changed: %v", out.Pix) } } func TestParseEditParamsWithoutColorIsBackwardCompatible(t *testing.T) { params, err := ParseEditParams([]byte(`{"light":{"exposure":0.5}}`)) if err != nil { t.Fatalf("parse: %v", err) } if !params.Color.IsEmpty() { t.Fatalf("missing color must be empty: %+v", params.Color) } if params.IsEmpty() { t.Fatal("light edits must not be empty") } } func TestApplyColorEmptyIsIdentity(t *testing.T) { src := image.NewNRGBA(image.Rect(0, 0, 3, 1)) colors := [][3]uint8{{0, 0, 0}, {200, 50, 25}, {33, 210, 128}} for i, c := range colors { src.Pix[i*4] = c[0] src.Pix[i*4+1] = c[1] src.Pix[i*4+2] = c[2] src.Pix[i*4+3] = 255 } before := make([]byte, len(src.Pix)) copy(before, src.Pix) applyColor(src, ColorParams{}) for i := range before { if before[i] != src.Pix[i] { t.Fatalf("empty color changed pixel %d: %d != %d", i, src.Pix[i], before[i]) } } }