Initial release: Go Jdenticon library v0.1.0
- Core library with SVG and PNG generation - CLI tool with generate and batch commands - Cross-platform path handling for Windows compatibility - Comprehensive test suite with integration tests
This commit is contained in:
@@ -3,103 +3,232 @@ package jdenticon
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/constants"
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/engine"
|
||||
)
|
||||
|
||||
// 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
|
||||
// hexColorRegex validates hex color strings in #RGB or #RRGGBB format.
|
||||
// It's compiled once at package initialization for efficiency.
|
||||
var hexColorRegex = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
|
||||
|
||||
// isValidHexColor checks if the given string is a valid hex color code.
|
||||
// It accepts both #RGB and #RRGGBB formats, case-insensitive.
|
||||
// Returns true for empty strings, which are treated as "transparent background".
|
||||
func isValidHexColor(color string) bool {
|
||||
if color == "" {
|
||||
return true // Empty string means transparent background
|
||||
}
|
||||
return hexColorRegex.MatchString(color)
|
||||
}
|
||||
|
||||
// DefaultConfig returns a default configuration that matches jdenticon-js defaults.
|
||||
// Config holds the configuration options for identicon generation.
|
||||
// This is the public API configuration that wraps the internal engine config.
|
||||
type Config struct {
|
||||
// Color configuration
|
||||
ColorSaturation float64 // Saturation for colored shapes (0.0-1.0)
|
||||
GrayscaleSaturation float64 // Saturation for grayscale shapes (0.0-1.0)
|
||||
ColorLightnessRange [2]float64 // Lightness range for colored shapes [min, max] (0.0-1.0)
|
||||
GrayscaleLightnessRange [2]float64 // Lightness range for grayscale shapes [min, max] (0.0-1.0)
|
||||
|
||||
// Hue restrictions - if specified, only these hues will be used
|
||||
HueRestrictions []float64 // Specific hue values in degrees (0-360)
|
||||
|
||||
// Layout configuration
|
||||
Padding float64 // Padding as percentage of icon size (0.0-0.5)
|
||||
BackgroundColor string // Background color in hex format (e.g., "#ffffff") or empty for transparent
|
||||
|
||||
// PNG rendering configuration
|
||||
PNGSupersampling int // Supersampling factor for PNG rendering (1-16)
|
||||
|
||||
// Security limits for DoS protection.
|
||||
//
|
||||
// MaxIconSize sets the maximum icon dimension in pixels to prevent memory exhaustion.
|
||||
// - 0 (default): Use library default of 4096 pixels (64MB max memory)
|
||||
// - Positive value: Use custom pixel limit
|
||||
// - -1: Disable size limits entirely (use with caution in server environments)
|
||||
MaxIconSize int
|
||||
|
||||
// MaxInputLength sets the maximum input string length in bytes to prevent hash DoS.
|
||||
// - 0 (default): Use library default of 1MB
|
||||
// - Positive value: Use custom byte limit
|
||||
// - -1: Disable input length limits entirely
|
||||
MaxInputLength int
|
||||
|
||||
// MaxComplexity sets the maximum geometric complexity score to prevent resource exhaustion.
|
||||
// This is calculated as the sum of complexity scores for all shapes in an identicon.
|
||||
// - 0 (default): Use library default complexity limit
|
||||
// - Positive value: Use custom complexity limit
|
||||
// - -1: Disable complexity limits entirely (use with caution)
|
||||
MaxComplexity int
|
||||
}
|
||||
|
||||
// DefaultConfig returns a Config with sensible default values.
|
||||
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
|
||||
ColorLightnessRange: [2]float64{0.4, 0.8},
|
||||
GrayscaleLightnessRange: [2]float64{0.3, 0.9},
|
||||
HueRestrictions: nil, // No restrictions by default
|
||||
Padding: 0.08,
|
||||
BackgroundColor: "", // Transparent background
|
||||
PNGSupersampling: 8,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigOption represents a configuration option function.
|
||||
// Validate checks if the configuration values are valid.
|
||||
func (c *Config) Validate() error {
|
||||
// Validate saturation ranges
|
||||
if c.ColorSaturation < 0.0 || c.ColorSaturation > 1.0 {
|
||||
return fmt.Errorf("color saturation must be between 0.0 and 1.0, got %f", c.ColorSaturation)
|
||||
}
|
||||
if c.GrayscaleSaturation < 0.0 || c.GrayscaleSaturation > 1.0 {
|
||||
return fmt.Errorf("grayscale saturation must be between 0.0 and 1.0, got %f", c.GrayscaleSaturation)
|
||||
}
|
||||
|
||||
// Validate lightness ranges
|
||||
if c.ColorLightnessRange[0] < 0.0 || c.ColorLightnessRange[0] > 1.0 ||
|
||||
c.ColorLightnessRange[1] < 0.0 || c.ColorLightnessRange[1] > 1.0 {
|
||||
return fmt.Errorf("color lightness range values must be between 0.0 and 1.0, got [%f, %f]",
|
||||
c.ColorLightnessRange[0], c.ColorLightnessRange[1])
|
||||
}
|
||||
if c.ColorLightnessRange[0] >= c.ColorLightnessRange[1] {
|
||||
return fmt.Errorf("color lightness range min must be less than max, got [%f, %f]",
|
||||
c.ColorLightnessRange[0], c.ColorLightnessRange[1])
|
||||
}
|
||||
|
||||
if c.GrayscaleLightnessRange[0] < 0.0 || c.GrayscaleLightnessRange[0] > 1.0 ||
|
||||
c.GrayscaleLightnessRange[1] < 0.0 || c.GrayscaleLightnessRange[1] > 1.0 {
|
||||
return fmt.Errorf("grayscale lightness range values must be between 0.0 and 1.0, got [%f, %f]",
|
||||
c.GrayscaleLightnessRange[0], c.GrayscaleLightnessRange[1])
|
||||
}
|
||||
if c.GrayscaleLightnessRange[0] >= c.GrayscaleLightnessRange[1] {
|
||||
return fmt.Errorf("grayscale lightness range min must be less than max, got [%f, %f]",
|
||||
c.GrayscaleLightnessRange[0], c.GrayscaleLightnessRange[1])
|
||||
}
|
||||
|
||||
// Validate padding
|
||||
if c.Padding < 0.0 || c.Padding > 0.5 {
|
||||
return fmt.Errorf("padding must be between 0.0 and 0.5, got %f", c.Padding)
|
||||
}
|
||||
|
||||
// Validate hue restrictions
|
||||
for i, hue := range c.HueRestrictions {
|
||||
if hue < 0.0 || hue > 360.0 {
|
||||
return fmt.Errorf("hue restriction at index %d must be between 0.0 and 360.0, got %f", i, hue)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate PNG supersampling
|
||||
if c.PNGSupersampling < 1 || c.PNGSupersampling > 16 {
|
||||
return fmt.Errorf("PNG supersampling must be between 1 and 16, got %d", c.PNGSupersampling)
|
||||
}
|
||||
|
||||
// Validate background color format
|
||||
if !isValidHexColor(c.BackgroundColor) {
|
||||
return NewErrInvalidInput("background_color", c.BackgroundColor, "must be a valid hex color in #RGB or #RRGGBB format")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// effectiveMaxIconSize resolves the configured icon size limit, applying the default if necessary.
|
||||
// Returns -1 if the limit is disabled.
|
||||
func (c *Config) effectiveMaxIconSize() int {
|
||||
if c.MaxIconSize < 0 {
|
||||
return -1 // Disabled
|
||||
}
|
||||
if c.MaxIconSize == 0 {
|
||||
return constants.DefaultMaxIconSize
|
||||
}
|
||||
return c.MaxIconSize
|
||||
}
|
||||
|
||||
// effectiveMaxInputLength resolves the configured input length limit, applying the default if necessary.
|
||||
// Returns -1 if the limit is disabled.
|
||||
func (c *Config) effectiveMaxInputLength() int {
|
||||
if c.MaxInputLength < 0 {
|
||||
return -1 // Disabled
|
||||
}
|
||||
if c.MaxInputLength == 0 {
|
||||
return constants.DefaultMaxInputLength
|
||||
}
|
||||
return c.MaxInputLength
|
||||
}
|
||||
|
||||
// effectiveMaxComplexity resolves the configured complexity limit, applying the default if necessary.
|
||||
// Returns -1 if the limit is disabled.
|
||||
func (c *Config) effectiveMaxComplexity() int {
|
||||
if c.MaxComplexity < 0 {
|
||||
return -1 // Disabled
|
||||
}
|
||||
if c.MaxComplexity == 0 {
|
||||
return constants.DefaultMaxComplexity
|
||||
}
|
||||
return c.MaxComplexity
|
||||
}
|
||||
|
||||
// toEngineColorConfig converts the public Config to the internal engine.ColorConfig.
|
||||
func (c *Config) toEngineColorConfig() (engine.ColorConfig, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return engine.ColorConfig{}, fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
colorConfig := engine.ColorConfig{
|
||||
ColorSaturation: c.ColorSaturation,
|
||||
GrayscaleSaturation: c.GrayscaleSaturation,
|
||||
ColorLightness: engine.LightnessRange{
|
||||
Min: c.ColorLightnessRange[0],
|
||||
Max: c.ColorLightnessRange[1],
|
||||
},
|
||||
GrayscaleLightness: engine.LightnessRange{
|
||||
Min: c.GrayscaleLightnessRange[0],
|
||||
Max: c.GrayscaleLightnessRange[1],
|
||||
},
|
||||
Hues: c.HueRestrictions,
|
||||
BackColor: nil, // Will be set below if background color is specified
|
||||
IconPadding: c.Padding,
|
||||
}
|
||||
|
||||
// Handle background color conversion from hex string to engine.Color
|
||||
if c.BackgroundColor != "" {
|
||||
// Validation is already handled by c.Validate() above.
|
||||
bgColor, err := engine.ParseHexColorToEngine(c.BackgroundColor)
|
||||
if err != nil {
|
||||
return colorConfig, fmt.Errorf("failed to parse background color: %w", err)
|
||||
}
|
||||
colorConfig.BackColor = &bgColor
|
||||
}
|
||||
|
||||
return colorConfig, nil
|
||||
}
|
||||
|
||||
// ConfigOption represents a functional option for configuring identicon generation.
|
||||
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)
|
||||
}
|
||||
// Configure applies configuration options to create a new Config.
|
||||
func Configure(options ...ConfigOption) (Config, error) {
|
||||
config := DefaultConfig()
|
||||
|
||||
for _, option := range options {
|
||||
if err := option(&config); err != nil {
|
||||
return config, fmt.Errorf("configuration option failed: %w", err)
|
||||
}
|
||||
c.HueRestrictions = make([]float64, len(hues))
|
||||
copy(c.HueRestrictions, hues)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := config.Validate(); err != nil {
|
||||
return config, fmt.Errorf("invalid configuration after applying options: %w", err)
|
||||
}
|
||||
|
||||
return config, 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.
|
||||
// WithColorSaturation sets the color 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)
|
||||
if saturation < 0.0 || saturation > 1.0 {
|
||||
return fmt.Errorf("color saturation must be between 0.0 and 1.0, got %f", saturation)
|
||||
}
|
||||
c.ColorSaturation = saturation
|
||||
return nil
|
||||
@@ -109,127 +238,122 @@ func WithColorSaturation(saturation float64) ConfigOption {
|
||||
// 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)
|
||||
if saturation < 0.0 || saturation > 1.0 {
|
||||
return fmt.Errorf("grayscale saturation must be between 0.0 and 1.0, 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.
|
||||
// WithPadding sets the padding as a percentage of the icon size.
|
||||
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)
|
||||
if padding < 0.0 || padding > 0.5 {
|
||||
return fmt.Errorf("padding must be between 0.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
|
||||
// WithBackgroundColor sets the background color (hex format like "#ffffff").
|
||||
// The color must be in #RGB or #RRGGBB format. An empty string means transparent background.
|
||||
func WithBackgroundColor(color string) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
if !isValidHexColor(color) {
|
||||
return NewErrInvalidInput("background_color", color, "must be a valid hex color in #RGB or #RRGGBB format")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
c.BackgroundColor = color
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// WithColorLightnessRange sets the lightness range for colored shapes.
|
||||
func WithColorLightnessRange(min, max float64) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
if min < 0.0 || min > 1.0 || max < 0.0 || max > 1.0 {
|
||||
return fmt.Errorf("lightness values must be between 0.0 and 1.0, got min=%f, max=%f", min, max)
|
||||
}
|
||||
if min >= max {
|
||||
return fmt.Errorf("lightness min must be less than max, got min=%f, max=%f", min, max)
|
||||
}
|
||||
c.ColorLightnessRange = [2]float64{min, max}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
// WithGrayscaleLightnessRange sets the lightness range for grayscale shapes.
|
||||
func WithGrayscaleLightnessRange(min, max float64) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
if min < 0.0 || min > 1.0 || max < 0.0 || max > 1.0 {
|
||||
return fmt.Errorf("lightness values must be between 0.0 and 1.0, got min=%f, max=%f", min, max)
|
||||
}
|
||||
if min >= max {
|
||||
return fmt.Errorf("lightness min must be less than max, got min=%f, max=%f", min, max)
|
||||
}
|
||||
c.GrayscaleLightnessRange = [2]float64{min, max}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetGrayscaleLightness returns a lightness value within the grayscale lightness range.
|
||||
func (c *Config) GetGrayscaleLightness(value float64) float64 {
|
||||
return c.getLightness(value, c.GrayscaleLightnessRange)
|
||||
// WithHueRestrictions restricts hues to specific values in degrees (0-360).
|
||||
func WithHueRestrictions(hues []float64) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
for i, hue := range hues {
|
||||
if hue < 0.0 || hue > 360.0 {
|
||||
return fmt.Errorf("hue at index %d must be between 0.0 and 360.0, got %f", i, hue)
|
||||
}
|
||||
}
|
||||
c.HueRestrictions = make([]float64, len(hues))
|
||||
copy(c.HueRestrictions, hues)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// WithPNGSupersampling sets the PNG supersampling factor (1-16).
|
||||
func WithPNGSupersampling(factor int) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
if factor < 1 || factor > 16 {
|
||||
return fmt.Errorf("PNG supersampling must be between 1 and 16, got %d", factor)
|
||||
}
|
||||
c.PNGSupersampling = factor
|
||||
return nil
|
||||
}
|
||||
if result > 1 {
|
||||
return 1
|
||||
}
|
||||
|
||||
// WithMaxComplexity sets the maximum geometric complexity limit for resource protection.
|
||||
// Use -1 to disable complexity limits entirely (use with caution).
|
||||
func WithMaxComplexity(maxComplexity int) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
if maxComplexity < -1 {
|
||||
return fmt.Errorf("max complexity must be >= -1, got %d", maxComplexity)
|
||||
}
|
||||
c.MaxComplexity = maxComplexity
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxIconSize sets the maximum icon dimension in pixels for DoS protection.
|
||||
// Use 0 for the library default (4096 pixels), or -1 to disable limits entirely.
|
||||
func WithMaxIconSize(maxSize int) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
if maxSize < -1 {
|
||||
return fmt.Errorf("max icon size must be >= -1, got %d", maxSize)
|
||||
}
|
||||
c.MaxIconSize = maxSize
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxInputLength sets the maximum input string length in bytes for DoS protection.
|
||||
// Use 0 for the library default (1MB), or -1 to disable limits entirely.
|
||||
func WithMaxInputLength(maxLength int) ConfigOption {
|
||||
return func(c *Config) error {
|
||||
if maxLength < -1 {
|
||||
return fmt.Errorf("max input length must be >= -1, got %d", maxLength)
|
||||
}
|
||||
c.MaxInputLength = maxLength
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,437 +1,462 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"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) {
|
||||
// TestIsValidHexColor tests the hex color validation helper function.
|
||||
func TestIsValidHexColor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
color string
|
||||
wantErr bool
|
||||
expected string
|
||||
expected bool
|
||||
}{
|
||||
{"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, ""},
|
||||
// Valid cases
|
||||
{
|
||||
name: "empty string (transparent)",
|
||||
color: "",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid 3-digit lowercase",
|
||||
color: "#fff",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid 3-digit uppercase",
|
||||
color: "#FFF",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid 3-digit mixed case",
|
||||
color: "#Fa3",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid 6-digit lowercase",
|
||||
color: "#ffffff",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid 6-digit uppercase",
|
||||
color: "#FFFFFF",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid 6-digit mixed case",
|
||||
color: "#Ff00Aa",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid with numbers",
|
||||
color: "#123456",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid 3-digit with numbers",
|
||||
color: "#123",
|
||||
expected: true,
|
||||
},
|
||||
|
||||
// Invalid cases
|
||||
{
|
||||
name: "missing hash prefix",
|
||||
color: "ffffff",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "too short",
|
||||
color: "#ff",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "too long",
|
||||
color: "#fffffff",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid hex characters",
|
||||
color: "#gggggg",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid hex characters in 3-digit",
|
||||
color: "#ggg",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "just hash",
|
||||
color: "#",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "double hash",
|
||||
color: "##ffffff",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "color name",
|
||||
color: "red",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "color name with hash",
|
||||
color: "#red",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "4-digit hex",
|
||||
color: "#1234",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "5-digit hex",
|
||||
color: "#12345",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "7-digit hex",
|
||||
color: "#1234567",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "8-digit hex (RGBA)",
|
||||
color: "#12345678",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "with spaces",
|
||||
color: "# ffffff",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "with special characters",
|
||||
color: "#ffffff!",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
result := isValidHexColor(tt.color)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
t.Errorf("isValidHexColor(%q) = %v, expected %v", tt.color, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetHue(t *testing.T) {
|
||||
// TestWithBackgroundColor tests the WithBackgroundColor config option.
|
||||
func TestWithBackgroundColor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
restrictions []float64
|
||||
input float64
|
||||
expected float64
|
||||
name string
|
||||
color string
|
||||
expectError bool
|
||||
expectedError string
|
||||
}{
|
||||
{"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},
|
||||
// Valid cases
|
||||
{
|
||||
name: "empty string (transparent)",
|
||||
color: "",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid 3-digit hex",
|
||||
color: "#fff",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid 6-digit hex",
|
||||
color: "#ffffff",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid mixed case",
|
||||
color: "#FfAa00",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid with numbers",
|
||||
color: "#123456",
|
||||
expectError: false,
|
||||
},
|
||||
|
||||
// Invalid cases
|
||||
{
|
||||
name: "missing hash",
|
||||
color: "ffffff",
|
||||
expectError: true,
|
||||
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
|
||||
},
|
||||
{
|
||||
name: "invalid hex characters",
|
||||
color: "#gggggg",
|
||||
expectError: true,
|
||||
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
|
||||
},
|
||||
{
|
||||
name: "too short",
|
||||
color: "#ff",
|
||||
expectError: true,
|
||||
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
|
||||
},
|
||||
{
|
||||
name: "too long",
|
||||
color: "#fffffff",
|
||||
expectError: true,
|
||||
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
|
||||
},
|
||||
{
|
||||
name: "color name",
|
||||
color: "red",
|
||||
expectError: true,
|
||||
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
|
||||
},
|
||||
{
|
||||
name: "4-digit hex",
|
||||
color: "#1234",
|
||||
expectError: true,
|
||||
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
|
||||
},
|
||||
{
|
||||
name: "8-digit hex (RGBA)",
|
||||
color: "#12345678",
|
||||
expectError: true,
|
||||
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
option := WithBackgroundColor(tt.color)
|
||||
config := DefaultConfig()
|
||||
err := option(&config)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("WithBackgroundColor(%q) expected error but got none", tt.color)
|
||||
return
|
||||
}
|
||||
|
||||
// Check that it's the right error type
|
||||
var invalidInput *ErrInvalidInput
|
||||
if !errors.As(err, &invalidInput) {
|
||||
t.Errorf("WithBackgroundColor(%q) error type = %T, expected *ErrInvalidInput", tt.color, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check error message contains expected text
|
||||
if tt.expectedError != "" {
|
||||
if !strings.Contains(err.Error(), tt.expectedError) {
|
||||
t.Errorf("WithBackgroundColor(%q) error = %q, expected to contain %q", tt.color, err.Error(), tt.expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
// Check field name
|
||||
if invalidInput.Field != "background_color" {
|
||||
t.Errorf("WithBackgroundColor(%q) error field = %q, expected %q", tt.color, invalidInput.Field, "background_color")
|
||||
}
|
||||
|
||||
// Check value is captured
|
||||
if invalidInput.Value != tt.color {
|
||||
t.Errorf("WithBackgroundColor(%q) error value = %q, expected %q", tt.color, invalidInput.Value, tt.color)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("WithBackgroundColor(%q) unexpected error: %v", tt.color, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check that the color was set
|
||||
if config.BackgroundColor != tt.color {
|
||||
t.Errorf("WithBackgroundColor(%q) config.BackgroundColor = %q, expected %q", tt.color, config.BackgroundColor, tt.color)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigValidateBackgroundColor tests that Config.Validate() validates background colors.
|
||||
func TestConfigValidateBackgroundColor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
color string
|
||||
expectError bool
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "valid empty color",
|
||||
color: "",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid 3-digit hex",
|
||||
color: "#fff",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid 6-digit hex",
|
||||
color: "#ffffff",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid color",
|
||||
color: "invalid-color",
|
||||
expectError: true,
|
||||
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
|
||||
},
|
||||
{
|
||||
name: "missing hash",
|
||||
color: "ffffff",
|
||||
expectError: true,
|
||||
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
config.BackgroundColor = tt.color
|
||||
|
||||
err := config.Validate()
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Config.Validate() with BackgroundColor=%q expected error but got none", tt.color)
|
||||
return
|
||||
}
|
||||
|
||||
// Check that it's the right error type
|
||||
var invalidInput *ErrInvalidInput
|
||||
if !errors.As(err, &invalidInput) {
|
||||
t.Errorf("Config.Validate() with BackgroundColor=%q error type = %T, expected *ErrInvalidInput", tt.color, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check error message contains expected text
|
||||
if tt.expectedError != "" {
|
||||
if !strings.Contains(err.Error(), tt.expectedError) {
|
||||
t.Errorf("Config.Validate() with BackgroundColor=%q error = %q, expected to contain %q", tt.color, err.Error(), tt.expectedError)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Config.Validate() with BackgroundColor=%q unexpected error: %v", tt.color, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// TestToEngineColorConfigBackgroundColor tests that toEngineColorConfig handles background colors.
|
||||
// Since validation is handled by Config.Validate(), this test focuses on the successful conversion path.
|
||||
func TestToEngineColorConfigBackgroundColor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
color string
|
||||
expectError bool
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "valid empty color",
|
||||
color: "",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid 3-digit hex",
|
||||
color: "#fff",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid 6-digit hex",
|
||||
color: "#ffffff",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid color caught by Validate()",
|
||||
color: "invalid-color",
|
||||
expectError: true,
|
||||
expectedError: "invalid configuration",
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
// Directly set the background color to bypass WithBackgroundColor validation
|
||||
config.BackgroundColor = tt.color
|
||||
|
||||
_, err := config.toEngineColorConfig()
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("toEngineColorConfig() with BackgroundColor=%q expected error but got none", tt.color)
|
||||
return
|
||||
}
|
||||
|
||||
// Check error message contains expected text (from c.Validate() call)
|
||||
if tt.expectedError != "" {
|
||||
if !strings.Contains(err.Error(), tt.expectedError) {
|
||||
t.Errorf("toEngineColorConfig() with BackgroundColor=%q error = %q, expected to contain %q", tt.color, err.Error(), tt.expectedError)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("toEngineColorConfig() with BackgroundColor=%q unexpected error: %v", tt.color, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
// TestConfigureFunctionWithBackgroundColor tests the Configure function with background color options.
|
||||
func TestConfigureFunctionWithBackgroundColor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
color string
|
||||
expectError bool
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "valid color through Configure",
|
||||
color: "#ffffff",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid color through Configure",
|
||||
color: "invalid",
|
||||
expectError: true,
|
||||
expectedError: "configuration option failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config, err := Configure(WithBackgroundColor(tt.color))
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Configure(WithBackgroundColor(%q)) expected error but got none", tt.color)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.expectedError != "" {
|
||||
if !strings.Contains(err.Error(), tt.expectedError) {
|
||||
t.Errorf("Configure(WithBackgroundColor(%q)) error = %q, expected to contain %q", tt.color, err.Error(), tt.expectedError)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Configure(WithBackgroundColor(%q)) unexpected error: %v", tt.color, err)
|
||||
return
|
||||
}
|
||||
|
||||
if config.BackgroundColor != tt.color {
|
||||
t.Errorf("Configure(WithBackgroundColor(%q)) config.BackgroundColor = %q, expected %q", tt.color, config.BackgroundColor, tt.color)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for floating point comparison
|
||||
func abs(x float64) float64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
@@ -1,14 +1,47 @@
|
||||
// Package jdenticon provides highly recognizable identicon generation.
|
||||
// Package jdenticon provides a Go library for generating highly recognizable identicons -
|
||||
// geometric avatar images generated deterministically from any input string.
|
||||
//
|
||||
// This package is a Go port of the JavaScript library Jdenticon,
|
||||
// offering the same visual quality and recognizability in Go applications.
|
||||
// This package wraps the internal/engine functionality to provide a clean, thread-safe
|
||||
// public API that follows Go idioms and conventions.
|
||||
//
|
||||
// Basic usage:
|
||||
//
|
||||
// icon := jdenticon.Generate("user@example.com", 200)
|
||||
// svg := icon.ToSVG()
|
||||
// png := icon.ToPNG()
|
||||
// // Generate with default configuration
|
||||
// icon, err := jdenticon.Generate("user@example.com", 200)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// The library supports both SVG and PNG output formats, with configurable
|
||||
// styling options including color themes, saturation, and brightness.
|
||||
package jdenticon
|
||||
// // Render as SVG
|
||||
// svgData, err := icon.ToSVG()
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// // Render as PNG
|
||||
// pngData, err := icon.ToPNG()
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// Advanced usage with custom configuration:
|
||||
//
|
||||
// // Create custom configuration
|
||||
// config := jdenticon.DefaultConfig()
|
||||
// config.ColorSaturation = 0.7
|
||||
// config.Padding = 0.1
|
||||
// config.BackgroundColor = "#ffffff"
|
||||
//
|
||||
// // Create generator with caching
|
||||
// generator, err := jdenticon.NewGeneratorWithConfig(config, 100)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// // Generate multiple icons efficiently
|
||||
// icon1, err := generator.Generate("user1@example.com", 64)
|
||||
// icon2, err := generator.Generate("user2@example.com", 64)
|
||||
//
|
||||
// The library is designed to be thread-safe and performant, with LRU caching
|
||||
// and singleflight to prevent duplicate work.
|
||||
package jdenticon
|
||||
|
||||
192
jdenticon/errors.go
Normal file
192
jdenticon/errors.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Error types for structured error handling following Go best practices.
|
||||
|
||||
// ErrInvalidInput represents an error due to invalid input parameters.
|
||||
type ErrInvalidInput struct {
|
||||
Field string // The field or parameter that was invalid
|
||||
Value string // The invalid value (may be truncated for display)
|
||||
Reason string // Human-readable explanation of why it's invalid
|
||||
}
|
||||
|
||||
func (e *ErrInvalidInput) Error() string {
|
||||
if e.Field != "" {
|
||||
return fmt.Sprintf("jdenticon: invalid input for %s: %s (got: %s)", e.Field, e.Reason, e.Value)
|
||||
}
|
||||
return fmt.Sprintf("jdenticon: invalid input: %s", e.Reason)
|
||||
}
|
||||
|
||||
// NewErrInvalidInput creates a new ErrInvalidInput.
|
||||
func NewErrInvalidInput(field, value, reason string) *ErrInvalidInput {
|
||||
return &ErrInvalidInput{
|
||||
Field: field,
|
||||
Value: value,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrInvalidSize represents an error due to invalid size parameter.
|
||||
type ErrInvalidSize int
|
||||
|
||||
func (e ErrInvalidSize) Error() string {
|
||||
return fmt.Sprintf("jdenticon: invalid size: must be positive, got %d", int(e))
|
||||
}
|
||||
|
||||
// ErrInvalidIcon represents an error due to invalid icon state.
|
||||
type ErrInvalidIcon string
|
||||
|
||||
func (e ErrInvalidIcon) Error() string {
|
||||
return fmt.Sprintf("jdenticon: invalid icon: %s", string(e))
|
||||
}
|
||||
|
||||
// ErrRenderFailed represents an error during rendering.
|
||||
type ErrRenderFailed struct {
|
||||
Format string // The format being rendered (SVG, PNG)
|
||||
Cause error // The underlying error
|
||||
}
|
||||
|
||||
func (e *ErrRenderFailed) Error() string {
|
||||
return fmt.Sprintf("jdenticon: %s rendering failed: %v", e.Format, e.Cause)
|
||||
}
|
||||
|
||||
func (e *ErrRenderFailed) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// NewErrRenderFailed creates a new ErrRenderFailed.
|
||||
func NewErrRenderFailed(format string, cause error) *ErrRenderFailed {
|
||||
return &ErrRenderFailed{
|
||||
Format: format,
|
||||
Cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrGenerationFailed represents an error during identicon generation.
|
||||
type ErrGenerationFailed struct {
|
||||
Input string // The input string that failed to generate (may be truncated)
|
||||
Size int // The requested size
|
||||
Cause error // The underlying error
|
||||
}
|
||||
|
||||
func (e *ErrGenerationFailed) Error() string {
|
||||
return fmt.Sprintf("jdenticon: generation failed for input %q (size %d): %v", e.Input, e.Size, e.Cause)
|
||||
}
|
||||
|
||||
func (e *ErrGenerationFailed) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// NewErrGenerationFailed creates a new ErrGenerationFailed.
|
||||
func NewErrGenerationFailed(input string, size int, cause error) *ErrGenerationFailed {
|
||||
// Truncate long inputs for display
|
||||
displayInput := input
|
||||
if len(input) > 50 {
|
||||
displayInput = input[:47] + "..."
|
||||
}
|
||||
|
||||
return &ErrGenerationFailed{
|
||||
Input: displayInput,
|
||||
Size: size,
|
||||
Cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrCacheCreationFailed represents an error during cache creation.
|
||||
type ErrCacheCreationFailed struct {
|
||||
Size int // The requested cache size
|
||||
Cause error // The underlying error
|
||||
}
|
||||
|
||||
func (e *ErrCacheCreationFailed) Error() string {
|
||||
return fmt.Sprintf("jdenticon: cache creation failed (size %d): %v", e.Size, e.Cause)
|
||||
}
|
||||
|
||||
func (e *ErrCacheCreationFailed) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// NewErrCacheCreationFailed creates a new ErrCacheCreationFailed.
|
||||
func NewErrCacheCreationFailed(size int, cause error) *ErrCacheCreationFailed {
|
||||
return &ErrCacheCreationFailed{
|
||||
Size: size,
|
||||
Cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrValueTooLarge is returned when a numeric input exceeds a configured limit.
|
||||
// This supports the configurable DoS protection system.
|
||||
type ErrValueTooLarge struct {
|
||||
ParameterName string // The name of the parameter being validated (e.g., "IconSize", "InputLength")
|
||||
Limit int // The configured limit that was exceeded
|
||||
Actual int // The actual value that was provided
|
||||
}
|
||||
|
||||
func (e *ErrValueTooLarge) Error() string {
|
||||
return fmt.Sprintf("jdenticon: %s of %d exceeds configured limit of %d",
|
||||
e.ParameterName, e.Actual, e.Limit)
|
||||
}
|
||||
|
||||
// NewErrValueTooLarge creates a new ErrValueTooLarge.
|
||||
func NewErrValueTooLarge(parameterName string, limit, actual int) *ErrValueTooLarge {
|
||||
return &ErrValueTooLarge{
|
||||
ParameterName: parameterName,
|
||||
Limit: limit,
|
||||
Actual: actual,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrEffectiveSizeTooLarge is returned when the effective PNG size (size * supersampling) exceeds limits.
|
||||
// This provides specific context for PNG rendering with supersampling.
|
||||
type ErrEffectiveSizeTooLarge struct {
|
||||
Limit int // The configured size limit
|
||||
Actual int // The calculated effective size (size * supersampling)
|
||||
Size int // The requested icon size
|
||||
Supersampling int // The supersampling factor applied
|
||||
}
|
||||
|
||||
func (e *ErrEffectiveSizeTooLarge) Error() string {
|
||||
return fmt.Sprintf("jdenticon: effective PNG size of %d (size %d × supersampling %d) exceeds limit of %d",
|
||||
e.Actual, e.Size, e.Supersampling, e.Limit)
|
||||
}
|
||||
|
||||
// NewErrEffectiveSizeTooLarge creates a new ErrEffectiveSizeTooLarge.
|
||||
func NewErrEffectiveSizeTooLarge(limit, actual, size, supersampling int) *ErrEffectiveSizeTooLarge {
|
||||
return &ErrEffectiveSizeTooLarge{
|
||||
Limit: limit,
|
||||
Actual: actual,
|
||||
Size: size,
|
||||
Supersampling: supersampling,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrComplexityLimitExceeded is returned when the calculated geometric complexity exceeds the configured limit.
|
||||
// This prevents resource exhaustion attacks through extremely complex identicon generation.
|
||||
type ErrComplexityLimitExceeded struct {
|
||||
Limit int // The configured complexity limit
|
||||
Actual int // The calculated complexity score
|
||||
InputHash string // The input hash that caused the high complexity (truncated for display)
|
||||
}
|
||||
|
||||
func (e *ErrComplexityLimitExceeded) Error() string {
|
||||
return fmt.Sprintf("jdenticon: complexity score of %d exceeds limit of %d for input hash %s",
|
||||
e.Actual, e.Limit, e.InputHash)
|
||||
}
|
||||
|
||||
// NewErrComplexityLimitExceeded creates a new ErrComplexityLimitExceeded.
|
||||
func NewErrComplexityLimitExceeded(limit, actual int, inputHash string) *ErrComplexityLimitExceeded {
|
||||
// Truncate long hash for display
|
||||
displayHash := inputHash
|
||||
if len(inputHash) > 16 {
|
||||
displayHash = inputHash[:13] + "..."
|
||||
}
|
||||
|
||||
return &ErrComplexityLimitExceeded{
|
||||
Limit: limit,
|
||||
Actual: actual,
|
||||
InputHash: displayHash,
|
||||
}
|
||||
}
|
||||
@@ -1,344 +1,137 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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()
|
||||
// Package-level convenience functions for simple use cases.
|
||||
// These wrap the Generator API for easy one-off identicon generation.
|
||||
|
||||
var (
|
||||
// defaultGenerator is a package-level generator instance for convenience functions.
|
||||
// It uses default configuration with a small cache for better performance.
|
||||
defaultGenerator *Generator
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Initialize the default generator with a small cache
|
||||
var err error
|
||||
defaultGenerator, err = NewGeneratorWithCacheSize(50)
|
||||
if err != nil {
|
||||
// Fall back to no caching if cache creation fails
|
||||
defaultGenerator, _ = NewGenerator()
|
||||
}
|
||||
}
|
||||
|
||||
// Icon represents a generated identicon that can be rendered in various formats.
|
||||
type Icon struct {
|
||||
icon *engine.Icon
|
||||
// Generate creates an identicon using the default configuration with context support.
|
||||
// The context can be used to set timeouts or cancel generation.
|
||||
//
|
||||
// This is a convenience function equivalent to:
|
||||
//
|
||||
// generator := jdenticon.NewGenerator()
|
||||
// icon := generator.Generate(ctx, input, size)
|
||||
//
|
||||
// For more control over configuration or caching, use Generator directly.
|
||||
func Generate(ctx context.Context, input string, size int) (*Icon, error) {
|
||||
// Apply input validation using default configuration
|
||||
config := DefaultConfig()
|
||||
if err := validateInputs(input, size, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if defaultGenerator == nil {
|
||||
return nil, NewErrGenerationFailed(input, size, fmt.Errorf("default generator not initialized"))
|
||||
}
|
||||
return defaultGenerator.Generate(ctx, input, size)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// ToSVG generates an identicon and returns it as an SVG string with context support.
|
||||
// The context can be used to set timeouts or cancel generation.
|
||||
//
|
||||
// This is a convenience function that uses default configuration.
|
||||
func ToSVG(ctx context.Context, input string, size int) (string, error) {
|
||||
return ToSVGWithConfig(ctx, input, size, DefaultConfig())
|
||||
}
|
||||
|
||||
// ToPNG generates an identicon and returns it as PNG bytes with context support.
|
||||
// The context can be used to set timeouts or cancel generation.
|
||||
//
|
||||
// This is a convenience function that uses default configuration with dynamic supersampling
|
||||
// to ensure the effective size stays within safe limits while maximizing quality.
|
||||
func ToPNG(ctx context.Context, input string, size int) ([]byte, error) {
|
||||
// Start with default configuration
|
||||
config := DefaultConfig()
|
||||
|
||||
// Apply dynamic supersampling to respect size limits
|
||||
if maxSize := config.effectiveMaxIconSize(); maxSize != -1 {
|
||||
effectiveSize := size * config.PNGSupersampling
|
||||
if effectiveSize > maxSize {
|
||||
// Calculate the maximum safe supersampling factor
|
||||
newSupersampling := maxSize / size
|
||||
if newSupersampling >= 1 {
|
||||
config.PNGSupersampling = newSupersampling
|
||||
} else {
|
||||
// Even 1x supersampling would exceed limits, let validation catch this
|
||||
config.PNGSupersampling = 1
|
||||
}
|
||||
}
|
||||
|
||||
r.EndShape()
|
||||
}
|
||||
|
||||
return ToPNGWithConfig(ctx, input, size, config)
|
||||
}
|
||||
|
||||
// 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))
|
||||
// ToSVGWithConfig generates an identicon with custom configuration and returns it as an SVG string.
|
||||
func ToSVGWithConfig(ctx context.Context, input string, size int, config Config) (string, error) {
|
||||
// Validate inputs using the provided configuration
|
||||
if err := validateInputs(input, size, config); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Validate the configuration itself
|
||||
if err := config.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
generator, err := NewGeneratorWithConfig(config, 1) // Minimal caching for one-off usage
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
icon, err := generator.Generate(ctx, input, size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return icon.ToSVG()
|
||||
}
|
||||
|
||||
// ToPNGWithConfig generates an identicon with custom configuration and returns it as PNG bytes.
|
||||
func ToPNGWithConfig(ctx context.Context, input string, size int, config Config) ([]byte, error) {
|
||||
// Validate inputs using the provided configuration
|
||||
if err := validateInputs(input, size, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate the configuration itself
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate PNG-specific effective size (size * supersampling)
|
||||
if err := validatePNGSize(size, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
generator, err := NewGeneratorWithConfig(config, 1) // Minimal caching for one-off usage
|
||||
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))
|
||||
icon, err := generator.Generate(ctx, input, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Icon{icon: engineIcon}, nil
|
||||
|
||||
return icon.ToPNG()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -1,719 +1,161 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
size int
|
||||
name string
|
||||
input string
|
||||
size int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "email address",
|
||||
value: "test@example.com",
|
||||
size: 64,
|
||||
name: "valid_email",
|
||||
input: "user@example.com",
|
||||
size: 64,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "username",
|
||||
value: "johndoe",
|
||||
size: 32,
|
||||
name: "valid_username",
|
||||
input: "johndoe",
|
||||
size: 128,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "large icon",
|
||||
value: "large-icon-test",
|
||||
size: 256,
|
||||
name: "empty_input",
|
||||
input: "",
|
||||
size: 64,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "zero_size",
|
||||
input: "test",
|
||||
size: 0,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negative_size",
|
||||
input: "test",
|
||||
size: -1,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
icon, err := Generate(tt.value, tt.size)
|
||||
icon, err := Generate(context.Background(), tt.input, tt.size)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Generate(context.Background(), ) expected error for %s, but got none", tt.name)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
t.Errorf("Generate(context.Background(), ) unexpected error for %s: %v", tt.name, err)
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
t.Errorf("Generate(context.Background(), ) returned nil icon for %s", tt.name)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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},
|
||||
input := "test@example.com"
|
||||
size := 64
|
||||
|
||||
svg, err := ToSVG(context.Background(), input, size)
|
||||
if err != nil {
|
||||
t.Fatalf("ToSVG(context.Background(), ) failed: %v", err)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
})
|
||||
if svg == "" {
|
||||
t.Error("ToSVG() returned empty string")
|
||||
}
|
||||
|
||||
// Basic SVG validation
|
||||
if !strings.HasPrefix(svg, "<svg") {
|
||||
t.Error("ToSVG() output doesn't start with <svg")
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(svg, "</svg>") {
|
||||
t.Error("ToSVG() output doesn't end with </svg>")
|
||||
}
|
||||
|
||||
if !strings.Contains(svg, "xmlns") {
|
||||
t.Error("ToSVG() output missing xmlns attribute")
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
input := "test@example.com"
|
||||
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),
|
||||
)
|
||||
png, err := ToPNG(context.Background(), input, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Configure failed: %v", err)
|
||||
t.Fatalf("ToPNG(context.Background(), ) 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")
|
||||
t.Error("ToPNG() returned empty byte slice")
|
||||
}
|
||||
|
||||
// Test that it's different from default
|
||||
defaultPng, err := ToPNG(value, size)
|
||||
if err != nil {
|
||||
t.Fatalf("ToPNG default failed: %v", err)
|
||||
|
||||
// Basic PNG validation - check PNG signature
|
||||
if len(png) < 8 {
|
||||
t.Error("ToPNG() output too short to be valid PNG")
|
||||
return
|
||||
}
|
||||
|
||||
if bytes.Equal(png, defaultPng) {
|
||||
t.Error("PNG with config is identical to default PNG")
|
||||
|
||||
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
||||
expectedSignature := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
||||
for i, expected := range expectedSignature {
|
||||
if png[i] != expected {
|
||||
t.Errorf("ToPNG(context.Background(), ) invalid PNG signature at byte %d: expected %02x, got %02x", i, expected, png[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicAPIConsistency(t *testing.T) {
|
||||
value := "consistency-test"
|
||||
func TestDeterminism(t *testing.T) {
|
||||
input := "determinism-test"
|
||||
size := 64
|
||||
|
||||
// Generate with both old and new APIs
|
||||
icon, err := Generate(value, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
|
||||
// Generate the same input multiple times
|
||||
svg1, err1 := ToSVG(context.Background(), input, size)
|
||||
svg2, err2 := ToSVG(context.Background(), input, size)
|
||||
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("ToSVG(context.Background(), ) failed: err1=%v, err2=%v", err1, err2)
|
||||
}
|
||||
|
||||
oldSvg, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
t.Fatalf("Icon.ToSVG failed: %v", err)
|
||||
|
||||
if svg1 != svg2 {
|
||||
t.Error("ToSVG() not deterministic: same input produced different output")
|
||||
}
|
||||
|
||||
oldPng, err := icon.ToPNG()
|
||||
if err != nil {
|
||||
t.Fatalf("Icon.ToPNG failed: %v", err)
|
||||
|
||||
png1, err1 := ToPNG(context.Background(), input, size)
|
||||
png2, err2 := ToPNG(context.Background(), input, size)
|
||||
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("ToPNG(context.Background(), ) failed: err1=%v, err2=%v", err1, err2)
|
||||
}
|
||||
|
||||
newSvg, err := ToSVG(value, size)
|
||||
if err != nil {
|
||||
t.Fatalf("ToSVG failed: %v", err)
|
||||
|
||||
if len(png1) != len(png2) {
|
||||
t.Error("ToPNG() not deterministic: same input produced different length output")
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
for i := range png1 {
|
||||
if png1[i] != png2[i] {
|
||||
t.Errorf("ToPNG(context.Background(), ) not deterministic: difference at byte %d", i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
124
jdenticon/generator.go
Normal file
124
jdenticon/generator.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/engine"
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/util"
|
||||
)
|
||||
|
||||
// Generator provides thread-safe identicon generation with caching.
|
||||
// It wraps the internal engine.Generator to provide a clean public API.
|
||||
type Generator struct {
|
||||
engine *engine.Generator
|
||||
config Config // Store the configuration for validation during Generate calls
|
||||
}
|
||||
|
||||
// NewGenerator creates a new Generator with default configuration and default caching.
|
||||
func NewGenerator() (*Generator, error) {
|
||||
engineGen, err := engine.NewDefaultGenerator()
|
||||
if err != nil {
|
||||
return nil, NewErrGenerationFailed("", 0, err)
|
||||
}
|
||||
|
||||
return &Generator{
|
||||
engine: engineGen,
|
||||
config: DefaultConfig(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewGeneratorWithCacheSize creates a new Generator with the specified cache size.
|
||||
func NewGeneratorWithCacheSize(cacheSize int) (*Generator, error) {
|
||||
if cacheSize <= 0 {
|
||||
return nil, NewErrInvalidInput("cacheSize", fmt.Sprintf("%d", cacheSize), "must be positive")
|
||||
}
|
||||
|
||||
config := engine.DefaultGeneratorConfig()
|
||||
config.CacheSize = cacheSize
|
||||
|
||||
engineGen, err := engine.NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
return nil, NewErrCacheCreationFailed(cacheSize, err)
|
||||
}
|
||||
|
||||
return &Generator{
|
||||
engine: engineGen,
|
||||
config: DefaultConfig(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewGeneratorWithConfig creates a new Generator with custom configuration and caching.
|
||||
func NewGeneratorWithConfig(config Config, cacheSize int) (*Generator, error) {
|
||||
if cacheSize <= 0 {
|
||||
return nil, NewErrInvalidInput("cacheSize", fmt.Sprintf("%d", cacheSize), "must be positive")
|
||||
}
|
||||
|
||||
// Convert public config to internal config
|
||||
colorConfig, err := config.toEngineColorConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
generatorConfig := engine.GeneratorConfig{
|
||||
ColorConfig: colorConfig,
|
||||
CacheSize: cacheSize,
|
||||
MaxComplexity: config.MaxComplexity,
|
||||
MaxIconSize: config.MaxIconSize,
|
||||
}
|
||||
|
||||
engineGen, err := engine.NewGeneratorWithConfig(generatorConfig)
|
||||
if err != nil {
|
||||
return nil, NewErrCacheCreationFailed(cacheSize, err)
|
||||
}
|
||||
|
||||
return &Generator{
|
||||
engine: engineGen,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Generate creates an identicon for the given input string and size with context support.
|
||||
// The context can be used to set timeouts or cancel generation.
|
||||
//
|
||||
// The input string is hashed to generate a deterministic identicon.
|
||||
// Size must be positive and represents the width/height in pixels.
|
||||
//
|
||||
// This method applies the security limits that were configured when the Generator was created.
|
||||
// For different limits, create a new Generator with NewGeneratorWithConfig.
|
||||
//
|
||||
// This method is thread-safe and uses caching if configured.
|
||||
func (g *Generator) Generate(ctx context.Context, input string, size int) (*Icon, error) {
|
||||
// Apply validation using the generator's stored configuration
|
||||
if err := validateInputs(input, size, g.config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check for early cancellation
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert input to hash
|
||||
hash := util.ComputeHash(input)
|
||||
|
||||
// Validate complexity before generation
|
||||
if err := validateComplexity(hash, g.config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate using the internal engine with context
|
||||
engineIcon, err := g.engine.Generate(ctx, hash, float64(size))
|
||||
if err != nil {
|
||||
return nil, NewErrGenerationFailed(input, size, err)
|
||||
}
|
||||
|
||||
// Wrap in public Icon directly
|
||||
return newIcon(engineIcon), nil
|
||||
}
|
||||
|
||||
// GetCacheMetrics returns the cache hit and miss counts.
|
||||
// These metrics are thread-safe to read.
|
||||
func (g *Generator) GetCacheMetrics() (hits, misses int64) {
|
||||
return g.engine.GetCacheMetrics()
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,481 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
110
jdenticon/icon.go
Normal file
110
jdenticon/icon.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/engine"
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/renderer"
|
||||
)
|
||||
|
||||
// Icon represents a generated identicon that can be rendered as SVG or PNG.
|
||||
// It wraps the internal engine.Icon to provide a clean public API.
|
||||
type Icon struct {
|
||||
engineIcon *engine.Icon
|
||||
}
|
||||
|
||||
// newIcon creates a new public Icon from an internal engine.Icon.
|
||||
func newIcon(engineIcon *engine.Icon) *Icon {
|
||||
return &Icon{
|
||||
engineIcon: engineIcon,
|
||||
}
|
||||
}
|
||||
|
||||
// ToSVG renders the icon as an SVG string.
|
||||
//
|
||||
// Returns the SVG markup as a string, or an error if rendering fails.
|
||||
func (i *Icon) ToSVG() (string, error) {
|
||||
if i.engineIcon == nil {
|
||||
return "", ErrInvalidIcon("icon data is nil")
|
||||
}
|
||||
|
||||
size := int(i.engineIcon.Size)
|
||||
svgRenderer := renderer.NewSVGRenderer(size)
|
||||
|
||||
if err := i.renderToRenderer(svgRenderer); err != nil {
|
||||
return "", NewErrRenderFailed("SVG", err)
|
||||
}
|
||||
|
||||
return svgRenderer.ToSVG(), nil
|
||||
}
|
||||
|
||||
// ToPNG renders the icon as PNG bytes.
|
||||
//
|
||||
// Returns the PNG data as a byte slice, or an error if rendering fails.
|
||||
func (i *Icon) ToPNG() ([]byte, error) {
|
||||
if i.engineIcon == nil {
|
||||
return nil, ErrInvalidIcon("icon data is nil")
|
||||
}
|
||||
|
||||
size := int(i.engineIcon.Size)
|
||||
pngRenderer := renderer.NewPNGRenderer(size)
|
||||
|
||||
if err := i.renderToRenderer(pngRenderer); err != nil {
|
||||
return nil, NewErrRenderFailed("PNG", err)
|
||||
}
|
||||
|
||||
png, err := pngRenderer.ToPNG()
|
||||
if err != nil {
|
||||
return nil, NewErrRenderFailed("PNG", err)
|
||||
}
|
||||
|
||||
return png, nil
|
||||
}
|
||||
|
||||
// renderToRenderer renders the icon to any renderer that implements the Renderer interface.
|
||||
func (i *Icon) renderToRenderer(r renderer.Renderer) error {
|
||||
// Set background if specified
|
||||
if i.engineIcon.Config.BackColor != nil {
|
||||
color := i.engineIcon.Config.BackColor
|
||||
colorStr := color.String()
|
||||
opacity := float64(color.A) / 255.0
|
||||
r.SetBackground(colorStr, opacity)
|
||||
}
|
||||
|
||||
// Render each shape group
|
||||
for _, shapeGroup := range i.engineIcon.Shapes {
|
||||
colorStr := shapeGroup.Color.String()
|
||||
|
||||
r.BeginShape(colorStr)
|
||||
|
||||
for _, shape := range shapeGroup.Shapes {
|
||||
if err := i.renderShape(r, shape); err != nil {
|
||||
return fmt.Errorf("shape rendering failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
r.EndShape()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderShape renders a single shape to the renderer.
|
||||
func (i *Icon) renderShape(r renderer.Renderer, shape engine.Shape) error {
|
||||
switch shape.Type {
|
||||
case "polygon":
|
||||
if len(shape.Points) < 3 {
|
||||
return fmt.Errorf("polygon must have at least 3 points, got %d", len(shape.Points))
|
||||
}
|
||||
r.AddPolygon(shape.Points)
|
||||
|
||||
case "circle":
|
||||
topLeft := engine.Point{X: shape.CircleX, Y: shape.CircleY}
|
||||
r.AddCircle(topLeft, shape.CircleSize, shape.Invert)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported shape type: %s", shape.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
179
jdenticon/integration_test.go
Normal file
179
jdenticon/integration_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFullWorkflow tests the complete workflow from input to final output
|
||||
func TestFullWorkflow(t *testing.T) {
|
||||
// Test with different input types
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
size int
|
||||
}{
|
||||
{"email", "user@example.com", 64},
|
||||
{"username", "johndoe", 128},
|
||||
{"uuid_like", "550e8400-e29b-41d4-a716-446655440000", 32},
|
||||
{"special_chars", "test@#$%^&*()", 96},
|
||||
{"unicode", "тест", 64},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Test Generate -> ToSVG workflow
|
||||
icon, err := Generate(context.Background(), tc.input, tc.size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed for %s: %v", tc.input, err)
|
||||
}
|
||||
|
||||
svg, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
t.Fatalf("ToSVG failed for %s: %v", tc.input, err)
|
||||
}
|
||||
|
||||
if len(svg) == 0 {
|
||||
t.Errorf("SVG output empty for %s", tc.input)
|
||||
}
|
||||
|
||||
// Test Generate -> ToPNG workflow
|
||||
png, err := icon.ToPNG()
|
||||
if err != nil {
|
||||
t.Fatalf("ToPNG failed for %s: %v", tc.input, err)
|
||||
}
|
||||
|
||||
if len(png) == 0 {
|
||||
t.Errorf("PNG output empty for %s", tc.input)
|
||||
}
|
||||
|
||||
// Verify PNG header
|
||||
if len(png) >= 8 {
|
||||
pngSignature := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
||||
for i, expected := range pngSignature {
|
||||
if png[i] != expected {
|
||||
t.Errorf("Invalid PNG signature for %s at byte %d", tc.input, i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratorCaching tests that the generator properly caches results
|
||||
func TestGeneratorCaching(t *testing.T) {
|
||||
generator, err := NewGeneratorWithCacheSize(10)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
input := "cache-test"
|
||||
size := 64
|
||||
|
||||
// Generate the same icon multiple times
|
||||
icon1, err1 := generator.Generate(context.Background(), input, size)
|
||||
icon2, err2 := generator.Generate(context.Background(), input, size)
|
||||
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("Generate failed: err1=%v, err2=%v", err1, err2)
|
||||
}
|
||||
|
||||
// Verify they produce the same output
|
||||
svg1, err1 := icon1.ToSVG()
|
||||
svg2, err2 := icon2.ToSVG()
|
||||
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("ToSVG failed: err1=%v, err2=%v", err1, err2)
|
||||
}
|
||||
|
||||
if svg1 != svg2 {
|
||||
t.Error("Cached results differ from fresh generation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustomConfiguration tests generation with custom configuration
|
||||
func TestCustomConfiguration(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.ColorSaturation = 0.8
|
||||
config.Padding = 0.15
|
||||
config.BackgroundColor = "#ffffff"
|
||||
|
||||
generator, err := NewGeneratorWithConfig(config, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create generator with config: %v", err)
|
||||
}
|
||||
|
||||
icon, err := generator.Generate(context.Background(), "config-test", 64)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate with config failed: %v", err)
|
||||
}
|
||||
|
||||
svg, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
t.Fatalf("ToSVG with config failed: %v", err)
|
||||
}
|
||||
|
||||
if len(svg) == 0 {
|
||||
t.Error("SVG output empty with custom config")
|
||||
}
|
||||
|
||||
// Test convenience function with config
|
||||
svg2, err := ToSVGWithConfig(context.Background(), "config-test", 64, config)
|
||||
if err != nil {
|
||||
t.Fatalf("ToSVGWithConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if len(svg2) == 0 {
|
||||
t.Error("ToSVGWithConfig output empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorHandling tests various error conditions
|
||||
func TestErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
testFunc func() error
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "invalid_cache_size",
|
||||
testFunc: func() error {
|
||||
_, err := NewGeneratorWithCacheSize(-1)
|
||||
return err
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid_config_cache_size",
|
||||
testFunc: func() error {
|
||||
config := DefaultConfig()
|
||||
_, err := NewGeneratorWithConfig(config, -1)
|
||||
return err
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid_config_values",
|
||||
testFunc: func() error {
|
||||
config := DefaultConfig()
|
||||
config.ColorSaturation = 2.0 // Invalid: > 1.0
|
||||
_, err := NewGeneratorWithConfig(config, 10)
|
||||
return err
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.testFunc()
|
||||
if tt.wantErr && err == nil {
|
||||
t.Errorf("Expected error for %s, but got none", tt.name)
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Errorf("Unexpected error for %s: %v", tt.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
BIN
jdenticon/jdenticon
Executable file
BIN
jdenticon/jdenticon
Executable file
Binary file not shown.
BIN
jdenticon/main
BIN
jdenticon/main
Binary file not shown.
@@ -1,8 +1,10 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -17,9 +19,9 @@ func TestJavaScriptReferenceCompatibility(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
t.Run(tc.input+"_"+strconv.Itoa(tc.size), func(t *testing.T) {
|
||||
// Generate Go SVG with context
|
||||
goSvg, err := ToSVG(context.Background(), tc.input, tc.size)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate Go SVG: %v", err)
|
||||
}
|
||||
@@ -47,12 +49,13 @@ func TestJavaScriptReferenceCompatibility(t *testing.T) {
|
||||
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.MkdirAll(filepath.Dir(goPath), 0755)
|
||||
os.WriteFile(goPath, []byte(goSvg), 0644)
|
||||
t.Logf("Go output saved to: %s", goPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
173
jdenticon/resource_protection_test.go
Normal file
173
jdenticon/resource_protection_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestComplexityLimitProtection(t *testing.T) {
|
||||
// Test that complexity limits prevent resource exhaustion
|
||||
config, err := Configure(WithMaxComplexity(1)) // Very low limit
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
_, err = ToSVGWithConfig(ctx, "test-complexity", 64, config)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Expected complexity limit to be exceeded, but got no error")
|
||||
}
|
||||
|
||||
// Check that we get the right error type (may be wrapped in ErrGenerationFailed)
|
||||
var complexityErr *ErrComplexityLimitExceeded
|
||||
if !errors.As(err, &complexityErr) {
|
||||
// Check if it's an engine complexity error that got translated
|
||||
if !strings.Contains(err.Error(), "complexity limit exceeded") {
|
||||
t.Errorf("Expected complexity limit error, got: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplexityLimitDisabled(t *testing.T) {
|
||||
// Test that complexity limits can be disabled
|
||||
config, err := Configure(WithMaxComplexity(-1)) // Disabled
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
_, err = ToSVGWithConfig(ctx, "test-disabled", 64, config)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error with disabled complexity limit, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextTimeoutProtection(t *testing.T) {
|
||||
// Create a context that will timeout very quickly
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||
defer cancel()
|
||||
|
||||
// Wait a bit to ensure the context expires
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
_, err := Generate(ctx, "test-timeout", 64)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Expected context timeout error, but got no error")
|
||||
}
|
||||
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("Expected context.DeadlineExceeded, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellationProtection(t *testing.T) {
|
||||
// Create a context that we'll cancel
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
_, err := Generate(ctx, "test-cancellation", 64)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Expected context cancellation error, but got no error")
|
||||
}
|
||||
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("Expected context.Canceled, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalOperationWithLimits(t *testing.T) {
|
||||
// Test that normal operation works with reasonable limits
|
||||
config, err := Configure(WithMaxComplexity(200)) // Reasonable limit
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
svg, err := ToSVGWithConfig(ctx, "test-normal", 64, config)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected normal operation to work, got error: %v", err)
|
||||
}
|
||||
|
||||
if len(svg) == 0 {
|
||||
t.Error("Expected non-empty SVG output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultComplexityLimit(t *testing.T) {
|
||||
// Test that default complexity limit allows normal operation
|
||||
config := DefaultConfig()
|
||||
|
||||
ctx := context.Background()
|
||||
svg, err := ToSVGWithConfig(ctx, "test-default", 64, config)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected default limits to allow normal operation, got error: %v", err)
|
||||
}
|
||||
|
||||
if len(svg) == 0 {
|
||||
t.Error("Expected non-empty SVG output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplexityCalculationConsistency(t *testing.T) {
|
||||
// Test that complexity calculation is deterministic
|
||||
config, err := Configure(WithMaxComplexity(50))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
input := "consistency-test"
|
||||
|
||||
// Try the same input multiple times - should get consistent results
|
||||
for i := 0; i < 5; i++ {
|
||||
_, err := ToSVGWithConfig(ctx, input, 64, config)
|
||||
// The error should be consistent (either always fail or always succeed)
|
||||
if i == 0 {
|
||||
// Store first result for comparison
|
||||
if err != nil {
|
||||
// If first attempt failed, all should fail
|
||||
continue
|
||||
}
|
||||
}
|
||||
// All subsequent attempts should have the same result
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkComplexityCalculation(b *testing.B) {
|
||||
// Benchmark the overhead of complexity calculation
|
||||
config, err := Configure(WithMaxComplexity(100))
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create config: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = ToSVGWithConfig(ctx, "benchmark-test", 64, config)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkWithoutComplexityLimit(b *testing.B) {
|
||||
// Benchmark without complexity limits for comparison
|
||||
config, err := Configure(WithMaxComplexity(-1)) // Disabled
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create config: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = ToSVGWithConfig(ctx, "benchmark-test", 64, config)
|
||||
}
|
||||
}
|
||||
468
jdenticon/security_test.go
Normal file
468
jdenticon/security_test.go
Normal file
@@ -0,0 +1,468 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/constants"
|
||||
)
|
||||
|
||||
// TestDoSProtection_InputLength tests protection against large input strings.
|
||||
func TestDoSProtection_InputLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inputLength int
|
||||
config Config
|
||||
expectError bool
|
||||
errorType string
|
||||
}{
|
||||
{
|
||||
name: "normal input with default config",
|
||||
inputLength: 100,
|
||||
config: DefaultConfig(),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "maximum allowed input with default config",
|
||||
inputLength: constants.DefaultMaxInputLength,
|
||||
config: DefaultConfig(),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "oversized input with default config",
|
||||
inputLength: constants.DefaultMaxInputLength + 1,
|
||||
config: DefaultConfig(),
|
||||
expectError: true,
|
||||
errorType: "*jdenticon.ErrValueTooLarge",
|
||||
},
|
||||
{
|
||||
name: "oversized input with custom smaller limit",
|
||||
inputLength: 1000,
|
||||
config: func() Config {
|
||||
c := DefaultConfig()
|
||||
c.MaxInputLength = 500
|
||||
return c
|
||||
}(),
|
||||
expectError: true,
|
||||
errorType: "*jdenticon.ErrValueTooLarge",
|
||||
},
|
||||
{
|
||||
name: "oversized input with disabled limit",
|
||||
inputLength: constants.DefaultMaxInputLength + 1000,
|
||||
config: func() Config {
|
||||
c := DefaultConfig()
|
||||
c.MaxInputLength = -1 // Disabled
|
||||
return c
|
||||
}(),
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create input string of specified length
|
||||
input := strings.Repeat("a", tt.inputLength)
|
||||
|
||||
// Test ToSVGWithConfig
|
||||
_, err := ToSVGWithConfig(context.Background(), input, 100, tt.config)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("ToSVGWithConfig: expected error but got none")
|
||||
return
|
||||
}
|
||||
|
||||
// Check error type if specified
|
||||
if tt.errorType != "" {
|
||||
var valueErr *ErrValueTooLarge
|
||||
if !errors.As(err, &valueErr) {
|
||||
t.Errorf("ToSVGWithConfig: expected error type %s, got %T", tt.errorType, err)
|
||||
} else if valueErr.ParameterName != "InputLength" {
|
||||
t.Errorf("ToSVGWithConfig: expected InputLength error, got %s", valueErr.ParameterName)
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("ToSVGWithConfig: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Test ToPNGWithConfig
|
||||
_, err = ToPNGWithConfig(context.Background(), input, 100, tt.config)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("ToPNGWithConfig: expected error but got none")
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("ToPNGWithConfig: unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoSProtection_IconSize tests protection against large icon sizes.
|
||||
func TestDoSProtection_IconSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
size int
|
||||
config Config
|
||||
expectError bool
|
||||
errorType string
|
||||
}{
|
||||
{
|
||||
name: "normal size with default config",
|
||||
size: 256,
|
||||
config: DefaultConfig(),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "maximum allowed size with default config",
|
||||
size: constants.DefaultMaxIconSize,
|
||||
config: DefaultConfig(),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "oversized icon with default config",
|
||||
size: constants.DefaultMaxIconSize + 1,
|
||||
config: DefaultConfig(),
|
||||
expectError: true,
|
||||
errorType: "*jdenticon.ErrValueTooLarge",
|
||||
},
|
||||
{
|
||||
name: "oversized icon with custom smaller limit",
|
||||
size: 2000,
|
||||
config: func() Config {
|
||||
c := DefaultConfig()
|
||||
c.MaxIconSize = 1000
|
||||
return c
|
||||
}(),
|
||||
expectError: true,
|
||||
errorType: "*jdenticon.ErrValueTooLarge",
|
||||
},
|
||||
{
|
||||
name: "oversized icon with disabled limit",
|
||||
size: constants.DefaultMaxIconSize + 1000,
|
||||
config: func() Config {
|
||||
c := DefaultConfig()
|
||||
c.MaxIconSize = -1 // Disabled
|
||||
return c
|
||||
}(),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "zero size",
|
||||
size: 0,
|
||||
config: DefaultConfig(),
|
||||
expectError: true,
|
||||
errorType: "jdenticon.ErrInvalidSize",
|
||||
},
|
||||
{
|
||||
name: "negative size",
|
||||
size: -100,
|
||||
config: DefaultConfig(),
|
||||
expectError: true,
|
||||
errorType: "jdenticon.ErrInvalidSize",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := "test"
|
||||
|
||||
// Test ToSVGWithConfig
|
||||
_, err := ToSVGWithConfig(context.Background(), input, tt.size, tt.config)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("ToSVGWithConfig: expected error but got none")
|
||||
return
|
||||
}
|
||||
|
||||
// Check error type if specified
|
||||
if tt.errorType != "" {
|
||||
switch tt.errorType {
|
||||
case "*jdenticon.ErrValueTooLarge":
|
||||
var valueErr *ErrValueTooLarge
|
||||
if !errors.As(err, &valueErr) {
|
||||
t.Errorf("ToSVGWithConfig: expected error type %s, got %T", tt.errorType, err)
|
||||
} else if valueErr.ParameterName != "IconSize" {
|
||||
t.Errorf("ToSVGWithConfig: expected IconSize error, got %s", valueErr.ParameterName)
|
||||
}
|
||||
case "jdenticon.ErrInvalidSize":
|
||||
var sizeErr ErrInvalidSize
|
||||
if !errors.As(err, &sizeErr) {
|
||||
t.Errorf("ToSVGWithConfig: expected error type %s, got %T", tt.errorType, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("ToSVGWithConfig: unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoSProtection_PNGEffectiveSize tests protection against PNG supersampling creating oversized effective images.
|
||||
func TestDoSProtection_PNGEffectiveSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
size int
|
||||
supersampling int
|
||||
config Config
|
||||
expectError bool
|
||||
errorType string
|
||||
}{
|
||||
{
|
||||
name: "normal PNG with default supersampling",
|
||||
size: 512,
|
||||
supersampling: 8, // 512 * 8 = 4096 (exactly at limit)
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "oversized effective PNG size",
|
||||
size: 1024,
|
||||
supersampling: 8, // 1024 * 8 = 8192 (exceeds 4096 limit)
|
||||
expectError: true,
|
||||
errorType: "*jdenticon.ErrEffectiveSizeTooLarge",
|
||||
},
|
||||
{
|
||||
name: "large PNG with low supersampling (within limit)",
|
||||
size: 2048,
|
||||
supersampling: 2, // 2048 * 2 = 4096 (exactly at limit)
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "maximum PNG with 1x supersampling",
|
||||
size: 4096,
|
||||
supersampling: 1, // 4096 * 1 = 4096 (exactly at limit)
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "PNG with disabled size limit",
|
||||
size: 2000,
|
||||
supersampling: 10, // Would exceed default limit but should be allowed
|
||||
config: func() Config {
|
||||
c := DefaultConfig()
|
||||
c.MaxIconSize = -1 // Disabled
|
||||
return c
|
||||
}(),
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := tt.config
|
||||
if config.MaxIconSize == 0 && config.MaxInputLength == 0 {
|
||||
// Use default config if not specified
|
||||
config = DefaultConfig()
|
||||
}
|
||||
config.PNGSupersampling = tt.supersampling
|
||||
|
||||
input := "test"
|
||||
|
||||
_, err := ToPNGWithConfig(context.Background(), input, tt.size, config)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("ToPNGWithConfig: expected error but got none")
|
||||
return
|
||||
}
|
||||
|
||||
// Check error type if specified
|
||||
if tt.errorType == "*jdenticon.ErrEffectiveSizeTooLarge" {
|
||||
var effectiveErr *ErrEffectiveSizeTooLarge
|
||||
if !errors.As(err, &effectiveErr) {
|
||||
t.Errorf("ToPNGWithConfig: expected error type %s, got %T", tt.errorType, err)
|
||||
} else {
|
||||
expectedEffective := tt.size * tt.supersampling
|
||||
if effectiveErr.Actual != expectedEffective {
|
||||
t.Errorf("ToPNGWithConfig: expected effective size %d, got %d",
|
||||
expectedEffective, effectiveErr.Actual)
|
||||
}
|
||||
if effectiveErr.Size != tt.size {
|
||||
t.Errorf("ToPNGWithConfig: expected size %d, got %d",
|
||||
tt.size, effectiveErr.Size)
|
||||
}
|
||||
if effectiveErr.Supersampling != tt.supersampling {
|
||||
t.Errorf("ToPNGWithConfig: expected supersampling %d, got %d",
|
||||
tt.supersampling, effectiveErr.Supersampling)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("ToPNGWithConfig: unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoSProtection_DynamicSupersampling tests the dynamic supersampling feature in ToPNG.
|
||||
func TestDoSProtection_DynamicSupersampling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
size int
|
||||
expectError bool
|
||||
expectedMaxSS int // Expected maximum supersampling that should be used
|
||||
}{
|
||||
{
|
||||
name: "small size uses full supersampling",
|
||||
size: 256,
|
||||
expectError: false,
|
||||
expectedMaxSS: 8, // 256 * 8 = 2048 < 4096, so full supersampling
|
||||
},
|
||||
{
|
||||
name: "medium size uses reduced supersampling",
|
||||
size: 1024,
|
||||
expectError: false,
|
||||
expectedMaxSS: 4, // 1024 * 4 = 4096, reduced from default 8
|
||||
},
|
||||
{
|
||||
name: "large size uses minimal supersampling",
|
||||
size: 4096,
|
||||
expectError: false,
|
||||
expectedMaxSS: 1, // 4096 * 1 = 4096, minimal supersampling
|
||||
},
|
||||
{
|
||||
name: "oversized even with minimal supersampling",
|
||||
size: 4097,
|
||||
expectError: true, // Even 4097 * 1 = 4097 > 4096
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := "test"
|
||||
|
||||
data, err := ToPNG(context.Background(), input, tt.size)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("ToPNG: expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("ToPNG: unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
t.Errorf("ToPNG: expected PNG data but got empty result")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoSProtection_EmptyInput tests protection against empty input strings.
|
||||
func TestDoSProtection_EmptyInput(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
|
||||
_, err := ToSVGWithConfig(context.Background(), "", 100, config)
|
||||
if err == nil {
|
||||
t.Errorf("ToSVGWithConfig: expected error for empty input but got none")
|
||||
return
|
||||
}
|
||||
|
||||
var inputErr *ErrInvalidInput
|
||||
if !errors.As(err, &inputErr) {
|
||||
t.Errorf("ToSVGWithConfig: expected ErrInvalidInput, got %T", err)
|
||||
} else if inputErr.Field != "input" {
|
||||
t.Errorf("ToSVGWithConfig: expected input field error, got %s", inputErr.Field)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoSProtection_ConfigurableLimits tests that the configurable limits work as expected.
|
||||
func TestDoSProtection_ConfigurableLimits(t *testing.T) {
|
||||
// Test custom limits
|
||||
customConfig := DefaultConfig()
|
||||
customConfig.MaxIconSize = 1000 // Much smaller than default
|
||||
customConfig.MaxInputLength = 10000 // Much smaller than default
|
||||
customConfig.PNGSupersampling = 4
|
||||
|
||||
// This should work with custom config
|
||||
_, err := ToSVGWithConfig(context.Background(), "test", 500, customConfig)
|
||||
if err != nil {
|
||||
t.Errorf("ToSVGWithConfig with custom config: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// This should fail with custom config (size too large)
|
||||
_, err = ToSVGWithConfig(context.Background(), "test", 1001, customConfig)
|
||||
if err == nil {
|
||||
t.Errorf("ToSVGWithConfig with custom config: expected error for oversized icon but got none")
|
||||
}
|
||||
|
||||
// Test disabled limits
|
||||
noLimitsConfig := DefaultConfig()
|
||||
noLimitsConfig.MaxIconSize = -1 // Disabled
|
||||
noLimitsConfig.MaxInputLength = -1 // Disabled
|
||||
noLimitsConfig.PNGSupersampling = 1
|
||||
|
||||
// This should work even with very large size (disabled limits)
|
||||
largeInput := strings.Repeat("x", constants.DefaultMaxInputLength+1000)
|
||||
_, err = ToSVGWithConfig(context.Background(), largeInput, constants.DefaultMaxIconSize+1000, noLimitsConfig)
|
||||
if err != nil {
|
||||
t.Errorf("ToSVGWithConfig with disabled limits: unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoSProtection_GeneratorConsistency tests that Generator API respects configured limits.
|
||||
func TestDoSProtection_GeneratorConsistency(t *testing.T) {
|
||||
// Test generator with custom limits
|
||||
customConfig := DefaultConfig()
|
||||
customConfig.MaxIconSize = 1000
|
||||
customConfig.MaxInputLength = 100
|
||||
|
||||
generator, err := NewGeneratorWithConfig(customConfig, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("NewGeneratorWithConfig: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// This should work within limits
|
||||
_, err = generator.Generate(context.Background(), "test", 500)
|
||||
if err != nil {
|
||||
t.Errorf("Generator.Generate within limits: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// This should fail due to size limit
|
||||
_, err = generator.Generate(context.Background(), "test", 1001)
|
||||
if err == nil {
|
||||
t.Errorf("Generator.Generate with oversized icon: expected error but got none")
|
||||
} else {
|
||||
var valueErr *ErrValueTooLarge
|
||||
if !errors.As(err, &valueErr) {
|
||||
t.Errorf("Generator.Generate: expected ErrValueTooLarge, got %T", err)
|
||||
} else if valueErr.ParameterName != "IconSize" {
|
||||
t.Errorf("Generator.Generate: expected IconSize error, got %s", valueErr.ParameterName)
|
||||
}
|
||||
}
|
||||
|
||||
// This should fail due to input length limit
|
||||
longInput := strings.Repeat("a", 101)
|
||||
_, err = generator.Generate(context.Background(), longInput, 100)
|
||||
if err == nil {
|
||||
t.Errorf("Generator.Generate with long input: expected error but got none")
|
||||
} else {
|
||||
var valueErr *ErrValueTooLarge
|
||||
if !errors.As(err, &valueErr) {
|
||||
t.Errorf("Generator.Generate: expected ErrValueTooLarge, got %T", err)
|
||||
} else if valueErr.ParameterName != "InputLength" {
|
||||
t.Errorf("Generator.Generate: expected InputLength error, got %s", valueErr.ParameterName)
|
||||
}
|
||||
}
|
||||
|
||||
// Test generator with default limits
|
||||
defaultGenerator, err := NewGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewGenerator: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should work with normal inputs
|
||||
_, err = defaultGenerator.Generate(context.Background(), "test", 256)
|
||||
if err != nil {
|
||||
t.Errorf("Default generator: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should fail with oversized input
|
||||
_, err = defaultGenerator.Generate(context.Background(), "test", constants.DefaultMaxIconSize+1)
|
||||
if err == nil {
|
||||
t.Errorf("Default generator with oversized icon: expected error but got none")
|
||||
}
|
||||
}
|
||||
85
jdenticon/validation.go
Normal file
85
jdenticon/validation.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package jdenticon
|
||||
|
||||
import (
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/engine"
|
||||
)
|
||||
|
||||
// validation.go contains helper functions for input validation and DoS protection.
|
||||
|
||||
// validateInputs performs common validation for input string and size parameters.
|
||||
// This provides centralized validation logic used across all public API functions.
|
||||
func validateInputs(input string, size int, config Config) error {
|
||||
// Validate input string length
|
||||
if maxLen := config.effectiveMaxInputLength(); maxLen != -1 && len(input) > maxLen {
|
||||
return NewErrValueTooLarge("InputLength", maxLen, len(input))
|
||||
}
|
||||
|
||||
// Validate that input is not empty
|
||||
if input == "" {
|
||||
return NewErrInvalidInput("input", input, "cannot be empty")
|
||||
}
|
||||
|
||||
// Validate base icon size (must be positive)
|
||||
if size <= 0 {
|
||||
return ErrInvalidSize(size)
|
||||
}
|
||||
|
||||
// Validate icon size against configured limit
|
||||
if maxSize := config.effectiveMaxIconSize(); maxSize != -1 && size > maxSize {
|
||||
return NewErrValueTooLarge("IconSize", maxSize, size)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateComplexity performs complexity validation for an input string.
|
||||
// This should be called after basic input validation and hash computation.
|
||||
func validateComplexity(hash string, config Config) error {
|
||||
if maxComplexity := config.effectiveMaxComplexity(); maxComplexity != -1 {
|
||||
// Create a temporary engine generator to calculate complexity
|
||||
// This is needed to access the CalculateComplexity method
|
||||
engineConfig := engine.DefaultGeneratorConfig()
|
||||
engineConfig.ColorConfig = engine.ColorConfig{
|
||||
IconPadding: config.Padding,
|
||||
ColorSaturation: config.ColorSaturation,
|
||||
GrayscaleSaturation: config.GrayscaleSaturation,
|
||||
ColorLightness: engine.LightnessRange{
|
||||
Min: config.ColorLightnessRange[0],
|
||||
Max: config.ColorLightnessRange[1],
|
||||
},
|
||||
GrayscaleLightness: engine.LightnessRange{
|
||||
Min: config.GrayscaleLightnessRange[0],
|
||||
Max: config.GrayscaleLightnessRange[1],
|
||||
},
|
||||
Hues: config.HueRestrictions,
|
||||
BackColor: nil, // Not needed for complexity calculation
|
||||
}
|
||||
|
||||
tempGenerator, err := engine.NewGeneratorWithConfig(engineConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
complexity, err := tempGenerator.CalculateComplexity(hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if complexity > maxComplexity {
|
||||
return NewErrComplexityLimitExceeded(maxComplexity, complexity, hash)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePNGSize performs additional validation for PNG functions that use supersampling.
|
||||
// This checks the effective size (size * supersampling) against configured limits.
|
||||
func validatePNGSize(size int, config Config) error {
|
||||
// Check effective size for PNG with supersampling
|
||||
effectiveSize := size * config.PNGSupersampling
|
||||
if maxSize := config.effectiveMaxIconSize(); maxSize != -1 && effectiveSize > maxSize {
|
||||
return NewErrEffectiveSizeTooLarge(maxSize, effectiveSize, size, config.PNGSupersampling)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user