init
This commit is contained in:
235
jdenticon/config.go
Normal file
235
jdenticon/config.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Config holds configuration options for identicon generation.
|
||||
type Config struct {
|
||||
// HueRestrictions specifies the hues (in degrees) that are allowed for the identicon.
|
||||
// If empty, any hue can be used. Values should be between 0 and 360.
|
||||
HueRestrictions []float64
|
||||
|
||||
// ColorLightnessRange specifies the lightness range for colored shapes [min, max].
|
||||
// Values should be between 0.0 and 1.0. Default: [0.4, 0.8]
|
||||
ColorLightnessRange [2]float64
|
||||
|
||||
// GrayscaleLightnessRange specifies the lightness range for grayscale shapes [min, max].
|
||||
// Values should be between 0.0 and 1.0. Default: [0.3, 0.9]
|
||||
GrayscaleLightnessRange [2]float64
|
||||
|
||||
// ColorSaturation controls the saturation of colored shapes.
|
||||
// Values should be between 0.0 and 1.0. Default: 0.5
|
||||
ColorSaturation float64
|
||||
|
||||
// GrayscaleSaturation controls the saturation of grayscale shapes.
|
||||
// Values should be between 0.0 and 1.0. Default: 0.0
|
||||
GrayscaleSaturation float64
|
||||
|
||||
// BackgroundColor sets the background color for the identicon.
|
||||
// Accepts hex colors like "#fff", "#ffffff", "#ffffff80" (with alpha).
|
||||
// If empty, the background will be transparent.
|
||||
BackgroundColor string
|
||||
|
||||
// Padding controls the padding around the identicon as a percentage of the size.
|
||||
// Values should be between 0.0 and 0.5. Default: 0.08
|
||||
Padding float64
|
||||
}
|
||||
|
||||
// DefaultConfig returns a default configuration that matches jdenticon-js defaults.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
HueRestrictions: nil, // No restrictions
|
||||
ColorLightnessRange: [2]float64{0.4, 0.8},
|
||||
GrayscaleLightnessRange: [2]float64{0.3, 0.9},
|
||||
ColorSaturation: 0.5,
|
||||
GrayscaleSaturation: 0.0,
|
||||
BackgroundColor: "", // Transparent
|
||||
Padding: 0.08,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigOption represents a configuration option function.
|
||||
type ConfigOption func(*Config) error
|
||||
|
||||
// WithHueRestrictions sets the allowed hues in degrees (0-360).
|
||||
func WithHueRestrictions(hues []float64) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
for _, hue := range hues {
|
||||
if hue < 0 || hue >= 360 {
|
||||
return fmt.Errorf("hue must be between 0 and 360, got %f", hue)
|
||||
}
|
||||
}
|
||||
c.HueRestrictions = make([]float64, len(hues))
|
||||
copy(c.HueRestrictions, hues)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithColorLightnessRange sets the lightness range for colored shapes.
|
||||
func WithColorLightnessRange(min, max float64) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
if min < 0 || min > 1 || max < 0 || max > 1 {
|
||||
return fmt.Errorf("lightness values must be between 0 and 1, got min=%f max=%f", min, max)
|
||||
}
|
||||
if min > max {
|
||||
return fmt.Errorf("minimum lightness cannot be greater than maximum, got min=%f max=%f", min, max)
|
||||
}
|
||||
c.ColorLightnessRange = [2]float64{min, max}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithGrayscaleLightnessRange sets the lightness range for grayscale shapes.
|
||||
func WithGrayscaleLightnessRange(min, max float64) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
if min < 0 || min > 1 || max < 0 || max > 1 {
|
||||
return fmt.Errorf("lightness values must be between 0 and 1, got min=%f max=%f", min, max)
|
||||
}
|
||||
if min > max {
|
||||
return fmt.Errorf("minimum lightness cannot be greater than maximum, got min=%f max=%f", min, max)
|
||||
}
|
||||
c.GrayscaleLightnessRange = [2]float64{min, max}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithColorSaturation sets the saturation for colored shapes.
|
||||
func WithColorSaturation(saturation float64) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
if saturation < 0 || saturation > 1 {
|
||||
return fmt.Errorf("saturation must be between 0 and 1, got %f", saturation)
|
||||
}
|
||||
c.ColorSaturation = saturation
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithGrayscaleSaturation sets the saturation for grayscale shapes.
|
||||
func WithGrayscaleSaturation(saturation float64) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
if saturation < 0 || saturation > 1 {
|
||||
return fmt.Errorf("saturation must be between 0 and 1, got %f", saturation)
|
||||
}
|
||||
c.GrayscaleSaturation = saturation
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithBackgroundColor sets the background color.
|
||||
func WithBackgroundColor(color string) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
if color != "" {
|
||||
if err := validateHexColor(color); err != nil {
|
||||
return fmt.Errorf("invalid background color: %w", err)
|
||||
}
|
||||
}
|
||||
c.BackgroundColor = color
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithPadding sets the padding around the identicon.
|
||||
func WithPadding(padding float64) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
if padding < 0 || padding > 0.5 {
|
||||
return fmt.Errorf("padding must be between 0 and 0.5, got %f", padding)
|
||||
}
|
||||
c.Padding = padding
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Configure creates a new configuration with the given options.
|
||||
func Configure(options ...ConfigOption) (Config, error) {
|
||||
config := DefaultConfig()
|
||||
|
||||
for _, option := range options {
|
||||
if err := option(&config); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// validateHexColor validates that a color string is a valid hex color.
|
||||
func validateHexColor(color string) error {
|
||||
hexColorPattern := regexp.MustCompile(`^#[0-9a-fA-F]{3,8}$`)
|
||||
if !hexColorPattern.MatchString(color) {
|
||||
return fmt.Errorf("color must be a hex color like #fff, #ffffff, or #ffffff80")
|
||||
}
|
||||
|
||||
// Validate length - must be 3, 4, 6, or 8 characters after #
|
||||
length := len(color) - 1
|
||||
if length != 3 && length != 4 && length != 6 && length != 8 {
|
||||
return fmt.Errorf("hex color must be 3, 4, 6, or 8 characters after #")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseColor normalizes a hex color string to the full #RRGGBB or #RRGGBBAA format.
|
||||
func ParseColor(color string) (string, error) {
|
||||
if color == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err := validateHexColor(color); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Normalize short forms to full forms
|
||||
switch len(color) {
|
||||
case 4: // #RGB -> #RRGGBB
|
||||
r, g, b := color[1], color[2], color[3]
|
||||
return fmt.Sprintf("#%c%c%c%c%c%c", r, r, g, g, b, b), nil
|
||||
case 5: // #RGBA -> #RRGGBBAA
|
||||
r, g, b, a := color[1], color[2], color[3], color[4]
|
||||
return fmt.Sprintf("#%c%c%c%c%c%c%c%c", r, r, g, g, b, b, a, a), nil
|
||||
case 7, 9: // #RRGGBB or #RRGGBBAA - already normalized
|
||||
return color, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported color format")
|
||||
}
|
||||
}
|
||||
|
||||
// GetHue returns the hue for the given original hue value, taking into account hue restrictions.
|
||||
func (c *Config) GetHue(originalHue float64) float64 {
|
||||
if len(c.HueRestrictions) == 0 {
|
||||
return originalHue
|
||||
}
|
||||
|
||||
// Map originalHue [0,1] to one of the restricted hues
|
||||
index := int(originalHue * 0.999 * float64(len(c.HueRestrictions)))
|
||||
if index >= len(c.HueRestrictions) {
|
||||
index = len(c.HueRestrictions) - 1
|
||||
}
|
||||
|
||||
hue := c.HueRestrictions[index]
|
||||
// Convert degrees to [0,1] range and normalize
|
||||
return ((hue/360.0)+1.0) - float64(int((hue/360.0)+1.0))
|
||||
}
|
||||
|
||||
// GetColorLightness returns a lightness value within the color lightness range.
|
||||
func (c *Config) GetColorLightness(value float64) float64 {
|
||||
return c.getLightness(value, c.ColorLightnessRange)
|
||||
}
|
||||
|
||||
// GetGrayscaleLightness returns a lightness value within the grayscale lightness range.
|
||||
func (c *Config) GetGrayscaleLightness(value float64) float64 {
|
||||
return c.getLightness(value, c.GrayscaleLightnessRange)
|
||||
}
|
||||
|
||||
// getLightness maps a value [0,1] to the specified lightness range.
|
||||
func (c *Config) getLightness(value float64, lightnessRange [2]float64) float64 {
|
||||
result := lightnessRange[0] + value*(lightnessRange[1]-lightnessRange[0])
|
||||
if result < 0 {
|
||||
return 0
|
||||
}
|
||||
if result > 1 {
|
||||
return 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
437
jdenticon/config_test.go
Normal file
437
jdenticon/config_test.go
Normal file
@@ -0,0 +1,437 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
|
||||
// Test default values match jdenticon-js
|
||||
if len(config.HueRestrictions) != 0 {
|
||||
t.Errorf("Expected no hue restrictions, got %v", config.HueRestrictions)
|
||||
}
|
||||
|
||||
expectedColorRange := [2]float64{0.4, 0.8}
|
||||
if config.ColorLightnessRange != expectedColorRange {
|
||||
t.Errorf("Expected color lightness range %v, got %v", expectedColorRange, config.ColorLightnessRange)
|
||||
}
|
||||
|
||||
expectedGrayscaleRange := [2]float64{0.3, 0.9}
|
||||
if config.GrayscaleLightnessRange != expectedGrayscaleRange {
|
||||
t.Errorf("Expected grayscale lightness range %v, got %v", expectedGrayscaleRange, config.GrayscaleLightnessRange)
|
||||
}
|
||||
|
||||
if config.ColorSaturation != 0.5 {
|
||||
t.Errorf("Expected color saturation 0.5, got %f", config.ColorSaturation)
|
||||
}
|
||||
|
||||
if config.GrayscaleSaturation != 0.0 {
|
||||
t.Errorf("Expected grayscale saturation 0.0, got %f", config.GrayscaleSaturation)
|
||||
}
|
||||
|
||||
if config.BackgroundColor != "" {
|
||||
t.Errorf("Expected empty background color, got %s", config.BackgroundColor)
|
||||
}
|
||||
|
||||
if config.Padding != 0.08 {
|
||||
t.Errorf("Expected padding 0.08, got %f", config.Padding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithHueRestrictions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hues []float64
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid hues", []float64{0, 90, 180, 270}, false},
|
||||
{"single hue", []float64{120}, false},
|
||||
{"empty slice", []float64{}, false},
|
||||
{"negative hue", []float64{-10}, true},
|
||||
{"hue too large", []float64{360}, true},
|
||||
{"mixed valid/invalid", []float64{120, 400}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config, err := Configure(WithHueRestrictions(tt.hues))
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for hues %v, got none", tt.hues)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(config.HueRestrictions) != len(tt.hues) {
|
||||
t.Errorf("Expected %d hue restrictions, got %d", len(tt.hues), len(config.HueRestrictions))
|
||||
}
|
||||
|
||||
for i, hue := range tt.hues {
|
||||
if config.HueRestrictions[i] != hue {
|
||||
t.Errorf("Expected hue %f at index %d, got %f", hue, i, config.HueRestrictions[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithLightnessRanges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
min float64
|
||||
max float64
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid range", 0.2, 0.8, false},
|
||||
{"full range", 0.0, 1.0, false},
|
||||
{"equal values", 0.5, 0.5, false},
|
||||
{"min > max", 0.8, 0.2, true},
|
||||
{"negative min", -0.1, 0.5, true},
|
||||
{"max > 1", 0.5, 1.1, true},
|
||||
{"both invalid", -0.1, 1.1, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run("color_"+tt.name, func(t *testing.T) {
|
||||
config, err := Configure(WithColorLightnessRange(tt.min, tt.max))
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for range [%f, %f], got none", tt.min, tt.max)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
expected := [2]float64{tt.min, tt.max}
|
||||
if config.ColorLightnessRange != expected {
|
||||
t.Errorf("Expected range %v, got %v", expected, config.ColorLightnessRange)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("grayscale_"+tt.name, func(t *testing.T) {
|
||||
config, err := Configure(WithGrayscaleLightnessRange(tt.min, tt.max))
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for range [%f, %f], got none", tt.min, tt.max)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
expected := [2]float64{tt.min, tt.max}
|
||||
if config.GrayscaleLightnessRange != expected {
|
||||
t.Errorf("Expected range %v, got %v", expected, config.GrayscaleLightnessRange)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithSaturation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
saturation float64
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid saturation", 0.5, false},
|
||||
{"zero saturation", 0.0, false},
|
||||
{"max saturation", 1.0, false},
|
||||
{"negative saturation", -0.1, true},
|
||||
{"saturation > 1", 1.1, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run("color_"+tt.name, func(t *testing.T) {
|
||||
config, err := Configure(WithColorSaturation(tt.saturation))
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for saturation %f, got none", tt.saturation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if config.ColorSaturation != tt.saturation {
|
||||
t.Errorf("Expected saturation %f, got %f", tt.saturation, config.ColorSaturation)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("grayscale_"+tt.name, func(t *testing.T) {
|
||||
config, err := Configure(WithGrayscaleSaturation(tt.saturation))
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for saturation %f, got none", tt.saturation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if config.GrayscaleSaturation != tt.saturation {
|
||||
t.Errorf("Expected saturation %f, got %f", tt.saturation, config.GrayscaleSaturation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithBackgroundColor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
color string
|
||||
wantErr bool
|
||||
expected string
|
||||
}{
|
||||
{"empty color", "", false, ""},
|
||||
{"3-char hex", "#fff", false, "#fff"},
|
||||
{"4-char hex with alpha", "#ffff", false, "#ffff"},
|
||||
{"6-char hex", "#ffffff", false, "#ffffff"},
|
||||
{"8-char hex with alpha", "#ffffff80", false, "#ffffff80"},
|
||||
{"lowercase", "#abc123", false, "#abc123"},
|
||||
{"uppercase", "#ABC123", false, "#ABC123"},
|
||||
{"invalid format", "ffffff", true, ""},
|
||||
{"invalid chars", "#gggggg", true, ""},
|
||||
{"too short", "#ff", true, ""},
|
||||
{"5-char hex", "#fffff", true, ""},
|
||||
{"7-char hex", "#fffffff", true, ""},
|
||||
{"9-char hex", "#fffffffff", true, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config, err := Configure(WithBackgroundColor(tt.color))
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for color %s, got none", tt.color)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if config.BackgroundColor != tt.expected {
|
||||
t.Errorf("Expected color %s, got %s", tt.expected, config.BackgroundColor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithPadding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
padding float64
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid padding", 0.08, false},
|
||||
{"zero padding", 0.0, false},
|
||||
{"max padding", 0.5, false},
|
||||
{"negative padding", -0.1, true},
|
||||
{"padding > 0.5", 0.6, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config, err := Configure(WithPadding(tt.padding))
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for padding %f, got none", tt.padding)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if config.Padding != tt.padding {
|
||||
t.Errorf("Expected padding %f, got %f", tt.padding, config.Padding)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureMultipleOptions(t *testing.T) {
|
||||
config, err := Configure(
|
||||
WithHueRestrictions([]float64{120, 240}),
|
||||
WithColorSaturation(0.7),
|
||||
WithPadding(0.1),
|
||||
WithBackgroundColor("#ff0000"),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(config.HueRestrictions) != 2 || config.HueRestrictions[0] != 120 || config.HueRestrictions[1] != 240 {
|
||||
t.Errorf("Expected hue restrictions [120, 240], got %v", config.HueRestrictions)
|
||||
}
|
||||
|
||||
if config.ColorSaturation != 0.7 {
|
||||
t.Errorf("Expected color saturation 0.7, got %f", config.ColorSaturation)
|
||||
}
|
||||
|
||||
if config.Padding != 0.1 {
|
||||
t.Errorf("Expected padding 0.1, got %f", config.Padding)
|
||||
}
|
||||
|
||||
if config.BackgroundColor != "#ff0000" {
|
||||
t.Errorf("Expected background color #ff0000, got %s", config.BackgroundColor)
|
||||
}
|
||||
|
||||
// Check that other values are still default
|
||||
expectedColorRange := [2]float64{0.4, 0.8}
|
||||
if config.ColorLightnessRange != expectedColorRange {
|
||||
t.Errorf("Expected default color lightness range %v, got %v", expectedColorRange, config.ColorLightnessRange)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseColor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", "", false},
|
||||
{"3-char to 6-char", "#abc", "#aabbcc", false},
|
||||
{"4-char to 8-char", "#abcd", "#aabbccdd", false},
|
||||
{"6-char unchanged", "#abcdef", "#abcdef", false},
|
||||
{"8-char unchanged", "#abcdef80", "#abcdef80", false},
|
||||
{"invalid format", "abcdef", "", true},
|
||||
{"invalid length", "#abcde", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ParseColor(tt.input)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for input %s, got none", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetHue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
restrictions []float64
|
||||
input float64
|
||||
expected float64
|
||||
}{
|
||||
{"no restrictions", nil, 0.5, 0.5},
|
||||
{"single restriction", []float64{180}, 0.5, 0.5},
|
||||
{"multiple restrictions", []float64{0, 120, 240}, 0.0, 0.0},
|
||||
{"multiple restrictions mid", []float64{0, 120, 240}, 0.5, 120.0 / 360.0},
|
||||
{"multiple restrictions high", []float64{0, 120, 240}, 0.99, 240.0 / 360.0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.HueRestrictions = tt.restrictions
|
||||
|
||||
result := config.GetHue(tt.input)
|
||||
|
||||
// Allow small floating point differences
|
||||
if abs(result-tt.expected) > 0.001 {
|
||||
t.Errorf("Expected hue %f, got %f", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetLightness(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
|
||||
// Test color lightness
|
||||
colorLight := config.GetColorLightness(0.0)
|
||||
if colorLight != 0.4 {
|
||||
t.Errorf("Expected min color lightness 0.4, got %f", colorLight)
|
||||
}
|
||||
|
||||
colorLight = config.GetColorLightness(1.0)
|
||||
if colorLight != 0.8 {
|
||||
t.Errorf("Expected max color lightness 0.8, got %f", colorLight)
|
||||
}
|
||||
|
||||
colorLight = config.GetColorLightness(0.5)
|
||||
expected := 0.4 + 0.5*(0.8-0.4)
|
||||
if abs(colorLight-expected) > 0.001 {
|
||||
t.Errorf("Expected mid color lightness %f, got %f", expected, colorLight)
|
||||
}
|
||||
|
||||
// Test grayscale lightness
|
||||
grayLight := config.GetGrayscaleLightness(0.0)
|
||||
if grayLight != 0.3 {
|
||||
t.Errorf("Expected min grayscale lightness 0.3, got %f", grayLight)
|
||||
}
|
||||
|
||||
grayLight = config.GetGrayscaleLightness(1.0)
|
||||
if abs(grayLight-0.9) > 0.001 {
|
||||
t.Errorf("Expected max grayscale lightness 0.9, got %f", grayLight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureFailing(t *testing.T) {
|
||||
// Test that Configure fails on invalid options
|
||||
_, err := Configure(
|
||||
WithHueRestrictions([]float64{400}), // Invalid hue
|
||||
WithColorSaturation(0.5), // Valid option after invalid
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected Configure to fail with invalid hue restriction")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for floating point comparison
|
||||
func abs(x float64) float64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
14
jdenticon/doc.go
Normal file
14
jdenticon/doc.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Package jdenticon provides highly recognizable identicon generation.
|
||||
//
|
||||
// This package is a Go port of the JavaScript library Jdenticon,
|
||||
// offering the same visual quality and recognizability in Go applications.
|
||||
//
|
||||
// Basic usage:
|
||||
//
|
||||
// icon := jdenticon.Generate("user@example.com", 200)
|
||||
// svg := icon.ToSVG()
|
||||
// png := icon.ToPNG()
|
||||
//
|
||||
// The library supports both SVG and PNG output formats, with configurable
|
||||
// styling options including color themes, saturation, and brightness.
|
||||
package jdenticon
|
||||
344
jdenticon/generate.go
Normal file
344
jdenticon/generate.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"github.com/kevin/go-jdenticon/internal/engine"
|
||||
"github.com/kevin/go-jdenticon/internal/renderer"
|
||||
)
|
||||
|
||||
// iconRenderer defines the common interface for rendering identicons to different formats
|
||||
type iconRenderer interface {
|
||||
SetBackground(fillColor string, opacity float64)
|
||||
BeginShape(color string)
|
||||
AddPolygon(points []engine.Point)
|
||||
AddCircle(topLeft engine.Point, size float64, invert bool)
|
||||
EndShape()
|
||||
}
|
||||
|
||||
// Icon represents a generated identicon that can be rendered in various formats.
|
||||
type Icon struct {
|
||||
icon *engine.Icon
|
||||
}
|
||||
|
||||
// renderTo renders the icon to the given renderer, handling all common rendering logic
|
||||
func (i *Icon) renderTo(r iconRenderer) {
|
||||
if i.icon == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Set background color if configured
|
||||
if i.icon.Config.BackColor != nil {
|
||||
r.SetBackground(i.icon.Config.BackColor.String(), 1.0)
|
||||
}
|
||||
|
||||
// Render each shape group
|
||||
for _, group := range i.icon.Shapes {
|
||||
r.BeginShape(group.Color.String())
|
||||
|
||||
for _, shape := range group.Shapes {
|
||||
// Skip empty shapes
|
||||
if shape.Type == "empty" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch shape.Type {
|
||||
case "polygon":
|
||||
// Transform points
|
||||
transformedPoints := make([]engine.Point, len(shape.Points))
|
||||
for j, point := range shape.Points {
|
||||
transformedPoints[j] = shape.Transform.TransformIconPoint(point.X, point.Y, 0, 0)
|
||||
}
|
||||
r.AddPolygon(transformedPoints)
|
||||
|
||||
case "circle":
|
||||
// Use dedicated circle fields - CircleX, CircleY represent top-left corner
|
||||
topLeft := shape.Transform.TransformIconPoint(shape.CircleX, shape.CircleY, 0, 0)
|
||||
r.AddCircle(topLeft, shape.CircleSize, shape.Invert)
|
||||
}
|
||||
}
|
||||
|
||||
r.EndShape()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate creates an identicon for the given input value and size.
|
||||
// The input value is typically an email address, username, or any string
|
||||
// that should produce a consistent visual representation.
|
||||
func Generate(value string, size int) (*Icon, error) {
|
||||
// Compute hash from the input value
|
||||
hash := ComputeHash(value)
|
||||
|
||||
// Create generator with default configuration
|
||||
generator := engine.NewDefaultGenerator()
|
||||
|
||||
// Generate the icon
|
||||
engineIcon, err := generator.Generate(hash, float64(size))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Icon{icon: engineIcon}, nil
|
||||
}
|
||||
|
||||
// ToSVG renders the icon as an SVG string.
|
||||
func (i *Icon) ToSVG() (string, error) {
|
||||
if i.icon == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
svgRenderer := renderer.NewSVGRenderer(int(i.icon.Size))
|
||||
i.renderTo(svgRenderer)
|
||||
return svgRenderer.ToSVG(), nil
|
||||
}
|
||||
|
||||
// ToPNG renders the icon as PNG image data.
|
||||
func (i *Icon) ToPNG() ([]byte, error) {
|
||||
if i.icon == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
pngRenderer := renderer.NewPNGRenderer(int(i.icon.Size))
|
||||
i.renderTo(pngRenderer)
|
||||
return pngRenderer.ToPNG(), nil
|
||||
}
|
||||
|
||||
// ToSVG generates an identicon as an SVG string for the given input value.
|
||||
// The value can be any type - it will be converted to a string and hashed.
|
||||
// Size specifies the icon size in pixels.
|
||||
// Optional config parameters can be provided to customize the appearance.
|
||||
func ToSVG(value interface{}, size int, config ...Config) (string, error) {
|
||||
if size <= 0 {
|
||||
return "", fmt.Errorf("size must be positive, got %d", size)
|
||||
}
|
||||
|
||||
// Generate icon with the provided configuration
|
||||
icon, err := generateWithConfig(value, size, config...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate icon: %w", err)
|
||||
}
|
||||
|
||||
// Render as SVG
|
||||
svg, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to render SVG: %w", err)
|
||||
}
|
||||
|
||||
return svg, nil
|
||||
}
|
||||
|
||||
// ToPNG generates an identicon as PNG image data for the given input value.
|
||||
// The value can be any type - it will be converted to a string and hashed.
|
||||
// Size specifies the icon size in pixels.
|
||||
// Optional config parameters can be provided to customize the appearance.
|
||||
func ToPNG(value interface{}, size int, config ...Config) ([]byte, error) {
|
||||
if size <= 0 {
|
||||
return nil, fmt.Errorf("size must be positive, got %d", size)
|
||||
}
|
||||
|
||||
// Generate icon with the provided configuration
|
||||
icon, err := generateWithConfig(value, size, config...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate icon: %w", err)
|
||||
}
|
||||
|
||||
// Render as PNG
|
||||
png, err := icon.ToPNG()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to render PNG: %w", err)
|
||||
}
|
||||
|
||||
return png, nil
|
||||
}
|
||||
|
||||
// ToHash generates a hash string for the given input value.
|
||||
// This is a convenience function that wraps ComputeHash with better type handling.
|
||||
// The hash can be used with other functions or stored for consistent icon generation.
|
||||
func ToHash(value interface{}) string {
|
||||
return ComputeHash(value)
|
||||
}
|
||||
|
||||
// generateWithConfig is a helper function that creates an icon with optional configuration.
|
||||
func generateWithConfig(value interface{}, size int, configs ...Config) (*Icon, error) {
|
||||
// Convert value to string representation
|
||||
stringValue := convertToString(value)
|
||||
|
||||
// Compute hash from the input value
|
||||
hash := ComputeHash(stringValue)
|
||||
|
||||
// Create generator with configuration
|
||||
var generator *engine.Generator
|
||||
if len(configs) > 0 {
|
||||
// Use the provided configuration
|
||||
engineConfig := convertToEngineConfig(configs[0])
|
||||
generator = engine.NewGenerator(engineConfig)
|
||||
} else {
|
||||
// Use default configuration
|
||||
generator = engine.NewDefaultGenerator()
|
||||
}
|
||||
|
||||
// Generate the icon
|
||||
engineIcon, err := generator.Generate(hash, float64(size))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Icon{icon: engineIcon}, nil
|
||||
}
|
||||
|
||||
// convertToString converts any value to its string representation using reflection.
|
||||
// This handles various types similar to how JavaScript would convert them.
|
||||
func convertToString(value interface{}) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Use reflection to handle different types
|
||||
v := reflect.ValueOf(value)
|
||||
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
return v.String()
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return fmt.Sprintf("%d", v.Int())
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return fmt.Sprintf("%d", v.Uint())
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return fmt.Sprintf("%g", v.Float())
|
||||
case reflect.Bool:
|
||||
if v.Bool() {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case reflect.Slice:
|
||||
// Handle byte slices specially
|
||||
if v.Type().Elem().Kind() == reflect.Uint8 {
|
||||
return string(v.Bytes())
|
||||
}
|
||||
fallthrough
|
||||
default:
|
||||
// For all other types, use fmt.Sprintf
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
|
||||
// convertToEngineConfig converts a public Config to an internal engine.ColorConfig.
|
||||
func convertToEngineConfig(config Config) engine.ColorConfig {
|
||||
engineConfig := engine.DefaultColorConfig()
|
||||
|
||||
// Convert hue restrictions
|
||||
if len(config.HueRestrictions) > 0 {
|
||||
engineConfig.Hues = make([]float64, len(config.HueRestrictions))
|
||||
copy(engineConfig.Hues, config.HueRestrictions)
|
||||
}
|
||||
|
||||
// Convert lightness ranges
|
||||
engineConfig.ColorLightness = engine.LightnessRange{
|
||||
Min: config.ColorLightnessRange[0],
|
||||
Max: config.ColorLightnessRange[1],
|
||||
}
|
||||
engineConfig.GrayscaleLightness = engine.LightnessRange{
|
||||
Min: config.GrayscaleLightnessRange[0],
|
||||
Max: config.GrayscaleLightnessRange[1],
|
||||
}
|
||||
|
||||
// Convert saturation values
|
||||
engineConfig.ColorSaturation = config.ColorSaturation
|
||||
engineConfig.GrayscaleSaturation = config.GrayscaleSaturation
|
||||
|
||||
// Convert background color
|
||||
if config.BackgroundColor != "" {
|
||||
normalizedColor, err := ParseColor(config.BackgroundColor)
|
||||
if err == nil {
|
||||
// Parse the normalized hex color into an engine.Color
|
||||
color, parseErr := parseHexToEngineColor(normalizedColor)
|
||||
if parseErr == nil {
|
||||
engineConfig.BackColor = &color
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert padding
|
||||
engineConfig.IconPadding = config.Padding
|
||||
|
||||
return engineConfig
|
||||
}
|
||||
|
||||
// parseHexToEngineColor converts a hex color string to an engine.Color.
|
||||
func parseHexToEngineColor(hexColor string) (engine.Color, error) {
|
||||
if hexColor == "" {
|
||||
return engine.Color{}, fmt.Errorf("empty color string")
|
||||
}
|
||||
|
||||
// Remove # if present
|
||||
if len(hexColor) > 0 && hexColor[0] == '#' {
|
||||
hexColor = hexColor[1:]
|
||||
}
|
||||
|
||||
var r, g, b, a uint8 = 0, 0, 0, 255
|
||||
|
||||
switch len(hexColor) {
|
||||
case 6: // RRGGBB
|
||||
rgb, err := parseHexComponent(hexColor[0:2])
|
||||
if err != nil {
|
||||
return engine.Color{}, err
|
||||
}
|
||||
r = rgb
|
||||
|
||||
rgb, err = parseHexComponent(hexColor[2:4])
|
||||
if err != nil {
|
||||
return engine.Color{}, err
|
||||
}
|
||||
g = rgb
|
||||
|
||||
rgb, err = parseHexComponent(hexColor[4:6])
|
||||
if err != nil {
|
||||
return engine.Color{}, err
|
||||
}
|
||||
b = rgb
|
||||
|
||||
case 8: // RRGGBBAA
|
||||
rgb, err := parseHexComponent(hexColor[0:2])
|
||||
if err != nil {
|
||||
return engine.Color{}, err
|
||||
}
|
||||
r = rgb
|
||||
|
||||
rgb, err = parseHexComponent(hexColor[2:4])
|
||||
if err != nil {
|
||||
return engine.Color{}, err
|
||||
}
|
||||
g = rgb
|
||||
|
||||
rgb, err = parseHexComponent(hexColor[4:6])
|
||||
if err != nil {
|
||||
return engine.Color{}, err
|
||||
}
|
||||
b = rgb
|
||||
|
||||
rgb, err = parseHexComponent(hexColor[6:8])
|
||||
if err != nil {
|
||||
return engine.Color{}, err
|
||||
}
|
||||
a = rgb
|
||||
|
||||
default:
|
||||
return engine.Color{}, fmt.Errorf("invalid hex color length: %d", len(hexColor))
|
||||
}
|
||||
|
||||
return engine.NewColorRGBA(r, g, b, a), nil
|
||||
}
|
||||
|
||||
// parseHexComponent parses a 2-character hex string to a uint8 value.
|
||||
func parseHexComponent(hex string) (uint8, error) {
|
||||
if len(hex) != 2 {
|
||||
return 0, fmt.Errorf("hex component must be 2 characters, got %d", len(hex))
|
||||
}
|
||||
value, err := strconv.ParseUint(hex, 16, 8)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid hex component '%s': %w", hex, err)
|
||||
}
|
||||
return uint8(value), nil
|
||||
}
|
||||
719
jdenticon/generate_test.go
Normal file
719
jdenticon/generate_test.go
Normal file
@@ -0,0 +1,719 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
size int
|
||||
}{
|
||||
{
|
||||
name: "email address",
|
||||
value: "test@example.com",
|
||||
size: 64,
|
||||
},
|
||||
{
|
||||
name: "username",
|
||||
value: "johndoe",
|
||||
size: 32,
|
||||
},
|
||||
{
|
||||
name: "large icon",
|
||||
value: "large-icon-test",
|
||||
size: 256,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
icon, err := Generate(tt.value, tt.size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
if icon == nil {
|
||||
t.Fatal("Generate returned nil icon")
|
||||
}
|
||||
|
||||
// Test SVG generation
|
||||
svg, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
t.Fatalf("ToSVG failed: %v", err)
|
||||
}
|
||||
|
||||
if svg == "" {
|
||||
t.Error("ToSVG returned empty string")
|
||||
}
|
||||
|
||||
// Basic SVG validation
|
||||
if !strings.Contains(svg, "<svg") {
|
||||
t.Error("SVG output does not contain svg tag")
|
||||
}
|
||||
|
||||
if !strings.Contains(svg, "</svg>") {
|
||||
t.Error("SVG output does not contain closing svg tag")
|
||||
}
|
||||
|
||||
// Test PNG generation
|
||||
png, err := icon.ToPNG()
|
||||
if err != nil {
|
||||
t.Fatalf("ToPNG failed: %v", err)
|
||||
}
|
||||
|
||||
if len(png) == 0 {
|
||||
t.Error("ToPNG returned empty data")
|
||||
}
|
||||
|
||||
// Basic PNG validation (check PNG signature)
|
||||
if len(png) < 8 || string(png[1:4]) != "PNG" {
|
||||
t.Error("PNG output does not have valid PNG signature")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConsistency(t *testing.T) {
|
||||
value := "consistency-test"
|
||||
size := 64
|
||||
|
||||
// Generate the same icon multiple times
|
||||
icon1, err := Generate(value, size)
|
||||
if err != nil {
|
||||
t.Fatalf("First generate failed: %v", err)
|
||||
}
|
||||
|
||||
icon2, err := Generate(value, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Second generate failed: %v", err)
|
||||
}
|
||||
|
||||
// SVG should be identical
|
||||
svg1, err := icon1.ToSVG()
|
||||
if err != nil {
|
||||
t.Fatalf("First ToSVG failed: %v", err)
|
||||
}
|
||||
|
||||
svg2, err := icon2.ToSVG()
|
||||
if err != nil {
|
||||
t.Fatalf("Second ToSVG failed: %v", err)
|
||||
}
|
||||
|
||||
if svg1 != svg2 {
|
||||
t.Error("SVG outputs are not consistent for same input")
|
||||
}
|
||||
|
||||
// PNG should be identical
|
||||
png1, err := icon1.ToPNG()
|
||||
if err != nil {
|
||||
t.Fatalf("First ToPNG failed: %v", err)
|
||||
}
|
||||
|
||||
png2, err := icon2.ToPNG()
|
||||
if err != nil {
|
||||
t.Fatalf("Second ToPNG failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(png1, png2) {
|
||||
t.Error("PNG outputs are not consistent for same input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateInvalidInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
size int
|
||||
}{
|
||||
{
|
||||
name: "zero size",
|
||||
value: "test",
|
||||
size: 0,
|
||||
},
|
||||
{
|
||||
name: "negative size",
|
||||
value: "test",
|
||||
size: -10,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := Generate(tt.value, tt.size)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid input")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateVariety(t *testing.T) {
|
||||
// Test that different inputs produce different outputs
|
||||
values := []string{"value1", "value2", "value3", "test@example.com", "another-test"}
|
||||
size := 64
|
||||
|
||||
svgs := make([]string, len(values))
|
||||
|
||||
for i, value := range values {
|
||||
icon, err := Generate(value, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed for %s: %v", value, err)
|
||||
}
|
||||
|
||||
svg, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
t.Fatalf("ToSVG failed for %s: %v", value, err)
|
||||
}
|
||||
|
||||
svgs[i] = svg
|
||||
}
|
||||
|
||||
// Check that all SVGs are different
|
||||
for i := 0; i < len(svgs); i++ {
|
||||
for j := i + 1; j < len(svgs); j++ {
|
||||
if svgs[i] == svgs[j] {
|
||||
t.Errorf("SVG outputs are identical for different inputs: %s and %s", values[i], values[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerate(b *testing.B) {
|
||||
value := "benchmark-test"
|
||||
size := 64
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := Generate(value, size)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkGenerateVariousSizes tests generation performance across different icon sizes
|
||||
func BenchmarkGenerateVariousSizes(b *testing.B) {
|
||||
sizes := []int{64, 128, 256, 512, 1024}
|
||||
|
||||
for _, size := range sizes {
|
||||
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := Generate("benchmark@test.com", size)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed for size %d: %v", size, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkGenerateVariousInputs tests generation performance with different input types
|
||||
func BenchmarkGenerateVariousInputs(b *testing.B) {
|
||||
inputs := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"email", "user@example.com"},
|
||||
{"username", "john_doe_123"},
|
||||
{"uuid", "550e8400-e29b-41d4-a716-446655440000"},
|
||||
{"short", "abc"},
|
||||
{"long", "this_is_a_very_long_identifier_that_might_be_used_for_generating_identicons_in_some_applications"},
|
||||
{"special_chars", "user+test@domain.co.uk"},
|
||||
{"numbers", "12345678901234567890"},
|
||||
}
|
||||
|
||||
for _, input := range inputs {
|
||||
b.Run(input.name, func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := Generate(input.value, 128)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed for input %s: %v", input.name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkToSVG(b *testing.B) {
|
||||
icon, err := Generate("benchmark-test", 64)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
b.Fatalf("ToSVG failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkToPNG(b *testing.B) {
|
||||
icon, err := Generate("benchmark-test", 64)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := icon.ToPNG()
|
||||
if err != nil {
|
||||
b.Fatalf("ToPNG failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkHashGeneration benchmarks just hash computation performance
|
||||
func BenchmarkHashGeneration(b *testing.B) {
|
||||
inputs := []string{
|
||||
"user@example.com",
|
||||
"john_doe_123",
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"abc",
|
||||
"this_is_a_very_long_identifier_that_might_be_used_for_generating_identicons",
|
||||
}
|
||||
|
||||
for _, input := range inputs {
|
||||
b.Run(fmt.Sprintf("len_%d", len(input)), func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = ComputeHash(input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSVGRenderingVariousSizes benchmarks SVG rendering across different sizes
|
||||
func BenchmarkSVGRenderingVariousSizes(b *testing.B) {
|
||||
sizes := []int{64, 128, 256, 512}
|
||||
icons := make(map[int]*Icon)
|
||||
|
||||
// Pre-generate icons
|
||||
for _, size := range sizes {
|
||||
icon, err := Generate("benchmark@test.com", size)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to generate icon for size %d: %v", size, err)
|
||||
}
|
||||
icons[size] = icon
|
||||
}
|
||||
|
||||
for _, size := range sizes {
|
||||
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
|
||||
icon := icons[size]
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
b.Fatalf("ToSVG failed for size %d: %v", size, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkPNGRenderingVariousSizes benchmarks PNG rendering across different sizes
|
||||
func BenchmarkPNGRenderingVariousSizes(b *testing.B) {
|
||||
sizes := []int{64, 128, 256} // Smaller range for PNG due to higher memory usage
|
||||
icons := make(map[int]*Icon)
|
||||
|
||||
// Pre-generate icons
|
||||
for _, size := range sizes {
|
||||
icon, err := Generate("benchmark@test.com", size)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to generate icon for size %d: %v", size, err)
|
||||
}
|
||||
icons[size] = icon
|
||||
}
|
||||
|
||||
for _, size := range sizes {
|
||||
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
|
||||
icon := icons[size]
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := icon.ToPNG()
|
||||
if err != nil {
|
||||
b.Fatalf("ToPNG failed for size %d: %v", size, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkWithCustomConfig benchmarks generation with custom configuration
|
||||
func BenchmarkWithCustomConfig(b *testing.B) {
|
||||
config, err := Configure(
|
||||
WithHueRestrictions([]float64{0.0, 0.33, 0.66}),
|
||||
WithColorSaturation(0.6),
|
||||
WithBackgroundColor("#ffffff"),
|
||||
WithPadding(0.1),
|
||||
)
|
||||
if err != nil {
|
||||
b.Fatalf("Configure failed: %v", err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := ToSVG("benchmark@test.com", 128, config)
|
||||
if err != nil {
|
||||
b.Fatalf("ToSVG with config failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkWithCustomConfigPNG benchmarks PNG generation with custom configuration
|
||||
func BenchmarkWithCustomConfigPNG(b *testing.B) {
|
||||
config, err := Configure(
|
||||
WithColorSaturation(0.6),
|
||||
WithBackgroundColor("#123456"),
|
||||
WithPadding(0.1),
|
||||
)
|
||||
if err != nil {
|
||||
b.Fatalf("Configure failed: %v", err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := ToPNG("benchmark-png-config@test.com", 128, config)
|
||||
if err != nil {
|
||||
b.Fatalf("ToPNG with config failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkConcurrentGeneration tests performance under concurrent load
|
||||
func BenchmarkConcurrentGeneration(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
value := fmt.Sprintf("concurrent_user_%d@example.com", i)
|
||||
_, err := Generate(value, 128)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
i++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkBatchGeneration tests memory allocation patterns for batch generation
|
||||
func BenchmarkBatchGeneration(b *testing.B) {
|
||||
const batchSize = 100
|
||||
|
||||
b.SetBytes(batchSize) // Report throughput as items/sec
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
for j := 0; j < batchSize; j++ {
|
||||
value := fmt.Sprintf("batch_user_%d@example.com", j)
|
||||
_, err := Generate(value, 64)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tests for new public API functions
|
||||
|
||||
func TestToSVG(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value interface{}
|
||||
size int
|
||||
valid bool
|
||||
}{
|
||||
{"string input", "test@example.com", 64, true},
|
||||
{"int input", 12345, 64, true},
|
||||
{"float input", 123.45, 64, true},
|
||||
{"bool input", true, 64, true},
|
||||
{"nil input", nil, 64, true},
|
||||
{"byte slice", []byte("hello"), 64, true},
|
||||
{"zero size", "test", 0, false},
|
||||
{"negative size", "test", -10, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svg, err := ToSVG(tt.value, tt.size)
|
||||
|
||||
if tt.valid {
|
||||
if err != nil {
|
||||
t.Fatalf("ToSVG failed: %v", err)
|
||||
}
|
||||
|
||||
if svg == "" {
|
||||
t.Error("ToSVG returned empty string")
|
||||
}
|
||||
|
||||
// Basic SVG validation
|
||||
if !strings.Contains(svg, "<svg") {
|
||||
t.Error("SVG output does not contain svg tag")
|
||||
}
|
||||
|
||||
if !strings.Contains(svg, "</svg>") {
|
||||
t.Error("SVG output does not contain closing svg tag")
|
||||
}
|
||||
} else {
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid input")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPNG(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value interface{}
|
||||
size int
|
||||
valid bool
|
||||
}{
|
||||
{"string input", "test@example.com", 64, true},
|
||||
{"int input", 12345, 64, true},
|
||||
{"float input", 123.45, 64, true},
|
||||
{"bool input", false, 64, true},
|
||||
{"nil input", nil, 64, true},
|
||||
{"byte slice", []byte("hello"), 64, true},
|
||||
{"zero size", "test", 0, false},
|
||||
{"negative size", "test", -10, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
png, err := ToPNG(tt.value, tt.size)
|
||||
|
||||
if tt.valid {
|
||||
if err != nil {
|
||||
t.Fatalf("ToPNG failed: %v", err)
|
||||
}
|
||||
|
||||
if len(png) == 0 {
|
||||
t.Error("ToPNG returned empty data")
|
||||
}
|
||||
|
||||
// Basic PNG validation (check PNG signature)
|
||||
if len(png) < 8 || string(png[1:4]) != "PNG" {
|
||||
t.Error("PNG output does not have valid PNG signature")
|
||||
}
|
||||
} else {
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid input")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToHash(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value interface{}
|
||||
expected string
|
||||
}{
|
||||
{"string", "test", ComputeHash("test")},
|
||||
{"int", 123, ComputeHash("123")},
|
||||
{"float", 123.45, ComputeHash("123.45")},
|
||||
{"bool true", true, ComputeHash("true")},
|
||||
{"bool false", false, ComputeHash("false")},
|
||||
{"nil", nil, ComputeHash("")},
|
||||
{"byte slice", []byte("hello"), ComputeHash("hello")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hash := ToHash(tt.value)
|
||||
|
||||
if hash != tt.expected {
|
||||
t.Errorf("ToHash(%v) = %s, expected %s", tt.value, hash, tt.expected)
|
||||
}
|
||||
|
||||
// Hash should be non-empty and valid hex
|
||||
if hash == "" {
|
||||
t.Error("ToHash returned empty string")
|
||||
}
|
||||
|
||||
// Should be valid hex characters
|
||||
for _, c := range hash {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||
t.Errorf("Hash contains invalid character: %c", c)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToSVGWithConfig(t *testing.T) {
|
||||
value := "config-test"
|
||||
size := 64
|
||||
|
||||
// Test with custom configuration
|
||||
config, err := Configure(
|
||||
WithHueRestrictions([]float64{120, 240}), // Blue/green hues
|
||||
WithColorSaturation(0.8),
|
||||
WithBackgroundColor("#ffffff"),
|
||||
WithPadding(0.1),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Configure failed: %v", err)
|
||||
}
|
||||
|
||||
svg, err := ToSVG(value, size, config)
|
||||
if err != nil {
|
||||
t.Fatalf("ToSVG with config failed: %v", err)
|
||||
}
|
||||
|
||||
if svg == "" {
|
||||
t.Error("ToSVG with config returned empty string")
|
||||
}
|
||||
|
||||
// Test that it's different from default
|
||||
defaultSvg, err := ToSVG(value, size)
|
||||
if err != nil {
|
||||
t.Fatalf("ToSVG default failed: %v", err)
|
||||
}
|
||||
|
||||
if svg == defaultSvg {
|
||||
t.Error("SVG with config is identical to default SVG")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPNGWithConfig(t *testing.T) {
|
||||
value := "config-test"
|
||||
size := 64
|
||||
|
||||
// Test with custom configuration
|
||||
config, err := Configure(
|
||||
WithHueRestrictions([]float64{60, 180}), // Yellow/cyan hues
|
||||
WithColorSaturation(0.9),
|
||||
WithBackgroundColor("#000000"),
|
||||
WithPadding(0.05),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Configure failed: %v", err)
|
||||
}
|
||||
|
||||
png, err := ToPNG(value, size, config)
|
||||
if err != nil {
|
||||
t.Fatalf("ToPNG with config failed: %v", err)
|
||||
}
|
||||
|
||||
if len(png) == 0 {
|
||||
t.Error("ToPNG with config returned empty data")
|
||||
}
|
||||
|
||||
// Test that it's different from default
|
||||
defaultPng, err := ToPNG(value, size)
|
||||
if err != nil {
|
||||
t.Fatalf("ToPNG default failed: %v", err)
|
||||
}
|
||||
|
||||
if bytes.Equal(png, defaultPng) {
|
||||
t.Error("PNG with config is identical to default PNG")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicAPIConsistency(t *testing.T) {
|
||||
value := "consistency-test"
|
||||
size := 64
|
||||
|
||||
// Generate with both old and new APIs
|
||||
icon, err := Generate(value, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
oldSvg, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
t.Fatalf("Icon.ToSVG failed: %v", err)
|
||||
}
|
||||
|
||||
oldPng, err := icon.ToPNG()
|
||||
if err != nil {
|
||||
t.Fatalf("Icon.ToPNG failed: %v", err)
|
||||
}
|
||||
|
||||
newSvg, err := ToSVG(value, size)
|
||||
if err != nil {
|
||||
t.Fatalf("ToSVG failed: %v", err)
|
||||
}
|
||||
|
||||
newPng, err := ToPNG(value, size)
|
||||
if err != nil {
|
||||
t.Fatalf("ToPNG failed: %v", err)
|
||||
}
|
||||
|
||||
// Results should be identical
|
||||
if oldSvg != newSvg {
|
||||
t.Error("SVG output differs between old and new APIs")
|
||||
}
|
||||
|
||||
if !bytes.Equal(oldPng, newPng) {
|
||||
t.Error("PNG output differs between old and new APIs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeConversion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected string
|
||||
}{
|
||||
{"string", "hello", "hello"},
|
||||
{"int", 42, "42"},
|
||||
{"int64", int64(42), "42"},
|
||||
{"float64", 3.14, "3.14"},
|
||||
{"bool true", true, "true"},
|
||||
{"bool false", false, "false"},
|
||||
{"nil", nil, ""},
|
||||
{"byte slice", []byte("test"), "test"},
|
||||
{"struct", struct{ Name string }{"test"}, "{test}"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := convertToString(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("convertToString(%v) = %s, expected %s", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for min
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
226
jdenticon/hash.go
Normal file
226
jdenticon/hash.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kevin/go-jdenticon/internal/util"
|
||||
)
|
||||
|
||||
// ComputeHash computes a SHA-1 hash for any value and returns it as a hexadecimal string.
|
||||
// This function mimics the JavaScript version's behavior for compatibility.
|
||||
func ComputeHash(value interface{}) string {
|
||||
var input string
|
||||
|
||||
// Handle different input types, converting to string like JavaScript version
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
input = ""
|
||||
case string:
|
||||
input = v
|
||||
case []byte:
|
||||
input = string(v)
|
||||
case int:
|
||||
input = strconv.Itoa(v)
|
||||
case int64:
|
||||
input = strconv.FormatInt(v, 10)
|
||||
case float64:
|
||||
input = fmt.Sprintf("%g", v)
|
||||
default:
|
||||
// Convert to string using fmt.Sprintf for other types
|
||||
input = fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// Compute SHA-1 hash using Go's crypto/sha1 package
|
||||
h := sha1.New()
|
||||
h.Write([]byte(input))
|
||||
hash := h.Sum(nil)
|
||||
|
||||
// Convert to hexadecimal string (lowercase to match JavaScript)
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// HashValue is a convenience function that wraps ComputeHash for string inputs.
|
||||
// Kept for backward compatibility.
|
||||
func HashValue(value string) string {
|
||||
return ComputeHash(value)
|
||||
}
|
||||
|
||||
// IsValidHash checks if a string is a valid hash for Jdenticon.
|
||||
// It must be a hexadecimal string with at least 11 characters.
|
||||
func IsValidHash(hashCandidate string) bool {
|
||||
return util.IsValidHash(hashCandidate)
|
||||
}
|
||||
|
||||
// isValidHash is a private wrapper for backward compatibility with existing tests
|
||||
func isValidHash(hashCandidate string) bool {
|
||||
return IsValidHash(hashCandidate)
|
||||
}
|
||||
|
||||
// parseHex extracts a value from a hex string at a specific position.
|
||||
// This function is used to deterministically extract shape and color information
|
||||
// from the hash string, matching the JavaScript implementation.
|
||||
// When octets is 0 or negative, it reads from startPosition to the end of the string.
|
||||
func parseHex(hash string, startPosition int, octets int) (int, error) {
|
||||
return util.ParseHex(hash, startPosition, octets)
|
||||
}
|
||||
|
||||
// ParseHex provides a public API that matches the JavaScript parseHex function exactly.
|
||||
// It extracts a hexadecimal value from the hash string at the specified position.
|
||||
// If octets is not provided or is <= 0, it reads from the position to the end of the string.
|
||||
// Returns 0 on error to maintain compatibility with the JavaScript implementation.
|
||||
func ParseHex(hash string, startPosition int, octets ...int) int {
|
||||
octetCount := 0
|
||||
if len(octets) > 0 {
|
||||
octetCount = octets[0]
|
||||
}
|
||||
result, err := parseHex(hash, startPosition, octetCount)
|
||||
if err != nil {
|
||||
return 0 // Maintain JavaScript compatibility: return 0 on error
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ParseHash converts a hexadecimal hash string into a byte array for further processing.
|
||||
// It validates the input hash string and handles common prefixes like "0x".
|
||||
// Returns an error if the hash contains invalid hexadecimal characters.
|
||||
func ParseHash(hash string) ([]byte, error) {
|
||||
if hash == "" {
|
||||
return nil, errors.New("hash string cannot be empty")
|
||||
}
|
||||
|
||||
// Remove "0x" prefix if present
|
||||
cleanHash := strings.TrimPrefix(hash, "0x")
|
||||
cleanHash = strings.TrimPrefix(cleanHash, "0X")
|
||||
|
||||
// Validate hash length (must be even for proper byte conversion)
|
||||
if len(cleanHash)%2 != 0 {
|
||||
return nil, errors.New("hash string must have even length")
|
||||
}
|
||||
|
||||
// Decode hex string to bytes
|
||||
bytes, err := hex.DecodeString(cleanHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid hexadecimal string: %w", err)
|
||||
}
|
||||
|
||||
return bytes, nil
|
||||
}
|
||||
|
||||
// ExtractInt extracts a specific number of bits from a hash byte array and converts them to an integer.
|
||||
// The index parameter specifies the starting position (negative values count from the end).
|
||||
// The bits parameter specifies how many bits to extract.
|
||||
func ExtractInt(hash []byte, index, bits int) int {
|
||||
if len(hash) == 0 || bits <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Handle negative indices (count from end)
|
||||
if index < 0 {
|
||||
index = len(hash) + index
|
||||
}
|
||||
|
||||
// Ensure index is within bounds
|
||||
if index < 0 || index >= len(hash) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate how many bytes we need to read
|
||||
bytesNeeded := (bits + 7) / 8 // Round up to nearest byte
|
||||
|
||||
// Ensure we don't read past the end of the array
|
||||
if index+bytesNeeded > len(hash) {
|
||||
bytesNeeded = len(hash) - index
|
||||
}
|
||||
|
||||
if bytesNeeded <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Extract bytes and convert to integer
|
||||
var result int
|
||||
for i := 0; i < bytesNeeded; i++ {
|
||||
if index+i < len(hash) {
|
||||
result = (result << 8) | int(hash[index+i])
|
||||
}
|
||||
}
|
||||
|
||||
// Mask to only include the requested number of bits
|
||||
if bits < 64 {
|
||||
mask := (1 << bits) - 1
|
||||
result &= mask
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ExtractFloat extracts a specific number of bits from a hash byte array and converts them to a float64 value between 0 and 1.
|
||||
// The value is normalized by dividing by the maximum possible value for the given number of bits.
|
||||
func ExtractFloat(hash []byte, index, bits int) float64 {
|
||||
if bits <= 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Extract integer value
|
||||
intValue := ExtractInt(hash, index, bits)
|
||||
|
||||
// Calculate maximum possible value for the given number of bits
|
||||
maxValue := (1 << bits) - 1
|
||||
if maxValue == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Normalize to [0,1] range
|
||||
return float64(intValue) / float64(maxValue)
|
||||
}
|
||||
|
||||
// ExtractHue extracts the hue value from a hash string using the same algorithm as the JavaScript version.
|
||||
// This is a convenience function that extracts the last 7 characters and normalizes to [0,1] range.
|
||||
// Returns 0.0 on error to maintain compatibility with the JavaScript implementation.
|
||||
func ExtractHue(hash string) float64 {
|
||||
hueValue, err := parseHex(hash, -7, 0) // Read from -7 to end
|
||||
if err != nil {
|
||||
return 0.0 // Maintain JavaScript compatibility: return 0.0 on error
|
||||
}
|
||||
return float64(hueValue) / 0xfffffff
|
||||
}
|
||||
|
||||
// ExtractShapeIndex extracts a shape index from the hash at the specified position.
|
||||
// This is a convenience function that matches the JavaScript shape selection logic.
|
||||
// Returns 0 on error to maintain compatibility with the JavaScript implementation.
|
||||
func ExtractShapeIndex(hash string, position int) int {
|
||||
result, err := parseHex(hash, position, 1)
|
||||
if err != nil {
|
||||
return 0 // Maintain JavaScript compatibility: return 0 on error
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ExtractRotation extracts a rotation value from the hash at the specified position.
|
||||
// This is a convenience function that matches the JavaScript rotation logic.
|
||||
// Returns 0 on error to maintain compatibility with the JavaScript implementation.
|
||||
func ExtractRotation(hash string, position int) int {
|
||||
result, err := parseHex(hash, position, 1)
|
||||
if err != nil {
|
||||
return 0 // Maintain JavaScript compatibility: return 0 on error
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ExtractColorIndex extracts a color index from the hash at the specified position.
|
||||
// This is a convenience function that matches the JavaScript color selection logic.
|
||||
// Returns 0 on error to maintain compatibility with the JavaScript implementation.
|
||||
func ExtractColorIndex(hash string, position int, availableColors int) int {
|
||||
value, err := parseHex(hash, position, 1)
|
||||
if err != nil {
|
||||
return 0 // Maintain JavaScript compatibility: return 0 on error
|
||||
}
|
||||
if availableColors > 0 {
|
||||
return value % availableColors
|
||||
}
|
||||
return value
|
||||
}
|
||||
481
jdenticon/hash_test.go
Normal file
481
jdenticon/hash_test.go
Normal file
@@ -0,0 +1,481 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComputeHash(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected string // Known SHA-1 hash values
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
|
||||
},
|
||||
{
|
||||
name: "simple string",
|
||||
input: "hello",
|
||||
expected: "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d",
|
||||
},
|
||||
{
|
||||
name: "email address",
|
||||
input: "user@example.com",
|
||||
expected: "63a710569261a24b3766275b7000ce8d7b32e2f7",
|
||||
},
|
||||
{
|
||||
name: "nil input",
|
||||
input: nil,
|
||||
expected: "da39a3ee5e6b4b0d3255bfef95601890afd80709", // Same as empty string
|
||||
},
|
||||
{
|
||||
name: "integer input",
|
||||
input: 123,
|
||||
expected: "40bd001563085fc35165329ea1ff5c5ecbdbbeef", // SHA-1 of "123"
|
||||
},
|
||||
{
|
||||
name: "byte slice input",
|
||||
input: []byte("test"),
|
||||
expected: "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ComputeHash(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ComputeHash(%v) = %s, want %s", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashValue(t *testing.T) {
|
||||
// Test that HashValue produces the same result as ComputeHash for strings
|
||||
testStrings := []string{"", "hello", "user@example.com", "test123"}
|
||||
|
||||
for _, str := range testStrings {
|
||||
hashValue := HashValue(str)
|
||||
computeHash := ComputeHash(str)
|
||||
|
||||
if hashValue != computeHash {
|
||||
t.Errorf("HashValue(%s) = %s, but ComputeHash(%s) = %s", str, hashValue, str, computeHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidHash(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "valid long hash",
|
||||
input: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid minimum length",
|
||||
input: "da39a3ee5e6",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "too short",
|
||||
input: "da39a3ee5e",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid character",
|
||||
input: "da39a3ee5e6g4b0d3255bfef95601890afd80709",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "uppercase valid",
|
||||
input: "DA39A3EE5E6B4B0D3255BFEF95601890AFD80709",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isValidHash(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isValidHash(%s) = %v, want %v", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHex(t *testing.T) {
|
||||
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startPosition int
|
||||
octets int
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "first character",
|
||||
startPosition: 0,
|
||||
octets: 1,
|
||||
expected: 0xd, // 'd' in hex
|
||||
},
|
||||
{
|
||||
name: "two characters",
|
||||
startPosition: 0,
|
||||
octets: 2,
|
||||
expected: 0xda,
|
||||
},
|
||||
{
|
||||
name: "middle position",
|
||||
startPosition: 10,
|
||||
octets: 1,
|
||||
expected: 0x6, // '6' at position 10
|
||||
},
|
||||
{
|
||||
name: "negative index",
|
||||
startPosition: -1,
|
||||
octets: 1,
|
||||
expected: 0x9, // last character '9'
|
||||
},
|
||||
{
|
||||
name: "negative index multiple chars",
|
||||
startPosition: -2,
|
||||
octets: 2,
|
||||
expected: 0x09, // last two characters "09"
|
||||
},
|
||||
{
|
||||
name: "out of bounds",
|
||||
startPosition: 100,
|
||||
octets: 1,
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "zero octets reads to end",
|
||||
startPosition: -7,
|
||||
octets: 0,
|
||||
expected: 0xfd80709, // Last 7 characters "fd80709"
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseHex(hash, tt.startPosition, tt.octets)
|
||||
if err != nil {
|
||||
// For out of bounds case, we expect an error but return 0
|
||||
if tt.expected == 0 && tt.name == "out of bounds" {
|
||||
// This is expected
|
||||
return
|
||||
}
|
||||
t.Errorf("parseHex(%s, %d, %d) unexpected error: %v", hash, tt.startPosition, tt.octets, err)
|
||||
return
|
||||
}
|
||||
if result != tt.expected {
|
||||
t.Errorf("parseHex(%s, %d, %d) = %d, want %d", hash, tt.startPosition, tt.octets, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHexErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hash string
|
||||
startPosition int
|
||||
octets int
|
||||
expectError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "invalid hex character",
|
||||
hash: "da39g3ee5e6b4b0d3255bfef95601890afd80709",
|
||||
startPosition: 4,
|
||||
octets: 1,
|
||||
expectError: true,
|
||||
errorContains: "failed to parse hex",
|
||||
},
|
||||
{
|
||||
name: "out of bounds positive",
|
||||
hash: "da39a3ee",
|
||||
startPosition: 10,
|
||||
octets: 1,
|
||||
expectError: true,
|
||||
errorContains: "out of bounds",
|
||||
},
|
||||
{
|
||||
name: "out of bounds negative",
|
||||
hash: "da39a3ee",
|
||||
startPosition: -20,
|
||||
octets: 1,
|
||||
expectError: true,
|
||||
errorContains: "out of bounds",
|
||||
},
|
||||
{
|
||||
name: "valid hex",
|
||||
hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
|
||||
startPosition: 0,
|
||||
octets: 2,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseHex(tt.hash, tt.startPosition, tt.octets)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("parseHex(%s, %d, %d) expected error, got nil", tt.hash, tt.startPosition, tt.octets)
|
||||
return
|
||||
}
|
||||
if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
|
||||
t.Errorf("parseHex(%s, %d, %d) error = %v, want error containing %s", tt.hash, tt.startPosition, tt.octets, err, tt.errorContains)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("parseHex(%s, %d, %d) unexpected error: %v", tt.hash, tt.startPosition, tt.octets, err)
|
||||
}
|
||||
// For valid case, just ensure no error and result >= 0
|
||||
if result < 0 {
|
||||
t.Errorf("parseHex(%s, %d, %d) = %d, want >= 0", tt.hash, tt.startPosition, tt.octets, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHash(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectError bool
|
||||
expected []byte
|
||||
}{
|
||||
{
|
||||
name: "valid hex string",
|
||||
input: "da39a3ee",
|
||||
expectError: false,
|
||||
expected: []byte{0xda, 0x39, 0xa3, 0xee},
|
||||
},
|
||||
{
|
||||
name: "with 0x prefix",
|
||||
input: "0xda39a3ee",
|
||||
expectError: false,
|
||||
expected: []byte{0xda, 0x39, 0xa3, 0xee},
|
||||
},
|
||||
{
|
||||
name: "with 0X prefix",
|
||||
input: "0Xda39a3ee",
|
||||
expectError: false,
|
||||
expected: []byte{0xda, 0x39, 0xa3, 0xee},
|
||||
},
|
||||
{
|
||||
name: "uppercase hex",
|
||||
input: "DA39A3EE",
|
||||
expectError: false,
|
||||
expected: []byte{0xda, 0x39, 0xa3, 0xee},
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expectError: true,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "odd length",
|
||||
input: "da39a3e",
|
||||
expectError: true,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid character",
|
||||
input: "da39g3ee",
|
||||
expectError: true,
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ParseHash(tt.input)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("ParseHash(%s) expected error, got nil", tt.input)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("ParseHash(%s) unexpected error: %v", tt.input, err)
|
||||
}
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("ParseHash(%s) length = %d, want %d", tt.input, len(result), len(tt.expected))
|
||||
}
|
||||
for i, b := range result {
|
||||
if i < len(tt.expected) && b != tt.expected[i] {
|
||||
t.Errorf("ParseHash(%s)[%d] = %x, want %x", tt.input, i, b, tt.expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHue(t *testing.T) {
|
||||
// Test with a known hash to ensure consistent behavior with JavaScript
|
||||
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
|
||||
hue := ExtractHue(hash)
|
||||
|
||||
// Verify it's in [0,1] range
|
||||
if hue < 0.0 || hue > 1.0 {
|
||||
t.Errorf("ExtractHue(%s) = %f, want value in [0,1] range", hash, hue)
|
||||
}
|
||||
|
||||
// Verify it matches manual calculation
|
||||
expectedValue, err := parseHex(hash, -7, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parseHex failed: %v", err)
|
||||
}
|
||||
expectedHue := float64(expectedValue) / 0xfffffff
|
||||
if hue != expectedHue {
|
||||
t.Errorf("ExtractHue(%s) = %f, want %f", hash, hue, expectedHue)
|
||||
}
|
||||
|
||||
// Test error cases - should return 0.0 for JavaScript compatibility
|
||||
t.Run("invalid hash", func(t *testing.T) {
|
||||
invalidHash := "invalid-hash-with-non-hex-chars"
|
||||
hue := ExtractHue(invalidHash)
|
||||
if hue != 0.0 {
|
||||
t.Errorf("ExtractHue with invalid hash should return 0.0, got %f", hue)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("short hash", func(t *testing.T) {
|
||||
shortHash := "short"
|
||||
hue := ExtractHue(shortHash)
|
||||
if hue != 0.0 {
|
||||
t.Errorf("ExtractHue with short hash should return 0.0, got %f", hue)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractShapeIndex(t *testing.T) {
|
||||
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
|
||||
|
||||
tests := []struct {
|
||||
position int
|
||||
expected int
|
||||
}{
|
||||
{1, 0xa}, // 'a' at position 1
|
||||
{2, 0x3}, // '3' at position 2
|
||||
{4, 0xa}, // 'a' at position 4
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := ExtractShapeIndex(hash, tt.position)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ExtractShapeIndex(%s, %d) = %d, want %d", hash, tt.position, result, tt.expected)
|
||||
}
|
||||
}
|
||||
|
||||
// Test error cases - should return 0 for JavaScript compatibility
|
||||
t.Run("invalid hash", func(t *testing.T) {
|
||||
invalidHash := "invalid-hash-with-non-hex-chars"
|
||||
result := ExtractShapeIndex(invalidHash, 0)
|
||||
if result != 0 {
|
||||
t.Errorf("ExtractShapeIndex with invalid hash should return 0, got %d", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("out of bounds position", func(t *testing.T) {
|
||||
result := ExtractShapeIndex(hash, 100)
|
||||
if result != 0 {
|
||||
t.Errorf("ExtractShapeIndex with out of bounds position should return 0, got %d", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractColorIndex(t *testing.T) {
|
||||
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
|
||||
|
||||
// Test modulo behavior
|
||||
availableColors := 5
|
||||
position := 8
|
||||
rawValue, err := parseHex(hash, position, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("parseHex failed: %v", err)
|
||||
}
|
||||
expected := rawValue % availableColors
|
||||
|
||||
result := ExtractColorIndex(hash, position, availableColors)
|
||||
if result != expected {
|
||||
t.Errorf("ExtractColorIndex(%s, %d, %d) = %d, want %d", hash, position, availableColors, result, expected)
|
||||
}
|
||||
|
||||
// Test with zero availableColors
|
||||
result = ExtractColorIndex(hash, position, 0)
|
||||
if result != rawValue {
|
||||
t.Errorf("ExtractColorIndex(%s, %d, 0) = %d, want %d", hash, position, result, rawValue)
|
||||
}
|
||||
|
||||
// Test error cases - should return 0 for JavaScript compatibility
|
||||
t.Run("invalid hash", func(t *testing.T) {
|
||||
invalidHash := "invalid-hash-with-non-hex-chars"
|
||||
result := ExtractColorIndex(invalidHash, 0, 5)
|
||||
if result != 0 {
|
||||
t.Errorf("ExtractColorIndex with invalid hash should return 0, got %d", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("out of bounds position", func(t *testing.T) {
|
||||
result := ExtractColorIndex(hash, 100, 5)
|
||||
if result != 0 {
|
||||
t.Errorf("ExtractColorIndex with out of bounds position should return 0, got %d", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractRotation(t *testing.T) {
|
||||
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
|
||||
|
||||
tests := []struct {
|
||||
position int
|
||||
expected int
|
||||
}{
|
||||
{0, 0xd}, // 'd' at position 0
|
||||
{1, 0xa}, // 'a' at position 1
|
||||
{5, 0x3}, // '3' at position 5
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := ExtractRotation(hash, tt.position)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ExtractRotation(%s, %d) = %d, want %d", hash, tt.position, result, tt.expected)
|
||||
}
|
||||
}
|
||||
|
||||
// Test error cases - should return 0 for JavaScript compatibility
|
||||
t.Run("invalid hash", func(t *testing.T) {
|
||||
invalidHash := "invalid-hash-with-non-hex-chars"
|
||||
result := ExtractRotation(invalidHash, 0)
|
||||
if result != 0 {
|
||||
t.Errorf("ExtractRotation with invalid hash should return 0, got %d", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("out of bounds position", func(t *testing.T) {
|
||||
result := ExtractRotation(hash, 100)
|
||||
if result != 0 {
|
||||
t.Errorf("ExtractRotation with out of bounds position should return 0, got %d", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
BIN
jdenticon/main
Executable file
BIN
jdenticon/main
Executable file
Binary file not shown.
58
jdenticon/reference_test.go
Normal file
58
jdenticon/reference_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJavaScriptReferenceCompatibility(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
size int
|
||||
}{
|
||||
{"test-hash", 64},
|
||||
{"example1@gmail.com", 64},
|
||||
{"example2@yahoo.com", 64},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.input+"_"+string(rune(tc.size)), func(t *testing.T) {
|
||||
// Generate Go SVG
|
||||
goSvg, err := ToSVG(tc.input, tc.size)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate Go SVG: %v", err)
|
||||
}
|
||||
|
||||
// Read reference JavaScript SVG
|
||||
var refFilename string
|
||||
if tc.input == "test-hash" {
|
||||
refFilename = "test-hash_64.svg"
|
||||
} else if tc.input == "example1@gmail.com" {
|
||||
refFilename = "example1_at_gmail_com_64.svg"
|
||||
} else if tc.input == "example2@yahoo.com" {
|
||||
refFilename = "example2_at_yahoo_com_64.svg"
|
||||
}
|
||||
|
||||
refPath := filepath.Join("../reference", refFilename)
|
||||
refData, err := os.ReadFile(refPath)
|
||||
if err != nil {
|
||||
t.Skipf("Reference file not found: %s", refPath)
|
||||
return
|
||||
}
|
||||
refSvg := string(refData)
|
||||
|
||||
// Compare
|
||||
if goSvg != refSvg {
|
||||
t.Errorf("SVG output differs from JavaScript reference")
|
||||
t.Logf("Go output:\n%s", goSvg)
|
||||
t.Logf("JS reference:\n%s", refSvg)
|
||||
|
||||
// Save Go output for manual inspection
|
||||
goPath := filepath.Join("../go-output", refFilename)
|
||||
os.WriteFile(goPath, []byte(goSvg), 0644)
|
||||
t.Logf("Go output saved to: %s", goPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user