This commit is contained in:
Kevin McIntyre
2025-06-18 01:00:00 -04:00
commit f84b511895
228 changed files with 42509 additions and 0 deletions

346
internal/engine/color.go Normal file
View File

@@ -0,0 +1,346 @@
package engine
import (
"fmt"
"math"
"strconv"
)
// Lightness correctors for each hue segment (based on JavaScript implementation)
var correctors = []float64{0.55, 0.5, 0.5, 0.46, 0.6, 0.55, 0.55}
// Color represents a color with both HSL and RGB representations
type Color struct {
H, S, L float64 // HSL values: H=[0,1], S=[0,1], L=[0,1]
R, G, B uint8 // RGB values: [0,255]
A uint8 // Alpha channel: [0,255]
}
// NewColorHSL creates a new Color from HSL values
func NewColorHSL(h, s, l float64) Color {
r, g, b := HSLToRGB(h, s, l)
return Color{
H: h, S: s, L: l,
R: r, G: g, B: b,
A: 255,
}
}
// NewColorCorrectedHSL creates a new Color from HSL values with lightness correction
func NewColorCorrectedHSL(h, s, l float64) Color {
r, g, b := CorrectedHSLToRGB(h, s, l)
return Color{
H: h, S: s, L: l,
R: r, G: g, B: b,
A: 255,
}
}
// NewColorRGB creates a new Color from RGB values and calculates HSL
func NewColorRGB(r, g, b uint8) Color {
h, s, l := RGBToHSL(r, g, b)
return Color{
H: h, S: s, L: l,
R: r, G: g, B: b,
A: 255,
}
}
// NewColorRGBA creates a new Color from RGBA values and calculates HSL
func NewColorRGBA(r, g, b, a uint8) Color {
h, s, l := RGBToHSL(r, g, b)
return Color{
H: h, S: s, L: l,
R: r, G: g, B: b,
A: a,
}
}
// String returns the hex representation of the color
func (c Color) String() string {
if c.A == 255 {
return RGBToHex(c.R, c.G, c.B)
}
return fmt.Sprintf("#%02x%02x%02x%02x", c.R, c.G, c.B, c.A)
}
// Equals compares two colors for equality
func (c Color) Equals(other Color) bool {
return c.R == other.R && c.G == other.G && c.B == other.B && c.A == other.A
}
// WithAlpha returns a new color with the specified alpha value
func (c Color) WithAlpha(alpha uint8) Color {
return Color{
H: c.H, S: c.S, L: c.L,
R: c.R, G: c.G, B: c.B,
A: alpha,
}
}
// IsGrayscale returns true if the color is grayscale (saturation near zero)
func (c Color) IsGrayscale() bool {
return c.S < 0.01 // Small tolerance for floating point comparison
}
// Darken returns a new color with reduced lightness
func (c Color) Darken(amount float64) Color {
newL := clamp(c.L-amount, 0, 1)
return NewColorCorrectedHSL(c.H, c.S, newL)
}
// Lighten returns a new color with increased lightness
func (c Color) Lighten(amount float64) Color {
newL := clamp(c.L+amount, 0, 1)
return NewColorCorrectedHSL(c.H, c.S, newL)
}
// RGBToHSL converts RGB values to HSL
// Returns H=[0,1], S=[0,1], L=[0,1]
func RGBToHSL(r, g, b uint8) (h, s, l float64) {
rf := float64(r) / 255.0
gf := float64(g) / 255.0
bf := float64(b) / 255.0
max := math.Max(rf, math.Max(gf, bf))
min := math.Min(rf, math.Min(gf, bf))
// Calculate lightness
l = (max + min) / 2.0
if max == min {
// Achromatic (gray)
h, s = 0, 0
} else {
delta := max - min
// Calculate saturation
if l > 0.5 {
s = delta / (2.0 - max - min)
} else {
s = delta / (max + min)
}
// Calculate hue
switch max {
case rf:
h = (gf-bf)/delta + (func() float64 {
if gf < bf {
return 6
}
return 0
})()
case gf:
h = (bf-rf)/delta + 2
case bf:
h = (rf-gf)/delta + 4
}
h /= 6.0
}
return h, s, l
}
// HSLToRGB converts HSL color values to RGB.
// h: hue in range [0, 1]
// s: saturation in range [0, 1]
// l: lightness in range [0, 1]
// Returns RGB values in range [0, 255]
func HSLToRGB(h, s, l float64) (r, g, b uint8) {
// Clamp input values to valid ranges
h = math.Mod(h, 1.0)
if h < 0 {
h += 1.0
}
s = clamp(s, 0, 1)
l = clamp(l, 0, 1)
// Handle grayscale case (saturation = 0)
if s == 0 {
// All RGB components are equal for grayscale
gray := uint8(clamp(l*255, 0, 255))
return gray, gray, gray
}
// Calculate intermediate values for HSL to RGB conversion
var m2 float64
if l <= 0.5 {
m2 = l * (s + 1)
} else {
m2 = l + s - l*s
}
m1 := l*2 - m2
// Convert each RGB component
r = uint8(clamp(hueToRGB(m1, m2, h*6+2)*255, 0, 255))
g = uint8(clamp(hueToRGB(m1, m2, h*6)*255, 0, 255))
b = uint8(clamp(hueToRGB(m1, m2, h*6-2)*255, 0, 255))
return r, g, b
}
// CorrectedHSLToRGB converts HSL to RGB with lightness correction for better visual perception.
// This function adjusts the lightness based on the hue to compensate for the human eye's
// different sensitivity to different colors.
func CorrectedHSLToRGB(h, s, l float64) (r, g, b uint8) {
// Get the corrector for the current hue
hueIndex := int((h*6 + 0.5)) % len(correctors)
corrector := correctors[hueIndex]
// Adjust lightness relative to the corrector
if l < 0.5 {
l = l * corrector * 2
} else {
l = corrector + (l-0.5)*(1-corrector)*2
}
// Clamp the corrected lightness
l = clamp(l, 0, 1)
return HSLToRGB(h, s, l)
}
// hueToRGB converts a hue value to an RGB component value
// Based on the W3C CSS3 color specification
func hueToRGB(m1, m2, h float64) float64 {
// Normalize hue to [0, 6) range
if h < 0 {
h += 6
} else if h > 6 {
h -= 6
}
// Calculate RGB component based on hue position
if h < 1 {
return m1 + (m2-m1)*h
} else if h < 3 {
return m2
} else if h < 4 {
return m1 + (m2-m1)*(4-h)
} else {
return m1
}
}
// clamp constrains a value to the specified range [min, max]
func clamp(value, min, max float64) float64 {
if value < min {
return min
}
if value > max {
return max
}
return value
}
// RGBToHex converts RGB values to a hexadecimal color string
func RGBToHex(r, g, b uint8) string {
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
// ParseHexColor parses a hexadecimal color string and returns RGB values
// Supports formats: #RGB, #RRGGBB, #RRGGBBAA
// Returns error if the format is invalid
func ParseHexColor(color string) (r, g, b, a uint8, err error) {
if len(color) == 0 || color[0] != '#' {
return 0, 0, 0, 255, fmt.Errorf("invalid color format: %s", color)
}
hex := color[1:] // Remove '#' prefix
a = 255 // Default alpha
// Helper to parse a component and chain errors
parse := func(target *uint8, hexStr string) {
if err != nil {
return // Don't parse if a previous component failed
}
*target, err = hexToByte(hexStr)
}
switch len(hex) {
case 3: // #RGB
parse(&r, hex[0:1]+hex[0:1])
parse(&g, hex[1:2]+hex[1:2])
parse(&b, hex[2:3]+hex[2:3])
case 6: // #RRGGBB
parse(&r, hex[0:2])
parse(&g, hex[2:4])
parse(&b, hex[4:6])
case 8: // #RRGGBBAA
parse(&r, hex[0:2])
parse(&g, hex[2:4])
parse(&b, hex[4:6])
parse(&a, hex[6:8])
default:
return 0, 0, 0, 255, fmt.Errorf("invalid hex color length: %s", color)
}
if err != nil {
// Return zero values for color components on error, but keep default alpha
return 0, 0, 0, 255, fmt.Errorf("failed to parse color '%s': %w", color, err)
}
return r, g, b, a, nil
}
// hexToByte converts a 2-character hex string to a byte value
func hexToByte(hex string) (uint8, error) {
if len(hex) != 2 {
return 0, fmt.Errorf("invalid hex string length: expected 2 characters, got %d", len(hex))
}
n, err := strconv.ParseUint(hex, 16, 8)
if err != nil {
return 0, fmt.Errorf("invalid hex value '%s': %w", hex, err)
}
return uint8(n), nil
}
// GenerateColor creates a color with the specified hue and configuration-based saturation and lightness
func GenerateColor(hue float64, config ColorConfig, lightnessValue float64) Color {
// Restrict hue according to configuration
restrictedHue := config.RestrictHue(hue)
// Get lightness from configuration range
lightness := config.ColorLightness.GetLightness(lightnessValue)
// Use corrected HSL to RGB conversion
return NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, lightness)
}
// GenerateGrayscale creates a grayscale color with configuration-based saturation and lightness
func GenerateGrayscale(config ColorConfig, lightnessValue float64) Color {
// For grayscale, hue doesn't matter, but we'll use 0
hue := 0.0
// Get lightness from grayscale configuration range
lightness := config.GrayscaleLightness.GetLightness(lightnessValue)
// Use grayscale saturation (typically 0)
return NewColorCorrectedHSL(hue, config.GrayscaleSaturation, lightness)
}
// GenerateColorTheme generates a set of color candidates based on the JavaScript colorTheme function
// This matches the JavaScript implementation that creates 5 colors:
// 0: Dark gray, 1: Mid color, 2: Light gray, 3: Light color, 4: Dark color
func GenerateColorTheme(hue float64, config ColorConfig) []Color {
// Restrict hue according to configuration
restrictedHue := config.RestrictHue(hue)
return []Color{
// Dark gray (grayscale with lightness 0)
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(0)),
// Mid color (normal color with lightness 0.5)
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(0.5)),
// Light gray (grayscale with lightness 1)
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(1)),
// Light color (normal color with lightness 1)
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(1)),
// Dark color (normal color with lightness 0)
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(0)),
}
}

View File

@@ -0,0 +1,35 @@
package engine
import (
"testing"
)
var benchmarkCases = []struct {
h, s, l float64
}{
{0.0, 0.5, 0.5}, // Red
{0.33, 0.5, 0.5}, // Green
{0.66, 0.5, 0.5}, // Blue
{0.5, 1.0, 0.3}, // Cyan dark
{0.8, 0.8, 0.7}, // Purple light
}
func BenchmarkCorrectedHSLToRGB(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
tc := benchmarkCases[i%len(benchmarkCases)]
CorrectedHSLToRGB(tc.h, tc.s, tc.l)
}
}
func BenchmarkNewColorCorrectedHSL(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
tc := benchmarkCases[i%len(benchmarkCases)]
NewColorCorrectedHSL(tc.h, tc.s, tc.l)
}
}

View File

@@ -0,0 +1,663 @@
package engine
import (
"math"
"testing"
)
func TestHSLToRGB(t *testing.T) {
tests := []struct {
name string
h, s, l float64
r, g, b uint8
}{
{
name: "pure red",
h: 0.0, s: 1.0, l: 0.5,
r: 255, g: 0, b: 0,
},
{
name: "pure green",
h: 1.0/3.0, s: 1.0, l: 0.5,
r: 0, g: 255, b: 0,
},
{
name: "pure blue",
h: 2.0/3.0, s: 1.0, l: 0.5,
r: 0, g: 0, b: 255,
},
{
name: "white",
h: 0.0, s: 0.0, l: 1.0,
r: 255, g: 255, b: 255,
},
{
name: "black",
h: 0.0, s: 0.0, l: 0.0,
r: 0, g: 0, b: 0,
},
{
name: "gray",
h: 0.0, s: 0.0, l: 0.5,
r: 127, g: 127, b: 127,
},
{
name: "dark red",
h: 0.0, s: 1.0, l: 0.25,
r: 127, g: 0, b: 0,
},
{
name: "light blue",
h: 2.0/3.0, s: 1.0, l: 0.75,
r: 127, g: 127, b: 255,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, g, b := HSLToRGB(tt.h, tt.s, tt.l)
// Allow small tolerance due to floating point arithmetic
tolerance := uint8(2)
if abs(int(r), int(tt.r)) > int(tolerance) ||
abs(int(g), int(tt.g)) > int(tolerance) ||
abs(int(b), int(tt.b)) > int(tolerance) {
t.Errorf("HSLToRGB(%f, %f, %f) = (%d, %d, %d), want (%d, %d, %d)",
tt.h, tt.s, tt.l, r, g, b, tt.r, tt.g, tt.b)
}
})
}
}
func TestCorrectedHSLToRGB(t *testing.T) {
// Test that corrected HSL produces valid RGB values
testCases := []struct {
name string
h, s, l float64
}{
{"Red", 0.0, 1.0, 0.5},
{"Green", 0.33, 1.0, 0.5},
{"Blue", 0.67, 1.0, 0.5},
{"Gray", 0.0, 0.0, 0.5},
{"DarkCyan", 0.5, 0.7, 0.3},
{"LightMagenta", 0.8, 0.8, 0.8},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
r, g, b := CorrectedHSLToRGB(tc.h, tc.s, tc.l)
// Verify RGB values are in valid range
if r > 255 || g > 255 || b > 255 {
t.Errorf("CorrectedHSLToRGB(%f, %f, %f) = (%d, %d, %d), RGB values should be <= 255",
tc.h, tc.s, tc.l, r, g, b)
}
})
}
}
func TestRGBToHex(t *testing.T) {
tests := []struct {
name string
r, g, b uint8
expected string
}{
{"black", 0, 0, 0, "#000000"},
{"white", 255, 255, 255, "#ffffff"},
{"red", 255, 0, 0, "#ff0000"},
{"green", 0, 255, 0, "#00ff00"},
{"blue", 0, 0, 255, "#0000ff"},
{"gray", 128, 128, 128, "#808080"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RGBToHex(tt.r, tt.g, tt.b)
if result != tt.expected {
t.Errorf("RGBToHex(%d, %d, %d) = %s, want %s", tt.r, tt.g, tt.b, result, tt.expected)
}
})
}
}
func TestHexToByte(t *testing.T) {
tests := []struct {
name string
input string
expected uint8
expectError bool
}{
{
name: "valid hex 00",
input: "00",
expected: 0,
},
{
name: "valid hex ff",
input: "ff",
expected: 255,
},
{
name: "valid hex a5",
input: "a5",
expected: 165,
},
{
name: "valid hex A5 uppercase",
input: "A5",
expected: 165,
},
{
name: "invalid length - too short",
input: "f",
expectError: true,
},
{
name: "invalid length - too long",
input: "fff",
expectError: true,
},
{
name: "invalid character x",
input: "fx",
expectError: true,
},
{
name: "invalid character z",
input: "zz",
expectError: true,
},
{
name: "empty string",
input: "",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := hexToByte(tt.input)
if tt.expectError {
if err == nil {
t.Errorf("hexToByte(%s) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Errorf("hexToByte(%s) unexpected error: %v", tt.input, err)
return
}
if result != tt.expected {
t.Errorf("hexToByte(%s) = %d, want %d", tt.input, result, tt.expected)
}
})
}
}
func TestParseHexColor(t *testing.T) {
tests := []struct {
name string
input string
expectError bool
r, g, b, a uint8
}{
{
name: "3-char hex",
input: "#f0a",
r: 255, g: 0, b: 170, a: 255,
},
{
name: "6-char hex",
input: "#ff00aa",
r: 255, g: 0, b: 170, a: 255,
},
{
name: "8-char hex with alpha",
input: "#ff00aa80",
r: 255, g: 0, b: 170, a: 128,
},
{
name: "black",
input: "#000",
r: 0, g: 0, b: 0, a: 255,
},
{
name: "white",
input: "#fff",
r: 255, g: 255, b: 255, a: 255,
},
{
name: "invalid format - no hash",
input: "ff0000",
expectError: true,
},
{
name: "invalid format - too short",
input: "#f",
expectError: true,
},
{
name: "invalid format - too long",
input: "#ff00aa12345",
expectError: true,
},
{
name: "invalid hex character in 3-char",
input: "#fxz",
expectError: true,
},
{
name: "invalid hex character in 6-char",
input: "#ff00xz",
expectError: true,
},
{
name: "invalid hex character in 8-char",
input: "#ff00aaxz",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, g, b, a, err := ParseHexColor(tt.input)
if tt.expectError {
if err == nil {
t.Errorf("ParseHexColor(%s) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Errorf("ParseHexColor(%s) unexpected error: %v", tt.input, err)
return
}
if r != tt.r || g != tt.g || b != tt.b || a != tt.a {
t.Errorf("ParseHexColor(%s) = (%d, %d, %d, %d), want (%d, %d, %d, %d)",
tt.input, r, g, b, a, tt.r, tt.g, tt.b, tt.a)
}
})
}
}
func TestClamp(t *testing.T) {
tests := []struct {
value, min, max, expected float64
}{
{0.5, 0.0, 1.0, 0.5}, // within range
{-0.5, 0.0, 1.0, 0.0}, // below min
{1.5, 0.0, 1.0, 1.0}, // above max
{0.0, 0.0, 1.0, 0.0}, // at min
{1.0, 0.0, 1.0, 1.0}, // at max
}
for _, tt := range tests {
result := clamp(tt.value, tt.min, tt.max)
if result != tt.expected {
t.Errorf("clamp(%f, %f, %f) = %f, want %f", tt.value, tt.min, tt.max, result, tt.expected)
}
}
}
func TestNewColorHSL(t *testing.T) {
color := NewColorHSL(0.0, 1.0, 0.5) // Pure red
if color.H != 0.0 || color.S != 1.0 || color.L != 0.5 {
t.Errorf("NewColorHSL(0.0, 1.0, 0.5) HSL = (%f, %f, %f), want (0.0, 1.0, 0.5)",
color.H, color.S, color.L)
}
if color.R != 255 || color.G != 0 || color.B != 0 {
t.Errorf("NewColorHSL(0.0, 1.0, 0.5) RGB = (%d, %d, %d), want (255, 0, 0)",
color.R, color.G, color.B)
}
if color.A != 255 {
t.Errorf("NewColorHSL(0.0, 1.0, 0.5) A = %d, want 255", color.A)
}
}
func TestNewColorRGB(t *testing.T) {
color := NewColorRGB(255, 0, 0) // Pure red
if color.R != 255 || color.G != 0 || color.B != 0 {
t.Errorf("NewColorRGB(255, 0, 0) RGB = (%d, %d, %d), want (255, 0, 0)",
color.R, color.G, color.B)
}
// HSL values should be approximately (0, 1, 0.5) for pure red
tolerance := 0.01
if math.Abs(color.H-0.0) > tolerance || math.Abs(color.S-1.0) > tolerance || math.Abs(color.L-0.5) > tolerance {
t.Errorf("NewColorRGB(255, 0, 0) HSL = (%f, %f, %f), want approximately (0.0, 1.0, 0.5)",
color.H, color.S, color.L)
}
}
func TestColorString(t *testing.T) {
tests := []struct {
name string
color Color
expected string
}{
{
name: "red without alpha",
color: NewColorRGB(255, 0, 0),
expected: "#ff0000",
},
{
name: "blue with alpha",
color: NewColorRGBA(0, 0, 255, 128),
expected: "#0000ff80",
},
{
name: "black",
color: NewColorRGB(0, 0, 0),
expected: "#000000",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.color.String()
if result != tt.expected {
t.Errorf("Color.String() = %s, want %s", result, tt.expected)
}
})
}
}
func TestColorEquals(t *testing.T) {
color1 := NewColorRGB(255, 0, 0)
color2 := NewColorRGB(255, 0, 0)
color3 := NewColorRGB(0, 255, 0)
color4 := NewColorRGBA(255, 0, 0, 128)
if !color1.Equals(color2) {
t.Error("Expected equal colors to be equal")
}
if color1.Equals(color3) {
t.Error("Expected different colors to not be equal")
}
if color1.Equals(color4) {
t.Error("Expected colors with different alpha to not be equal")
}
}
func TestColorWithAlpha(t *testing.T) {
color := NewColorRGB(255, 0, 0)
newColor := color.WithAlpha(128)
if newColor.A != 128 {
t.Errorf("WithAlpha(128) A = %d, want 128", newColor.A)
}
// RGB and HSL should remain the same
if newColor.R != color.R || newColor.G != color.G || newColor.B != color.B {
t.Error("WithAlpha should not change RGB values")
}
if newColor.H != color.H || newColor.S != color.S || newColor.L != color.L {
t.Error("WithAlpha should not change HSL values")
}
}
func TestColorIsGrayscale(t *testing.T) {
grayColor := NewColorRGB(128, 128, 128)
redColor := NewColorRGB(255, 0, 0)
if !grayColor.IsGrayscale() {
t.Error("Expected gray color to be identified as grayscale")
}
if redColor.IsGrayscale() {
t.Error("Expected red color to not be identified as grayscale")
}
}
func TestColorDarkenLighten(t *testing.T) {
color := NewColorHSL(0.0, 1.0, 0.5) // Pure red
darker := color.Darken(0.2)
if darker.L >= color.L {
t.Error("Darken should reduce lightness")
}
lighter := color.Lighten(0.2)
if lighter.L <= color.L {
t.Error("Lighten should increase lightness")
}
// Test clamping
veryDark := color.Darken(1.0)
if veryDark.L != 0.0 {
t.Errorf("Darken with large amount should clamp to 0, got %f", veryDark.L)
}
veryLight := color.Lighten(1.0)
if veryLight.L != 1.0 {
t.Errorf("Lighten with large amount should clamp to 1, got %f", veryLight.L)
}
}
func TestRGBToHSL(t *testing.T) {
tests := []struct {
name string
r, g, b uint8
h, s, l float64
}{
{
name: "red",
r: 255, g: 0, b: 0,
h: 0.0, s: 1.0, l: 0.5,
},
{
name: "green",
r: 0, g: 255, b: 0,
h: 1.0/3.0, s: 1.0, l: 0.5,
},
{
name: "blue",
r: 0, g: 0, b: 255,
h: 2.0/3.0, s: 1.0, l: 0.5,
},
{
name: "white",
r: 255, g: 255, b: 255,
h: 0.0, s: 0.0, l: 1.0,
},
{
name: "black",
r: 0, g: 0, b: 0,
h: 0.0, s: 0.0, l: 0.0,
},
{
name: "gray",
r: 128, g: 128, b: 128,
h: 0.0, s: 0.0, l: 0.502, // approximately 0.5
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h, s, l := RGBToHSL(tt.r, tt.g, tt.b)
tolerance := 0.01
if math.Abs(h-tt.h) > tolerance || math.Abs(s-tt.s) > tolerance || math.Abs(l-tt.l) > tolerance {
t.Errorf("RGBToHSL(%d, %d, %d) = (%f, %f, %f), want approximately (%f, %f, %f)",
tt.r, tt.g, tt.b, h, s, l, tt.h, tt.s, tt.l)
}
})
}
}
func TestGenerateColor(t *testing.T) {
config := DefaultColorConfig()
// Test color generation with mid-range lightness
color := GenerateColor(0.0, config, 0.5) // Red hue, mid lightness
// Should be approximately red with default saturation (0.5) and mid lightness (0.6)
expectedLightness := config.ColorLightness.GetLightness(0.5) // Should be 0.6
tolerance := 0.01
if math.Abs(color.H-0.0) > tolerance {
t.Errorf("GenerateColor hue = %f, want approximately 0.0", color.H)
}
if math.Abs(color.S-config.ColorSaturation) > tolerance {
t.Errorf("GenerateColor saturation = %f, want %f", color.S, config.ColorSaturation)
}
if math.Abs(color.L-expectedLightness) > tolerance {
t.Errorf("GenerateColor lightness = %f, want approximately %f", color.L, expectedLightness)
}
}
func TestGenerateGrayscale(t *testing.T) {
config := DefaultColorConfig()
// Test grayscale generation
color := GenerateGrayscale(config, 0.5)
// Should be grayscale (saturation 0) with mid lightness
expectedLightness := config.GrayscaleLightness.GetLightness(0.5) // Should be 0.6
tolerance := 0.01
if math.Abs(color.S-config.GrayscaleSaturation) > tolerance {
t.Errorf("GenerateGrayscale saturation = %f, want %f", color.S, config.GrayscaleSaturation)
}
if math.Abs(color.L-expectedLightness) > tolerance {
t.Errorf("GenerateGrayscale lightness = %f, want approximately %f", color.L, expectedLightness)
}
if !color.IsGrayscale() {
t.Error("GenerateGrayscale should produce a grayscale color")
}
}
func TestGenerateColorTheme(t *testing.T) {
config := DefaultColorConfig()
hue := 0.25 // Green-ish hue
theme := GenerateColorTheme(hue, config)
// Should have exactly 5 colors
if len(theme) != 5 {
t.Errorf("GenerateColorTheme returned %d colors, want 5", len(theme))
}
// Test color indices according to JavaScript implementation:
// 0: Dark gray, 1: Mid color, 2: Light gray, 3: Light color, 4: Dark color
// Index 0: Dark gray (grayscale with lightness 0)
darkGray := theme[0]
if !darkGray.IsGrayscale() {
t.Error("Theme color 0 should be grayscale (dark gray)")
}
expectedLightness := config.GrayscaleLightness.GetLightness(0)
if math.Abs(darkGray.L-expectedLightness) > 0.01 {
t.Errorf("Dark gray lightness = %f, want %f", darkGray.L, expectedLightness)
}
// Index 1: Mid color (normal color with lightness 0.5)
midColor := theme[1]
if midColor.IsGrayscale() {
t.Error("Theme color 1 should not be grayscale (mid color)")
}
expectedLightness = config.ColorLightness.GetLightness(0.5)
if math.Abs(midColor.L-expectedLightness) > 0.01 {
t.Errorf("Mid color lightness = %f, want %f", midColor.L, expectedLightness)
}
// Index 2: Light gray (grayscale with lightness 1)
lightGray := theme[2]
if !lightGray.IsGrayscale() {
t.Error("Theme color 2 should be grayscale (light gray)")
}
expectedLightness = config.GrayscaleLightness.GetLightness(1)
if math.Abs(lightGray.L-expectedLightness) > 0.01 {
t.Errorf("Light gray lightness = %f, want %f", lightGray.L, expectedLightness)
}
// Index 3: Light color (normal color with lightness 1)
lightColor := theme[3]
if lightColor.IsGrayscale() {
t.Error("Theme color 3 should not be grayscale (light color)")
}
expectedLightness = config.ColorLightness.GetLightness(1)
if math.Abs(lightColor.L-expectedLightness) > 0.01 {
t.Errorf("Light color lightness = %f, want %f", lightColor.L, expectedLightness)
}
// Index 4: Dark color (normal color with lightness 0)
darkColor := theme[4]
if darkColor.IsGrayscale() {
t.Error("Theme color 4 should not be grayscale (dark color)")
}
expectedLightness = config.ColorLightness.GetLightness(0)
if math.Abs(darkColor.L-expectedLightness) > 0.01 {
t.Errorf("Dark color lightness = %f, want %f", darkColor.L, expectedLightness)
}
// All colors should have the same hue (or close to it for grayscale)
for i, color := range theme {
if !color.IsGrayscale() { // Only check hue for non-grayscale colors
if math.Abs(color.H-hue) > 0.01 {
t.Errorf("Theme color %d hue = %f, want approximately %f", i, color.H, hue)
}
}
}
}
func TestGenerateColorThemeWithHueRestriction(t *testing.T) {
// Test with hue restriction
config := NewColorConfigBuilder().
WithHues(180). // Only allow cyan (180 degrees = 0.5 turns)
Build()
theme := GenerateColorTheme(0.25, config) // Request green, should get cyan
for i, color := range theme {
if !color.IsGrayscale() { // Only check hue for non-grayscale colors
if math.Abs(color.H-0.5) > 0.01 {
t.Errorf("Theme color %d hue = %f, want approximately 0.5 (restricted)", i, color.H)
}
}
}
}
func TestGenerateColorWithConfiguration(t *testing.T) {
// Test with custom configuration
config := NewColorConfigBuilder().
WithColorSaturation(0.8).
WithColorLightness(0.2, 0.6).
Build()
color := GenerateColor(0.33, config, 1.0) // Green hue, max lightness
tolerance := 0.01
if math.Abs(color.S-0.8) > tolerance {
t.Errorf("Custom config saturation = %f, want 0.8", color.S)
}
expectedLightness := config.ColorLightness.GetLightness(1.0) // Should be 0.6
if math.Abs(color.L-expectedLightness) > tolerance {
t.Errorf("Custom config lightness = %f, want %f", color.L, expectedLightness)
}
}
// Helper function for absolute difference
func abs(a, b int) int {
if a > b {
return a - b
}
return b - a
}

175
internal/engine/config.go Normal file
View File

@@ -0,0 +1,175 @@
package engine
import "math"
// ColorConfig represents the configuration for color generation
type ColorConfig struct {
// Saturation settings
ColorSaturation float64 // Saturation for normal colors [0, 1]
GrayscaleSaturation float64 // Saturation for grayscale colors [0, 1]
// Lightness ranges
ColorLightness LightnessRange // Lightness range for normal colors
GrayscaleLightness LightnessRange // Lightness range for grayscale colors
// Hue restrictions
Hues []float64 // Allowed hues in degrees [0, 360] or range [0, 1]. Empty means no restriction
// Background color
BackColor *Color // Background color (nil for transparent)
// Icon padding
IconPadding float64 // Padding as percentage of icon size [0, 1]
}
// LightnessRange represents a range of lightness values
type LightnessRange struct {
Min float64 // Minimum lightness [0, 1]
Max float64 // Maximum lightness [0, 1]
}
// GetLightness returns a lightness value for the given position in range [0, 1]
// where 0 returns Min and 1 returns Max
func (lr LightnessRange) GetLightness(value float64) float64 {
// Clamp value to [0, 1] range
value = clamp(value, 0, 1)
// Linear interpolation between min and max
result := lr.Min + value*(lr.Max-lr.Min)
// Clamp result to valid lightness range
return clamp(result, 0, 1)
}
// DefaultColorConfig returns the default configuration matching the JavaScript implementation
func DefaultColorConfig() ColorConfig {
return ColorConfig{
ColorSaturation: 0.5,
GrayscaleSaturation: 0.0,
ColorLightness: LightnessRange{Min: 0.4, Max: 0.8},
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
Hues: nil, // No hue restriction
BackColor: nil, // Transparent background
IconPadding: 0.08,
}
}
// RestrictHue applies hue restrictions to the given hue value
// Returns the restricted hue in range [0, 1]
func (c ColorConfig) RestrictHue(originalHue float64) float64 {
// Normalize hue to [0, 1) range
hue := math.Mod(originalHue, 1.0)
if hue < 0 {
hue += 1.0
}
// If no hue restrictions, return original
if len(c.Hues) == 0 {
return hue
}
// Find the closest allowed hue
// originalHue is in range [0, 1], multiply by 0.999 to get range [0, 1)
// then truncate to get index
index := int((0.999 * hue * float64(len(c.Hues))))
if index >= len(c.Hues) {
index = len(c.Hues) - 1
}
restrictedHue := c.Hues[index]
// Convert from degrees to turns in range [0, 1)
// Handle any turn - e.g. 746° is valid
result := math.Mod(restrictedHue/360.0, 1.0)
if result < 0 {
result += 1.0
}
return result
}
// ValidateConfig validates and corrects a ColorConfig to ensure all values are within valid ranges
func (c *ColorConfig) Validate() {
// Clamp saturation values
c.ColorSaturation = clamp(c.ColorSaturation, 0, 1)
c.GrayscaleSaturation = clamp(c.GrayscaleSaturation, 0, 1)
// Validate lightness ranges
c.ColorLightness.Min = clamp(c.ColorLightness.Min, 0, 1)
c.ColorLightness.Max = clamp(c.ColorLightness.Max, 0, 1)
if c.ColorLightness.Min > c.ColorLightness.Max {
c.ColorLightness.Min, c.ColorLightness.Max = c.ColorLightness.Max, c.ColorLightness.Min
}
c.GrayscaleLightness.Min = clamp(c.GrayscaleLightness.Min, 0, 1)
c.GrayscaleLightness.Max = clamp(c.GrayscaleLightness.Max, 0, 1)
if c.GrayscaleLightness.Min > c.GrayscaleLightness.Max {
c.GrayscaleLightness.Min, c.GrayscaleLightness.Max = c.GrayscaleLightness.Max, c.GrayscaleLightness.Min
}
// Clamp icon padding
c.IconPadding = clamp(c.IconPadding, 0, 1)
// Validate hues (no need to clamp as RestrictHue handles normalization)
}
// ColorConfigBuilder provides a fluent interface for building ColorConfig
type ColorConfigBuilder struct {
config ColorConfig
}
// NewColorConfigBuilder creates a new builder with default values
func NewColorConfigBuilder() *ColorConfigBuilder {
return &ColorConfigBuilder{
config: DefaultColorConfig(),
}
}
// WithColorSaturation sets the color saturation
func (b *ColorConfigBuilder) WithColorSaturation(saturation float64) *ColorConfigBuilder {
b.config.ColorSaturation = saturation
return b
}
// WithGrayscaleSaturation sets the grayscale saturation
func (b *ColorConfigBuilder) WithGrayscaleSaturation(saturation float64) *ColorConfigBuilder {
b.config.GrayscaleSaturation = saturation
return b
}
// WithColorLightness sets the color lightness range
func (b *ColorConfigBuilder) WithColorLightness(min, max float64) *ColorConfigBuilder {
b.config.ColorLightness = LightnessRange{Min: min, Max: max}
return b
}
// WithGrayscaleLightness sets the grayscale lightness range
func (b *ColorConfigBuilder) WithGrayscaleLightness(min, max float64) *ColorConfigBuilder {
b.config.GrayscaleLightness = LightnessRange{Min: min, Max: max}
return b
}
// WithHues sets the allowed hues in degrees
func (b *ColorConfigBuilder) WithHues(hues ...float64) *ColorConfigBuilder {
b.config.Hues = make([]float64, len(hues))
copy(b.config.Hues, hues)
return b
}
// WithBackColor sets the background color
func (b *ColorConfigBuilder) WithBackColor(color Color) *ColorConfigBuilder {
b.config.BackColor = &color
return b
}
// WithIconPadding sets the icon padding
func (b *ColorConfigBuilder) WithIconPadding(padding float64) *ColorConfigBuilder {
b.config.IconPadding = padding
return b
}
// Build returns the configured ColorConfig after validation
func (b *ColorConfigBuilder) Build() ColorConfig {
b.config.Validate()
return b.config
}

View File

@@ -0,0 +1,218 @@
package engine
import (
"math"
"testing"
)
func TestDefaultColorConfig(t *testing.T) {
config := DefaultColorConfig()
// Test default values match JavaScript implementation
if config.ColorSaturation != 0.5 {
t.Errorf("ColorSaturation = %f, want 0.5", config.ColorSaturation)
}
if config.GrayscaleSaturation != 0.0 {
t.Errorf("GrayscaleSaturation = %f, want 0.0", config.GrayscaleSaturation)
}
if config.ColorLightness.Min != 0.4 || config.ColorLightness.Max != 0.8 {
t.Errorf("ColorLightness = {%f, %f}, want {0.4, 0.8}",
config.ColorLightness.Min, config.ColorLightness.Max)
}
if config.GrayscaleLightness.Min != 0.3 || config.GrayscaleLightness.Max != 0.9 {
t.Errorf("GrayscaleLightness = {%f, %f}, want {0.3, 0.9}",
config.GrayscaleLightness.Min, config.GrayscaleLightness.Max)
}
if len(config.Hues) != 0 {
t.Errorf("Hues should be empty by default, got %v", config.Hues)
}
if config.BackColor != nil {
t.Error("BackColor should be nil by default")
}
if config.IconPadding != 0.08 {
t.Errorf("IconPadding = %f, want 0.08", config.IconPadding)
}
}
func TestLightnessRangeGetLightness(t *testing.T) {
lr := LightnessRange{Min: 0.3, Max: 0.9}
tests := []struct {
value float64
expected float64
}{
{0.0, 0.3}, // Min value
{1.0, 0.9}, // Max value
{0.5, 0.6}, // Middle value: 0.3 + 0.5 * (0.9 - 0.3) = 0.6
{-0.5, 0.3}, // Below range, should clamp to min
{1.5, 0.9}, // Above range, should clamp to max
}
for _, tt := range tests {
result := lr.GetLightness(tt.value)
if math.Abs(result-tt.expected) > 0.001 {
t.Errorf("GetLightness(%f) = %f, want %f", tt.value, result, tt.expected)
}
}
}
func TestConfigRestrictHue(t *testing.T) {
tests := []struct {
name string
hues []float64
originalHue float64
expectedHue float64
}{
{
name: "no restriction",
hues: nil,
originalHue: 0.25,
expectedHue: 0.25,
},
{
name: "empty restriction",
hues: []float64{},
originalHue: 0.25,
expectedHue: 0.25,
},
{
name: "single hue restriction",
hues: []float64{180}, // 180 degrees = 0.5 turns
originalHue: 0.25,
expectedHue: 0.5,
},
{
name: "multiple hue restriction",
hues: []float64{0, 120, 240}, // Red, Green, Blue
originalHue: 0.1, // Should map to first hue (0 degrees)
expectedHue: 0.0,
},
{
name: "hue normalization - negative",
hues: []float64{90}, // 90 degrees = 0.25 turns
originalHue: -0.5,
expectedHue: 0.25,
},
{
name: "hue normalization - over 1",
hues: []float64{270}, // 270 degrees = 0.75 turns
originalHue: 1.5,
expectedHue: 0.75,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := ColorConfig{Hues: tt.hues}
result := config.RestrictHue(tt.originalHue)
if math.Abs(result-tt.expectedHue) > 0.001 {
t.Errorf("RestrictHue(%f) = %f, want %f", tt.originalHue, result, tt.expectedHue)
}
})
}
}
func TestConfigValidate(t *testing.T) {
// Test that validation corrects invalid values
config := ColorConfig{
ColorSaturation: -0.5, // Invalid: below 0
GrayscaleSaturation: 1.5, // Invalid: above 1
ColorLightness: LightnessRange{Min: 0.8, Max: 0.2}, // Invalid: min > max
GrayscaleLightness: LightnessRange{Min: -0.1, Max: 1.1}, // Invalid: out of range
IconPadding: 2.0, // Invalid: above 1
}
config.Validate()
if config.ColorSaturation != 0.0 {
t.Errorf("ColorSaturation after validation = %f, want 0.0", config.ColorSaturation)
}
if config.GrayscaleSaturation != 1.0 {
t.Errorf("GrayscaleSaturation after validation = %f, want 1.0", config.GrayscaleSaturation)
}
// Min and max should be swapped
if config.ColorLightness.Min != 0.2 || config.ColorLightness.Max != 0.8 {
t.Errorf("ColorLightness after validation = {%f, %f}, want {0.2, 0.8}",
config.ColorLightness.Min, config.ColorLightness.Max)
}
// Values should be clamped
if config.GrayscaleLightness.Min != 0.0 || config.GrayscaleLightness.Max != 1.0 {
t.Errorf("GrayscaleLightness after validation = {%f, %f}, want {0.0, 1.0}",
config.GrayscaleLightness.Min, config.GrayscaleLightness.Max)
}
if config.IconPadding != 1.0 {
t.Errorf("IconPadding after validation = %f, want 1.0", config.IconPadding)
}
}
func TestColorConfigBuilder(t *testing.T) {
redColor := NewColorRGB(255, 0, 0)
config := NewColorConfigBuilder().
WithColorSaturation(0.7).
WithGrayscaleSaturation(0.1).
WithColorLightness(0.2, 0.8).
WithGrayscaleLightness(0.1, 0.9).
WithHues(0, 120, 240).
WithBackColor(redColor).
WithIconPadding(0.1).
Build()
if config.ColorSaturation != 0.7 {
t.Errorf("ColorSaturation = %f, want 0.7", config.ColorSaturation)
}
if config.GrayscaleSaturation != 0.1 {
t.Errorf("GrayscaleSaturation = %f, want 0.1", config.GrayscaleSaturation)
}
if config.ColorLightness.Min != 0.2 || config.ColorLightness.Max != 0.8 {
t.Errorf("ColorLightness = {%f, %f}, want {0.2, 0.8}",
config.ColorLightness.Min, config.ColorLightness.Max)
}
if config.GrayscaleLightness.Min != 0.1 || config.GrayscaleLightness.Max != 0.9 {
t.Errorf("GrayscaleLightness = {%f, %f}, want {0.1, 0.9}",
config.GrayscaleLightness.Min, config.GrayscaleLightness.Max)
}
if len(config.Hues) != 3 || config.Hues[0] != 0 || config.Hues[1] != 120 || config.Hues[2] != 240 {
t.Errorf("Hues = %v, want [0, 120, 240]", config.Hues)
}
if config.BackColor == nil || !config.BackColor.Equals(redColor) {
t.Error("BackColor should be set to red")
}
if config.IconPadding != 0.1 {
t.Errorf("IconPadding = %f, want 0.1", config.IconPadding)
}
}
func TestColorConfigBuilderValidation(t *testing.T) {
// Test that builder validates configuration
config := NewColorConfigBuilder().
WithColorSaturation(-0.5). // Invalid
WithGrayscaleSaturation(1.5). // Invalid
Build()
// Should be corrected by validation
if config.ColorSaturation != 0.0 {
t.Errorf("ColorSaturation = %f, want 0.0 (corrected)", config.ColorSaturation)
}
if config.GrayscaleSaturation != 1.0 {
t.Errorf("GrayscaleSaturation = %f, want 1.0 (corrected)", config.GrayscaleSaturation)
}
}

View File

@@ -0,0 +1,353 @@
package engine
import (
"fmt"
"sync"
"github.com/kevin/go-jdenticon/internal/util"
)
// Icon represents a generated jdenticon with its configuration and geometry
type Icon struct {
Hash string
Size float64
Config ColorConfig
Shapes []ShapeGroup
}
// ShapeGroup represents a group of shapes with the same color
type ShapeGroup struct {
Color Color
Shapes []Shape
ShapeType string
}
// Shape represents a single geometric shape. It acts as a discriminated union
// where the `Type` field determines which other fields are valid.
// - For "polygon", `Points` is used.
// - For "circle", `CircleX`, `CircleY`, and `CircleSize` are used.
type Shape struct {
Type string
Points []Point
Transform Transform
Invert bool
// Circle-specific fields
CircleX float64
CircleY float64
CircleSize float64
}
// Generator encapsulates the icon generation logic and provides caching
type Generator struct {
config ColorConfig
cache map[string]*Icon
mu sync.RWMutex
}
// NewGenerator creates a new Generator with the specified configuration
func NewGenerator(config ColorConfig) *Generator {
config.Validate()
return &Generator{
config: config,
cache: make(map[string]*Icon),
}
}
// NewDefaultGenerator creates a new Generator with default configuration
func NewDefaultGenerator() *Generator {
return NewGenerator(DefaultColorConfig())
}
// Generate creates an icon from a hash string using the configured settings
func (g *Generator) Generate(hash string, size float64) (*Icon, error) {
if hash == "" {
return nil, fmt.Errorf("hash cannot be empty")
}
if size <= 0 {
return nil, fmt.Errorf("size must be positive, got %f", size)
}
// Check cache first
cacheKey := g.cacheKey(hash, size)
g.mu.RLock()
if cached, exists := g.cache[cacheKey]; exists {
g.mu.RUnlock()
return cached, nil
}
g.mu.RUnlock()
// Validate hash format
if !util.IsValidHash(hash) {
return nil, fmt.Errorf("invalid hash format: %s", hash)
}
// Generate new icon
icon, err := g.generateIcon(hash, size)
if err != nil {
return nil, err
}
// Cache the result
g.mu.Lock()
g.cache[cacheKey] = icon
g.mu.Unlock()
return icon, nil
}
// generateIcon performs the actual icon generation
func (g *Generator) generateIcon(hash string, size float64) (*Icon, error) {
// Calculate padding and round to nearest integer (matching JavaScript)
padding := int((0.5 + size*g.config.IconPadding))
iconSize := size - float64(padding*2)
// Calculate cell size and ensure it is an integer (matching JavaScript)
cell := int(iconSize / 4)
// Since the cell size is integer based, the actual icon will be slightly smaller than specified => center icon
x := int(float64(padding) + iconSize/2 - float64(cell*2))
y := int(float64(padding) + iconSize/2 - float64(cell*2))
// Extract hue from hash (last 7 characters)
hue, err := g.extractHue(hash)
if err != nil {
return nil, fmt.Errorf("generateIcon: %w", err)
}
// Generate color theme
availableColors := GenerateColorTheme(hue, g.config)
// Select colors for each shape layer
selectedColorIndexes, err := g.selectColors(hash, availableColors)
if err != nil {
return nil, err
}
// Generate shape groups in exact JavaScript order
shapeGroups := make([]ShapeGroup, 0, 3)
// 1. Sides (outer edges) - renderShape(0, outerShape, 2, 3, [[1, 0], [2, 0], [2, 3], [1, 3], [0, 1], [3, 1], [3, 2], [0, 2]]);
sideShapes, err := g.renderShape(hash, 0, 2, 3,
[][]int{{1, 0}, {2, 0}, {2, 3}, {1, 3}, {0, 1}, {3, 1}, {3, 2}, {0, 2}},
x, y, cell, true)
if err != nil {
return nil, fmt.Errorf("generateIcon: failed to render side shapes: %w", err)
}
if len(sideShapes) > 0 {
shapeGroups = append(shapeGroups, ShapeGroup{
Color: availableColors[selectedColorIndexes[0]],
Shapes: sideShapes,
ShapeType: "sides",
})
}
// 2. Corners - renderShape(1, outerShape, 4, 5, [[0, 0], [3, 0], [3, 3], [0, 3]]);
cornerShapes, err := g.renderShape(hash, 1, 4, 5,
[][]int{{0, 0}, {3, 0}, {3, 3}, {0, 3}},
x, y, cell, true)
if err != nil {
return nil, fmt.Errorf("generateIcon: failed to render corner shapes: %w", err)
}
if len(cornerShapes) > 0 {
shapeGroups = append(shapeGroups, ShapeGroup{
Color: availableColors[selectedColorIndexes[1]],
Shapes: cornerShapes,
ShapeType: "corners",
})
}
// 3. Center - renderShape(2, centerShape, 1, null, [[1, 1], [2, 1], [2, 2], [1, 2]]);
centerShapes, err := g.renderShape(hash, 2, 1, -1,
[][]int{{1, 1}, {2, 1}, {2, 2}, {1, 2}},
x, y, cell, false)
if err != nil {
return nil, fmt.Errorf("generateIcon: failed to render center shapes: %w", err)
}
if len(centerShapes) > 0 {
shapeGroups = append(shapeGroups, ShapeGroup{
Color: availableColors[selectedColorIndexes[2]],
Shapes: centerShapes,
ShapeType: "center",
})
}
return &Icon{
Hash: hash,
Size: size,
Config: g.config,
Shapes: shapeGroups,
}, nil
}
// extractHue extracts the hue value from the hash string
func (g *Generator) extractHue(hash string) (float64, error) {
// Use the last 7 characters of the hash to determine hue
hueValue, err := util.ParseHex(hash, -7, 7)
if err != nil {
return 0, fmt.Errorf("extractHue: %w", err)
}
return float64(hueValue) / 0xfffffff, nil
}
// selectColors selects 3 colors from the available color palette
func (g *Generator) selectColors(hash string, availableColors []Color) ([]int, error) {
if len(availableColors) == 0 {
return nil, fmt.Errorf("no available colors")
}
selectedIndexes := make([]int, 3)
for i := 0; i < 3; i++ {
indexValue, err := util.ParseHex(hash, 8+i, 1)
if err != nil {
return nil, fmt.Errorf("selectColors: failed to parse color index at position %d: %w", 8+i, err)
}
index := indexValue % len(availableColors)
// Apply color conflict resolution rules from JavaScript implementation
if g.isDuplicateColor(index, selectedIndexes[:i], []int{0, 4}) || // Disallow dark gray and dark color combo
g.isDuplicateColor(index, selectedIndexes[:i], []int{2, 3}) { // Disallow light gray and light color combo
index = 1 // Use mid color as fallback
}
selectedIndexes[i] = index
}
return selectedIndexes, nil
}
// contains checks if a slice contains a specific value
func contains(slice []int, value int) bool {
for _, item := range slice {
if item == value {
return true
}
}
return false
}
// isDuplicateColor checks for problematic color combinations
func (g *Generator) isDuplicateColor(index int, selected []int, forbidden []int) bool {
if !contains(forbidden, index) {
return false
}
for _, s := range selected {
if contains(forbidden, s) {
return true
}
}
return false
}
// renderShape implements the JavaScript renderShape function exactly
func (g *Generator) renderShape(hash string, colorIndex, shapeHashIndex, rotationHashIndex int, positions [][]int, x, y, cell int, isOuter bool) ([]Shape, error) {
shapeIndexValue, err := util.ParseHex(hash, shapeHashIndex, 1)
if err != nil {
return nil, fmt.Errorf("renderShape: failed to parse shape index at position %d: %w", shapeHashIndex, err)
}
shapeIndex := shapeIndexValue
var rotation int
if rotationHashIndex >= 0 {
rotationValue, err := util.ParseHex(hash, rotationHashIndex, 1)
if err != nil {
return nil, fmt.Errorf("renderShape: failed to parse rotation at position %d: %w", rotationHashIndex, err)
}
rotation = rotationValue
}
shapes := make([]Shape, 0, len(positions))
for i, pos := range positions {
// Calculate transform exactly like JavaScript: new Transform(x + positions[i][0] * cell, y + positions[i][1] * cell, cell, r++ % 4)
transformX := float64(x + pos[0]*cell)
transformY := float64(y + pos[1]*cell)
var transformRotation int
if rotationHashIndex >= 0 {
transformRotation = (rotation + i) % 4
} else {
// For center shapes (rotationIndex is null), r starts at 0 and increments
transformRotation = i % 4
}
transform := NewTransform(transformX, transformY, float64(cell), transformRotation)
// Create shape using graphics with transform
graphics := NewGraphicsWithTransform(&shapeCollector{}, transform)
if isOuter {
RenderOuterShape(graphics, shapeIndex, float64(cell))
} else {
RenderCenterShape(graphics, shapeIndex, float64(cell), float64(i))
}
collector := graphics.renderer.(*shapeCollector)
for _, shape := range collector.shapes {
shapes = append(shapes, shape)
}
}
return shapes, nil
}
// shapeCollector implements Renderer interface to collect shapes during generation
type shapeCollector struct {
shapes []Shape
}
func (sc *shapeCollector) AddPolygon(points []Point) {
sc.shapes = append(sc.shapes, Shape{
Type: "polygon",
Points: points,
})
}
func (sc *shapeCollector) AddCircle(topLeft Point, size float64, invert bool) {
// Store circle with dedicated circle geometry fields
sc.shapes = append(sc.shapes, Shape{
Type: "circle",
CircleX: topLeft.X,
CircleY: topLeft.Y,
CircleSize: size,
Invert: invert,
})
}
// cacheKey generates a cache key for the given parameters
func (g *Generator) cacheKey(hash string, size float64) string {
return fmt.Sprintf("%s:%.2f", hash, size)
}
// ClearCache clears the internal cache
func (g *Generator) ClearCache() {
g.mu.Lock()
defer g.mu.Unlock()
g.cache = make(map[string]*Icon)
}
// GetCacheSize returns the number of cached icons
func (g *Generator) GetCacheSize() int {
g.mu.RLock()
defer g.mu.RUnlock()
return len(g.cache)
}
// SetConfig updates the generator configuration and clears cache
func (g *Generator) SetConfig(config ColorConfig) {
config.Validate()
g.mu.Lock()
g.config = config
g.cache = make(map[string]*Icon)
g.mu.Unlock()
}
// GetConfig returns a copy of the current configuration
func (g *Generator) GetConfig() ColorConfig {
g.mu.RLock()
defer g.mu.RUnlock()
return g.config
}

View File

@@ -0,0 +1,517 @@
package engine
import (
"testing"
"github.com/kevin/go-jdenticon/internal/util"
)
func TestNewGenerator(t *testing.T) {
config := DefaultColorConfig()
generator := NewGenerator(config)
if generator == nil {
t.Fatal("NewGenerator returned nil")
}
if generator.config.IconPadding != config.IconPadding {
t.Errorf("Expected icon padding %f, got %f", config.IconPadding, generator.config.IconPadding)
}
if generator.cache == nil {
t.Error("Generator cache was not initialized")
}
}
func TestNewDefaultGenerator(t *testing.T) {
generator := NewDefaultGenerator()
if generator == nil {
t.Fatal("NewDefaultGenerator returned nil")
}
expectedConfig := DefaultColorConfig()
if generator.config.IconPadding != expectedConfig.IconPadding {
t.Errorf("Expected icon padding %f, got %f", expectedConfig.IconPadding, generator.config.IconPadding)
}
}
func TestGenerateValidHash(t *testing.T) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
icon, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("Generate failed with error: %v", err)
}
if icon == nil {
t.Fatal("Generate returned nil icon")
}
if icon.Hash != hash {
t.Errorf("Expected hash %s, got %s", hash, icon.Hash)
}
if icon.Size != size {
t.Errorf("Expected size %f, got %f", size, icon.Size)
}
if len(icon.Shapes) == 0 {
t.Error("Generated icon has no shapes")
}
}
func TestGenerateInvalidInputs(t *testing.T) {
generator := NewDefaultGenerator()
tests := []struct {
name string
hash string
size float64
wantErr bool
}{
{
name: "empty hash",
hash: "",
size: 64.0,
wantErr: true,
},
{
name: "zero size",
hash: "abcdef123456789",
size: 0.0,
wantErr: true,
},
{
name: "negative size",
hash: "abcdef123456789",
size: -10.0,
wantErr: true,
},
{
name: "short hash",
hash: "abc",
size: 64.0,
wantErr: true,
},
{
name: "invalid hex characters",
hash: "xyz123456789abc",
size: 64.0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := generator.Generate(tt.hash, tt.size)
if (err != nil) != tt.wantErr {
t.Errorf("Generate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestGenerateCaching(t *testing.T) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
// Generate icon first time
icon1, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("First generate failed: %v", err)
}
// Check cache size
if generator.GetCacheSize() != 1 {
t.Errorf("Expected cache size 1, got %d", generator.GetCacheSize())
}
// Generate same icon again
icon2, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("Second generate failed: %v", err)
}
// Should be the same instance from cache
if icon1 != icon2 {
t.Error("Second generate did not return cached instance")
}
// Cache size should still be 1
if generator.GetCacheSize() != 1 {
t.Errorf("Expected cache size 1 after second generate, got %d", generator.GetCacheSize())
}
}
func TestClearCache(t *testing.T) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
// Generate an icon to populate cache
_, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify cache has content
if generator.GetCacheSize() == 0 {
t.Error("Cache should not be empty after generate")
}
// Clear cache
generator.ClearCache()
// Verify cache is empty
if generator.GetCacheSize() != 0 {
t.Errorf("Expected cache size 0 after clear, got %d", generator.GetCacheSize())
}
}
func TestSetConfig(t *testing.T) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
// Generate an icon to populate cache
_, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify cache has content
if generator.GetCacheSize() == 0 {
t.Error("Cache should not be empty after generate")
}
// Set new config
newConfig := DefaultColorConfig()
newConfig.IconPadding = 0.1
generator.SetConfig(newConfig)
// Verify config was updated
if generator.GetConfig().IconPadding != 0.1 {
t.Errorf("Expected icon padding 0.1, got %f", generator.GetConfig().IconPadding)
}
// Verify cache was cleared
if generator.GetCacheSize() != 0 {
t.Errorf("Expected cache size 0 after config change, got %d", generator.GetCacheSize())
}
}
func TestExtractHue(t *testing.T) {
generator := NewDefaultGenerator()
tests := []struct {
name string
hash string
expected float64
tolerance float64
}{
{
name: "all zeros",
hash: "0000000000000000000",
expected: 0.0,
tolerance: 0.0001,
},
{
name: "all fs",
hash: "ffffffffffffffffff",
expected: 1.0,
tolerance: 0.0001,
},
{
name: "half value",
hash: "000000000007ffffff",
expected: 0.5,
tolerance: 0.001, // Allow small floating point variance
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := generator.extractHue(tt.hash)
if err != nil {
t.Fatalf("extractHue failed: %v", err)
}
diff := result - tt.expected
if diff < 0 {
diff = -diff
}
if diff > tt.tolerance {
t.Errorf("Expected hue %f, got %f (tolerance %f)", tt.expected, result, tt.tolerance)
}
})
}
}
func TestSelectColors(t *testing.T) {
generator := NewDefaultGenerator()
hash := "123456789abcdef"
// Create test color palette
availableColors := []Color{
NewColorRGB(50, 50, 50), // 0: Dark gray
NewColorRGB(100, 100, 200), // 1: Mid color
NewColorRGB(200, 200, 200), // 2: Light gray
NewColorRGB(150, 150, 255), // 3: Light color
NewColorRGB(25, 25, 100), // 4: Dark color
}
selectedIndexes, err := generator.selectColors(hash, availableColors)
if err != nil {
t.Fatalf("selectColors failed: %v", err)
}
if len(selectedIndexes) != 3 {
t.Fatalf("Expected 3 selected colors, got %d", len(selectedIndexes))
}
for i, index := range selectedIndexes {
if index < 0 || index >= len(availableColors) {
t.Errorf("Color index %d at position %d is out of range [0, %d)", index, i, len(availableColors))
}
}
}
func TestSelectColorsEmptyPalette(t *testing.T) {
generator := NewDefaultGenerator()
hash := "123456789abcdef"
_, err := generator.selectColors(hash, []Color{})
if err == nil {
t.Error("Expected error for empty color palette")
}
}
func TestIsValidHash(t *testing.T) {
tests := []struct {
name string
hash string
valid bool
}{
{
name: "valid hash",
hash: "abcdef123456789",
valid: true,
},
{
name: "too short",
hash: "abc",
valid: false,
},
{
name: "invalid characters",
hash: "xyz123456789abc",
valid: false,
},
{
name: "uppercase valid",
hash: "ABCDEF123456789",
valid: true,
},
{
name: "mixed case valid",
hash: "AbCdEf123456789",
valid: true,
},
{
name: "empty",
hash: "",
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := util.IsValidHash(tt.hash)
if result != tt.valid {
t.Errorf("Expected isValidHash(%s) = %v, got %v", tt.hash, tt.valid, result)
}
})
}
}
func TestParseHex(t *testing.T) {
hash := "123456789abcdef"
tests := []struct {
name string
start int
octets int
expected int
wantErr bool
}{
{
name: "single character",
start: 0,
octets: 1,
expected: 1,
wantErr: false,
},
{
name: "two characters",
start: 1,
octets: 2,
expected: 0x23,
wantErr: false,
},
{
name: "negative index",
start: -1,
octets: 1,
expected: 0xf,
wantErr: false,
},
{
name: "out of bounds",
start: 100,
octets: 1,
expected: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := util.ParseHex(hash, tt.start, tt.octets)
if tt.wantErr {
if err == nil {
t.Errorf("Expected an error, but got nil")
}
return // Test is done for error cases
}
if err != nil {
t.Fatalf("parseHex failed unexpectedly: %v", err)
}
if result != tt.expected {
t.Errorf("Expected %d, got %d", tt.expected, result)
}
})
}
}
func TestShapeCollector(t *testing.T) {
collector := &shapeCollector{}
// Test AddPolygon
points := []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 0, Y: 1}}
collector.AddPolygon(points)
if len(collector.shapes) != 1 {
t.Fatalf("Expected 1 shape after AddPolygon, got %d", len(collector.shapes))
}
shape := collector.shapes[0]
if shape.Type != "polygon" {
t.Errorf("Expected shape type 'polygon', got '%s'", shape.Type)
}
if len(shape.Points) != len(points) {
t.Errorf("Expected %d points, got %d", len(points), len(shape.Points))
}
// Test AddCircle
center := Point{X: 5, Y: 5}
radius := 2.5
collector.AddCircle(center, radius, false)
if len(collector.shapes) != 2 {
t.Fatalf("Expected 2 shapes after AddCircle, got %d", len(collector.shapes))
}
circleShape := collector.shapes[1]
if circleShape.Type != "circle" {
t.Errorf("Expected shape type 'circle', got '%s'", circleShape.Type)
}
// Verify circle fields are set correctly
if circleShape.CircleX != center.X {
t.Errorf("Expected CircleX %f, got %f", center.X, circleShape.CircleX)
}
if circleShape.CircleY != center.Y {
t.Errorf("Expected CircleY %f, got %f", center.Y, circleShape.CircleY)
}
if circleShape.CircleSize != radius {
t.Errorf("Expected CircleSize %f, got %f", radius, circleShape.CircleSize)
}
if circleShape.Invert != false {
t.Errorf("Expected Invert false, got %t", circleShape.Invert)
}
}
func BenchmarkGenerate(b *testing.B) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := generator.Generate(hash, size)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}
func BenchmarkGenerateWithCache(b *testing.B) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
// Pre-populate cache
_, err := generator.Generate(hash, size)
if err != nil {
b.Fatalf("Initial generate failed: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := generator.Generate(hash, size)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}
func TestConsistentGeneration(t *testing.T) {
generator1 := NewDefaultGenerator()
generator2 := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
icon1, err := generator1.Generate(hash, size)
if err != nil {
t.Fatalf("Generator1 failed: %v", err)
}
icon2, err := generator2.Generate(hash, size)
if err != nil {
t.Fatalf("Generator2 failed: %v", err)
}
// Icons should have same number of shape groups
if len(icon1.Shapes) != len(icon2.Shapes) {
t.Errorf("Different number of shape groups: %d vs %d", len(icon1.Shapes), len(icon2.Shapes))
}
// Colors should be the same
for i := range icon1.Shapes {
if i >= len(icon2.Shapes) {
break
}
if !icon1.Shapes[i].Color.Equals(icon2.Shapes[i].Color) {
t.Errorf("Different colors at group %d", i)
}
}
}

136
internal/engine/layout.go Normal file
View File

@@ -0,0 +1,136 @@
package engine
// Grid represents a 4x4 layout grid for positioning shapes in a jdenticon
type Grid struct {
Size float64
Cell int
X int
Y int
Padding int
}
// Position represents an x, y coordinate pair
type Position struct {
X, Y int
}
// NewGrid creates a new Grid with the specified icon size and padding ratio
func NewGrid(iconSize float64, paddingRatio float64) *Grid {
// Calculate padding and round to nearest integer (matches JS: (0.5 + size * parsedConfig.iconPadding) | 0)
padding := int(0.5 + iconSize*paddingRatio)
size := iconSize - float64(padding*2)
// Calculate cell size and ensure it is an integer (matches JS: 0 | (size / 4))
cell := int(size / 4)
// Center the icon since cell size is integer-based (matches JS implementation)
// Since the cell size is integer based, the actual icon will be slightly smaller than specified => center icon
x := padding + int((size - float64(cell*4))/2)
y := padding + int((size - float64(cell*4))/2)
return &Grid{
Size: size,
Cell: cell,
X: x,
Y: y,
Padding: padding,
}
}
// CellToCoordinate converts a grid cell position to actual coordinates
func (g *Grid) CellToCoordinate(cellX, cellY int) (x, y float64) {
return float64(g.X + cellX*g.Cell), float64(g.Y + cellY*g.Cell)
}
// GetCellSize returns the size of each cell in the grid
func (g *Grid) GetCellSize() float64 {
return float64(g.Cell)
}
// LayoutEngine manages the overall layout and positioning of icon elements
type LayoutEngine struct {
grid *Grid
}
// NewLayoutEngine creates a new LayoutEngine with the specified parameters
func NewLayoutEngine(iconSize float64, paddingRatio float64) *LayoutEngine {
return &LayoutEngine{
grid: NewGrid(iconSize, paddingRatio),
}
}
// Grid returns the underlying grid
func (le *LayoutEngine) Grid() *Grid {
return le.grid
}
// GetShapePositions returns the positions for different shape types based on the jdenticon pattern
func (le *LayoutEngine) GetShapePositions(shapeType string) []Position {
switch shapeType {
case "sides":
// Sides: positions around the perimeter (8 positions)
return []Position{
{1, 0}, {2, 0}, {2, 3}, {1, 3}, // top and bottom
{0, 1}, {3, 1}, {3, 2}, {0, 2}, // left and right
}
case "corners":
// Corners: four corner positions
return []Position{
{0, 0}, {3, 0}, {3, 3}, {0, 3},
}
case "center":
// Center: four center positions
return []Position{
{1, 1}, {2, 1}, {2, 2}, {1, 2},
}
default:
return []Position{}
}
}
// ApplySymmetry applies symmetrical transformations to position indices
// This ensures the icon has the characteristic jdenticon symmetry
func ApplySymmetry(positions []Position, index int) []Position {
if index >= len(positions) {
return positions
}
// For jdenticon, we apply rotational symmetry
// The pattern is designed to be symmetrical, so we don't need to modify positions
// The symmetry is achieved through the predefined position arrays
return positions
}
// GetTransformedPosition applies rotation and returns the final position
func (le *LayoutEngine) GetTransformedPosition(cellX, cellY int, rotation int) (x, y float64, cellSize float64) {
// Apply rotation if needed (rotation is 0-3 for 0°, 90°, 180°, 270°)
switch rotation % 4 {
case 0: // 0°
// No rotation
case 1: // 90° clockwise
cellX, cellY = cellY, 3-cellX
case 2: // 180°
cellX, cellY = 3-cellX, 3-cellY
case 3: // 270° clockwise (90° counter-clockwise)
cellX, cellY = 3-cellY, cellX
}
x, y = le.grid.CellToCoordinate(cellX, cellY)
cellSize = le.grid.GetCellSize()
return
}
// ValidateGrid checks if the grid configuration is valid
func (g *Grid) ValidateGrid() bool {
return g.Cell > 0 && g.Size > 0 && g.Padding >= 0
}
// GetIconBounds returns the bounds of the icon within the grid
func (g *Grid) GetIconBounds() (x, y, width, height float64) {
return float64(g.X), float64(g.Y), float64(g.Cell * 4), float64(g.Cell * 4)
}
// GetCenterOffset returns the offset needed to center content within a cell
func (g *Grid) GetCenterOffset() (dx, dy float64) {
return float64(g.Cell) / 2, float64(g.Cell) / 2
}

View File

@@ -0,0 +1,380 @@
package engine
import (
"math"
"testing"
)
func TestNewGrid(t *testing.T) {
tests := []struct {
name string
iconSize float64
paddingRatio float64
wantPadding int
wantCell int
}{
{
name: "standard 64px icon with 8% padding",
iconSize: 64.0,
paddingRatio: 0.08,
wantPadding: 5, // 0.5 + 64 * 0.08 = 5.62, rounded to 5
wantCell: 13, // (64 - 5*2) / 4 = 54/4 = 13.5, truncated to 13
},
{
name: "large 256px icon with 10% padding",
iconSize: 256.0,
paddingRatio: 0.10,
wantPadding: 26, // 0.5 + 256 * 0.10 = 26.1, rounded to 26
wantCell: 51, // (256 - 26*2) / 4 = 204/4 = 51
},
{
name: "small 32px icon with 5% padding",
iconSize: 32.0,
paddingRatio: 0.05,
wantPadding: 2, // 0.5 + 32 * 0.05 = 2.1, rounded to 2
wantCell: 7, // (32 - 2*2) / 4 = 28/4 = 7
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
grid := NewGrid(tt.iconSize, tt.paddingRatio)
if grid.Padding != tt.wantPadding {
t.Errorf("NewGrid() padding = %v, want %v", grid.Padding, tt.wantPadding)
}
if grid.Cell != tt.wantCell {
t.Errorf("NewGrid() cell = %v, want %v", grid.Cell, tt.wantCell)
}
// Verify that the grid is centered
expectedSize := tt.iconSize - float64(tt.wantPadding*2)
if math.Abs(grid.Size-expectedSize) > 0.1 {
t.Errorf("NewGrid() size = %v, want %v", grid.Size, expectedSize)
}
})
}
}
func TestGridCellToCoordinate(t *testing.T) {
grid := NewGrid(64.0, 0.08)
tests := []struct {
name string
cellX int
cellY int
wantX float64
wantY float64
}{
{
name: "origin cell (0,0)",
cellX: 0,
cellY: 0,
wantX: float64(grid.X),
wantY: float64(grid.Y),
},
{
name: "center cell (1,1)",
cellX: 1,
cellY: 1,
wantX: float64(grid.X + grid.Cell),
wantY: float64(grid.Y + grid.Cell),
},
{
name: "corner cell (3,3)",
cellX: 3,
cellY: 3,
wantX: float64(grid.X + 3*grid.Cell),
wantY: float64(grid.Y + 3*grid.Cell),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotX, gotY := grid.CellToCoordinate(tt.cellX, tt.cellY)
if gotX != tt.wantX {
t.Errorf("CellToCoordinate() x = %v, want %v", gotX, tt.wantX)
}
if gotY != tt.wantY {
t.Errorf("CellToCoordinate() y = %v, want %v", gotY, tt.wantY)
}
})
}
}
func TestLayoutEngineGetShapePositions(t *testing.T) {
le := NewLayoutEngine(64.0, 0.08)
tests := []struct {
name string
shapeType string
wantLen int
wantFirst Position
wantLast Position
}{
{
name: "sides positions",
shapeType: "sides",
wantLen: 8,
wantFirst: Position{1, 0},
wantLast: Position{0, 2},
},
{
name: "corners positions",
shapeType: "corners",
wantLen: 4,
wantFirst: Position{0, 0},
wantLast: Position{0, 3},
},
{
name: "center positions",
shapeType: "center",
wantLen: 4,
wantFirst: Position{1, 1},
wantLast: Position{1, 2},
},
{
name: "invalid shape type",
shapeType: "invalid",
wantLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
positions := le.GetShapePositions(tt.shapeType)
if len(positions) != tt.wantLen {
t.Errorf("GetShapePositions() len = %v, want %v", len(positions), tt.wantLen)
}
if tt.wantLen > 0 {
if positions[0] != tt.wantFirst {
t.Errorf("GetShapePositions() first = %v, want %v", positions[0], tt.wantFirst)
}
if positions[len(positions)-1] != tt.wantLast {
t.Errorf("GetShapePositions() last = %v, want %v", positions[len(positions)-1], tt.wantLast)
}
}
})
}
}
func TestLayoutEngineGetTransformedPosition(t *testing.T) {
le := NewLayoutEngine(64.0, 0.08)
tests := []struct {
name string
cellX int
cellY int
rotation int
wantX int // Expected cell X after rotation
wantY int // Expected cell Y after rotation
}{
{
name: "no rotation",
cellX: 1,
cellY: 0,
rotation: 0,
wantX: 1,
wantY: 0,
},
{
name: "90 degree rotation",
cellX: 1,
cellY: 0,
rotation: 1,
wantX: 0,
wantY: 2, // 3-1 = 2
},
{
name: "180 degree rotation",
cellX: 1,
cellY: 0,
rotation: 2,
wantX: 2, // 3-1 = 2
wantY: 3, // 3-0 = 3
},
{
name: "270 degree rotation",
cellX: 1,
cellY: 0,
rotation: 3,
wantX: 3, // 3-0 = 3
wantY: 1,
},
{
name: "rotation overflow (4 = 0)",
cellX: 1,
cellY: 0,
rotation: 4,
wantX: 1,
wantY: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotX, gotY, gotCellSize := le.GetTransformedPosition(tt.cellX, tt.cellY, tt.rotation)
// Convert back to cell coordinates to verify rotation
expectedX, expectedY := le.grid.CellToCoordinate(tt.wantX, tt.wantY)
if gotX != expectedX {
t.Errorf("GetTransformedPosition() x = %v, want %v", gotX, expectedX)
}
if gotY != expectedY {
t.Errorf("GetTransformedPosition() y = %v, want %v", gotY, expectedY)
}
if gotCellSize != float64(le.grid.Cell) {
t.Errorf("GetTransformedPosition() cellSize = %v, want %v", gotCellSize, float64(le.grid.Cell))
}
})
}
}
func TestApplySymmetry(t *testing.T) {
positions := []Position{{0, 0}, {1, 0}, {2, 0}, {3, 0}}
tests := []struct {
name string
index int
want int // expected length
}{
{
name: "valid index",
index: 1,
want: 4,
},
{
name: "index out of bounds",
index: 10,
want: 4,
},
{
name: "negative index",
index: -1,
want: 4,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ApplySymmetry(positions, tt.index)
if len(result) != tt.want {
t.Errorf("ApplySymmetry() len = %v, want %v", len(result), tt.want)
}
// Verify that the positions are unchanged (current implementation)
for i, pos := range result {
if pos != positions[i] {
t.Errorf("ApplySymmetry() changed position at index %d: got %v, want %v", i, pos, positions[i])
}
}
})
}
}
func TestGridValidateGrid(t *testing.T) {
tests := []struct {
name string
grid *Grid
want bool
}{
{
name: "valid grid",
grid: &Grid{Size: 64, Cell: 16, Padding: 4},
want: true,
},
{
name: "zero cell size",
grid: &Grid{Size: 64, Cell: 0, Padding: 4},
want: false,
},
{
name: "zero size",
grid: &Grid{Size: 0, Cell: 16, Padding: 4},
want: false,
},
{
name: "negative padding",
grid: &Grid{Size: 64, Cell: 16, Padding: -1},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.grid.ValidateGrid(); got != tt.want {
t.Errorf("ValidateGrid() = %v, want %v", got, tt.want)
}
})
}
}
func TestGridGetIconBounds(t *testing.T) {
grid := NewGrid(64.0, 0.08)
x, y, width, height := grid.GetIconBounds()
expectedX := float64(grid.X)
expectedY := float64(grid.Y)
expectedWidth := float64(grid.Cell * 4)
expectedHeight := float64(grid.Cell * 4)
if x != expectedX {
t.Errorf("GetIconBounds() x = %v, want %v", x, expectedX)
}
if y != expectedY {
t.Errorf("GetIconBounds() y = %v, want %v", y, expectedY)
}
if width != expectedWidth {
t.Errorf("GetIconBounds() width = %v, want %v", width, expectedWidth)
}
if height != expectedHeight {
t.Errorf("GetIconBounds() height = %v, want %v", height, expectedHeight)
}
}
func TestGridGetCenterOffset(t *testing.T) {
grid := NewGrid(64.0, 0.08)
dx, dy := grid.GetCenterOffset()
expected := float64(grid.Cell) / 2
if dx != expected {
t.Errorf("GetCenterOffset() dx = %v, want %v", dx, expected)
}
if dy != expected {
t.Errorf("GetCenterOffset() dy = %v, want %v", dy, expected)
}
}
func TestNewLayoutEngine(t *testing.T) {
le := NewLayoutEngine(64.0, 0.08)
if le.grid == nil {
t.Error("NewLayoutEngine() grid is nil")
}
if le.Grid() != le.grid {
t.Error("NewLayoutEngine() Grid() does not return internal grid")
}
// Verify grid configuration
if !le.grid.ValidateGrid() {
t.Error("NewLayoutEngine() created invalid grid")
}
}

266
internal/engine/shapes.go Normal file
View File

@@ -0,0 +1,266 @@
package engine
import "math"
// Point represents a 2D point
type Point struct {
X, Y float64
}
// Renderer interface defines the methods that a renderer must implement
type Renderer interface {
AddPolygon(points []Point)
AddCircle(topLeft Point, size float64, invert bool)
}
// Graphics provides helper functions for rendering common basic shapes
type Graphics struct {
renderer Renderer
currentTransform Transform
}
// NewGraphics creates a new Graphics instance with the given renderer
func NewGraphics(renderer Renderer) *Graphics {
return &Graphics{
renderer: renderer,
currentTransform: NoTransform,
}
}
// NewGraphicsWithTransform creates a new Graphics instance with the given renderer and transform
func NewGraphicsWithTransform(renderer Renderer, transform Transform) *Graphics {
return &Graphics{
renderer: renderer,
currentTransform: transform,
}
}
// AddPolygon adds a polygon to the underlying renderer
func (g *Graphics) AddPolygon(points []Point, invert bool) {
// Transform all points
transformedPoints := make([]Point, len(points))
if invert {
// Reverse the order and transform
for i, p := range points {
transformedPoints[len(points)-1-i] = g.currentTransform.TransformIconPoint(p.X, p.Y, 0, 0)
}
} else {
// Transform in order
for i, p := range points {
transformedPoints[i] = g.currentTransform.TransformIconPoint(p.X, p.Y, 0, 0)
}
}
g.renderer.AddPolygon(transformedPoints)
}
// AddCircle adds a circle to the underlying renderer
func (g *Graphics) AddCircle(x, y, size float64, invert bool) {
// Transform the circle position
transformedPoint := g.currentTransform.TransformIconPoint(x, y, size, size)
g.renderer.AddCircle(transformedPoint, size, invert)
}
// AddRectangle adds a rectangle to the underlying renderer
func (g *Graphics) AddRectangle(x, y, w, h float64, invert bool) {
points := []Point{
{X: x, Y: y},
{X: x + w, Y: y},
{X: x + w, Y: y + h},
{X: x, Y: y + h},
}
g.AddPolygon(points, invert)
}
// AddTriangle adds a right triangle to the underlying renderer
// r is the rotation (0-3), where 0 = right corner in lower left
func (g *Graphics) AddTriangle(x, y, w, h float64, r int, invert bool) {
points := []Point{
{X: x + w, Y: y},
{X: x + w, Y: y + h},
{X: x, Y: y + h},
{X: x, Y: y},
}
// Remove one corner based on rotation
removeIndex := (r % 4) * 1
if removeIndex < len(points) {
points = append(points[:removeIndex], points[removeIndex+1:]...)
}
g.AddPolygon(points, invert)
}
// AddRhombus adds a rhombus (diamond) to the underlying renderer
func (g *Graphics) AddRhombus(x, y, w, h float64, invert bool) {
points := []Point{
{X: x + w/2, Y: y},
{X: x + w, Y: y + h/2},
{X: x + w/2, Y: y + h},
{X: x, Y: y + h/2},
}
g.AddPolygon(points, invert)
}
// RenderCenterShape renders one of the 14 distinct center shape patterns
func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64) {
index := shapeIndex % 14
switch index {
case 0:
// Shape 0: Asymmetric polygon
k := cell * 0.42
points := []Point{
{X: 0, Y: 0},
{X: cell, Y: 0},
{X: cell, Y: cell - k*2},
{X: cell - k, Y: cell},
{X: 0, Y: cell},
}
g.AddPolygon(points, false)
case 1:
// Shape 1: Triangle
w := math.Floor(cell * 0.5)
h := math.Floor(cell * 0.8)
g.AddTriangle(cell-w, 0, w, h, 2, false)
case 2:
// Shape 2: Rectangle
w := math.Floor(cell / 3)
g.AddRectangle(w, w, cell-w, cell-w, false)
case 3:
// Shape 3: Nested rectangles
inner := cell * 0.1
var outer float64
if cell < 6 {
outer = 1
} else if cell < 8 {
outer = 2
} else {
outer = math.Floor(cell * 0.25)
}
if inner > 1 {
inner = math.Floor(inner)
} else if inner > 0.5 {
inner = 1
}
g.AddRectangle(outer, outer, cell-inner-outer, cell-inner-outer, false)
case 4:
// Shape 4: Circle
m := math.Floor(cell * 0.15)
w := math.Floor(cell * 0.5)
g.AddCircle(cell-w-m, cell-w-m, w, false)
case 5:
// Shape 5: Rectangle with triangular cutout
inner := cell * 0.1
outer := inner * 4
if outer > 3 {
outer = math.Floor(outer)
}
g.AddRectangle(0, 0, cell, cell, false)
points := []Point{
{X: outer, Y: outer},
{X: cell - inner, Y: outer},
{X: outer + (cell-outer-inner)/2, Y: cell - inner},
}
g.AddPolygon(points, true)
case 6:
// Shape 6: Complex polygon
points := []Point{
{X: 0, Y: 0},
{X: cell, Y: 0},
{X: cell, Y: cell * 0.7},
{X: cell * 0.4, Y: cell * 0.4},
{X: cell * 0.7, Y: cell},
{X: 0, Y: cell},
}
g.AddPolygon(points, false)
case 7:
// Shape 7: Small triangle
g.AddTriangle(cell/2, cell/2, cell/2, cell/2, 3, false)
case 8:
// Shape 8: Composite shape
g.AddRectangle(0, 0, cell, cell/2, false)
g.AddRectangle(0, cell/2, cell/2, cell/2, false)
g.AddTriangle(cell/2, cell/2, cell/2, cell/2, 1, false)
case 9:
// Shape 9: Rectangle with rectangular cutout
inner := cell * 0.14
var outer float64
if cell < 4 {
outer = 1
} else if cell < 6 {
outer = 2
} else {
outer = math.Floor(cell * 0.35)
}
if cell >= 8 {
inner = math.Floor(inner)
}
g.AddRectangle(0, 0, cell, cell, false)
g.AddRectangle(outer, outer, cell-outer-inner, cell-outer-inner, true)
case 10:
// Shape 10: Rectangle with circular cutout
inner := cell * 0.12
outer := inner * 3
g.AddRectangle(0, 0, cell, cell, false)
g.AddCircle(outer, outer, cell-inner-outer, true)
case 11:
// Shape 11: Small triangle (same as 7)
g.AddTriangle(cell/2, cell/2, cell/2, cell/2, 3, false)
case 12:
// Shape 12: Rectangle with rhombus cutout
m := cell * 0.25
g.AddRectangle(0, 0, cell, cell, false)
g.AddRhombus(m, m, cell-m, cell-m, true)
case 13:
// Shape 13: Large circle (only for position 0)
if positionIndex == 0 {
m := cell * 0.4
w := cell * 1.2
g.AddCircle(m, m, w, false)
}
}
}
// RenderOuterShape renders one of the 4 distinct outer shape patterns
func RenderOuterShape(g *Graphics, shapeIndex int, cell float64) {
index := shapeIndex % 4
switch index {
case 0:
// Shape 0: Triangle
g.AddTriangle(0, 0, cell, cell, 0, false)
case 1:
// Shape 1: Triangle (different orientation)
g.AddTriangle(0, cell/2, cell, cell/2, 0, false)
case 2:
// Shape 2: Rhombus
g.AddRhombus(0, 0, cell, cell, false)
case 3:
// Shape 3: Circle
m := cell / 6
g.AddCircle(m, m, cell-2*m, false)
}
}

View File

@@ -0,0 +1,257 @@
package engine
import (
"testing"
)
// MockRenderer implements the Renderer interface for testing
type MockRenderer struct {
Polygons [][]Point
Circles []MockCircle
}
type MockCircle struct {
TopLeft Point
Size float64
Invert bool
}
func (m *MockRenderer) AddPolygon(points []Point) {
m.Polygons = append(m.Polygons, points)
}
func (m *MockRenderer) AddCircle(topLeft Point, size float64, invert bool) {
m.Circles = append(m.Circles, MockCircle{
TopLeft: topLeft,
Size: size,
Invert: invert,
})
}
func (m *MockRenderer) Reset() {
m.Polygons = nil
m.Circles = nil
}
func TestGraphicsAddRectangle(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
g.AddRectangle(10, 20, 30, 40, false)
if len(mock.Polygons) != 1 {
t.Errorf("Expected 1 polygon, got %d", len(mock.Polygons))
return
}
expected := []Point{
{X: 10, Y: 20},
{X: 40, Y: 20},
{X: 40, Y: 60},
{X: 10, Y: 60},
}
polygon := mock.Polygons[0]
if len(polygon) != len(expected) {
t.Errorf("Expected %d points, got %d", len(expected), len(polygon))
return
}
for i, point := range expected {
if polygon[i].X != point.X || polygon[i].Y != point.Y {
t.Errorf("Point %d: expected (%f, %f), got (%f, %f)",
i, point.X, point.Y, polygon[i].X, polygon[i].Y)
}
}
}
func TestGraphicsAddCircle(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
g.AddCircle(10, 20, 30, false)
if len(mock.Circles) != 1 {
t.Errorf("Expected 1 circle, got %d", len(mock.Circles))
return
}
circle := mock.Circles[0]
expectedTopLeft := Point{X: 10, Y: 20}
expectedSize := float64(30)
if circle.TopLeft.X != expectedTopLeft.X || circle.TopLeft.Y != expectedTopLeft.Y {
t.Errorf("Expected top-left (%f, %f), got (%f, %f)",
expectedTopLeft.X, expectedTopLeft.Y, circle.TopLeft.X, circle.TopLeft.Y)
}
if circle.Size != expectedSize {
t.Errorf("Expected size %f, got %f", expectedSize, circle.Size)
}
if circle.Invert != false {
t.Errorf("Expected invert false, got %t", circle.Invert)
}
}
func TestGraphicsAddRhombus(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
g.AddRhombus(0, 0, 20, 30, false)
if len(mock.Polygons) != 1 {
t.Errorf("Expected 1 polygon, got %d", len(mock.Polygons))
return
}
expected := []Point{
{X: 10, Y: 0}, // top
{X: 20, Y: 15}, // right
{X: 10, Y: 30}, // bottom
{X: 0, Y: 15}, // left
}
polygon := mock.Polygons[0]
if len(polygon) != len(expected) {
t.Errorf("Expected %d points, got %d", len(expected), len(polygon))
return
}
for i, point := range expected {
if polygon[i].X != point.X || polygon[i].Y != point.Y {
t.Errorf("Point %d: expected (%f, %f), got (%f, %f)",
i, point.X, point.Y, polygon[i].X, polygon[i].Y)
}
}
}
func TestRenderCenterShape(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test each center shape
for i := 0; i < 14; i++ {
mock.Reset()
RenderCenterShape(g, i, cell, 0)
// Verify that some drawing commands were issued
if len(mock.Polygons) == 0 && len(mock.Circles) == 0 {
// Shape 13 at position != 0 doesn't draw anything, which is expected
if i == 13 {
continue
}
t.Errorf("Shape %d: expected some drawing commands, got none", i)
}
}
}
func TestRenderCenterShapeSpecific(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test shape 2 (rectangle)
mock.Reset()
RenderCenterShape(g, 2, cell, 0)
if len(mock.Polygons) != 1 {
t.Errorf("Shape 2: expected 1 polygon, got %d", len(mock.Polygons))
}
// Test shape 4 (circle)
mock.Reset()
RenderCenterShape(g, 4, cell, 0)
if len(mock.Circles) != 1 {
t.Errorf("Shape 4: expected 1 circle, got %d", len(mock.Circles))
}
// Test shape 13 at position 0 (should draw)
mock.Reset()
RenderCenterShape(g, 13, cell, 0)
if len(mock.Circles) != 1 {
t.Errorf("Shape 13 at position 0: expected 1 circle, got %d", len(mock.Circles))
}
// Test shape 13 at position 1 (should not draw)
mock.Reset()
RenderCenterShape(g, 13, cell, 1)
if len(mock.Circles) != 0 {
t.Errorf("Shape 13 at position 1: expected 0 circles, got %d", len(mock.Circles))
}
}
func TestRenderOuterShape(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test each outer shape
for i := 0; i < 4; i++ {
mock.Reset()
RenderOuterShape(g, i, cell)
// Verify that some drawing commands were issued
if len(mock.Polygons) == 0 && len(mock.Circles) == 0 {
t.Errorf("Outer shape %d: expected some drawing commands, got none", i)
}
}
}
func TestRenderOuterShapeSpecific(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test outer shape 2 (rhombus)
mock.Reset()
RenderOuterShape(g, 2, cell)
if len(mock.Polygons) != 1 {
t.Errorf("Outer shape 2: expected 1 polygon, got %d", len(mock.Polygons))
}
// Test outer shape 3 (circle)
mock.Reset()
RenderOuterShape(g, 3, cell)
if len(mock.Circles) != 1 {
t.Errorf("Outer shape 3: expected 1 circle, got %d", len(mock.Circles))
}
}
func TestShapeIndexModulo(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test that shape indices wrap around correctly
mock.Reset()
RenderCenterShape(g, 0, cell, 0)
polygonsShape0 := len(mock.Polygons)
circlesShape0 := len(mock.Circles)
mock.Reset()
RenderCenterShape(g, 14, cell, 0) // Should be same as shape 0
if len(mock.Polygons) != polygonsShape0 || len(mock.Circles) != circlesShape0 {
t.Errorf("Shape 14 should be equivalent to shape 0")
}
// Test outer shapes
mock.Reset()
RenderOuterShape(g, 0, cell)
polygonsOuter0 := len(mock.Polygons)
circlesOuter0 := len(mock.Circles)
mock.Reset()
RenderOuterShape(g, 4, cell) // Should be same as outer shape 0
if len(mock.Polygons) != polygonsOuter0 || len(mock.Circles) != circlesOuter0 {
t.Errorf("Outer shape 4 should be equivalent to outer shape 0")
}
}

View File

@@ -0,0 +1,103 @@
package engine
import "math"
// Matrix represents a 2D transformation matrix in the form:
// | A C E |
// | B D F |
// | 0 0 1 |
type Matrix struct {
A, B, C, D, E, F float64
}
// NewIdentityMatrix creates an identity matrix
func NewIdentityMatrix() Matrix {
return Matrix{
A: 1, B: 0, C: 0,
D: 1, E: 0, F: 0,
}
}
// Translate creates a translation matrix
func Translate(x, y float64) Matrix {
return Matrix{
A: 1, B: 0, C: 0,
D: 1, E: x, F: y,
}
}
// Rotate creates a rotation matrix for the given angle in radians
func Rotate(angle float64) Matrix {
cos := math.Cos(angle)
sin := math.Sin(angle)
return Matrix{
A: cos, B: sin, C: -sin,
D: cos, E: 0, F: 0,
}
}
// Scale creates a scaling matrix
func Scale(sx, sy float64) Matrix {
return Matrix{
A: sx, B: 0, C: 0,
D: sy, E: 0, F: 0,
}
}
// Multiply multiplies two matrices
func (m Matrix) Multiply(other Matrix) Matrix {
return Matrix{
A: m.A*other.A + m.C*other.B,
B: m.B*other.A + m.D*other.B,
C: m.A*other.C + m.C*other.D,
D: m.B*other.C + m.D*other.D,
E: m.A*other.E + m.C*other.F + m.E,
F: m.B*other.E + m.D*other.F + m.F,
}
}
// Transform represents a geometric transformation
type Transform struct {
x, y, size float64
rotation int // 0 = 0 rad, 1 = 0.5π rad, 2 = π rad, 3 = 1.5π rad
}
// NewTransform creates a new Transform
func NewTransform(x, y, size float64, rotation int) Transform {
return Transform{
x: x,
y: y,
size: size,
rotation: rotation,
}
}
// TransformIconPoint transforms a point based on the translation and rotation specification
// w and h represent the width and height of the transformed rectangle for proper corner positioning
func (t Transform) TransformIconPoint(x, y, w, h float64) Point {
right := t.x + t.size
bottom := t.y + t.size
rotation := t.rotation % 4
switch rotation {
case 1: // 90 degrees
return Point{X: right - y - h, Y: t.y + x}
case 2: // 180 degrees
return Point{X: right - x - w, Y: bottom - y - h}
case 3: // 270 degrees
return Point{X: t.x + y, Y: bottom - x - w}
default: // 0 degrees
return Point{X: t.x + x, Y: t.y + y}
}
}
// ApplyTransform applies a transformation matrix to a point
func ApplyTransform(point Point, matrix Matrix) Point {
return Point{
X: matrix.A*point.X + matrix.C*point.Y + matrix.E,
Y: matrix.B*point.X + matrix.D*point.Y + matrix.F,
}
}
// NoTransform represents an identity transformation
var NoTransform = NewTransform(0, 0, 0, 0)

View File

@@ -0,0 +1,182 @@
package engine
import (
"math"
"testing"
)
func TestNewIdentityMatrix(t *testing.T) {
m := NewIdentityMatrix()
expected := Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}
if m != expected {
t.Errorf("NewIdentityMatrix() = %v, want %v", m, expected)
}
}
func TestTranslate(t *testing.T) {
tests := []struct {
x, y float64
expected Matrix
}{
{10, 20, Matrix{A: 1, B: 0, C: 0, D: 1, E: 10, F: 20}},
{0, 0, Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}},
{-5, 15, Matrix{A: 1, B: 0, C: 0, D: 1, E: -5, F: 15}},
}
for _, tt := range tests {
result := Translate(tt.x, tt.y)
if result != tt.expected {
t.Errorf("Translate(%v, %v) = %v, want %v", tt.x, tt.y, result, tt.expected)
}
}
}
func TestRotate(t *testing.T) {
tests := []struct {
angle float64
expected Matrix
}{
{0, Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}},
{math.Pi / 2, Matrix{A: 0, B: 1, C: -1, D: 0, E: 0, F: 0}},
{math.Pi, Matrix{A: -1, B: 0, C: 0, D: -1, E: 0, F: 0}},
{3 * math.Pi / 2, Matrix{A: 0, B: -1, C: 1, D: 0, E: 0, F: 0}},
}
for _, tt := range tests {
result := Rotate(tt.angle)
// Use approximate equality for floating point comparison
if !approximatelyEqual(result.A, tt.expected.A) ||
!approximatelyEqual(result.B, tt.expected.B) ||
!approximatelyEqual(result.C, tt.expected.C) ||
!approximatelyEqual(result.D, tt.expected.D) ||
!approximatelyEqual(result.E, tt.expected.E) ||
!approximatelyEqual(result.F, tt.expected.F) {
t.Errorf("Rotate(%v) = %v, want %v", tt.angle, result, tt.expected)
}
}
}
func TestScale(t *testing.T) {
tests := []struct {
sx, sy float64
expected Matrix
}{
{1, 1, Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}},
{2, 3, Matrix{A: 2, B: 0, C: 0, D: 3, E: 0, F: 0}},
{0.5, 2, Matrix{A: 0.5, B: 0, C: 0, D: 2, E: 0, F: 0}},
}
for _, tt := range tests {
result := Scale(tt.sx, tt.sy)
if result != tt.expected {
t.Errorf("Scale(%v, %v) = %v, want %v", tt.sx, tt.sy, result, tt.expected)
}
}
}
func TestMatrixMultiply(t *testing.T) {
// Test identity multiplication
identity := NewIdentityMatrix()
translate := Translate(10, 20)
result := identity.Multiply(translate)
if result != translate {
t.Errorf("Identity * Translate = %v, want %v", result, translate)
}
// Test translation composition
t1 := Translate(10, 20)
t2 := Translate(5, 10)
result = t1.Multiply(t2)
expected := Translate(15, 30)
if result != expected {
t.Errorf("Translate(10,20) * Translate(5,10) = %v, want %v", result, expected)
}
// Test scale composition
s1 := Scale(2, 3)
s2 := Scale(0.5, 0.5)
result = s1.Multiply(s2)
expected = Scale(1, 1.5)
if result != expected {
t.Errorf("Scale(2,3) * Scale(0.5,0.5) = %v, want %v", result, expected)
}
}
func TestApplyTransform(t *testing.T) {
tests := []struct {
point Point
matrix Matrix
expected Point
}{
{Point{X: 0, Y: 0}, NewIdentityMatrix(), Point{X: 0, Y: 0}},
{Point{X: 10, Y: 20}, Translate(5, 10), Point{X: 15, Y: 30}},
{Point{X: 1, Y: 0}, Scale(3, 2), Point{X: 3, Y: 0}},
{Point{X: 0, Y: 1}, Scale(3, 2), Point{X: 0, Y: 2}},
}
for _, tt := range tests {
result := ApplyTransform(tt.point, tt.matrix)
if !approximatelyEqual(result.X, tt.expected.X) || !approximatelyEqual(result.Y, tt.expected.Y) {
t.Errorf("ApplyTransform(%v, %v) = %v, want %v", tt.point, tt.matrix, result, tt.expected)
}
}
}
func TestNewTransform(t *testing.T) {
transform := NewTransform(10, 20, 100, 1)
if transform.x != 10 || transform.y != 20 || transform.size != 100 || transform.rotation != 1 {
t.Errorf("NewTransform(10, 20, 100, 1) = %v, want {x:10, y:20, size:100, rotation:1}", transform)
}
}
func TestTransformIconPoint(t *testing.T) {
tests := []struct {
transform Transform
x, y, w, h float64
expected Point
}{
// No rotation (0 degrees)
{NewTransform(0, 0, 100, 0), 10, 20, 5, 5, Point{X: 10, Y: 20}},
{NewTransform(10, 20, 100, 0), 5, 10, 0, 0, Point{X: 15, Y: 30}},
// 90 degrees rotation
{NewTransform(0, 0, 100, 1), 10, 20, 5, 5, Point{X: 75, Y: 10}},
// 180 degrees rotation
{NewTransform(0, 0, 100, 2), 10, 20, 5, 5, Point{X: 85, Y: 75}},
// 270 degrees rotation
{NewTransform(0, 0, 100, 3), 10, 20, 5, 5, Point{X: 20, Y: 85}},
// Test rotation normalization (rotation > 3)
{NewTransform(0, 0, 100, 4), 10, 20, 0, 0, Point{X: 10, Y: 20}}, // Same as rotation 0
{NewTransform(0, 0, 100, 5), 10, 20, 5, 5, Point{X: 75, Y: 10}}, // Same as rotation 1
}
for _, tt := range tests {
result := tt.transform.TransformIconPoint(tt.x, tt.y, tt.w, tt.h)
if !approximatelyEqual(result.X, tt.expected.X) || !approximatelyEqual(result.Y, tt.expected.Y) {
t.Errorf("Transform(%v).TransformIconPoint(%v, %v, %v, %v) = %v, want %v",
tt.transform, tt.x, tt.y, tt.w, tt.h, result, tt.expected)
}
}
}
func TestNoTransform(t *testing.T) {
if NoTransform.x != 0 || NoTransform.y != 0 || NoTransform.size != 0 || NoTransform.rotation != 0 {
t.Errorf("NoTransform should be {x:0, y:0, size:0, rotation:0}, got %v", NoTransform)
}
// Test that NoTransform doesn't change points
point := Point{X: 10, Y: 20}
result := NoTransform.TransformIconPoint(point.X, point.Y, 0, 0)
if result != point {
t.Errorf("NoTransform should not change point %v, got %v", point, result)
}
}
// approximatelyEqual checks if two float64 values are approximately equal
func approximatelyEqual(a, b float64) bool {
const epsilon = 1e-9
return math.Abs(a-b) < epsilon
}