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
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# API Keys (Required to enable respective provider)
|
||||
ANTHROPIC_API_KEY="your_anthropic_api_key_here" # Required: Format: sk-ant-api03-...
|
||||
PERPLEXITY_API_KEY="your_perplexity_api_key_here" # Optional: Format: pplx-...
|
||||
OPENAI_API_KEY="your_openai_api_key_here" # Optional, for OpenAI/OpenRouter models. Format: sk-proj-...
|
||||
GOOGLE_API_KEY="your_google_api_key_here" # Optional, for Google Gemini models.
|
||||
MISTRAL_API_KEY="your_mistral_key_here" # Optional, for Mistral AI models.
|
||||
XAI_API_KEY="YOUR_XAI_KEY_HERE" # Optional, for xAI AI models.
|
||||
AZURE_OPENAI_API_KEY="your_azure_key_here" # Optional, for Azure OpenAI models (requires endpoint in .taskmaster/config.json).
|
||||
OLLAMA_API_KEY="your_ollama_api_key_here" # Optional: For remote Ollama servers that require authentication.
|
||||
GITHUB_API_KEY="your_github_api_key_here" # Optional: For GitHub import/export features. Format: ghp_... or github_pat_...
|
||||
250
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,250 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.24"
|
||||
|
||||
jobs:
|
||||
# Core testing across multiple Go versions and platforms
|
||||
test:
|
||||
name: Test (Go ${{ matrix.go-version }}, ${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
go-version: ["1.24.x"]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go ${{ matrix.go-version }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ matrix.go-version }}-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Verify dependencies
|
||||
run: go mod verify
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v ./...
|
||||
|
||||
- name: Run tests with race detector
|
||||
run: go test -race -v ./...
|
||||
|
||||
# Code quality and linting
|
||||
lint:
|
||||
name: Code Quality
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ env.GO_VERSION }}-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v4
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout=5m
|
||||
|
||||
- name: Check formatting
|
||||
run: |
|
||||
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
|
||||
echo "The following files are not formatted properly:"
|
||||
gofmt -s -l .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install staticcheck
|
||||
run: go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
|
||||
- name: Run staticcheck
|
||||
run: staticcheck ./...
|
||||
|
||||
# Security scanning
|
||||
security:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run govulncheck
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
govulncheck ./...
|
||||
|
||||
- name: Run gosec security scanner
|
||||
run: |
|
||||
go install github.com/securego/gosec/v2/cmd/gosec@latest
|
||||
gosec ./...
|
||||
|
||||
# Test coverage
|
||||
coverage:
|
||||
name: Test Coverage
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ env.GO_VERSION }}-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./coverage.txt
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
fail_ci_if_error: false
|
||||
|
||||
# Benchmarks
|
||||
benchmark:
|
||||
name: Benchmarks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ env.GO_VERSION }}-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run benchmarks
|
||||
run: go test -bench=. -benchmem -count=3 ./... > benchmark_results.txt
|
||||
|
||||
- name: Upload benchmark results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: benchmark-results
|
||||
path: benchmark_results.txt
|
||||
retention-days: 30
|
||||
|
||||
# Build verification for CLI tool
|
||||
build:
|
||||
name: Build CLI
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ env.GO_VERSION }}-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Build CLI tool
|
||||
run: go build -v -o jdenticon-cli ./cmd/jdenticon
|
||||
|
||||
- name: Test CLI tool
|
||||
run: |
|
||||
./jdenticon-cli generate --help
|
||||
./jdenticon-cli generate "test@example.com" -s 64 -o test.svg
|
||||
test -f test.svg
|
||||
|
||||
- name: Upload CLI artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: jdenticon-cli-linux
|
||||
path: jdenticon-cli
|
||||
retention-days: 7
|
||||
84
.github/workflows/performance-regression.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
name: Benchmarks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '**/*.go'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '**/*.go'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.24"
|
||||
|
||||
jobs:
|
||||
benchmarks:
|
||||
name: Run Benchmarks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run benchmarks
|
||||
run: |
|
||||
go test -bench=. -benchmem -count=3 ./... | tee benchmark_results.txt
|
||||
|
||||
- name: Upload benchmark results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: benchmark-results
|
||||
path: benchmark_results.txt
|
||||
retention-days: 30
|
||||
|
||||
- name: Comment benchmark results on PR
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
let comment = '## 📊 Benchmark Results\n\n';
|
||||
|
||||
try {
|
||||
if (fs.existsSync('benchmark_results.txt')) {
|
||||
const benchmarks = fs.readFileSync('benchmark_results.txt', 'utf8');
|
||||
const lines = benchmarks.split('\n').filter(line =>
|
||||
line.includes('Benchmark') || line.includes('ns/op') || line.includes('ok')
|
||||
);
|
||||
|
||||
if (lines.length > 0) {
|
||||
comment += '```\n';
|
||||
comment += lines.slice(0, 20).join('\n');
|
||||
if (lines.length > 20) {
|
||||
comment += `\n... and ${lines.length - 20} more lines\n`;
|
||||
}
|
||||
comment += '\n```\n';
|
||||
} else {
|
||||
comment += 'No benchmark results found.\n';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
comment += `⚠️ Could not read benchmark results: ${error.message}\n`;
|
||||
}
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: comment
|
||||
});
|
||||
127
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.24"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# Run tests before releasing
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run tests
|
||||
run: go test -race ./...
|
||||
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
|
||||
# Build binaries for multiple platforms
|
||||
build:
|
||||
name: Build
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
suffix: ""
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
suffix: ""
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
suffix: ""
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
suffix: ""
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
suffix: ".exe"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
LDFLAGS="-s -w -X main.Version=$VERSION -X main.Commit=$COMMIT -X main.BuildDate=$DATE"
|
||||
|
||||
mkdir -p dist
|
||||
go build -ldflags "$LDFLAGS" -o "dist/jdenticon-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.suffix }}" ./cmd/jdenticon/
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: jdenticon-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: dist/
|
||||
|
||||
# Create GitHub release with all binaries
|
||||
release:
|
||||
name: Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare release assets
|
||||
run: |
|
||||
mkdir -p release
|
||||
find artifacts -type f -name "jdenticon-*" -exec cp {} release/ \;
|
||||
ls -la release/
|
||||
|
||||
# Create checksums
|
||||
cd release
|
||||
sha256sum * > checksums.txt
|
||||
cat checksums.txt
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
release/*
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref, '-alpha') || contains(github.ref, '-beta') || contains(github.ref, '-rc') }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
52
.gitignore
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
dev-debug.log
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# OS specific
|
||||
.DS_Store
|
||||
|
||||
# Generated examples and benchmarks
|
||||
example-generator/output/
|
||||
example-generator/benchmark-results.json
|
||||
|
||||
# Development artifacts
|
||||
.taskmaster/
|
||||
.cursor/
|
||||
.roo/
|
||||
.claude/
|
||||
*.prof
|
||||
*.bench
|
||||
*.test
|
||||
*.bak
|
||||
test-*.svg
|
||||
avatar_*.svg
|
||||
avatar_*.png
|
||||
user_*.png
|
||||
performance_report.json
|
||||
.performance_baselines.json
|
||||
go-output/
|
||||
coverage-old/
|
||||
|
||||
# Development tools output
|
||||
quick_test.go
|
||||
|
||||
reference-javascript-implementation/
|
||||
153
.golangci.yml
Normal file
@@ -0,0 +1,153 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
modules-download-mode: readonly
|
||||
|
||||
linters-settings:
|
||||
errcheck:
|
||||
check-type-assertions: false
|
||||
check-blank: false
|
||||
exclude-functions:
|
||||
- (io.Closer).Close
|
||||
- (*github.com/spf13/pflag.FlagSet).GetString
|
||||
- (*github.com/spf13/pflag.FlagSet).GetInt
|
||||
- (*github.com/spf13/pflag.FlagSet).GetBool
|
||||
- (github.com/spf13/viper).BindPFlag
|
||||
- (*github.com/spf13/cobra.Command).MarkFlagRequired
|
||||
- (*github.com/spf13/cobra.Command).RegisterFlagCompletionFunc
|
||||
|
||||
gocyclo:
|
||||
min-complexity: 30
|
||||
|
||||
funlen:
|
||||
lines: 100
|
||||
statements: 60
|
||||
|
||||
govet:
|
||||
enable-all: false
|
||||
enable:
|
||||
- assign
|
||||
- atomic
|
||||
- bools
|
||||
- buildtag
|
||||
- copylocks
|
||||
- httpresponse
|
||||
- loopclosure
|
||||
- lostcancel
|
||||
- nilfunc
|
||||
- printf
|
||||
- shadow
|
||||
- shift
|
||||
- unreachable
|
||||
- unusedresult
|
||||
|
||||
misspell:
|
||||
locale: US
|
||||
|
||||
staticcheck:
|
||||
checks: ["all", "-SA4003"]
|
||||
|
||||
lll:
|
||||
line-length: 140
|
||||
|
||||
goconst:
|
||||
min-len: 3
|
||||
min-occurrences: 5
|
||||
|
||||
revive:
|
||||
rules:
|
||||
- name: var-naming
|
||||
disabled: true
|
||||
- name: unused-parameter
|
||||
disabled: true
|
||||
- name: redefines-builtin-id
|
||||
disabled: true
|
||||
|
||||
gosec:
|
||||
excludes:
|
||||
- G115 # integer overflow conversion - we handle this safely
|
||||
- G306 # WriteFile permissions - public output files are fine
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- bodyclose
|
||||
- dogsled
|
||||
- errcheck
|
||||
- goconst
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- gofmt
|
||||
- goimports
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- misspell
|
||||
- nakedret
|
||||
- noctx
|
||||
- nolintlint
|
||||
- revive
|
||||
- staticcheck
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unparam
|
||||
- unused
|
||||
- whitespace
|
||||
|
||||
disable:
|
||||
- depguard
|
||||
- funlen
|
||||
- gochecknoinits
|
||||
- lll
|
||||
- prealloc
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gocyclo
|
||||
- errcheck
|
||||
- dupl
|
||||
- gosec
|
||||
- funlen
|
||||
- goconst
|
||||
|
||||
- path: cmd/
|
||||
linters:
|
||||
- gochecknoinits
|
||||
|
||||
- text: "weak cryptographic primitive"
|
||||
linters:
|
||||
- gosec
|
||||
|
||||
- path: example_
|
||||
linters:
|
||||
- lll
|
||||
|
||||
# Allow unlambda in cobra command setup
|
||||
- text: "unlambda"
|
||||
linters:
|
||||
- gocritic
|
||||
|
||||
# Allow if-else chains in complex validation logic
|
||||
- text: "ifElseChain"
|
||||
linters:
|
||||
- gocritic
|
||||
|
||||
# Allow elseif suggestions
|
||||
- text: "elseif"
|
||||
linters:
|
||||
- gocritic
|
||||
|
||||
# Exclude appendAssign in specific cases
|
||||
- text: "appendAssign"
|
||||
linters:
|
||||
- gocritic
|
||||
|
||||
# Allow assignOp suggestions
|
||||
- text: "assignOp"
|
||||
linters:
|
||||
- gocritic
|
||||
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
188
FIXUP.md
@@ -1,188 +0,0 @@
|
||||
# FIXUP Plan: Go Jdenticon JavaScript Reference Compatibility
|
||||
|
||||
## Problem Summary
|
||||
|
||||
The Go implementation of Jdenticon generates completely different SVG output compared to the JavaScript reference implementation, despite having identical hash generation. The test case `TestJavaScriptReferenceCompatibility` reveals fundamental differences in the generation algorithm.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
Based on test results comparing Go vs JavaScript output for identical inputs:
|
||||
|
||||
### ✅ What's Working
|
||||
- Hash generation (SHA1) is identical between implementations
|
||||
- `svgValue()` rounding behavior now matches JavaScript "round half up"
|
||||
- Basic SVG structure and syntax
|
||||
|
||||
### ❌ What's Broken
|
||||
1. **Shape Generation Logic**: Completely different shapes and paths generated
|
||||
2. **Coordinate Calculations**: Different coordinate values (e.g., JS: `35.9`, `39.8` vs Go: `37.2`, `41.1`)
|
||||
3. **Path Ordering**: SVG paths appear in different sequence
|
||||
4. **Circle Positioning**: Circles generated at different locations
|
||||
5. **Transform Application**: Rotation/positioning logic differs
|
||||
|
||||
### Evidence from Test Case
|
||||
**Input**: `"test-hash"` (size 64)
|
||||
- **JavaScript**: `<path fill="#e8e8e8" d="M19 6L32 6L32 19Z..."/>` (first path)
|
||||
- **Go**: `<path fill="#d175c5" d="M19 19L6 19L6 12.5Z..."/>` (first path)
|
||||
- Completely different shapes, colors, and coordinates
|
||||
|
||||
## Investigation Plan
|
||||
|
||||
### Phase 1: Algorithm Deep Dive (High Priority)
|
||||
1. **Study JavaScript IconGenerator**
|
||||
- Examine `jdenticon-js/src/renderer/iconGenerator.js`
|
||||
- Understand shape selection and positioning logic
|
||||
- Document the exact algorithm flow
|
||||
|
||||
2. **Study JavaScript Shape Generation**
|
||||
- Examine `jdenticon-js/src/renderer/shapes.js`
|
||||
- Understand how shapes are created and positioned
|
||||
- Document shape types and their generation rules
|
||||
|
||||
3. **Study JavaScript Layout System**
|
||||
- Examine how the 4x4 grid layout works
|
||||
- Understand cell positioning and sizing
|
||||
- Document the exact coordinate calculation logic
|
||||
|
||||
### Phase 2: Go Implementation Analysis (High Priority)
|
||||
1. **Audit Go Generator Logic**
|
||||
- Compare `internal/engine/generator.go` with JavaScript equivalent
|
||||
- Identify algorithmic differences in shape selection
|
||||
- Check if we're using the same hash parsing logic
|
||||
|
||||
2. **Audit Go Shape Generation**
|
||||
- Compare `internal/engine/shapes.go` with JavaScript
|
||||
- Verify shape types and their implementation
|
||||
- Check transform application
|
||||
|
||||
3. **Audit Go Layout System**
|
||||
- Compare `internal/engine/layout.go` with JavaScript
|
||||
- Verify grid calculations and cell positioning
|
||||
- Check coordinate generation logic
|
||||
|
||||
### Phase 3: Systematic Fixes (High Priority)
|
||||
|
||||
#### 3.1 Fix Shape Selection Algorithm
|
||||
**Files to modify**: `internal/engine/generator.go`
|
||||
- Ensure hash bit extraction matches JavaScript exactly
|
||||
- Verify shape type selection logic
|
||||
- Fix shape positioning and rotation logic
|
||||
|
||||
#### 3.2 Fix Layout System
|
||||
**Files to modify**: `internal/engine/layout.go`
|
||||
- Match JavaScript grid calculations exactly
|
||||
- Fix cell size and positioning calculations
|
||||
- Ensure transforms are applied correctly
|
||||
|
||||
#### 3.3 Fix Shape Implementation
|
||||
**Files to modify**: `internal/engine/shapes.go`
|
||||
- Verify each shape type matches JavaScript geometry
|
||||
- Fix coordinate calculations for polygons and circles
|
||||
- Ensure proper transform application
|
||||
|
||||
#### 3.4 Fix Generation Order
|
||||
**Files to modify**: `internal/engine/generator.go`, `internal/renderer/svg.go`
|
||||
- Match the exact order of shape generation
|
||||
- Ensure SVG paths are written in same sequence as JavaScript
|
||||
- Fix color assignment order
|
||||
|
||||
### Phase 4: Validation (Medium Priority)
|
||||
|
||||
#### 4.1 Expand Test Coverage
|
||||
**Files to modify**: `jdenticon/reference_test.go`
|
||||
- Add more test inputs with known JavaScript outputs
|
||||
- Test different icon sizes (64, 128, 256)
|
||||
- Test edge cases and different hash patterns
|
||||
|
||||
#### 4.2 Coordinate-by-Coordinate Validation
|
||||
- Create debug output showing step-by-step coordinate generation
|
||||
- Compare each transform operation with JavaScript
|
||||
- Validate grid positioning calculations
|
||||
|
||||
#### 4.3 Shape-by-Shape Validation
|
||||
- Test individual shape generation in isolation
|
||||
- Verify each shape type produces identical output
|
||||
- Test rotation and transform application
|
||||
|
||||
### Phase 5: Performance & Polish (Low Priority)
|
||||
|
||||
#### 5.1 Optimize Performance
|
||||
- Ensure fixes don't degrade performance
|
||||
- Profile generation time vs JavaScript
|
||||
- Optimize hot paths if needed
|
||||
|
||||
#### 5.2 Documentation
|
||||
- Document the JavaScript compatibility
|
||||
- Update comments explaining the algorithm
|
||||
- Add examples showing identical output
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Step 1: JavaScript Reference Study (Day 1)
|
||||
1. Read and document JavaScript `iconGenerator.js` algorithm
|
||||
2. Create flowchart of JavaScript generation process
|
||||
3. Document exact hash bit usage and shape selection
|
||||
|
||||
### Step 2: Go Algorithm Audit (Day 1-2)
|
||||
1. Compare Go implementation line-by-line with JavaScript
|
||||
2. Identify all algorithmic differences
|
||||
3. Create detailed list of required changes
|
||||
|
||||
### Step 3: Systematic Implementation (Day 2-3)
|
||||
1. Fix most critical differences first (shape selection)
|
||||
2. Fix layout and coordinate calculation
|
||||
3. Fix shape implementation details
|
||||
4. Fix generation order and path sequencing
|
||||
|
||||
### Step 4: Validation Loop (Day 3-4)
|
||||
1. Run reference compatibility tests after each fix
|
||||
2. Add debug output to trace differences
|
||||
3. Iterate until tests pass
|
||||
4. Expand test coverage
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Primary Goals
|
||||
- [ ] `TestJavaScriptReferenceCompatibility` passes for all test cases
|
||||
- [ ] Byte-for-byte identical SVG output for same input hash/size
|
||||
- [ ] No regression in existing functionality
|
||||
|
||||
### Secondary Goals
|
||||
- [ ] Performance comparable to current implementation
|
||||
- [ ] Code remains maintainable and well-documented
|
||||
- [ ] All existing tests continue to pass
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### High Risk
|
||||
- **Scope Creep**: The fixes might require rewriting major portions of the generation engine
|
||||
- **Breaking Changes**: Existing users might rely on current (incorrect) output
|
||||
|
||||
### Medium Risk
|
||||
- **Performance Impact**: Algorithm changes might affect generation speed
|
||||
- **Test Maintenance**: Need to maintain both Go and JavaScript reference outputs
|
||||
|
||||
### Low Risk
|
||||
- **API Changes**: Public API should remain unchanged
|
||||
- **Backward Compatibility**: Hash generation stays the same
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If fixes prove too complex or risky:
|
||||
1. Keep current implementation as `v1-legacy`
|
||||
2. Implement JavaScript-compatible version as `v2`
|
||||
3. Provide migration guide for users
|
||||
4. Allow users to choose implementation version
|
||||
|
||||
## Notes
|
||||
|
||||
- The `svgValue()` rounding fix was correct but insufficient
|
||||
- This is not a minor coordinate issue - it's a fundamental algorithmic difference
|
||||
- Success requires matching JavaScript behavior exactly, not just approximating it
|
||||
- Consider this a "port" rather than a "reimplementation"
|
||||
|
||||
---
|
||||
|
||||
**Created**: Based on failing `TestJavaScriptReferenceCompatibility` test results
|
||||
**Priority**: High - Core functionality incorrectly implemented
|
||||
**Estimated Effort**: 3-4 days of focused development
|
||||
43
LICENSE
Normal file
@@ -0,0 +1,43 @@
|
||||
Elastic License
|
||||
Acceptance
|
||||
By using the software, you agree to all of the terms and conditions below.
|
||||
|
||||
Copyright License
|
||||
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject to the limitations and conditions below.
|
||||
|
||||
Limitations
|
||||
You may not provide the software to third parties as a hosted or managed service, where the service provides users with access to any substantial set of the features or functionality of the software.
|
||||
|
||||
You may not move, change, disable, or circumvent the license key functionality in the software, and you may not remove or obscure any functionality in the software that is protected by the license key.
|
||||
|
||||
You may not alter, remove, or obscure any licensing, copyright, or other notices of the licensor in the software. Any use of the licensor’s trademarks is subject to applicable law.
|
||||
|
||||
Patents
|
||||
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case subject to the limitations and conditions in this license. This license does not cover any patent claims that you cause to be infringed by modifications or additions to the software. If you or your company make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
||||
|
||||
Notices
|
||||
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms.
|
||||
|
||||
If you modify the software, you must include in any modified copies of the software prominent notices stating that you have modified the software.
|
||||
|
||||
No Other Rights
|
||||
These terms do not imply any licenses other than those expressly granted in these terms.
|
||||
|
||||
Termination
|
||||
If you use the software in violation of these terms, such use is not licensed, and your licenses will automatically terminate. If the licensor provides you with a notice of your violation, and you cease all violation of this license no later than 30 days after you receive that notice, your licenses will be reinstated retroactively. However, if you violate these terms after such reinstatement, any additional violation of these terms will cause your licenses to terminate automatically and permanently.
|
||||
|
||||
No Liability
|
||||
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.
|
||||
|
||||
Definitions
|
||||
The licensor is the entity offering these terms, and the software is the software the licensor makes available under these terms, including any portion of it.
|
||||
|
||||
you refers to the individual or entity agreeing to these terms.
|
||||
|
||||
your company is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. control means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
your licenses are all the licenses granted to you for the software under these terms.
|
||||
|
||||
use means anything you do with the software requiring one of your licenses.
|
||||
|
||||
trademark means trademarks, service marks, and similar rights.
|
||||
241
Makefile
Normal file
@@ -0,0 +1,241 @@
|
||||
# Go Jdenticon Makefile
|
||||
# Provides convenient commands for building, testing, and profiling
|
||||
|
||||
.PHONY: help build test bench clean memory-test memory-profile load-test analyze-memory
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Go Jdenticon - Available Commands:"
|
||||
@echo ""
|
||||
@echo "Building:"
|
||||
@echo " make build Build all binaries (CLI + tools)"
|
||||
@echo " make jdenticon-cli Build CLI only"
|
||||
@echo " make version-info Show version information"
|
||||
@echo " make clean Clean build artifacts"
|
||||
@echo ""
|
||||
@echo "Testing:"
|
||||
@echo " make test Run all tests"
|
||||
@echo " make test-race Run tests with race detection"
|
||||
@echo " make test-coverage Run tests with coverage report"
|
||||
@echo " make bench Run all benchmarks"
|
||||
@echo ""
|
||||
@echo "Memory Leak Validation (Task 26):"
|
||||
@echo " make memory-test Run memory leak validation tests"
|
||||
@echo " make memory-bench Run memory leak benchmarks with profiling"
|
||||
@echo " make load-test Run sustained load test (5 minutes)"
|
||||
@echo " make load-test-long Run extended load test (30 minutes)"
|
||||
@echo " make memory-profile Interactive memory profiling session"
|
||||
@echo " make analyze-memory Analyze latest memory profiles"
|
||||
@echo ""
|
||||
@echo "Advanced Profiling:"
|
||||
@echo " make profile-heap Generate heap profile"
|
||||
@echo " make profile-cpu Generate CPU profile"
|
||||
@echo " make profile-web Start web UI for profile analysis"
|
||||
@echo " make profile-continuous Run continuous profiling for 1 hour"
|
||||
@echo ""
|
||||
@echo "Examples:"
|
||||
@echo " make memory-test # Quick memory leak validation"
|
||||
@echo " make load-test # 5-minute sustained load test"
|
||||
@echo " make profile-web # Web UI for profile analysis"
|
||||
|
||||
# Go parameters
|
||||
GOCMD=go
|
||||
GOBUILD=$(GOCMD) build
|
||||
GOCLEAN=$(GOCMD) clean
|
||||
GOTEST=$(GOCMD) test
|
||||
GOGET=$(GOCMD) get
|
||||
GOMOD=$(GOCMD) mod
|
||||
|
||||
# Version information
|
||||
VERSION ?= $(shell git describe --tags --exact-match 2>/dev/null || git describe --tags 2>/dev/null || echo "dev")
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Build flags for version injection
|
||||
LDFLAGS=-ldflags "-X github.com/ungluedlabs/go-jdenticon/cmd/jdenticon.Version=$(VERSION) \
|
||||
-X github.com/ungluedlabs/go-jdenticon/cmd/jdenticon.Commit=$(COMMIT) \
|
||||
-X github.com/ungluedlabs/go-jdenticon/cmd/jdenticon.BuildDate=$(BUILD_DATE)"
|
||||
|
||||
# Build targets
|
||||
CLI_BINARY=jdenticon-cli
|
||||
LOAD_TEST_BINARY=cmd/memory-load-test/memory-load-test
|
||||
|
||||
# Build all binaries
|
||||
build: $(CLI_BINARY) $(LOAD_TEST_BINARY)
|
||||
@echo "Building Go Jdenticon..."
|
||||
$(GOBUILD) -v ./...
|
||||
|
||||
$(CLI_BINARY):
|
||||
@echo "Building jdenticon CLI with version $(VERSION)..."
|
||||
$(GOBUILD) $(LDFLAGS) -o $(CLI_BINARY) ./cmd/jdenticon
|
||||
|
||||
$(LOAD_TEST_BINARY):
|
||||
@echo "Building memory load test binary..."
|
||||
$(GOBUILD) -o $(LOAD_TEST_BINARY) ./cmd/memory-load-test
|
||||
|
||||
# Show version information that will be injected
|
||||
version-info:
|
||||
@echo "Version Information:"
|
||||
@echo " Version: $(VERSION)"
|
||||
@echo " Commit: $(COMMIT)"
|
||||
@echo " Build Date: $(BUILD_DATE)"
|
||||
|
||||
# Build specifically with version information (alias for jdenticon-cli)
|
||||
jdenticon: $(CLI_BINARY)
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
$(GOCLEAN)
|
||||
rm -f $(CLI_BINARY)
|
||||
rm -f $(LOAD_TEST_BINARY)
|
||||
rm -f *.prof
|
||||
rm -f cpu.prof mem.prof heap.prof
|
||||
|
||||
clean-profiles:
|
||||
@echo "Cleaning all profile data..."
|
||||
rm -rf profiles/
|
||||
|
||||
# Testing
|
||||
test:
|
||||
@echo "Running all tests..."
|
||||
$(GOTEST) -v ./...
|
||||
|
||||
test-race:
|
||||
@echo "Running tests with race detection..."
|
||||
$(GOTEST) -race -v ./...
|
||||
|
||||
test-coverage:
|
||||
@echo "Running tests with coverage..."
|
||||
$(GOTEST) -coverprofile=coverage.out -v ./...
|
||||
$(GOCMD) tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report: coverage.html"
|
||||
|
||||
bench:
|
||||
@echo "Running all benchmarks..."
|
||||
$(GOTEST) -bench=. -benchmem ./...
|
||||
|
||||
# Memory leak validation (Task 26)
|
||||
memory-test:
|
||||
@echo "Running memory leak validation tests..."
|
||||
$(GOTEST) -v -run TestMemoryLeak ./jdenticon
|
||||
|
||||
memory-bench:
|
||||
@echo "Running memory leak benchmarks with profiling..."
|
||||
mkdir -p profiles
|
||||
$(eval TIMESTAMP := $(shell date +%Y%m%d_%H%M%S))
|
||||
$(eval MEM_PROF := profiles/memory-bench-$(TIMESTAMP).prof)
|
||||
$(eval CPU_PROF := profiles/cpu-bench-$(TIMESTAMP).prof)
|
||||
$(GOTEST) -bench=BenchmarkMemoryLeak \
|
||||
-benchtime=5m \
|
||||
-memprofile=$(MEM_PROF) \
|
||||
-memprofilerate=1 \
|
||||
-cpuprofile=$(CPU_PROF) \
|
||||
-benchmem \
|
||||
./jdenticon
|
||||
@echo "Profiles saved in profiles/ directory"
|
||||
@ln -sf $(shell basename $(MEM_PROF)) profiles/mem-latest.prof
|
||||
@ln -sf $(shell basename $(CPU_PROF)) profiles/cpu-latest.prof
|
||||
@echo "Created symlinks for latest profiles (mem-latest.prof, cpu-latest.prof)"
|
||||
|
||||
load-test: $(LOAD_TEST_BINARY)
|
||||
@echo "Running 5-minute sustained load test..."
|
||||
./$(LOAD_TEST_BINARY) -duration=5m -workers=4 -cache-size=1000
|
||||
|
||||
load-test-long: $(LOAD_TEST_BINARY)
|
||||
@echo "Running 30-minute extended load test..."
|
||||
./$(LOAD_TEST_BINARY) -duration=30m -workers=8 -cache-size=500
|
||||
|
||||
memory-profile:
|
||||
@echo "Starting interactive memory profiling session..."
|
||||
./scripts/memory-profile.sh benchmark --duration=10m
|
||||
|
||||
analyze-memory:
|
||||
@echo "Analyzing latest memory profiles..."
|
||||
@if [ -f profiles/mem-latest.prof ]; then \
|
||||
echo "Opening memory profile analysis..."; \
|
||||
$(GOCMD) tool pprof profiles/mem-latest.prof; \
|
||||
else \
|
||||
echo "No memory profile found. Run 'make memory-bench' first."; \
|
||||
fi
|
||||
|
||||
# Advanced profiling commands
|
||||
profile-heap:
|
||||
@echo "Generating heap profile (30 seconds)..."
|
||||
mkdir -p profiles
|
||||
$(GOTEST) -bench=BenchmarkMemoryLeakSustainedLoad \
|
||||
-benchtime=30s \
|
||||
-memprofile=profiles/heap-$$(date +%Y%m%d_%H%M%S).prof \
|
||||
-memprofilerate=1 \
|
||||
./jdenticon
|
||||
|
||||
profile-cpu:
|
||||
@echo "Generating CPU profile (30 seconds)..."
|
||||
mkdir -p profiles
|
||||
$(GOTEST) -bench=BenchmarkMemoryLeakSustainedLoad \
|
||||
-benchtime=30s \
|
||||
-cpuprofile=profiles/cpu-$$(date +%Y%m%d_%H%M%S).prof \
|
||||
./jdenticon
|
||||
|
||||
profile-web:
|
||||
@echo "Starting web UI for profile analysis..."
|
||||
@if [ -f profiles/mem-latest.prof ]; then \
|
||||
echo "Opening http://localhost:8080 for profile analysis..."; \
|
||||
$(GOCMD) tool pprof -http=:8080 profiles/mem-latest.prof; \
|
||||
else \
|
||||
echo "No memory profile found. Run 'make memory-bench' first."; \
|
||||
fi
|
||||
|
||||
profile-continuous: $(LOAD_TEST_BINARY)
|
||||
@echo "Running continuous profiling for 1 hour..."
|
||||
./scripts/memory-profile.sh continuous --duration=1h --workers=6
|
||||
|
||||
# Development helpers
|
||||
deps:
|
||||
@echo "Downloading dependencies..."
|
||||
$(GOMOD) download
|
||||
$(GOMOD) tidy
|
||||
|
||||
verify:
|
||||
@echo "Verifying dependencies..."
|
||||
$(GOMOD) verify
|
||||
|
||||
fmt:
|
||||
@echo "Formatting code..."
|
||||
$(GOCMD) fmt ./...
|
||||
|
||||
vet:
|
||||
@echo "Running go vet..."
|
||||
$(GOCMD) vet ./...
|
||||
|
||||
lint:
|
||||
@echo "Running golangci-lint..."
|
||||
@if command -v golangci-lint >/dev/null 2>&1; then \
|
||||
golangci-lint run; \
|
||||
else \
|
||||
echo "golangci-lint not installed. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \
|
||||
fi
|
||||
|
||||
# Task 26 validation workflow
|
||||
task-26: memory-test memory-bench load-test
|
||||
@echo ""
|
||||
@echo "=== Task 26: Memory Leak Validation Complete ==="
|
||||
@echo "✅ Memory leak tests passed"
|
||||
@echo "✅ Memory benchmarks completed with profiling"
|
||||
@echo "✅ Sustained load test completed"
|
||||
@echo ""
|
||||
@echo "Next steps:"
|
||||
@echo " make analyze-memory # Analyze memory profiles"
|
||||
@echo " make profile-web # Web UI for detailed analysis"
|
||||
@echo " make load-test-long # Extended validation (30 min)"
|
||||
@echo ""
|
||||
@if [ -d profiles ]; then \
|
||||
echo "Profile files available in profiles/ directory:"; \
|
||||
ls -la profiles/; \
|
||||
fi
|
||||
|
||||
# Quick validation for CI
|
||||
ci-memory-check:
|
||||
@echo "Quick memory validation for CI..."
|
||||
$(GOTEST) -timeout=10m -run TestMemoryLeak ./jdenticon
|
||||
$(GOTEST) -bench=BenchmarkMemoryLeakSustainedLoad -benchtime=1m ./jdenticon
|
||||
472
README.md
@@ -1,105 +1,461 @@
|
||||
# [Jdenticon-go](https://jdenticon.com)
|
||||
# Go Jdenticon
|
||||
|
||||
Go library for generating highly recognizable identicons.
|
||||
[](https://github.com/ungluedlabs/go-jdenticon/actions/workflows/ci.yml)
|
||||
[](https://github.com/ungluedlabs/go-jdenticon/actions/workflows/performance-regression.yml)
|
||||
[](https://pkg.go.dev/github.com/ungluedlabs/go-jdenticon)
|
||||
[](https://goreportcard.com/report/github.com/ungluedlabs/go-jdenticon)
|
||||
[](https://codecov.io/gh/kevinmcintyre/go-jdenticon)
|
||||
[](LICENSE)
|
||||
|
||||
A high-performance, idiomatic Go library for generating [Jdenticon](https://jdenticon.com) identicons. This library provides a simple API for creating PNG and SVG avatars, is fully configurable, and includes a command-line tool for easy generation.
|
||||
|
||||
It produces identical SVG output to the original [Jdenticon JavaScript library](https://github.com/dmester/jdenticon).
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
go-jdenticon is a Go port of the JavaScript library [Jdenticon](https://github.com/dmester/jdenticon).
|
||||
* **Simple API:** Generate icons with just a few lines of code
|
||||
* **PNG & SVG Support:** Output to standard raster and vector formats
|
||||
* **Highly Configurable:** Adjust colors, saturation, padding, and more
|
||||
* **Performance Optimized:** Built-in LRU caching and efficient rendering
|
||||
* **Concurrent Batch Processing:** High-performance worker pool for bulk generation
|
||||
* **CLI Included:** Generate icons directly from your terminal
|
||||
* **Comprehensive Error Handling:** Structured error types with actionable messages
|
||||
* **Identical SVG Output:** Produces the same SVGs as the original JavaScript library
|
||||
* **High-Quality PNG Output:** Visually very close PNGs (different rendering engine; differences only noticeable in side-by-side comparison)
|
||||
|
||||
* Renders identicons as PNG or SVG with no external dependencies
|
||||
* Generates consistent, deterministic identicons from any input string
|
||||
* Highly customizable color themes and styling options
|
||||
* Simple, clean API for easy integration
|
||||
* Command-line tool included for standalone usage
|
||||
## Quick Start
|
||||
|
||||
## Installation
|
||||
This will get you generating your first icon in under a minute.
|
||||
|
||||
```bash
|
||||
go get github.com/kevin/go-jdenticon
|
||||
### 1. Installation
|
||||
|
||||
**Library:**
|
||||
```sh
|
||||
go get -u github.com/ungluedlabs/go-jdenticon
|
||||
```
|
||||
|
||||
## Usage
|
||||
**Command-Line Tool (Optional):**
|
||||
```sh
|
||||
go install github.com/ungluedlabs/go-jdenticon/cmd/jdenticon@latest
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
### 2. Basic Usage (Library)
|
||||
|
||||
Create a file `main.go` and add the following code:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/kevin/go-jdenticon/jdenticon"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Generate an identicon
|
||||
icon := jdenticon.Generate("user@example.com", 200)
|
||||
// Generate an icon from a string value
|
||||
icon, err := jdenticon.Generate(context.Background(), "user@example.com", 200)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create icon: %v", err)
|
||||
}
|
||||
|
||||
// Get SVG output
|
||||
svg := icon.ToSVG()
|
||||
fmt.Println(svg)
|
||||
// Save the icon as SVG
|
||||
svg, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate SVG: %v", err)
|
||||
}
|
||||
|
||||
// Get PNG output
|
||||
png := icon.ToPNG()
|
||||
// Save or use PNG data...
|
||||
if err := os.WriteFile("my-icon.svg", []byte(svg), 0644); err != nil {
|
||||
log.Fatalf("Failed to save SVG: %v", err)
|
||||
}
|
||||
|
||||
// Save the icon as PNG
|
||||
png, err := icon.ToPNG()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate PNG: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile("my-icon.png", png, 0644); err != nil {
|
||||
log.Fatalf("Failed to save PNG: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Generated my-icon.svg and my-icon.png successfully!")
|
||||
// Note: SVG output is identical to JavaScript library
|
||||
// PNG output is visually very close but uses a different rendering engine
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
Run the code:
|
||||
```sh
|
||||
go run main.go
|
||||
```
|
||||
|
||||
You will find `my-icon.svg` and `my-icon.png` files in the same directory.
|
||||
|
||||
## Advanced Usage & Configuration
|
||||
|
||||
The library offers fine-grained control over the icon's appearance via the `Config` struct and functional options.
|
||||
|
||||
### Customizing an Icon
|
||||
|
||||
Here's how to generate an icon with custom colors and settings:
|
||||
|
||||
```go
|
||||
config := &jdenticon.Config{
|
||||
Hue: 0.3,
|
||||
Saturation: 0.7,
|
||||
Lightness: 0.5,
|
||||
Padding: 0.1,
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create a custom configuration using functional options
|
||||
config, err := jdenticon.Configure(
|
||||
jdenticon.WithHueRestrictions([]float64{120, 180, 240}), // Greens, teals, blues only
|
||||
jdenticon.WithColorSaturation(0.7), // More vivid colors
|
||||
jdenticon.WithPadding(0.1), // 10% padding
|
||||
jdenticon.WithBackgroundColor("#f0f0f0"), // Light gray background
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create config: %v", err)
|
||||
}
|
||||
|
||||
// Create a generator with the custom config and caching
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(config, 1000)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
// Generate the icon
|
||||
icon, err := generator.Generate(context.Background(), "custom-identifier", 200)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate icon: %v", err)
|
||||
}
|
||||
|
||||
// Save as SVG
|
||||
svg, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate SVG: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile("custom-icon.svg", []byte(svg), 0644); err != nil {
|
||||
log.Fatalf("Failed to save SVG: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Generated custom-icon.svg successfully!")
|
||||
}
|
||||
```
|
||||
|
||||
### Package-Level Functions
|
||||
|
||||
For simple use cases, you can use the package-level functions:
|
||||
|
||||
```go
|
||||
// Generate SVG directly
|
||||
svg, err := jdenticon.ToSVG(context.Background(), "user@example.com", 200)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
icon := jdenticon.GenerateWithConfig("user@example.com", 200, config)
|
||||
// Generate PNG directly
|
||||
png, err := jdenticon.ToPNG(context.Background(), "user@example.com", 200)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// With custom configuration
|
||||
config := jdenticon.Config{
|
||||
ColorSaturation: 0.8,
|
||||
Padding: 0.15,
|
||||
}
|
||||
generator, _ := jdenticon.NewGeneratorWithConfig(config, 100)
|
||||
icon, _ := generator.Generate(context.Background(), "user@example.com", 200)
|
||||
```
|
||||
|
||||
### Command Line Tool
|
||||
For a full list of configuration options, please see the [GoDoc for the Config struct](https://pkg.go.dev/github.com/ungluedlabs/go-jdenticon/jdenticon#Config).
|
||||
|
||||
```bash
|
||||
# Generate SVG
|
||||
go run cmd/jdenticon/main.go -value "user@example.com" -size 200
|
||||
## Concurrency & Performance
|
||||
|
||||
# Generate PNG file
|
||||
go run cmd/jdenticon/main.go -value "user@example.com" -format png -output icon.png
|
||||
### Thread Safety
|
||||
|
||||
**All public functions and types are safe for concurrent use by multiple goroutines.** The library achieves thread safety through several mechanisms:
|
||||
|
||||
- **Immutable Icons**: Once created, Icon instances are read-only and can be safely shared across goroutines
|
||||
- **Thread-Safe Caching**: Internal LRU cache uses RWMutex for optimal concurrent read performance
|
||||
- **Atomic Operations**: Performance metrics use lock-free atomic counters
|
||||
- **Protected State**: All shared mutable state is properly synchronized
|
||||
|
||||
### Concurrent Usage Patterns
|
||||
|
||||
#### ✅ Recommended: Reuse Generator Instances
|
||||
|
||||
```go
|
||||
// Create one generator and share it across goroutines
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(config, 1000)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Safe: Multiple goroutines can use the same generator
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
|
||||
userID := fmt.Sprintf("user-%d@example.com", id)
|
||||
icon, err := generator.Generate(context.Background(), userID, 64)
|
||||
if err != nil {
|
||||
log.Printf("Failed to generate icon: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Icons are immutable and safe to share
|
||||
svg, _ := icon.ToSVG()
|
||||
// Use svg...
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
#### ✅ Also Safe: Package-Level Functions
|
||||
|
||||
```go
|
||||
// Safe: Uses internal singleton with caching
|
||||
var wg sync.WaitGroup
|
||||
for _, email := range emails {
|
||||
wg.Add(1)
|
||||
go func(e string) {
|
||||
defer wg.Done()
|
||||
icon, _ := jdenticon.Generate(context.Background(), e, 64)
|
||||
// Process icon...
|
||||
}(email)
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
### Performance Recommendations
|
||||
|
||||
1. **Larger Cache Sizes**: Use 1000+ entries for high-concurrency scenarios
|
||||
2. **Generator Reuse**: Create one generator per configuration, share across goroutines
|
||||
3. **Icon Sharing**: Generated icons are immutable and efficient to share
|
||||
4. **Monitor Metrics**: Use `GetCacheMetrics()` to track cache performance
|
||||
|
||||
```go
|
||||
// Example: Monitor cache performance
|
||||
hits, misses := generator.GetCacheMetrics()
|
||||
ratio := float64(hits) / float64(hits + misses) * 100
|
||||
log.Printf("Cache hit ratio: %.1f%%", ratio)
|
||||
```
|
||||
|
||||
### Benchmarks
|
||||
|
||||
The library is optimized for concurrent workloads:
|
||||
|
||||
- **Single-threaded**: ~15,000 icons/second (64x64 PNG)
|
||||
- **Concurrent (8 cores)**: ~85,000 icons/second with 99%+ cache hits
|
||||
- **Memory efficient**: ~2.1 KB per operation with LRU caching
|
||||
|
||||
Run benchmarks locally:
|
||||
```sh
|
||||
go test -bench=. -benchmem ./...
|
||||
```
|
||||
|
||||
## Command-Line Interface (CLI)
|
||||
|
||||
The `jdenticon` tool supports both single icon generation and high-performance batch processing.
|
||||
|
||||
### Single Icon Generation
|
||||
|
||||
**Generate a default 200x200 SVG icon:**
|
||||
```sh
|
||||
jdenticon generate "my-cli-icon" > my-cli-icon.svg
|
||||
```
|
||||
|
||||
**Generate a 256x256 PNG icon:**
|
||||
```sh
|
||||
jdenticon generate "my-cli-icon" -s 256 -f png -o my-cli-icon.png
|
||||
```
|
||||
|
||||
### Batch Processing
|
||||
|
||||
**Generate multiple icons from a text file (one value per line):**
|
||||
```sh
|
||||
jdenticon batch users.txt --output-dir ./avatars
|
||||
```
|
||||
|
||||
**High-performance concurrent batch processing:**
|
||||
```sh
|
||||
jdenticon batch large-list.txt --output-dir ./avatars --concurrency 8 --format png --size 128
|
||||
```
|
||||
|
||||
**Key batch features:**
|
||||
- **Concurrent Processing**: Uses worker pool pattern for optimal performance
|
||||
- **Configurable Workers**: `--concurrency` flag (defaults to CPU count)
|
||||
- **Progress Tracking**: Real-time progress bar with completion statistics
|
||||
- **Graceful Shutdown**: Responds to Ctrl+C for clean termination
|
||||
- **Performance**: Up to 3-4x speedup vs sequential processing
|
||||
|
||||
**All available options:**
|
||||
```sh
|
||||
jdenticon --help
|
||||
jdenticon batch --help
|
||||
```
|
||||
|
||||
## Development & Testing
|
||||
|
||||
This project includes comprehensive testing and validation infrastructure to ensure quality and identical SVG output.
|
||||
|
||||
### Directory Structure
|
||||
|
||||
#### `examples/`
|
||||
Contains practical Go usage examples demonstrating best practices:
|
||||
|
||||
- **`concurrent-usage.go`** - Thread-safe patterns, performance optimization, and cache monitoring
|
||||
- Run examples: `go run examples/concurrent-usage.go`
|
||||
- Run with race detection: `go run -race examples/concurrent-usage.go`
|
||||
|
||||
#### `benchmark/`
|
||||
Dedicated performance testing infrastructure:
|
||||
|
||||
- **Purpose**: Performance comparison between Go and JavaScript implementations
|
||||
- **Coverage**: Speed benchmarks, memory analysis, regression testing
|
||||
- **Usage**: Validates performance claims and catches regressions
|
||||
|
||||
### Building and Testing
|
||||
|
||||
To contribute or build from source:
|
||||
|
||||
1. Clone the repository:
|
||||
```sh
|
||||
git clone https://github.com/ungluedlabs/go-jdenticon.git
|
||||
cd go-jdenticon
|
||||
```
|
||||
|
||||
2. Run tests:
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
|
||||
3. Run tests with race detection:
|
||||
```sh
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
4. Run benchmarks:
|
||||
```sh
|
||||
go test -bench=. ./...
|
||||
```
|
||||
|
||||
5. Build the CLI tool:
|
||||
```sh
|
||||
go build ./cmd/jdenticon
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Functions
|
||||
### Core Functions
|
||||
|
||||
- `Generate(value string, size int) *Icon` - Generate an identicon with default settings
|
||||
- `GenerateWithConfig(value string, size int, config *Config) *Icon` - Generate with custom configuration
|
||||
- `DefaultConfig() *Config` - Get default configuration settings
|
||||
- `Generate(ctx context.Context, value string, size int) (*Icon, error)` - Generate an icon with default settings
|
||||
- `ToSVG(ctx context.Context, value string, size int) (string, error)` - Generate SVG directly
|
||||
- `ToPNG(ctx context.Context, value string, size int) ([]byte, error)` - Generate PNG directly
|
||||
|
||||
### Types
|
||||
### Generator Type
|
||||
|
||||
#### Icon
|
||||
- `ToSVG() string` - Render as SVG string
|
||||
- `ToPNG() []byte` - Render as PNG byte data
|
||||
For better performance with multiple icons:
|
||||
|
||||
#### Config
|
||||
- `Hue float64` - Color hue (0.0-1.0)
|
||||
- `Saturation float64` - Color saturation (0.0-1.0)
|
||||
- `Lightness float64` - Color lightness (0.0-1.0)
|
||||
- `BackgroundColor string` - Background color (hex or empty for transparent)
|
||||
- `Padding float64` - Padding as percentage of size (0.0-0.5)
|
||||
- `NewGenerator() (*Generator, error)` - Create generator with default cache (1000 entries)
|
||||
- `NewGeneratorWithCacheSize(size int) (*Generator, error)` - Create generator with custom cache size
|
||||
- `NewGeneratorWithConfig(config Config, cacheSize int) (*Generator, error)` - Create generator with custom config
|
||||
- `Generate(ctx context.Context, value string, size int) (*Icon, error)` - Generate icon using this generator
|
||||
|
||||
### Icon Type
|
||||
|
||||
- `ToSVG() (string, error)` - Render as SVG string
|
||||
- `ToPNG() ([]byte, error)` - Render as PNG byte data
|
||||
- `ToPNGWithSize(outputSize int) ([]byte, error)` - Render PNG at different size
|
||||
|
||||
### Configuration
|
||||
|
||||
Use functional options for flexible configuration:
|
||||
|
||||
```go
|
||||
config, err := jdenticon.Configure(
|
||||
jdenticon.WithColorSaturation(0.7),
|
||||
jdenticon.WithPadding(0.1),
|
||||
jdenticon.WithBackgroundColor("#ffffff"),
|
||||
jdenticon.WithHueRestrictions([]float64{0, 120, 240}),
|
||||
)
|
||||
```
|
||||
|
||||
## Security & Input Limits
|
||||
|
||||
Go Jdenticon includes configurable DoS protection to prevent resource exhaustion attacks:
|
||||
|
||||
```go
|
||||
config := jdenticon.Config{
|
||||
MaxIconSize: 4096, // Maximum icon dimension in pixels (default: 4096)
|
||||
MaxInputLength: 1048576, // Maximum input string length in bytes (default: 1MB)
|
||||
// ... other config options
|
||||
}
|
||||
|
||||
// Use 0 for default limits, positive values for custom limits, -1 to disable
|
||||
icon, err := jdenticon.ToSVGWithConfig(context.Background(), "user@example.com", 512, config)
|
||||
```
|
||||
|
||||
**Default Security Limits:**
|
||||
- **Icon Size:** 4096×4096 pixels maximum (~64MB memory usage)
|
||||
- **Input Length:** 1MB maximum string length
|
||||
- **PNG Effective Size:** Automatically adjusts supersampling to stay within limits
|
||||
|
||||
**Features:**
|
||||
- **Smart PNG Handling:** `ToPNG()` automatically reduces supersampling for large icons
|
||||
- **Configurable Limits:** Override defaults or disable limits entirely for special use cases
|
||||
- **Structured Errors:** Clear error messages explain limit violations and how to resolve them
|
||||
- **Fail-Fast Validation:** Invalid inputs are rejected before expensive operations
|
||||
|
||||
The Go implementation provides superior DoS protection compared to the JavaScript reference library while producing identical output.
|
||||
|
||||
## Performance
|
||||
|
||||
The library includes several performance optimizations:
|
||||
|
||||
- **LRU Caching:** Generators cache generated icons for repeated use
|
||||
- **Efficient Rendering:** Optimized SVG and PNG generation
|
||||
- **Reusable Generators:** Create once, use many times
|
||||
- **Minimal Allocations:** Careful memory management
|
||||
|
||||
Example with caching:
|
||||
|
||||
```go
|
||||
generator, _ := jdenticon.NewGenerator()
|
||||
|
||||
// These will be cached
|
||||
icon1, _ := generator.Generate(context.Background(), "user1@example.com", 64)
|
||||
icon2, _ := generator.Generate(context.Background(), "user2@example.com", 64)
|
||||
|
||||
// Check cache performance
|
||||
hits, misses := generator.GetCacheMetrics()
|
||||
fmt.Printf("Cache: %d hits, %d misses\n", hits, misses)
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see the original [Jdenticon](https://github.com/dmester/jdenticon) project for details.
|
||||
This project is licensed under the Elastic License 2.0 - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Contributing
|
||||
**Key points:**
|
||||
- Free to use, modify, and distribute
|
||||
- Cannot be offered as a hosted/managed service
|
||||
- Cannot remove or circumvent license key functionality
|
||||
|
||||
Contributions are welcome! Please ensure all tests pass and follow Go conventions.
|
||||
## Acknowledgments
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests if applicable
|
||||
5. Submit a pull request
|
||||
* This library is a Go port of the excellent [Jdenticon](https://jdenticon.com) by Daniel Mester
|
||||
* Special thanks to the original JavaScript implementation for the visual algorithm
|
||||
368
SECURITY.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We provide security updates for the following versions of go-jdenticon:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.2.x | :white_check_mark: |
|
||||
| 1.1.x | :white_check_mark: |
|
||||
| 1.0.x | :x: (EOL) |
|
||||
| < 1.0 | :x: (Pre-release) |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do NOT report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
If you discover a security vulnerability in go-jdenticon, please report it to us responsibly:
|
||||
|
||||
### Preferred Method: GitHub Private Vulnerability Reporting
|
||||
|
||||
1. Go to the [Security tab](../../security) of this repository
|
||||
2. Click "Report a vulnerability"
|
||||
3. Fill out the vulnerability report form with as much detail as possible
|
||||
|
||||
### Alternative Method: Email
|
||||
|
||||
If GitHub private reporting is not available, you can email the maintainer at:
|
||||
**kev@kev.codes**
|
||||
|
||||
### What to Include
|
||||
|
||||
Please include the following information in your report:
|
||||
|
||||
- **Description**: Clear description of the vulnerability
|
||||
- **Version**: Affected version(s) of go-jdenticon
|
||||
- **Component**: Which part of the library is affected (core API, CLI tool, specific renderer, etc.)
|
||||
- **Impact**: Your assessment of the potential impact and severity
|
||||
- **Reproduction**: Step-by-step instructions to reproduce the issue
|
||||
- **Proof of Concept**: If applicable, minimal code example demonstrating the vulnerability
|
||||
- **Suggested Fix**: If you have ideas for how to address the issue
|
||||
|
||||
### Our Commitment
|
||||
|
||||
- **Initial Response**: We will acknowledge your report within **48 hours**
|
||||
- **Progress Updates**: We will provide status updates every **7 days** until resolution
|
||||
- **Verification**: We will work with you to understand and verify the issue
|
||||
- **Timeline**: We aim to release security patches within **30 days** for critical issues, **90 days** for lower severity issues
|
||||
- **Credit**: We will credit you in our security advisory unless you prefer to remain anonymous
|
||||
|
||||
### Disclosure Timeline
|
||||
|
||||
We follow responsible disclosure practices:
|
||||
|
||||
1. **Day 0**: Vulnerability reported privately
|
||||
2. **Days 1-7**: Initial triage and verification
|
||||
3. **Days 8-30**: Development and testing of fix (for critical issues)
|
||||
4. **Day of Release**: Security advisory published with fixed version
|
||||
5. **30 days post-release**: Full technical details may be shared publicly
|
||||
|
||||
### Scope
|
||||
|
||||
This security policy covers:
|
||||
|
||||
- **Core Library**: The main go-jdenticon Go API (`jdenticon` package)
|
||||
- **CLI Tool**: The command-line interface (`cmd/jdenticon`)
|
||||
- **Internal Components**: Engine, renderers, and utilities
|
||||
- **Documentation**: If documentation could lead users to implement insecure patterns
|
||||
|
||||
### Out of Scope
|
||||
|
||||
The following are generally considered out of scope:
|
||||
|
||||
- Vulnerabilities in third-party dependencies (please report these upstream)
|
||||
- Issues that require physical access to the system
|
||||
- Social engineering attacks
|
||||
- Issues in example code that is clearly marked as "example only"
|
||||
|
||||
---
|
||||
|
||||
## Security Guide
|
||||
|
||||
### Overview
|
||||
|
||||
The go-jdenticon library generates deterministic identicons (geometric avatar images) from input strings. This guide provides comprehensive security information for developers, operators, and security teams integrating or deploying the library.
|
||||
|
||||
**⚠️ Important**: This library is designed for generating visual representations of data, **not for cryptographic or authentication purposes**. The output should never be used for security-critical decisions.
|
||||
|
||||
### Security Features
|
||||
|
||||
go-jdenticon includes several built-in security protections:
|
||||
|
||||
- **Resource Limits**: Configurable limits on input size, icon size, and complexity
|
||||
- **Input Validation**: Comprehensive validation of all user inputs
|
||||
- **Path Traversal Protection**: CLI tool prevents writing files outside intended directories
|
||||
- **Memory Protection**: Built-in protections against memory exhaustion attacks
|
||||
- **Defense in Depth**: Multiple layers of validation throughout the system
|
||||
|
||||
### Threat Model
|
||||
|
||||
#### Primary Security Concerns
|
||||
|
||||
The main security risks for an identicon generation library are related to **resource availability** rather than data confidentiality:
|
||||
|
||||
##### 1. Denial of Service (DoS) Attacks
|
||||
- **CPU Exhaustion**: Malicious inputs designed to consume excessive processing time
|
||||
- **Memory Exhaustion**: Requests for extremely large images that exhaust available memory
|
||||
- **Disk Exhaustion**: (CLI only) Repeated generation of large files filling storage
|
||||
|
||||
##### 2. Input Validation Vulnerabilities
|
||||
- **Malformed Inputs**: Invalid color strings, non-UTF8 characters, or edge cases in parsers
|
||||
- **Path Traversal**: (CLI only) Attempts to write files outside intended directories
|
||||
|
||||
##### 3. Resource Consumption Attacks
|
||||
- **Large Input Strings**: Extremely long input strings that consume CPU during processing
|
||||
- **Complex Generation**: Inputs that trigger computationally expensive generation paths
|
||||
|
||||
#### Built-in Protections
|
||||
|
||||
go-jdenticon includes comprehensive protections against these threats:
|
||||
|
||||
- ✅ **Configurable Resource Limits**: Input size, image dimensions, and complexity limits
|
||||
- ✅ **Robust Input Validation**: All inputs are validated before processing
|
||||
- ✅ **Path Traversal Protection**: CLI tool sanitizes output paths
|
||||
- ✅ **Memory Bounds Checking**: Internal limits prevent memory exhaustion
|
||||
- ✅ **Defense in Depth**: Multiple validation layers throughout the system
|
||||
|
||||
## Secure Usage Guidelines
|
||||
|
||||
### For Go API Consumers
|
||||
|
||||
#### 1. Treat All Inputs as Untrusted
|
||||
|
||||
**Always validate and sanitize user-provided data** before passing it to the library:
|
||||
|
||||
```go
|
||||
// ❌ DANGEROUS: Direct use of user input
|
||||
userInput := getUserInput() // Could be malicious
|
||||
icon, err := jdenticon.Generate(ctx, userInput, 256)
|
||||
|
||||
// ✅ SECURE: Validate and sanitize first
|
||||
userInput := strings.TrimSpace(getUserInput())
|
||||
if len(userInput) > 100 { // Your application limit
|
||||
return errors.New("input too long")
|
||||
}
|
||||
if !isValidInput(userInput) { // Your validation logic
|
||||
return errors.New("invalid input")
|
||||
}
|
||||
icon, err := jdenticon.Generate(ctx, userInput, 256)
|
||||
```
|
||||
|
||||
#### 2. Never Use Sensitive Data as Input
|
||||
|
||||
**Do not use PII, passwords, or secrets** as identicon input:
|
||||
|
||||
```go
|
||||
// ❌ DANGEROUS: Using sensitive data
|
||||
email := "user@example.com"
|
||||
password := "secret123"
|
||||
icon, _ := jdenticon.Generate(ctx, password, 256) // DON'T DO THIS
|
||||
|
||||
// ✅ SECURE: Use non-sensitive identifiers
|
||||
userID := "user-12345"
|
||||
icon, err := jdenticon.Generate(ctx, userID, 256)
|
||||
```
|
||||
|
||||
#### 3. Always Handle Errors
|
||||
|
||||
**Check for errors** - they may indicate security limit violations:
|
||||
|
||||
```go
|
||||
// ✅ SECURE: Proper error handling
|
||||
icon, err := jdenticon.Generate(ctx, input, size)
|
||||
if err != nil {
|
||||
// Log the error - could indicate attack attempt
|
||||
log.Printf("Icon generation failed: %v", err)
|
||||
|
||||
// Handle gracefully based on error type
|
||||
var sizeErr *jdenticon.ErrValueTooLarge
|
||||
if errors.As(err, &sizeErr) {
|
||||
return handleResourceLimitError(sizeErr)
|
||||
}
|
||||
|
||||
return handleGeneralError(err)
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Configure Resource Limits Explicitly
|
||||
|
||||
**Don't rely on defaults** - set limits appropriate for your environment:
|
||||
|
||||
```go
|
||||
config := jdenticon.Config{
|
||||
MaxIconSize: 1024, // Maximum 1024×1024 pixels
|
||||
MaxInputLength: 256, // Maximum 256 byte input strings
|
||||
MaxComplexity: 50, // Reduce computational complexity
|
||||
// ... other config options
|
||||
}
|
||||
|
||||
// Use 0 for default limits, positive values for custom limits, -1 to disable
|
||||
icon, err := jdenticon.ToSVGWithConfig(ctx, "user@example.com", 512, config)
|
||||
```
|
||||
|
||||
**Default Security Limits:**
|
||||
- **Icon Size:** 4096×4096 pixels maximum (~64MB memory usage)
|
||||
- **Input Length:** 1MB maximum input string length
|
||||
- **Complexity:** 100 maximum computational complexity score
|
||||
|
||||
### For Web Applications
|
||||
|
||||
#### 1. Content Security Policy for SVG Output
|
||||
|
||||
When serving SVG identicons, use proper Content-Security-Policy:
|
||||
|
||||
```html
|
||||
<!-- ✅ SECURE: Restrict SVG capabilities -->
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="img-src 'self' data:; object-src 'none';">
|
||||
|
||||
<img src="data:image/svg+xml;base64,..." alt="User Avatar">
|
||||
```
|
||||
|
||||
#### 2. Cache Generated Icons
|
||||
|
||||
**Avoid regenerating identical icons** to prevent resource exhaustion:
|
||||
|
||||
```go
|
||||
// ✅ SECURE: Use generator with caching
|
||||
generator, err := jdenticon.NewGeneratorWithCacheSize(1000)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Icons with same input will be cached
|
||||
icon1, _ := generator.Generate(ctx, "user123", 64)
|
||||
icon2, _ := generator.Generate(ctx, "user123", 64) // Served from cache
|
||||
```
|
||||
|
||||
### For CLI Tool Users
|
||||
|
||||
#### 1. Validate Output Paths
|
||||
|
||||
The CLI tool includes path traversal protection, but you should still validate:
|
||||
|
||||
```bash
|
||||
# ✅ SECURE: Use absolute paths in trusted directories
|
||||
jdenticon generate "user123" --output /var/www/avatars/user123.svg
|
||||
|
||||
# ❌ POTENTIALLY RISKY: Don't use user-controlled paths
|
||||
# jdenticon generate "$USER_INPUT" --output "$USER_PATH"
|
||||
```
|
||||
|
||||
#### 2. Run with Minimal Privileges
|
||||
|
||||
```bash
|
||||
# ✅ SECURE: Create dedicated user for CLI operations
|
||||
sudo useradd -r -s /bin/false jdenticon-user
|
||||
sudo -u jdenticon-user jdenticon generate "user123" --output avatar.svg
|
||||
```
|
||||
|
||||
### Deployment Security
|
||||
|
||||
#### Container Security
|
||||
|
||||
When deploying in containers, apply resource limits:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml or Kubernetes deployment
|
||||
resources:
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
```
|
||||
|
||||
#### Monitoring and Alerting
|
||||
|
||||
Monitor these metrics for security:
|
||||
|
||||
- Generation error rates (potential attack indicators)
|
||||
- Resource limit violations
|
||||
- Unusual input patterns or sizes
|
||||
- Memory/CPU usage spikes
|
||||
|
||||
```go
|
||||
// Example monitoring code
|
||||
func monitorGeneration(input string, size int, err error) {
|
||||
if err != nil {
|
||||
securityLog.Printf("Generation failed: input_len=%d, size=%d, error=%v",
|
||||
len(input), size, err)
|
||||
|
||||
// Check for potential attack patterns
|
||||
if len(input) > 1000 || size > 2048 {
|
||||
alertSecurityTeam("Suspicious identicon generation attempt", input, size, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Platform-Specific Considerations
|
||||
|
||||
#### 32-bit Systems
|
||||
- Integer overflow protections are in place for hash parsing
|
||||
- Memory limits may need adjustment for smaller address space
|
||||
|
||||
#### Container Environments
|
||||
- Apply CPU and memory limits as defense in depth
|
||||
- Monitor for container escape attempts through resource exhaustion
|
||||
|
||||
### Security Limitations & Known Issues
|
||||
|
||||
1. **Cryptographic Attacks**: The output is deterministic and predictable - not suitable for security purposes
|
||||
2. **Information Leakage**: Identical inputs produce identical outputs - avoid using sensitive data
|
||||
3. **Side-Channel Attacks**: Processing time may vary based on input complexity
|
||||
4. **Dependency Vulnerabilities**: Keep Go toolchain and any external dependencies updated
|
||||
|
||||
### Security Updates
|
||||
|
||||
Security updates are distributed through:
|
||||
|
||||
- **GitHub Releases**: All security fixes are released as new versions
|
||||
- **GitHub Security Advisories**: Critical issues are published as security advisories
|
||||
- **Go Module Proxy**: Updates are automatically available through `go get -u`
|
||||
|
||||
### Emergency Response
|
||||
|
||||
#### If You Suspect an Attack
|
||||
|
||||
1. **Monitor**: Check logs for patterns of failed generations or resource limit violations
|
||||
2. **Investigate**: Look for common source IPs or unusual input patterns
|
||||
3. **Respond**: Apply rate limiting, blocking, or input filtering as appropriate
|
||||
4. **Document**: Keep records for potential security team review
|
||||
|
||||
## Security Best Practices Summary
|
||||
|
||||
### For Developers ✅
|
||||
- Always validate inputs before passing to the library
|
||||
- Handle all errors returned by library functions
|
||||
- Never use sensitive data as identicon input
|
||||
- Configure resource limits explicitly for your environment
|
||||
- Use proper Content-Security-Policy when serving SVGs
|
||||
|
||||
### For Operators ✅
|
||||
- Apply container-level resource limits as defense in depth
|
||||
- Monitor generation metrics and error rates
|
||||
- Keep dependencies updated with vulnerability scanning
|
||||
- Run CLI tools with minimal privileges
|
||||
- Set up alerting for suspicious patterns
|
||||
|
||||
### For Security Teams ✅
|
||||
- Review configuration limits match threat model
|
||||
- Include go-jdenticon in vulnerability scanning processes
|
||||
- Monitor for new security advisories
|
||||
- Test disaster recovery procedures for DoS scenarios
|
||||
- Validate secure coding practices in applications using the library
|
||||
|
||||
---
|
||||
|
||||
### Questions?
|
||||
|
||||
If you have questions about this security policy or need clarification on whether an issue should be reported as a security vulnerability, please open a regular GitHub issue with the tag "security-question" (this is for questions about the policy, not for reporting actual vulnerabilities).
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-06-24
|
||||
**Policy Version**: 1.0
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect width="100%" height="100%" fill="#ffffff"/><path fill="#47ea47" d="M32.2 19.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M51.2 19.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M51.2 76.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M32.2 76.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M13.2 38.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M70.2 38.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M70.2 57.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M13.2 57.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M48 38.5L48 48L38.5 48ZM57.5 48L48 48L48 38.5ZM48 57.5L48 48L57.5 48ZM38.5 48L48 48L48 57.5Z"/><path fill="#4c4c4c" d="M29 19.5L19.5 29L10 19.5L19.5 10ZM76.5 29L67 19.5L76.5 10L86 19.5ZM67 76.5L76.5 67L86 76.5L76.5 86ZM19.5 67L29 76.5L19.5 86L10 76.5Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 840 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><path fill="#553db7" d="M37 23.5L50.5 10L64 23.5L50.5 37ZM77.5 10L91 23.5L77.5 37L64 23.5ZM91 104.5L77.5 118L64 104.5L77.5 91ZM50.5 118L37 104.5L50.5 91L64 104.5ZM10 50.5L23.5 37L37 50.5L23.5 64ZM104.5 37L118 50.5L104.5 64L91 50.5ZM118 77.5L104.5 91L91 77.5L104.5 64ZM23.5 91L10 77.5L23.5 64L37 77.5Z"/><path fill="#eaeaea" d="M37 37L10 37L10 23.5ZM91 37L91 10L104.5 10ZM91 91L118 91L118 104.5ZM37 91L37 118L23.5 118Z"/><path fill="#9484d6" d="M43 43L62 43L62 62L43 62ZM85 43L85 62L66 62L66 43ZM85 85L66 85L66 66L85 66ZM43 85L43 66L62 66L62 85Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 640 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path fill="#e8e8e8" d="M19 14L14 9L19 4L24 9ZM24 9L29 4L34 9L29 14ZM29 34L34 39L29 44L24 39ZM24 39L19 44L14 39L19 34ZM9 24L4 19L9 14L14 19ZM34 19L39 14L44 19L39 24ZM39 24L44 29L39 34L34 29ZM14 29L9 34L4 29L9 24Z"/><path fill="#a83866" d="M9 4L14 9L9 14L4 9ZM44 9L39 14L34 9L39 4ZM39 44L34 39L39 34L44 39ZM4 39L9 34L14 39L9 44Z"/><path fill="#d1759a" d="M14 14L24 14L24 24L14 24ZM17.6 20.2a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0M34 14L34 24L24 24L24 14ZM25.2 20.2a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0M34 34L24 34L24 24L34 24ZM25.2 27.8a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0M14 34L14 24L24 24L24 34ZM17.6 27.8a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0"/></svg>
|
||||
|
Before Width: | Height: | Size: 750 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path fill="#2e8c7e" d="M24 4L24 14L14 14ZM34 14L24 14L24 4ZM24 44L24 34L34 34ZM14 34L24 34L24 44ZM14 14L14 24L4 24ZM44 24L34 24L34 14ZM34 34L34 24L44 24ZM4 24L14 24L14 34Z"/><path fill="#ace3db" d="M4 4L14 4L14 14ZM44 4L44 14L34 14ZM44 44L34 44L34 34ZM4 44L4 34L14 34Z"/><path fill="#59c7b7" d="M17 17L24 17L24 24L17 24ZM31 17L31 24L24 24L24 17ZM31 31L24 31L24 24L31 24ZM17 31L17 24L24 24L24 31Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 488 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path fill="#2e808c" d="M14 9L19 4L24 9L19 14ZM29 4L34 9L29 14L24 9ZM34 39L29 44L24 39L29 34ZM19 44L14 39L19 34L24 39ZM4 19L9 14L14 19L9 24ZM39 14L44 19L39 24L34 19ZM44 29L39 34L34 29L39 24ZM9 34L4 29L9 24L14 29Z"/><path fill="#59b9c7" d="M5.7 9a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M35.7 9a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M35.7 39a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M5.7 39a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0"/><path fill="#e3e3e3" d="M18 24a6,6 0 1,1 12,0a6,6 0 1,1 -12,0"/></svg>
|
||||
|
Before Width: | Height: | Size: 587 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path fill="#c4c1ea" d="M15.7 9a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M25.7 9a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M25.7 39a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M15.7 39a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M5.7 19a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M35.7 19a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M35.7 29a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M5.7 29a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0"/><path fill="#8a84d6" d="M14 4L14 14L9 14ZM44 14L34 14L34 9ZM34 44L34 34L39 34ZM4 34L14 34L14 39Z"/><path fill="#5b5b5b" d="M24 14L24 22L19 14ZM34 24L26 24L34 19ZM24 34L24 26L29 34ZM14 24L22 24L14 29Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 706 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path fill="#e3e3e3" d="M24 4L24 14L19 14ZM34 14L24 14L24 9ZM24 44L24 34L29 34ZM14 34L24 34L24 39ZM14 14L14 24L9 24ZM44 24L34 24L34 19ZM34 34L34 24L39 24ZM4 24L14 24L14 29Z"/><path fill="#464646" d="M4 4L14 4L14 14ZM44 4L44 14L34 14ZM44 44L34 44L34 34ZM4 44L4 34L14 34Z"/><path fill="#59acc7" d="M24 19L24 24L19 24ZM29 24L24 24L24 19ZM24 29L24 24L29 24ZM19 24L24 24L24 29Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 464 B |
|
Before Width: | Height: | Size: 475 B |
140
benchmark/benchmark-js.js
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Node.js benchmark script for jdenticon-js using perf_hooks
|
||||
* Tests performance with JIT warm-up for fair comparison against Go implementation
|
||||
* Run with: node --expose-gc benchmark-js.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { performance } = require('perf_hooks');
|
||||
|
||||
// Check if jdenticon-js is available
|
||||
let jdenticon;
|
||||
try {
|
||||
jdenticon = require('../jdenticon-js/dist/jdenticon-node.js');
|
||||
} catch (err) {
|
||||
console.error('Error: jdenticon-js not found. Please ensure it\'s available in jdenticon-js/dist/');
|
||||
console.error('You may need to build the JS version first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Configuration ---
|
||||
const ICON_SIZE = 64;
|
||||
const WARMUP_RUNS = 3;
|
||||
|
||||
// Load test inputs
|
||||
const inputsPath = path.join(__dirname, 'inputs.json');
|
||||
if (!fs.existsSync(inputsPath)) {
|
||||
console.error('Error: inputs.json not found. Run generate-inputs.js first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputs = JSON.parse(fs.readFileSync(inputsPath, 'utf8'));
|
||||
const numInputs = inputs.length;
|
||||
|
||||
console.log('=== jdenticon-js Performance Benchmark ===');
|
||||
console.log(`Inputs: ${numInputs} unique hash strings`);
|
||||
console.log(`Icon size: ${ICON_SIZE}x${ICON_SIZE} pixels`);
|
||||
console.log(`Format: SVG`);
|
||||
console.log(`Node.js version: ${process.version}`);
|
||||
console.log(`V8 version: ${process.versions.v8}`);
|
||||
console.log('');
|
||||
|
||||
// --- Benchmark Function ---
|
||||
function generateAllIcons() {
|
||||
for (let i = 0; i < numInputs; i++) {
|
||||
jdenticon.toSvg(inputs[i], ICON_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Warm-up Phase ---
|
||||
console.log(`Warming up JIT with ${WARMUP_RUNS} runs...`);
|
||||
for (let i = 0; i < WARMUP_RUNS; i++) {
|
||||
console.log(` Warm-up run ${i + 1}/${WARMUP_RUNS}`);
|
||||
generateAllIcons();
|
||||
}
|
||||
|
||||
// Force garbage collection for clean baseline (if --expose-gc was used)
|
||||
if (global.gc) {
|
||||
console.log('Forcing garbage collection...');
|
||||
global.gc();
|
||||
} else {
|
||||
console.log('Note: Run with --expose-gc for more accurate memory measurements');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// --- Measurement Phase ---
|
||||
console.log(`Running benchmark with ${numInputs} icons...`);
|
||||
|
||||
const memBefore = process.memoryUsage();
|
||||
const startTime = performance.now();
|
||||
|
||||
generateAllIcons(); // The actual benchmark run
|
||||
|
||||
const endTime = performance.now();
|
||||
const memAfter = process.memoryUsage();
|
||||
|
||||
// --- Calculate Metrics ---
|
||||
const totalTimeMs = endTime - startTime;
|
||||
const timePerIconMs = totalTimeMs / numInputs;
|
||||
const timePerIconUs = timePerIconMs * 1000; // microseconds
|
||||
const iconsPerSecond = 1000 / timePerIconMs;
|
||||
|
||||
// Memory metrics
|
||||
const heapDelta = memAfter.heapUsed - memBefore.heapUsed;
|
||||
const heapDeltaKB = heapDelta / 1024;
|
||||
const heapDeltaPerIcon = heapDelta / numInputs;
|
||||
|
||||
// --- Results Report ---
|
||||
console.log('');
|
||||
console.log('=== jdenticon-js Results ===');
|
||||
console.log(`Total time: ${totalTimeMs.toFixed(2)} ms`);
|
||||
console.log(`Time per icon: ${timePerIconMs.toFixed(4)} ms (${timePerIconUs.toFixed(2)} μs)`);
|
||||
console.log(`Throughput: ${iconsPerSecond.toFixed(2)} icons/sec`);
|
||||
console.log('');
|
||||
console.log('Memory Usage:');
|
||||
console.log(` Heap before: ${(memBefore.heapUsed / 1024).toFixed(2)} KB`);
|
||||
console.log(` Heap after: ${(memAfter.heapUsed / 1024).toFixed(2)} KB`);
|
||||
console.log(` Heap delta: ${heapDeltaKB.toFixed(2)} KB`);
|
||||
console.log(` Per icon: ${heapDeltaPerIcon.toFixed(2)} bytes`);
|
||||
console.log('');
|
||||
console.log('Additional Memory Info:');
|
||||
console.log(` RSS: ${(memAfter.rss / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` External: ${(memAfter.external / 1024).toFixed(2)} KB`);
|
||||
|
||||
// --- Save Results for Comparison ---
|
||||
const results = {
|
||||
implementation: 'jdenticon-js',
|
||||
timestamp: new Date().toISOString(),
|
||||
nodeVersion: process.version,
|
||||
v8Version: process.versions.v8,
|
||||
config: {
|
||||
iconSize: ICON_SIZE,
|
||||
numInputs: numInputs,
|
||||
warmupRuns: WARMUP_RUNS
|
||||
},
|
||||
performance: {
|
||||
totalTimeMs: totalTimeMs,
|
||||
timePerIconMs: timePerIconMs,
|
||||
timePerIconUs: timePerIconUs,
|
||||
iconsPerSecond: iconsPerSecond
|
||||
},
|
||||
memory: {
|
||||
heapBeforeKB: memBefore.heapUsed / 1024,
|
||||
heapAfterKB: memAfter.heapUsed / 1024,
|
||||
heapDeltaKB: heapDeltaKB,
|
||||
heapDeltaPerIcon: heapDeltaPerIcon,
|
||||
rssKB: memAfter.rss / 1024,
|
||||
externalKB: memAfter.external / 1024
|
||||
}
|
||||
};
|
||||
|
||||
const resultsPath = path.join(__dirname, 'results-js.json');
|
||||
fs.writeFileSync(resultsPath, JSON.stringify(results, null, 2));
|
||||
console.log(`Results saved to: ${resultsPath}`);
|
||||
|
||||
console.log('');
|
||||
console.log('Run Go benchmark next: go test -bench=BenchmarkGenerate64pxIcon -benchmem ./...');
|
||||
72
benchmark/compare-results.js
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Compare benchmark results between Go and JavaScript implementations
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
// Read results
|
||||
const jsResults = JSON.parse(fs.readFileSync('./results-js.json', 'utf8'));
|
||||
const goResults = JSON.parse(fs.readFileSync('./results-go.json', 'utf8'));
|
||||
|
||||
console.log('=== jdenticon-js vs go-jdenticon Performance Comparison ===\n');
|
||||
|
||||
console.log('Environment:');
|
||||
console.log(` JavaScript: Node.js ${jsResults.nodeVersion}, V8 ${jsResults.v8Version}`);
|
||||
console.log(` Go: ${goResults.goVersion}`);
|
||||
console.log(` Test inputs: ${jsResults.config.numInputs} unique hash strings`);
|
||||
console.log(` Icon size: ${jsResults.config.iconSize}x${jsResults.config.iconSize} pixels\n`);
|
||||
|
||||
console.log('Performance Results:');
|
||||
console.log('┌─────────────────────────┬─────────────────┬─────────────────┬──────────────────┐');
|
||||
console.log('│ Metric │ jdenticon-js │ go-jdenticon │ Go vs JS │');
|
||||
console.log('├─────────────────────────┼─────────────────┼─────────────────┼──────────────────┤');
|
||||
|
||||
// Time per icon
|
||||
const jsTimeMs = jsResults.performance.timePerIconMs;
|
||||
const goTimeMs = goResults.performance.timePerIconMs;
|
||||
const timeDelta = ((goTimeMs - jsTimeMs) / jsTimeMs * 100);
|
||||
const timeComparison = timeDelta > 0 ? `+${timeDelta.toFixed(1)}% slower` : `${Math.abs(timeDelta).toFixed(1)}% faster`;
|
||||
|
||||
console.log(`│ Time/Icon (ms) │ ${jsTimeMs.toFixed(4).padStart(15)} │ ${goTimeMs.toFixed(4).padStart(15)} │ ${timeComparison.padStart(16)} │`);
|
||||
|
||||
// Throughput
|
||||
const jsThroughput = jsResults.performance.iconsPerSecond;
|
||||
const goThroughput = goResults.performance.iconsPerSecond;
|
||||
const throughputDelta = ((goThroughput - jsThroughput) / jsThroughput * 100);
|
||||
const throughputComparison = throughputDelta > 0 ? `+${throughputDelta.toFixed(1)}% faster` : `${Math.abs(throughputDelta).toFixed(1)}% slower`;
|
||||
|
||||
console.log(`│ Throughput (icons/sec) │ ${jsThroughput.toFixed(2).padStart(15)} │ ${goThroughput.toFixed(2).padStart(15)} │ ${throughputComparison.padStart(16)} │`);
|
||||
|
||||
// Memory - Report side-by-side without direct comparison (different methodologies)
|
||||
const jsMemoryKB = jsResults.memory.heapDeltaKB;
|
||||
const goMemoryB = goResults.memory.bytesPerOp;
|
||||
|
||||
console.log(`│ JS Heap Delta (KB) │ ${jsMemoryKB.toFixed(2).padStart(15)} │ ${'-'.padStart(15)} │ ${'N/A'.padStart(16)} │`);
|
||||
console.log(`│ Go Allocs/Op (bytes) │ ${'-'.padStart(15)} │ ${goMemoryB.toString().padStart(15)} │ ${'N/A'.padStart(16)} │`);
|
||||
|
||||
console.log('└─────────────────────────┴─────────────────┴─────────────────┴──────────────────┘\n');
|
||||
|
||||
console.log('Additional Go Metrics:');
|
||||
console.log(` Allocations per icon: ${goResults.memory.allocsPerOp} allocs`);
|
||||
console.log(` Benchmark iterations: ${goResults.config.benchmarkIterations}\n`);
|
||||
|
||||
console.log('Summary:');
|
||||
const faster = timeDelta < 0 ? 'Go' : 'JavaScript';
|
||||
const fasterPercent = Math.abs(timeDelta).toFixed(1);
|
||||
console.log(`• ${faster} is ${fasterPercent}% faster in CPU time`);
|
||||
|
||||
const targetMet = Math.abs(timeDelta) <= 20;
|
||||
const targetStatus = targetMet ? 'MEETS' : 'DOES NOT MEET';
|
||||
console.log(`• Go implementation ${targetStatus} the target of being within 20% of jdenticon-js performance`);
|
||||
|
||||
console.log(`• JS shows a heap increase of ${jsMemoryKB.toFixed(0)} KB for the batch of ${jsResults.config.numInputs} icons`);
|
||||
console.log(`• Go allocates ${goMemoryB} bytes per icon generation (different measurement methodology)`);
|
||||
console.log(`• Go has excellent memory allocation profile with only ${goResults.memory.allocsPerOp} allocations per icon`);
|
||||
|
||||
if (targetMet) {
|
||||
console.log('\n✅ Performance target achieved! Go implementation is competitive with JavaScript.');
|
||||
} else {
|
||||
console.log('\n⚠️ Performance target not met. Consider optimization opportunities.');
|
||||
}
|
||||
114
benchmark/correctness-test.js
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Correctness test to verify Go and JS implementations produce identical SVG output
|
||||
* This must pass before performance benchmarks are meaningful
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Check if jdenticon-js is available
|
||||
let jdenticon;
|
||||
try {
|
||||
jdenticon = require('../jdenticon-js/dist/jdenticon-node.js');
|
||||
} catch (err) {
|
||||
console.error('Error: jdenticon-js not found. Please ensure it\'s available in jdenticon-js/dist/');
|
||||
console.error('You may need to build the JS version first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Test inputs for correctness verification
|
||||
const testInputs = [
|
||||
'user@example.com',
|
||||
'test-hash-123',
|
||||
'benchmark-input-abc',
|
||||
'empty-string',
|
||||
'special-chars-!@#$%'
|
||||
];
|
||||
|
||||
const ICON_SIZE = 64;
|
||||
|
||||
console.log('Running correctness test...');
|
||||
console.log('Verifying Go and JS implementations produce identical SVG output\n');
|
||||
|
||||
// Create temporary directory for outputs
|
||||
const tempDir = './temp-correctness';
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir);
|
||||
}
|
||||
|
||||
let allPassed = true;
|
||||
|
||||
for (let i = 0; i < testInputs.length; i++) {
|
||||
const input = testInputs[i];
|
||||
console.log(`Testing input ${i + 1}/${testInputs.length}: "${input}"`);
|
||||
|
||||
try {
|
||||
// Generate SVG using JavaScript implementation
|
||||
const jsSvg = jdenticon.toSvg(input, ICON_SIZE);
|
||||
const jsPath = path.join(tempDir, `js-${i}.svg`);
|
||||
fs.writeFileSync(jsPath, jsSvg);
|
||||
|
||||
// Generate SVG using Go implementation via CLI
|
||||
const goPath = path.join(tempDir, `go-${i}.svg`);
|
||||
const absoluteGoPath = path.resolve(goPath);
|
||||
const goCommand = `cd .. && go run cmd/jdenticon/main.go -value="${input}" -size=${ICON_SIZE} -format=svg -output="${absoluteGoPath}"`;
|
||||
|
||||
try {
|
||||
execSync(goCommand, { stdio: 'pipe' });
|
||||
} catch (goErr) {
|
||||
console.error(` ❌ Failed to generate Go SVG: ${goErr.message}`);
|
||||
allPassed = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read Go-generated SVG
|
||||
if (!fs.existsSync(goPath)) {
|
||||
console.error(` ❌ Go SVG file not created: ${goPath}`);
|
||||
allPassed = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const goSvg = fs.readFileSync(goPath, 'utf8');
|
||||
|
||||
// Compare SVGs
|
||||
if (jsSvg === goSvg) {
|
||||
console.log(` ✅ PASS - SVGs are identical`);
|
||||
} else {
|
||||
console.log(` ❌ FAIL - SVGs differ`);
|
||||
console.log(` JS length: ${jsSvg.length} chars`);
|
||||
console.log(` Go length: ${goSvg.length} chars`);
|
||||
|
||||
// Save diff for analysis
|
||||
const diffPath = path.join(tempDir, `diff-${i}.txt`);
|
||||
fs.writeFileSync(diffPath, `JS SVG:\n${jsSvg}\n\nGo SVG:\n${goSvg}\n`);
|
||||
console.log(` Diff saved to: ${diffPath}`);
|
||||
|
||||
allPassed = false;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(` ❌ Error testing input "${input}": ${err.message}`);
|
||||
allPassed = false;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Clean up temporary files if all tests passed
|
||||
if (allPassed) {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true });
|
||||
console.log('✅ All correctness tests PASSED - SVG outputs are identical!');
|
||||
console.log(' Performance benchmarks will be meaningful.');
|
||||
} catch (cleanupErr) {
|
||||
console.log('✅ All correctness tests PASSED (cleanup failed)');
|
||||
}
|
||||
} else {
|
||||
console.log('❌ Some correctness tests FAILED');
|
||||
console.log(` Review differences in ${tempDir}/`);
|
||||
console.log(' Fix Go implementation before running performance benchmarks.');
|
||||
process.exit(1);
|
||||
}
|
||||
41
benchmark/generate-inputs.js
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate consistent test inputs for jdenticon benchmarking
|
||||
* Creates deterministic hash strings for fair comparison between Go and JS implementations
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const NUM_INPUTS = 1000;
|
||||
const inputs = [];
|
||||
|
||||
// Generate deterministic inputs by hashing incremental strings
|
||||
for (let i = 0; i < NUM_INPUTS; i++) {
|
||||
// Use a variety of input types to make the benchmark realistic
|
||||
let input;
|
||||
if (i % 4 === 0) {
|
||||
input = `user${i}@example.com`;
|
||||
} else if (i % 4 === 1) {
|
||||
input = `test-hash-${i}`;
|
||||
} else if (i % 4 === 2) {
|
||||
input = `benchmark-input-${i.toString(16)}`;
|
||||
} else {
|
||||
// Use a deterministic source for the "random" part
|
||||
const randomPart = crypto.createHash('sha1').update(`seed-${i}`).digest('hex').substring(0, 12);
|
||||
input = `random-string-${randomPart}`;
|
||||
}
|
||||
|
||||
// Generate SHA1 hash (same as jdenticon uses)
|
||||
const hash = crypto.createHash('sha1').update(input).digest('hex');
|
||||
inputs.push(hash);
|
||||
}
|
||||
|
||||
// Write inputs to JSON file
|
||||
const outputPath = './inputs.json';
|
||||
fs.writeFileSync(outputPath, JSON.stringify(inputs, null, 2));
|
||||
|
||||
console.log(`Generated ${NUM_INPUTS} test inputs and saved to ${outputPath}`);
|
||||
console.log(`Sample inputs:`);
|
||||
console.log(inputs.slice(0, 5));
|
||||
1002
benchmark/inputs.json
Normal file
58
benchmark/run-go-benchmark.sh
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Run Go benchmark and save results
|
||||
echo "=== Go jdenticon Performance Benchmark ==="
|
||||
echo "Running Go benchmark..."
|
||||
|
||||
# Run the benchmark and capture output
|
||||
BENCHMARK_OUTPUT=$(cd .. && go test -run="^$" -bench=BenchmarkGenerate64pxIcon -benchmem ./jdenticon 2>&1)
|
||||
|
||||
echo "$BENCHMARK_OUTPUT"
|
||||
|
||||
# Parse benchmark results
|
||||
# Example output: BenchmarkGenerate64pxIcon-10 92173 12492 ns/op 13174 B/op 239 allocs/op
|
||||
BENCHMARK_LINE=$(echo "$BENCHMARK_OUTPUT" | grep BenchmarkGenerate64pxIcon)
|
||||
ITERATIONS=$(echo "$BENCHMARK_LINE" | awk '{print $2}')
|
||||
NS_PER_OP=$(echo "$BENCHMARK_LINE" | awk '{print $3}')
|
||||
BYTES_PER_OP=$(echo "$BENCHMARK_LINE" | awk '{print $5}')
|
||||
ALLOCS_PER_OP=$(echo "$BENCHMARK_LINE" | awk '{print $7}')
|
||||
|
||||
# Convert nanoseconds to milliseconds and microseconds
|
||||
TIME_PER_ICON_MS=$(echo "scale=4; $NS_PER_OP / 1000000" | bc | awk '{printf "%.4f", $0}')
|
||||
TIME_PER_ICON_US=$(echo "scale=2; $NS_PER_OP / 1000" | bc | awk '{printf "%.2f", $0}')
|
||||
ICONS_PER_SECOND=$(echo "scale=2; 1000000000 / $NS_PER_OP" | bc)
|
||||
|
||||
echo ""
|
||||
echo "=== go-jdenticon Results ==="
|
||||
echo "Iterations: $ITERATIONS"
|
||||
echo "Time per icon: ${TIME_PER_ICON_MS} ms (${TIME_PER_ICON_US} μs)"
|
||||
echo "Throughput: ${ICONS_PER_SECOND} icons/sec"
|
||||
echo "Memory per icon: $BYTES_PER_OP bytes"
|
||||
echo "Allocations per icon: $ALLOCS_PER_OP allocs"
|
||||
|
||||
# Create JSON results
|
||||
cat > results-go.json << EOF
|
||||
{
|
||||
"implementation": "go-jdenticon",
|
||||
"timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")",
|
||||
"goVersion": "$(go version | awk '{print $3}')",
|
||||
"config": {
|
||||
"iconSize": 64,
|
||||
"numInputs": 1000,
|
||||
"benchmarkIterations": $ITERATIONS
|
||||
},
|
||||
"performance": {
|
||||
"nsPerOp": $NS_PER_OP,
|
||||
"timePerIconMs": $TIME_PER_ICON_MS,
|
||||
"timePerIconUs": $TIME_PER_ICON_US,
|
||||
"iconsPerSecond": $ICONS_PER_SECOND
|
||||
},
|
||||
"memory": {
|
||||
"bytesPerOp": $(echo "$BYTES_PER_OP" | sed 's/[^0-9]//g'),
|
||||
"allocsPerOp": $(echo "$ALLOCS_PER_OP" | sed 's/[^0-9]//g')
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "Results saved to: results-go.json"
|
||||
332
cmd/jdenticon/batch.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/schollz/progressbar/v3"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
const (
|
||||
// Maximum filename length to avoid filesystem issues
|
||||
maxFilenameLength = 200
|
||||
)
|
||||
|
||||
var (
|
||||
// Keep a-z, A-Z, 0-9, underscore, hyphen, and period. Replace everything else.
|
||||
sanitizeRegex = regexp.MustCompile(`[^a-zA-Z0-9_.-]+`)
|
||||
)
|
||||
|
||||
// batchJob represents a single identicon generation job
|
||||
type batchJob struct {
|
||||
value string
|
||||
outputPath string
|
||||
size int
|
||||
}
|
||||
|
||||
// batchStats tracks processing statistics atomically
|
||||
type batchStats struct {
|
||||
processed int64
|
||||
failed int64
|
||||
}
|
||||
|
||||
// batchCmd represents the batch command
|
||||
var batchCmd = &cobra.Command{
|
||||
Use: "batch <input-file>",
|
||||
Short: "Generate multiple identicons from a list using concurrent processing",
|
||||
Long: `Generate multiple identicons from a list of values in a text file.
|
||||
|
||||
The input file should contain one value per line. Each value will be used to generate
|
||||
an identicon saved to the output directory. The filename will be based on the input
|
||||
value with special characters replaced.
|
||||
|
||||
Uses concurrent processing with a worker pool for optimal performance. The number
|
||||
of concurrent workers can be controlled with the --concurrency flag.
|
||||
|
||||
Examples:
|
||||
jdenticon batch users.txt --output-dir ./avatars
|
||||
jdenticon batch emails.txt --output-dir ./avatars --format png --size 64
|
||||
jdenticon batch large-list.txt --output-dir ./avatars --concurrency 8`,
|
||||
Args: cobra.ExactArgs(1), // Validate argument count
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConcurrentBatch(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(batchCmd)
|
||||
|
||||
// Define local flags specific to 'batch'
|
||||
batchCmd.Flags().StringP("output-dir", "d", "", "Output directory for generated identicons (required)")
|
||||
_ = batchCmd.MarkFlagRequired("output-dir")
|
||||
|
||||
// Concurrency control
|
||||
batchCmd.Flags().IntP("concurrency", "c", runtime.NumCPU(),
|
||||
fmt.Sprintf("Number of concurrent workers (default: %d)", runtime.NumCPU()))
|
||||
}
|
||||
|
||||
// runConcurrentBatch executes the batch processing with concurrent workers
|
||||
func runConcurrentBatch(cmd *cobra.Command, args []string) error {
|
||||
inputFile := args[0]
|
||||
outputDir, _ := cmd.Flags().GetString("output-dir")
|
||||
concurrency, _ := cmd.Flags().GetInt("concurrency")
|
||||
|
||||
// Validate concurrency value
|
||||
if concurrency <= 0 {
|
||||
return fmt.Errorf("concurrency must be positive, got %d", concurrency)
|
||||
}
|
||||
|
||||
// Get format from viper
|
||||
format, err := getFormatFromViper()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Populate library config from root persistent flags
|
||||
config, size, err := populateConfigFromFlags()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create generator with custom config and larger cache for concurrent workload
|
||||
cacheSize := generatorCacheSize * concurrency // Scale cache with worker count
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(config, cacheSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create generator: %w", err)
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
// #nosec G301 -- 0755 is standard for directories; CLI tool needs world-readable output
|
||||
if err = os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
// Set up graceful shutdown context
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Process the file and collect jobs
|
||||
jobs, total, err := prepareJobs(inputFile, outputDir, format, size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
fmt.Fprintf(os.Stderr, "No valid entries found in input file\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize progress reporting
|
||||
showProgress := isTTY(os.Stderr)
|
||||
var bar *progressbar.ProgressBar
|
||||
if showProgress {
|
||||
bar = createProgressBar(total)
|
||||
}
|
||||
|
||||
// Execute concurrent processing
|
||||
stats, err := processConcurrentJobs(ctx, jobs, generator, format, concurrency, bar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Final status message
|
||||
processed := atomic.LoadInt64(&stats.processed)
|
||||
failed := atomic.LoadInt64(&stats.failed)
|
||||
|
||||
if showProgress {
|
||||
fmt.Fprintf(os.Stderr, "\nBatch processing complete: %d succeeded, %d failed\n", processed, failed)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Batch processing complete: %d succeeded, %d failed\n", processed, failed)
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("some identicons failed to generate")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareJobs reads the input file and creates a slice of jobs
|
||||
func prepareJobs(inputFile, outputDir string, format FormatFlag, size int) ([]batchJob, int, error) {
|
||||
// #nosec G304 -- inputFile is provided via CLI flag, validated by cobra
|
||||
file, err := os.Open(inputFile)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to open input file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var jobs []batchJob
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
value := strings.TrimSpace(scanner.Text())
|
||||
if value == "" {
|
||||
continue // Skip empty lines
|
||||
}
|
||||
|
||||
// Generate filename from value (sanitize for filesystem)
|
||||
filename := sanitizeFilename(value)
|
||||
extension := ".svg"
|
||||
if format == FormatPNG {
|
||||
extension = ".png"
|
||||
}
|
||||
outputPath := filepath.Join(outputDir, filename+extension)
|
||||
|
||||
jobs = append(jobs, batchJob{
|
||||
value: value,
|
||||
outputPath: outputPath,
|
||||
size: size,
|
||||
})
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("error reading input file: %w", err)
|
||||
}
|
||||
|
||||
return jobs, len(jobs), nil
|
||||
}
|
||||
|
||||
// createProgressBar creates a progress bar for the batch processing
|
||||
func createProgressBar(total int) *progressbar.ProgressBar {
|
||||
return progressbar.NewOptions(total,
|
||||
progressbar.OptionSetWriter(os.Stderr),
|
||||
progressbar.OptionSetDescription("Processing identicons..."),
|
||||
progressbar.OptionEnableColorCodes(true),
|
||||
progressbar.OptionShowCount(),
|
||||
progressbar.OptionSetWidth(15),
|
||||
progressbar.OptionSetTheme(progressbar.Theme{
|
||||
Saucer: "[green]=[reset]",
|
||||
SaucerHead: "[green]>[reset]",
|
||||
SaucerPadding: " ",
|
||||
BarStart: "[",
|
||||
BarEnd: "]",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// processConcurrentJobs executes jobs using a worker pool pattern
|
||||
func processConcurrentJobs(ctx context.Context, jobs []batchJob, generator *jdenticon.Generator,
|
||||
format FormatFlag, concurrency int, bar *progressbar.ProgressBar) (*batchStats, error) {
|
||||
stats := &batchStats{}
|
||||
jobChan := make(chan batchJob, len(jobs))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Start worker goroutines
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
batchWorker(ctx, workerID, jobChan, generator, format, stats, bar)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Send jobs to workers
|
||||
go func() {
|
||||
defer close(jobChan)
|
||||
for _, job := range jobs {
|
||||
select {
|
||||
case jobChan <- job:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for all workers to complete
|
||||
wg.Wait()
|
||||
|
||||
// Check if processing was interrupted
|
||||
if ctx.Err() != nil {
|
||||
return stats, fmt.Errorf("processing interrupted: %w", ctx.Err())
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// batchWorker processes jobs from the job channel
|
||||
func batchWorker(ctx context.Context, workerID int, jobs <-chan batchJob, generator *jdenticon.Generator,
|
||||
format FormatFlag, stats *batchStats, bar *progressbar.ProgressBar) {
|
||||
for {
|
||||
select {
|
||||
case job, ok := <-jobs:
|
||||
if !ok {
|
||||
// Channel closed, no more jobs
|
||||
return
|
||||
}
|
||||
|
||||
// Process the job with context for cancellation support
|
||||
if err := processJob(ctx, job, generator, format); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Worker %d failed to process %q: %v\n", workerID, job.value, err)
|
||||
atomic.AddInt64(&stats.failed, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&stats.processed, 1)
|
||||
}
|
||||
|
||||
// Update progress bar if available
|
||||
if bar != nil {
|
||||
_ = bar.Add(1)
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
// Shutdown signal received
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processJob handles a single identicon generation job
|
||||
func processJob(ctx context.Context, job batchJob, generator *jdenticon.Generator, format FormatFlag) error {
|
||||
// Generate identicon with context for cancellation support
|
||||
icon, err := generator.Generate(ctx, job.value, job.size)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate identicon: %w", err)
|
||||
}
|
||||
|
||||
// Generate output based on format
|
||||
result, err := renderIcon(icon, format)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render %s: %w", format, err)
|
||||
}
|
||||
|
||||
// Write file
|
||||
// #nosec G306 -- 0644 is appropriate for generated image files (world-readable)
|
||||
if err := os.WriteFile(job.outputPath, result, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeFilename converts a value to a safe filename using a whitelist approach
|
||||
func sanitizeFilename(value string) string {
|
||||
// Replace @ separately for readability if desired
|
||||
filename := strings.ReplaceAll(value, "@", "_at_")
|
||||
|
||||
// Replace all other invalid characters with a single underscore
|
||||
filename = sanitizeRegex.ReplaceAllString(filename, "_")
|
||||
|
||||
// Limit length to avoid filesystem issues
|
||||
if len(filename) > maxFilenameLength {
|
||||
filename = filename[:maxFilenameLength]
|
||||
}
|
||||
|
||||
return filename
|
||||
}
|
||||
|
||||
// isTTY checks if the given file descriptor is a terminal
|
||||
func isTTY(f *os.File) bool {
|
||||
return isatty.IsTerminal(f.Fd())
|
||||
}
|
||||
240
cmd/jdenticon/batch_bench_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
// benchmarkSizes defines different test scenarios for batch processing
|
||||
var benchmarkSizes = []struct {
|
||||
name string
|
||||
count int
|
||||
}{
|
||||
{"Small", 50},
|
||||
{"Medium", 200},
|
||||
{"Large", 1000},
|
||||
}
|
||||
|
||||
// BenchmarkBatchProcessing_Sequential benchmarks sequential processing (concurrency=1)
|
||||
func BenchmarkBatchProcessing_Sequential(b *testing.B) {
|
||||
for _, size := range benchmarkSizes {
|
||||
b.Run(size.name, func(b *testing.B) {
|
||||
benchmarkBatchWithConcurrency(b, size.count, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBatchProcessing_Concurrent benchmarks concurrent processing with different worker counts
|
||||
func BenchmarkBatchProcessing_Concurrent(b *testing.B) {
|
||||
concurrencyLevels := []int{2, 4, runtime.NumCPU(), runtime.NumCPU() * 2}
|
||||
|
||||
for _, size := range benchmarkSizes {
|
||||
for _, concurrency := range concurrencyLevels {
|
||||
b.Run(fmt.Sprintf("%s_Workers%d", size.name, concurrency), func(b *testing.B) {
|
||||
benchmarkBatchWithConcurrency(b, size.count, concurrency)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// benchmarkBatchWithConcurrency runs a benchmark with specific parameters
|
||||
func benchmarkBatchWithConcurrency(b *testing.B, iconCount, concurrency int) {
|
||||
// Create temporary directory for test
|
||||
tempDir := b.TempDir()
|
||||
inputFile := filepath.Join(tempDir, "test-input.txt")
|
||||
outputDir := filepath.Join(tempDir, "output")
|
||||
|
||||
// Generate test input file
|
||||
createTestInputFile(b, inputFile, iconCount)
|
||||
|
||||
// Create generator for testing with complexity limits disabled for consistent benchmarks
|
||||
config, err := jdenticon.Configure(jdenticon.WithMaxComplexity(-1))
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create config: %v", err)
|
||||
}
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(config, concurrency*100)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Clean and recreate output directory for each iteration
|
||||
os.RemoveAll(outputDir)
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
b.Fatalf("Failed to create output directory: %v", err)
|
||||
}
|
||||
|
||||
// Measure processing time
|
||||
start := time.Now()
|
||||
|
||||
// Execute batch processing
|
||||
jobs, total, err := prepareJobs(inputFile, outputDir, FormatSVG, 64)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to prepare jobs: %v", err)
|
||||
}
|
||||
|
||||
if total != iconCount {
|
||||
b.Fatalf("Expected %d jobs, got %d", iconCount, total)
|
||||
}
|
||||
|
||||
// Process jobs (simplified version without progress bar for benchmarking)
|
||||
stats := processBenchmarkJobs(jobs, generator, FormatSVG, concurrency)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
// Verify all jobs completed successfully
|
||||
processed := atomic.LoadInt64(&stats.processed)
|
||||
failed := atomic.LoadInt64(&stats.failed)
|
||||
|
||||
if processed != int64(iconCount) {
|
||||
b.Fatalf("Expected %d processed, got %d", iconCount, processed)
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
b.Fatalf("Expected 0 failures, got %d", failed)
|
||||
}
|
||||
|
||||
// Report custom metrics
|
||||
b.ReportMetric(float64(iconCount)/duration.Seconds(), "icons/sec")
|
||||
b.ReportMetric(float64(concurrency), "workers")
|
||||
}
|
||||
}
|
||||
|
||||
// processBenchmarkJobs executes jobs for benchmarking (without context cancellation)
|
||||
func processBenchmarkJobs(jobs []batchJob, generator *jdenticon.Generator, format FormatFlag, concurrency int) *batchStats {
|
||||
stats := &batchStats{}
|
||||
jobChan := make(chan batchJob, len(jobs))
|
||||
|
||||
// Start workers
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
for job := range jobChan {
|
||||
if err := processJob(context.Background(), job, generator, format); err != nil {
|
||||
atomic.AddInt64(&stats.failed, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&stats.processed, 1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Send jobs
|
||||
go func() {
|
||||
defer close(jobChan)
|
||||
for _, job := range jobs {
|
||||
jobChan <- job
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for completion
|
||||
for i := 0; i < concurrency; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// createTestInputFile generates a test input file with specified number of entries
|
||||
func createTestInputFile(b *testing.B, filename string, count int) {
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create test input file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var builder strings.Builder
|
||||
for i := 0; i < count; i++ {
|
||||
builder.WriteString(fmt.Sprintf("user%d@example.com\n", i))
|
||||
}
|
||||
|
||||
if _, err := file.WriteString(builder.String()); err != nil {
|
||||
b.Fatalf("Failed to write test input file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkJobPreparation benchmarks the job preparation phase
|
||||
func BenchmarkJobPreparation(b *testing.B) {
|
||||
for _, size := range benchmarkSizes {
|
||||
b.Run(size.name, func(b *testing.B) {
|
||||
tempDir := b.TempDir()
|
||||
inputFile := filepath.Join(tempDir, "test-input.txt")
|
||||
outputDir := filepath.Join(tempDir, "output")
|
||||
|
||||
createTestInputFile(b, inputFile, size.count)
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
jobs, total, err := prepareJobs(inputFile, outputDir, FormatSVG, 64)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to prepare jobs: %v", err)
|
||||
}
|
||||
|
||||
if total != size.count {
|
||||
b.Fatalf("Expected %d jobs, got %d", size.count, total)
|
||||
}
|
||||
|
||||
// Prevent compiler optimization
|
||||
_ = jobs
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSingleJobProcessing benchmarks individual job processing
|
||||
func BenchmarkSingleJobProcessing(b *testing.B) {
|
||||
tempDir := b.TempDir()
|
||||
config, err := jdenticon.Configure(jdenticon.WithMaxComplexity(-1))
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create config: %v", err)
|
||||
}
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(config, 100)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
job := batchJob{
|
||||
value: "test@example.com",
|
||||
outputPath: filepath.Join(tempDir, "test.svg"),
|
||||
size: 64,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := processJob(context.Background(), job, generator, FormatSVG); err != nil {
|
||||
b.Fatalf("Failed to process job: %v", err)
|
||||
}
|
||||
|
||||
// Clean up for next iteration
|
||||
os.Remove(job.outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkConcurrencyScaling analyzes how performance scales with worker count
|
||||
func BenchmarkConcurrencyScaling(b *testing.B) {
|
||||
const iconCount = 500
|
||||
maxWorkers := runtime.NumCPU() * 2
|
||||
|
||||
for workers := 1; workers <= maxWorkers; workers *= 2 {
|
||||
b.Run(fmt.Sprintf("Workers%d", workers), func(b *testing.B) {
|
||||
benchmarkBatchWithConcurrency(b, iconCount, workers)
|
||||
})
|
||||
}
|
||||
}
|
||||
599
cmd/jdenticon/batch_test.go
Normal file
@@ -0,0 +1,599 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
// TestBatchCommand tests the batch command functionality
|
||||
func TestBatchCommand(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-batch-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create test input file
|
||||
inputFile := filepath.Join(tempDir, "input.txt")
|
||||
testInputs := []string{
|
||||
"user1@example.com",
|
||||
"user2@example.com",
|
||||
"test-user",
|
||||
"unicode-üser",
|
||||
"", // Empty line should be skipped
|
||||
"special@chars!",
|
||||
}
|
||||
inputContent := strings.Join(testInputs, "\n")
|
||||
if err := os.WriteFile(inputFile, []byte(inputContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create input file: %v", err)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(tempDir, "output")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
outputCheck func(t *testing.T, outputDir string)
|
||||
}{
|
||||
{
|
||||
name: "batch generate SVG",
|
||||
args: []string{"batch", inputFile, "--output-dir", outputDir},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, outputDir string) {
|
||||
// Check that SVG files were created
|
||||
expectedFiles := []string{
|
||||
"user1_at_example.com.svg",
|
||||
"user2_at_example.com.svg",
|
||||
"test-user.svg",
|
||||
"unicode-_ser.svg", // Unicode characters get sanitized
|
||||
"special_at_chars_.svg", // Special chars get sanitized
|
||||
}
|
||||
|
||||
for _, filename := range expectedFiles {
|
||||
filepath := filepath.Join(outputDir, filename)
|
||||
if _, err := os.Stat(filepath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected file %s to be created", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check file content
|
||||
content, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read file %s: %v", filename, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.Contains(string(content), "<svg") {
|
||||
t.Errorf("File %s should contain SVG content", filename)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "batch generate PNG",
|
||||
args: []string{"batch", "--format", "png", inputFile, "--output-dir", outputDir + "-png"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, outputDir string) {
|
||||
// Check that PNG files were created
|
||||
expectedFiles := []string{
|
||||
"user1_at_example.com.png",
|
||||
"user2_at_example.com.png",
|
||||
}
|
||||
|
||||
for _, filename := range expectedFiles {
|
||||
filepath := filepath.Join(outputDir, filename)
|
||||
if _, err := os.Stat(filepath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected file %s to be created", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check PNG magic bytes
|
||||
content, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read file %s: %v", filename, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(content) < 8 || string(content[:4]) != "\x89PNG" {
|
||||
t.Errorf("File %s should contain PNG content", filename)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing output-dir",
|
||||
args: []string{"batch", inputFile},
|
||||
wantErr: true,
|
||||
outputCheck: func(t *testing.T, outputDir string) {
|
||||
// Should not create any files
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing input file",
|
||||
args: []string{"batch", "nonexistent.txt", "--output-dir", outputDir},
|
||||
wantErr: true,
|
||||
outputCheck: func(t *testing.T, outputDir string) {
|
||||
// Should not create any files
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no arguments",
|
||||
args: []string{},
|
||||
wantErr: false, // Root command shows help, doesn't error
|
||||
outputCheck: func(t *testing.T, outputDir string) {
|
||||
// Should not create any files
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
|
||||
cmd := createTestBatchCommand()
|
||||
cmd.SetArgs(tt.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("batchCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && tt.outputCheck != nil {
|
||||
// Extract output dir from args
|
||||
var testOutputDir string
|
||||
for i, arg := range tt.args {
|
||||
if arg == "--output-dir" && i+1 < len(tt.args) {
|
||||
testOutputDir = tt.args[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
if testOutputDir != "" {
|
||||
tt.outputCheck(t, testOutputDir)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchSanitizeFilename tests filename sanitization
|
||||
func TestBatchSanitizeFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
input: "user@example.com",
|
||||
expected: "user_at_example.com",
|
||||
},
|
||||
{
|
||||
input: "test-user_123",
|
||||
expected: "test-user_123",
|
||||
},
|
||||
{
|
||||
input: "unicode-üser",
|
||||
expected: "unicode-_ser",
|
||||
},
|
||||
{
|
||||
input: "special!@#$%^&*()",
|
||||
expected: "special__at__",
|
||||
},
|
||||
{
|
||||
input: "very-long-username-with-many-characters-that-exceeds-normal-length-limits-and-should-be-truncated-to-prevent-filesystem-issues",
|
||||
expected: func() string {
|
||||
longStr := "very-long-username-with-many-characters-that-exceeds-normal-length-limits-and-should-be-truncated-to-prevent-filesystem-issues"
|
||||
if len(longStr) > 200 {
|
||||
return longStr[:200]
|
||||
}
|
||||
return longStr
|
||||
}(),
|
||||
},
|
||||
{
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result := sanitizeFilename(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchWithCustomConfig tests batch generation with custom configuration
|
||||
func TestBatchWithCustomConfig(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-batch-config-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create simple input file
|
||||
inputFile := filepath.Join(tempDir, "input.txt")
|
||||
if err := os.WriteFile(inputFile, []byte("test@example.com\n"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create input file: %v", err)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(tempDir, "output")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
outputCheck func(t *testing.T, outputPath string)
|
||||
}{
|
||||
{
|
||||
name: "custom size",
|
||||
args: []string{"batch", "--size", "128", inputFile, "--output-dir", outputDir + "-size"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, outputPath string) {
|
||||
content, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "width=\"128\"") {
|
||||
t.Error("Expected SVG to have custom size")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom background color",
|
||||
args: []string{"batch", "--bg-color", "#ff0000", inputFile, "--output-dir", outputDir + "-bg"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, outputPath string) {
|
||||
content, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "#ff0000") {
|
||||
t.Error("Expected SVG to have custom background color")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom padding",
|
||||
args: []string{"batch", "--padding", "0.2", inputFile, "--output-dir", outputDir + "-pad"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, outputPath string) {
|
||||
content, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output: %v", err)
|
||||
}
|
||||
// Should still generate valid SVG
|
||||
if !strings.Contains(string(content), "<svg") {
|
||||
t.Error("Expected valid SVG output")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
|
||||
cmd := createTestBatchCommand()
|
||||
cmd.SetArgs(tt.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("batchCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && tt.outputCheck != nil {
|
||||
// Find the output directory and check the generated file
|
||||
var testOutputDir string
|
||||
for i, arg := range tt.args {
|
||||
if arg == "--output-dir" && i+1 < len(tt.args) {
|
||||
testOutputDir = tt.args[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if testOutputDir != "" {
|
||||
expectedFile := filepath.Join(testOutputDir, "test_at_example.com.svg")
|
||||
tt.outputCheck(t, expectedFile)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// createTestBatchCommand creates a batch command for testing
|
||||
func createTestBatchCommand() *cobra.Command {
|
||||
// Create root command with flags
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "jdenticon",
|
||||
Short: "Generate identicons from any input string",
|
||||
}
|
||||
|
||||
// Initialize root flags
|
||||
initTestFlags(rootCmd)
|
||||
|
||||
// Create batch command similar to the actual one
|
||||
batchCmd := &cobra.Command{
|
||||
Use: "batch <input-file>",
|
||||
Short: "Generate multiple identicons from a list",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConcurrentBatch(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
// Add batch-specific flags
|
||||
batchCmd.Flags().StringP("output-dir", "d", "", "Output directory for generated identicons (required)")
|
||||
batchCmd.MarkFlagRequired("output-dir")
|
||||
|
||||
// Concurrency control
|
||||
batchCmd.Flags().IntP("concurrency", "c", runtime.NumCPU(),
|
||||
fmt.Sprintf("Number of concurrent workers (default: %d)", runtime.NumCPU()))
|
||||
|
||||
// Add to root command
|
||||
rootCmd.AddCommand(batchCmd)
|
||||
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// TestConcurrentBatchProcessing tests the concurrent batch processing functionality
|
||||
func TestConcurrentBatchProcessing(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-concurrent-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create test input file with more entries to test concurrency
|
||||
inputFile := filepath.Join(tempDir, "input.txt")
|
||||
var inputs []string
|
||||
for i := 0; i < 50; i++ {
|
||||
inputs = append(inputs, fmt.Sprintf("user%d@example.com", i))
|
||||
}
|
||||
inputContent := strings.Join(inputs, "\n")
|
||||
if err := os.WriteFile(inputFile, []byte(inputContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create input file: %v", err)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(tempDir, "output")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
concurrency int
|
||||
expectFiles int
|
||||
}{
|
||||
{"sequential", 1, 50},
|
||||
{"small_pool", 2, 50},
|
||||
{"medium_pool", 4, 50},
|
||||
{"large_pool", runtime.NumCPU(), 50},
|
||||
{"over_provisioned", runtime.NumCPU() * 2, 50},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Clean output directory
|
||||
os.RemoveAll(outputDir)
|
||||
|
||||
// Test the concurrent batch command
|
||||
cmd := createTestBatchCommand()
|
||||
args := []string{"batch", inputFile, "--output-dir", outputDir, "--concurrency", fmt.Sprintf("%d", tt.concurrency)}
|
||||
cmd.SetArgs(args)
|
||||
|
||||
start := time.Now()
|
||||
err := cmd.Execute()
|
||||
duration := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Command failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify output files
|
||||
files, err := os.ReadDir(outputDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output directory: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != tt.expectFiles {
|
||||
t.Errorf("Expected %d files, got %d", tt.expectFiles, len(files))
|
||||
}
|
||||
|
||||
// Verify all files are valid SVG
|
||||
for _, file := range files {
|
||||
if !strings.HasSuffix(file.Name(), ".svg") {
|
||||
t.Errorf("Expected SVG file, got %s", file.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(outputDir, file.Name()))
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read file %s: %v", file.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.Contains(string(content), "<svg") {
|
||||
t.Errorf("File %s does not contain SVG content", file.Name())
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Processed %d files with %d workers in %v", tt.expectFiles, tt.concurrency, duration)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestJobPreparation tests the job preparation functionality
|
||||
func TestJobPreparation(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-job-prep-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Test with various input scenarios
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
expectJobs int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "normal_inputs",
|
||||
content: "user1@example.com\nuser2@example.com\nuser3@example.com",
|
||||
expectJobs: 3,
|
||||
},
|
||||
{
|
||||
name: "with_empty_lines",
|
||||
content: "user1@example.com\n\nuser2@example.com\n\n",
|
||||
expectJobs: 2,
|
||||
},
|
||||
{
|
||||
name: "only_empty_lines",
|
||||
content: "\n\n\n",
|
||||
expectJobs: 0,
|
||||
},
|
||||
{
|
||||
name: "mixed_content",
|
||||
content: "user1@example.com\n \nuser2@example.com\n\t\nuser3@example.com",
|
||||
expectJobs: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
inputFile := filepath.Join(tempDir, "input.txt")
|
||||
if err := os.WriteFile(inputFile, []byte(tt.content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create input file: %v", err)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(tempDir, "output")
|
||||
jobs, total, err := prepareJobs(inputFile, outputDir, FormatSVG, 64)
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Errorf("Expected error, got none")
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if total != tt.expectJobs {
|
||||
t.Errorf("Expected %d jobs, got %d", tt.expectJobs, total)
|
||||
}
|
||||
|
||||
if len(jobs) != tt.expectJobs {
|
||||
t.Errorf("Expected %d jobs in slice, got %d", tt.expectJobs, len(jobs))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchWorkerShutdown tests graceful shutdown of batch workers
|
||||
func TestBatchWorkerShutdown(t *testing.T) {
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(jdenticon.DefaultConfig(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
// Create a temp directory for output files (cross-platform)
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a context that will be canceled
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Create job channel with some jobs
|
||||
jobChan := make(chan batchJob, 10)
|
||||
for i := 0; i < 5; i++ {
|
||||
jobChan <- batchJob{
|
||||
value: fmt.Sprintf("user%d@example.com", i),
|
||||
outputPath: filepath.Join(tempDir, fmt.Sprintf("test%d.svg", i)),
|
||||
size: 64,
|
||||
}
|
||||
}
|
||||
|
||||
stats := &batchStats{}
|
||||
|
||||
// Start worker
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
batchWorker(ctx, 1, jobChan, generator, FormatSVG, stats, nil)
|
||||
}()
|
||||
|
||||
// Let it process a bit, then cancel
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
// Wait for worker to shutdown (should be quick)
|
||||
select {
|
||||
case <-done:
|
||||
// Good, worker shut down gracefully
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Error("Worker did not shut down within timeout")
|
||||
}
|
||||
|
||||
// Verify some jobs were processed
|
||||
processed := atomic.LoadInt64(&stats.processed)
|
||||
t.Logf("Processed %d jobs before shutdown", processed)
|
||||
}
|
||||
|
||||
// TestConcurrencyFlagValidation tests the validation of concurrency flag
|
||||
func TestConcurrencyFlagValidation(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-concurrency-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create minimal input file
|
||||
inputFile := filepath.Join(tempDir, "input.txt")
|
||||
if err := os.WriteFile(inputFile, []byte("test@example.com\n"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create input file: %v", err)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(tempDir, "output")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
concurrency string
|
||||
expectError bool
|
||||
}{
|
||||
{"valid_positive", "4", false},
|
||||
{"valid_one", "1", false},
|
||||
{"invalid_zero", "0", true},
|
||||
{"invalid_negative", "-1", true},
|
||||
{"invalid_non_numeric", "abc", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := createTestBatchCommand()
|
||||
args := []string{"batch", inputFile, "--output-dir", outputDir, "--concurrency", tt.concurrency}
|
||||
cmd.SetArgs(args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Errorf("Expected error for concurrency %s, got none", tt.concurrency)
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("Unexpected error for concurrency %s: %v", tt.concurrency, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
83
cmd/jdenticon/doc.go
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
The jdenticon command provides a command-line interface for generating Jdenticon
|
||||
identicons.
|
||||
|
||||
It serves as a wrapper around the `jdenticon` library, allowing users to generate
|
||||
icons directly from their terminal without writing any Go code. The tool supports
|
||||
both single icon generation and high-performance batch processing with concurrent
|
||||
workers.
|
||||
|
||||
# Usage
|
||||
|
||||
Single icon generation:
|
||||
|
||||
jdenticon [flags] <input-string>
|
||||
|
||||
Batch processing:
|
||||
|
||||
jdenticon batch [flags] <input-file>
|
||||
|
||||
# Single Icon Examples
|
||||
|
||||
1. Generate a default SVG icon and save it to a file:
|
||||
|
||||
jdenticon "my-awesome-user" > avatar.svg
|
||||
|
||||
2. Generate a 128x128 PNG icon with a custom output path:
|
||||
|
||||
jdenticon --size=128 --format=png --output=avatar.png "my-awesome-user"
|
||||
|
||||
3. Generate an SVG with custom styling:
|
||||
|
||||
jdenticon --hue=0.5 --saturation=0.8 --padding=0.1 "user@example.com" > avatar.svg
|
||||
|
||||
# Batch Processing Examples
|
||||
|
||||
1. Generate icons for multiple users (one per line in input file):
|
||||
|
||||
jdenticon batch users.txt --output-dir ./avatars
|
||||
|
||||
2. High-performance concurrent batch processing:
|
||||
|
||||
jdenticon batch large-list.txt --output-dir ./avatars --concurrency 8 --format png
|
||||
|
||||
3. Sequential processing (disable concurrency):
|
||||
|
||||
jdenticon batch users.txt --output-dir ./avatars --concurrency 1
|
||||
|
||||
# Available Flags
|
||||
|
||||
## Global Flags (apply to both single and batch generation):
|
||||
- --size: Icon size in pixels (default: 200)
|
||||
- --format: Output format, either "svg" or "png" (default: "svg")
|
||||
- --padding: Padding as percentage between 0.0 and 0.5 (default: 0.08)
|
||||
- --color-saturation: Saturation for colored shapes between 0.0 and 1.0 (default: 0.5)
|
||||
- --grayscale-saturation: Saturation for grayscale shapes between 0.0 and 1.0 (default: 0.0)
|
||||
- --bg-color: Background color in hex format (e.g., "#ffffff")
|
||||
- --hue-restrictions: Restrict hues to specific degrees (0-360)
|
||||
- --color-lightness: Color lightness range as min,max (default: "0.4,0.8")
|
||||
- --grayscale-lightness: Grayscale lightness range as min,max (default: "0.3,0.9")
|
||||
|
||||
## Single Icon Flags:
|
||||
- --output: Output file path (default: stdout)
|
||||
|
||||
## Batch Processing Flags:
|
||||
- --output-dir: Output directory for generated identicons (required)
|
||||
- --concurrency: Number of concurrent workers (default: CPU count)
|
||||
|
||||
# Performance Features
|
||||
|
||||
The batch command uses a high-performance worker pool pattern for concurrent
|
||||
processing. Key features include:
|
||||
|
||||
- Concurrent generation with configurable worker count
|
||||
- Graceful shutdown on interrupt signals (Ctrl+C)
|
||||
- Real-time progress tracking with statistics
|
||||
- Up to 3-4x performance improvement vs sequential processing
|
||||
- Thread-safe operation with no race conditions
|
||||
|
||||
This tool serves as both a practical utility and a reference implementation
|
||||
for consuming the `jdenticon` library with proper error handling and
|
||||
configuration management.
|
||||
*/
|
||||
package main
|
||||
180
cmd/jdenticon/generate.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
// isRootPath checks if the given path is a filesystem root.
|
||||
// On Unix, this is "/". On Windows, this is a drive root like "C:\" or a UNC root.
|
||||
func isRootPath(path string) bool {
|
||||
if path == "/" {
|
||||
return true
|
||||
}
|
||||
// On Windows, check for drive roots (e.g., "C:\") or when Dir(path) == path
|
||||
if runtime.GOOS == "windows" {
|
||||
// filepath.VolumeName returns "C:" for "C:\", empty for Unix paths
|
||||
vol := filepath.VolumeName(path)
|
||||
if vol != "" && (path == vol || path == vol+string(os.PathSeparator)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Generic check: if going up doesn't change the path, we're at root
|
||||
return filepath.Dir(path) == path
|
||||
}
|
||||
|
||||
// validateAndResolveOutputPath ensures the target path is within or is the base directory.
|
||||
// It returns the absolute, cleaned, and validated path, or an error.
|
||||
// The baseDir is typically os.Getwd() for a CLI tool, representing the trusted root.
|
||||
func validateAndResolveOutputPath(baseDir, outputPath string) (string, error) {
|
||||
// 1. Get absolute and cleaned path of the base directory.
|
||||
// This ensures we have a canonical, absolute reference for our allowed root.
|
||||
absBaseDir, err := filepath.Abs(baseDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get absolute path for base directory %q: %w", baseDir, err)
|
||||
}
|
||||
absBaseDir = filepath.Clean(absBaseDir)
|
||||
|
||||
// 2. Get absolute and cleaned path of the user-provided output file.
|
||||
// This resolves all relative components (., ..) and converts to an absolute path.
|
||||
absOutputPath, err := filepath.Abs(outputPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get absolute path for output file %q: %w", outputPath, err)
|
||||
}
|
||||
absOutputPath = filepath.Clean(absOutputPath)
|
||||
|
||||
// 3. Resolve any symlinks to ensure we're comparing canonical paths.
|
||||
// This handles cases like macOS where /var is symlinked to /private/var.
|
||||
absBaseDir, err = filepath.EvalSymlinks(absBaseDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve symlinks for base directory %q: %w", absBaseDir, err)
|
||||
}
|
||||
|
||||
originalOutputPath := absOutputPath
|
||||
resolvedDir, err := filepath.EvalSymlinks(filepath.Dir(absOutputPath))
|
||||
if err != nil {
|
||||
// If the directory doesn't exist yet, try to resolve up to the existing parent
|
||||
parentDir := filepath.Dir(absOutputPath)
|
||||
for !isRootPath(parentDir) && parentDir != "." {
|
||||
if resolvedParent, err := filepath.EvalSymlinks(parentDir); err == nil {
|
||||
absOutputPath = filepath.Join(resolvedParent, filepath.Base(originalOutputPath))
|
||||
break
|
||||
}
|
||||
parentDir = filepath.Dir(parentDir)
|
||||
}
|
||||
// If we couldn't resolve any parent, keep the original path
|
||||
// (absOutputPath already equals originalOutputPath, nothing to do)
|
||||
} else {
|
||||
absOutputPath = filepath.Join(resolvedDir, filepath.Base(originalOutputPath))
|
||||
}
|
||||
|
||||
absOutputPath = filepath.Clean(absOutputPath)
|
||||
|
||||
// 4. Crucial Security Check: Ensure absOutputPath is within absBaseDir.
|
||||
// a. If absOutputPath is identical to absBaseDir (e.g., user specified "."), it's allowed.
|
||||
// b. Otherwise, absOutputPath must start with absBaseDir followed by a path separator.
|
||||
// This prevents cases where absOutputPath is merely a prefix of absBaseDir
|
||||
// (e.g., /home/user/project_other vs /home/user/project).
|
||||
if absOutputPath != absBaseDir && !strings.HasPrefix(absOutputPath, absBaseDir+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("invalid output path: %q (resolved to %q) is outside allowed directory %q",
|
||||
outputPath, absOutputPath, absBaseDir)
|
||||
}
|
||||
|
||||
return absOutputPath, nil
|
||||
}
|
||||
|
||||
// generateCmd represents the generate command
|
||||
var generateCmd = &cobra.Command{
|
||||
Use: "generate <value>",
|
||||
Short: "Generate a single identicon",
|
||||
Long: `Generate a single identicon based on the provided value (e.g., a hash, username, or email).
|
||||
|
||||
The identicon will be written to stdout by default, or to a file if --output is specified.
|
||||
All configuration options from the root command apply.
|
||||
|
||||
Examples:
|
||||
jdenticon generate "user@example.com"
|
||||
jdenticon generate "user@example.com" --size 128 --format png --output avatar.png
|
||||
jdenticon generate "github-username" --color-saturation 0.7 --padding 0.1`,
|
||||
Args: cobra.ExactArgs(1), // Validate argument count at the Cobra level
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Get the input value
|
||||
value := args[0]
|
||||
|
||||
// Get output file flag
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
|
||||
// Get format from viper
|
||||
format, err := getFormatFromViper()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Populate library config from root persistent flags
|
||||
config, size, err := populateConfigFromFlags()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate the identicon with custom config
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(config, generatorCacheSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create generator: %w", err)
|
||||
}
|
||||
|
||||
icon, err := generator.Generate(context.Background(), value, size)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate identicon: %w", err)
|
||||
}
|
||||
|
||||
// Generate output based on format
|
||||
result, err := renderIcon(icon, format)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Output to file or stdout
|
||||
if outputFile != "" {
|
||||
// Determine the base directory for allowed writes. For a CLI, this is typically the CWD.
|
||||
baseDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current working directory: %w", err)
|
||||
}
|
||||
|
||||
// Validate and resolve the user-provided output path.
|
||||
safeOutputPath, err := validateAndResolveOutputPath(baseDir, outputFile)
|
||||
if err != nil {
|
||||
// This is a security-related error, explicitly state it.
|
||||
return fmt.Errorf("security error: %w", err)
|
||||
}
|
||||
|
||||
// Now use the safe and validated path for writing.
|
||||
// #nosec G306 -- 0644 is appropriate for generated image files (world-readable)
|
||||
if err := os.WriteFile(safeOutputPath, result, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write output file %q: %w", safeOutputPath, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Identicon saved to %s\n", safeOutputPath)
|
||||
} else {
|
||||
// Write to stdout for piping
|
||||
if _, err := cmd.OutOrStdout().Write(result); err != nil {
|
||||
return fmt.Errorf("failed to write to stdout: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(generateCmd)
|
||||
|
||||
// Define local flags specific to 'generate'
|
||||
generateCmd.Flags().StringP("output", "o", "", "Output file path. If empty, writes to stdout.")
|
||||
}
|
||||
660
cmd/jdenticon/generate_test.go
Normal file
@@ -0,0 +1,660 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
// TestGenerateCommand tests the generate command functionality
|
||||
func TestGenerateCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
outputCheck func(t *testing.T, output []byte, outputFile string)
|
||||
}{
|
||||
{
|
||||
name: "generate SVG to stdout",
|
||||
args: []string{"generate", "test@example.com"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
||||
if !bytes.Contains(output, []byte("<svg")) {
|
||||
t.Error("Expected SVG output to contain <svg tag")
|
||||
}
|
||||
if !bytes.Contains(output, []byte("xmlns=\"http://www.w3.org/2000/svg\"")) {
|
||||
t.Error("Expected SVG output to contain namespace declaration")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generate with custom size",
|
||||
args: []string{"generate", "--size", "128", "test@example.com"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
||||
if !bytes.Contains(output, []byte("width=\"128\"")) {
|
||||
t.Error("Expected SVG to have width=128")
|
||||
}
|
||||
if !bytes.Contains(output, []byte("height=\"128\"")) {
|
||||
t.Error("Expected SVG to have height=128")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generate PNG format",
|
||||
args: []string{"generate", "--format", "png", "test@example.com"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
||||
// PNG files start with specific magic bytes
|
||||
if len(output) < 8 || !bytes.Equal(output[:8], []byte{137, 80, 78, 71, 13, 10, 26, 10}) {
|
||||
t.Error("Expected PNG output to have PNG magic bytes")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generate with background color",
|
||||
args: []string{"generate", "--bg-color", "#ffffff", "test@example.com"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
||||
// SVG should contain background rect
|
||||
if !bytes.Contains(output, []byte("fill=\"#ffffff\"")) {
|
||||
t.Error("Expected SVG to contain background color")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generate with custom padding",
|
||||
args: []string{"generate", "--padding", "0.2", "test@example.com"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
||||
// Should generate valid SVG
|
||||
if !bytes.Contains(output, []byte("<svg")) {
|
||||
t.Error("Expected valid SVG output")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no arguments",
|
||||
args: []string{},
|
||||
wantErr: false, // Shows help, doesn't error
|
||||
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
||||
// Should show help text
|
||||
outputStr := string(output)
|
||||
if !strings.Contains(outputStr, "Usage:") && !strings.Contains(outputStr, "generate") {
|
||||
t.Error("Expected help text to be shown when no arguments provided")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "too many arguments",
|
||||
args: []string{"arg1", "arg2"},
|
||||
wantErr: true,
|
||||
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
||||
// Should not produce output on error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset viper for clean state
|
||||
viper.Reset()
|
||||
|
||||
// Create a buffer to capture output
|
||||
var output bytes.Buffer
|
||||
|
||||
// Create the generate command for testing
|
||||
cmd := createTestGenerateCommand()
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
|
||||
// Set args and execute
|
||||
cmd.SetArgs(tt.args)
|
||||
err := cmd.Execute()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("generateCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && tt.outputCheck != nil {
|
||||
tt.outputCheck(t, output.Bytes(), "")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateToFile tests file output functionality
|
||||
func TestGenerateToFile(t *testing.T) {
|
||||
// Create a temporary directory for test outputs
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Change to temp directory to test file creation there
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current directory: %v", err)
|
||||
}
|
||||
defer os.Chdir(originalDir)
|
||||
|
||||
if err := os.Chdir(tempDir); err != nil {
|
||||
t.Fatalf("Failed to change to temp directory: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
filename string
|
||||
wantErr bool
|
||||
fileCheck func(t *testing.T, filepath string)
|
||||
}{
|
||||
{
|
||||
name: "generate SVG to file",
|
||||
args: []string{"generate", "test@example.com"},
|
||||
filename: "test.svg",
|
||||
wantErr: false,
|
||||
fileCheck: func(t *testing.T, filepath string) {
|
||||
content, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
if !bytes.Contains(content, []byte("<svg")) {
|
||||
t.Error("Expected SVG file to contain <svg tag")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generate PNG to file",
|
||||
args: []string{"generate", "--format", "png", "test@example.com"},
|
||||
filename: "test.png",
|
||||
wantErr: false,
|
||||
fileCheck: func(t *testing.T, filepath string) {
|
||||
content, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
// Check PNG magic bytes
|
||||
if len(content) < 8 || !bytes.Equal(content[:8], []byte{137, 80, 78, 71, 13, 10, 26, 10}) {
|
||||
t.Error("Expected PNG file to have PNG magic bytes")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
|
||||
// Use relative path since we're in temp directory
|
||||
outputPath := tt.filename
|
||||
args := append(tt.args, "--output", outputPath)
|
||||
|
||||
var output bytes.Buffer
|
||||
cmd := createTestGenerateCommand()
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs(args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("generateCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
// Check that file was created
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected output file to be created at %s", outputPath)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.fileCheck != nil {
|
||||
tt.fileCheck(t, outputPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateValidation tests input validation
|
||||
func TestGenerateValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
errorCheck func(t *testing.T, err error)
|
||||
}{
|
||||
{
|
||||
name: "negative size",
|
||||
args: []string{"generate", "--size", "-1", "test@example.com"},
|
||||
wantErr: true,
|
||||
errorCheck: func(t *testing.T, err error) {
|
||||
if !strings.Contains(err.Error(), "size must be positive") {
|
||||
t.Errorf("Expected size validation error, got: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zero size",
|
||||
args: []string{"generate", "--size", "0", "test@example.com"},
|
||||
wantErr: true,
|
||||
errorCheck: func(t *testing.T, err error) {
|
||||
if !strings.Contains(err.Error(), "size must be positive") {
|
||||
t.Errorf("Expected size validation error, got: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid padding",
|
||||
args: []string{"generate", "--padding", "-0.1", "test@example.com"},
|
||||
wantErr: true,
|
||||
errorCheck: func(t *testing.T, err error) {
|
||||
if !strings.Contains(err.Error(), "padding") {
|
||||
t.Errorf("Expected padding validation error, got: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid color saturation",
|
||||
args: []string{"generate", "--color-saturation", "1.5", "test@example.com"},
|
||||
wantErr: true,
|
||||
errorCheck: func(t *testing.T, err error) {
|
||||
if !strings.Contains(err.Error(), "saturation") {
|
||||
t.Errorf("Expected saturation validation error, got: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
|
||||
var output bytes.Buffer
|
||||
cmd := createTestGenerateCommand()
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs(tt.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("generateCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantErr && tt.errorCheck != nil {
|
||||
tt.errorCheck(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathTraversalSecurity tests the path traversal vulnerability fix
|
||||
func TestPathTraversalSecurity(t *testing.T) {
|
||||
// Create a temporary directory for test outputs
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-security-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Change to temp directory to have a controlled test environment
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current directory: %v", err)
|
||||
}
|
||||
defer os.Chdir(originalDir)
|
||||
|
||||
if err := os.Chdir(tempDir); err != nil {
|
||||
t.Fatalf("Failed to change to temp directory: %v", err)
|
||||
}
|
||||
|
||||
// Create a subdirectory to test valid subdirectory writes
|
||||
subDir := filepath.Join(tempDir, "images")
|
||||
if err := os.Mkdir(subDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create subdirectory: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
outputPath string
|
||||
expectError bool
|
||||
errorMessage string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "valid_current_dir",
|
||||
outputPath: "avatar.png",
|
||||
expectError: false,
|
||||
description: "Should allow files in current directory",
|
||||
},
|
||||
{
|
||||
name: "valid_subdirectory",
|
||||
outputPath: "images/avatar.png",
|
||||
expectError: false,
|
||||
description: "Should allow files in subdirectories",
|
||||
},
|
||||
{
|
||||
name: "valid_relative_current",
|
||||
outputPath: "./avatar.png",
|
||||
expectError: false,
|
||||
description: "Should allow explicit current directory notation",
|
||||
},
|
||||
{
|
||||
name: "path_traversal_up_one",
|
||||
outputPath: "../avatar.png",
|
||||
expectError: true,
|
||||
errorMessage: "outside allowed directory",
|
||||
description: "Should block path traversal attempts with ../",
|
||||
},
|
||||
{
|
||||
name: "path_traversal_up_multiple",
|
||||
outputPath: "../../avatar.png",
|
||||
expectError: true,
|
||||
errorMessage: "outside allowed directory",
|
||||
description: "Should block multiple directory traversal attempts",
|
||||
},
|
||||
{
|
||||
name: "path_traversal_complex",
|
||||
outputPath: "../../../etc/passwd",
|
||||
expectError: true,
|
||||
errorMessage: "outside allowed directory",
|
||||
description: "Should block attempts to write to system directories",
|
||||
},
|
||||
{
|
||||
name: "absolute_path_system",
|
||||
outputPath: filepath.Join(os.TempDir(), "avatar.png"),
|
||||
expectError: true,
|
||||
errorMessage: "outside allowed directory",
|
||||
description: "Should block absolute paths to system directories",
|
||||
},
|
||||
{
|
||||
name: "absolute_path_root",
|
||||
outputPath: func() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return `C:\Windows\test.png`
|
||||
} else {
|
||||
return "/etc/passwd"
|
||||
}
|
||||
}(),
|
||||
expectError: true,
|
||||
errorMessage: "outside allowed directory",
|
||||
description: "Should block attempts to overwrite system files",
|
||||
},
|
||||
{
|
||||
name: "mixed_path_traversal",
|
||||
outputPath: filepath.Join(".", "images", "..", "..", "..", os.TempDir(), "avatar.png"),
|
||||
expectError: true,
|
||||
errorMessage: "outside allowed directory",
|
||||
description: "Should block mixed path traversal attempts",
|
||||
},
|
||||
{
|
||||
name: "windows_style_traversal",
|
||||
outputPath: "..\\..\\evil.exe",
|
||||
expectError: runtime.GOOS == "windows", // Only expect error on Windows
|
||||
errorMessage: "outside allowed directory",
|
||||
description: "Should block Windows-style path traversal on Windows (backslashes are valid filename chars on Unix)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
|
||||
// Test args for generating an identicon with the specified output path
|
||||
args := []string{"generate", "--output", tt.outputPath, "test@example.com"}
|
||||
|
||||
var output bytes.Buffer
|
||||
cmd := createTestGenerateCommand()
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs(args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Test %s: Expected error but got none. %s", tt.name, tt.description)
|
||||
return
|
||||
}
|
||||
if tt.errorMessage != "" && !strings.Contains(err.Error(), tt.errorMessage) {
|
||||
t.Errorf("Test %s: Expected error to contain %q, got: %v", tt.name, tt.errorMessage, err)
|
||||
}
|
||||
t.Logf("Test %s: Correctly blocked path %q with error: %v", tt.name, tt.outputPath, err)
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Test %s: Expected no error but got: %v. %s", tt.name, err, tt.description)
|
||||
return
|
||||
}
|
||||
|
||||
// For successful cases, verify the file was created in the expected location
|
||||
expectedPath := filepath.Join(tempDir, tt.outputPath)
|
||||
expectedPath = filepath.Clean(expectedPath)
|
||||
|
||||
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
||||
t.Errorf("Test %s: Expected file to be created at %s", tt.name, expectedPath)
|
||||
} else {
|
||||
t.Logf("Test %s: Successfully created file at %s", tt.name, expectedPath)
|
||||
// Clean up the created file
|
||||
os.Remove(expectedPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAndResolveOutputPath tests the security helper function directly
|
||||
func TestValidateAndResolveOutputPath(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-path-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Change to temp directory to test relative path resolution correctly
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current directory: %v", err)
|
||||
}
|
||||
defer os.Chdir(originalDir)
|
||||
|
||||
if err := os.Chdir(tempDir); err != nil {
|
||||
t.Fatalf("Failed to change to temp directory: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseDir string
|
||||
outputPath string
|
||||
expectError bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "valid_relative_file",
|
||||
baseDir: tempDir,
|
||||
outputPath: "test.png",
|
||||
expectError: false,
|
||||
description: "Valid relative file path should be allowed",
|
||||
},
|
||||
{
|
||||
name: "valid_subdirectory_file",
|
||||
baseDir: tempDir,
|
||||
outputPath: "sub/test.png",
|
||||
expectError: false,
|
||||
description: "Valid file in subdirectory should be allowed",
|
||||
},
|
||||
{
|
||||
name: "traversal_attack",
|
||||
baseDir: tempDir,
|
||||
outputPath: "../../../etc/passwd",
|
||||
expectError: true,
|
||||
description: "Path traversal attack should be blocked",
|
||||
},
|
||||
{
|
||||
name: "absolute_outside_path",
|
||||
baseDir: tempDir,
|
||||
outputPath: filepath.Join(os.TempDir(), "test.png"),
|
||||
expectError: true,
|
||||
description: "Absolute path outside base should be blocked",
|
||||
},
|
||||
{
|
||||
name: "current_dir_notation",
|
||||
baseDir: tempDir,
|
||||
outputPath: "./test.png",
|
||||
expectError: false,
|
||||
description: "Current directory notation should be allowed",
|
||||
},
|
||||
{
|
||||
name: "complex_traversal",
|
||||
baseDir: tempDir,
|
||||
outputPath: "sub/../../../secret.txt",
|
||||
expectError: true,
|
||||
description: "Complex path traversal should be blocked",
|
||||
},
|
||||
{
|
||||
name: "absolute_inside_allowed",
|
||||
baseDir: tempDir,
|
||||
outputPath: filepath.Join(tempDir, "allowed.png"),
|
||||
expectError: false,
|
||||
description: "Absolute path inside base directory should be allowed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := validateAndResolveOutputPath(tt.baseDir, tt.outputPath)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for %s, but got none. Result: %s", tt.description, result)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for %s: %v", tt.description, err)
|
||||
} else {
|
||||
// Verify the result is within the base directory
|
||||
// Use EvalSymlinks to handle macOS symlink issues
|
||||
absBase, _ := filepath.Abs(tt.baseDir)
|
||||
resolvedBase, err := filepath.EvalSymlinks(absBase)
|
||||
if err != nil {
|
||||
resolvedBase = absBase // fallback to original if symlink resolution fails
|
||||
}
|
||||
|
||||
resolvedResult, err := filepath.EvalSymlinks(filepath.Dir(result))
|
||||
if err != nil {
|
||||
resolvedResult = filepath.Dir(result) // fallback to original if symlink resolution fails
|
||||
}
|
||||
resolvedResult = filepath.Join(resolvedResult, filepath.Base(result))
|
||||
|
||||
if !strings.HasPrefix(resolvedResult, resolvedBase) {
|
||||
t.Errorf("Result path %s (resolved: %s) is not within base directory %s (resolved: %s)", result, resolvedResult, absBase, resolvedBase)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// createTestGenerateCommand creates a generate command for testing
|
||||
func createTestGenerateCommand() *cobra.Command {
|
||||
// Create root command with flags
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "jdenticon",
|
||||
Short: "Generate identicons from any input string",
|
||||
}
|
||||
|
||||
// Initialize root flags
|
||||
initTestFlags(rootCmd)
|
||||
|
||||
// Create generate command similar to the actual one
|
||||
generateCmd := &cobra.Command{
|
||||
Use: "generate <value>",
|
||||
Short: "Generate a single identicon",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Get the input value
|
||||
value := args[0]
|
||||
|
||||
// Get output file flag
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
|
||||
// Get format from viper
|
||||
format, err := getFormatFromViper()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Populate library config from root persistent flags
|
||||
config, size, err := populateConfigFromFlags()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate the identicon with custom config
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(config, generatorCacheSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
icon, err := generator.Generate(context.Background(), value, size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate output based on format
|
||||
result, err := renderIcon(icon, format)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Output to file or stdout
|
||||
if outputFile != "" {
|
||||
// Determine the base directory for allowed writes. For a CLI, this is typically the CWD.
|
||||
baseDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate and resolve the user-provided output path.
|
||||
safeOutputPath, err := validateAndResolveOutputPath(baseDir, outputFile)
|
||||
if err != nil {
|
||||
// This is a security-related error, explicitly state it.
|
||||
return err
|
||||
}
|
||||
|
||||
// Now use the safe and validated path for writing.
|
||||
if err := os.WriteFile(safeOutputPath, result, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Write to stdout for piping
|
||||
if _, err := cmd.OutOrStdout().Write(result); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Add generate-specific flags
|
||||
generateCmd.Flags().StringP("output", "o", "", "Output file path. If empty, writes to stdout.")
|
||||
|
||||
// Add to root command
|
||||
rootCmd.AddCommand(generateCmd)
|
||||
|
||||
return rootCmd
|
||||
}
|
||||
402
cmd/jdenticon/integration_test.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
// testBinaryName returns the correct test binary name for the current OS.
|
||||
// On Windows, executables need the .exe extension.
|
||||
func testBinaryName() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "jdenticon-test.exe"
|
||||
}
|
||||
return "jdenticon-test"
|
||||
}
|
||||
|
||||
// TestCLIVsLibraryOutputIdentical verifies that CLI generates identical output to the library API
|
||||
func TestCLIVsLibraryOutputIdentical(t *testing.T) {
|
||||
// Build the CLI binary first
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-integration-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cliBinary := filepath.Join(tempDir, testBinaryName())
|
||||
cmd := exec.Command("go", "build", "-o", cliBinary, ".")
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("Failed to build CLI binary: %v", err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
size int
|
||||
cliArgs []string
|
||||
configFunc func() jdenticon.Config
|
||||
}{
|
||||
{
|
||||
name: "basic SVG generation",
|
||||
input: "test@example.com",
|
||||
size: 200,
|
||||
cliArgs: []string{"generate", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
return jdenticon.DefaultConfig()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom size",
|
||||
input: "test@example.com",
|
||||
size: 128,
|
||||
cliArgs: []string{"generate", "--size", "128", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
return jdenticon.DefaultConfig()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom padding",
|
||||
input: "test@example.com",
|
||||
size: 200,
|
||||
cliArgs: []string{"generate", "--padding", "0.15", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
config := jdenticon.DefaultConfig()
|
||||
config.Padding = 0.15
|
||||
return config
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom color saturation",
|
||||
input: "test@example.com",
|
||||
size: 200,
|
||||
cliArgs: []string{"generate", "--color-saturation", "0.8", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
config := jdenticon.DefaultConfig()
|
||||
config.ColorSaturation = 0.8
|
||||
return config
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "background color",
|
||||
input: "test@example.com",
|
||||
size: 200,
|
||||
cliArgs: []string{"generate", "--bg-color", "#ffffff", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
config := jdenticon.DefaultConfig()
|
||||
config.BackgroundColor = "#ffffff"
|
||||
return config
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "grayscale saturation",
|
||||
input: "test@example.com",
|
||||
size: 200,
|
||||
cliArgs: []string{"generate", "--grayscale-saturation", "0.1", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
config := jdenticon.DefaultConfig()
|
||||
config.GrayscaleSaturation = 0.1
|
||||
return config
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom lightness ranges",
|
||||
input: "test@example.com",
|
||||
size: 200,
|
||||
cliArgs: []string{"generate", "--color-lightness", "0.3,0.7", "--grayscale-lightness", "0.2,0.8", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
config := jdenticon.DefaultConfig()
|
||||
config.ColorLightnessRange = [2]float64{0.3, 0.7}
|
||||
config.GrayscaleLightnessRange = [2]float64{0.2, 0.8}
|
||||
return config
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hue restrictions",
|
||||
input: "test@example.com",
|
||||
size: 200,
|
||||
cliArgs: []string{"generate", "--hue-restrictions", "0,120,240", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
config := jdenticon.DefaultConfig()
|
||||
config.HueRestrictions = []float64{0, 120, 240}
|
||||
return config
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Generate using library API
|
||||
config := tc.configFunc()
|
||||
librarySVG, err := jdenticon.ToSVGWithConfig(context.Background(), tc.input, tc.size, config)
|
||||
if err != nil {
|
||||
t.Fatalf("Library generation failed: %v", err)
|
||||
}
|
||||
|
||||
// Generate using CLI
|
||||
cmd := exec.Command(cliBinary, tc.cliArgs...)
|
||||
var cliOutput bytes.Buffer
|
||||
cmd.Stdout = &cliOutput
|
||||
cmd.Stderr = &cliOutput
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("CLI command failed: %v, output: %s", err, cliOutput.String())
|
||||
}
|
||||
|
||||
cliSVG := cliOutput.String()
|
||||
|
||||
// Compare outputs
|
||||
if cliSVG != librarySVG {
|
||||
t.Errorf("CLI and library outputs differ")
|
||||
t.Logf("Library output length: %d", len(librarySVG))
|
||||
t.Logf("CLI output length: %d", len(cliSVG))
|
||||
|
||||
// Find the first difference
|
||||
minLen := len(librarySVG)
|
||||
if len(cliSVG) < minLen {
|
||||
minLen = len(cliSVG)
|
||||
}
|
||||
|
||||
for i := 0; i < minLen; i++ {
|
||||
if librarySVG[i] != cliSVG[i] {
|
||||
start := i - 20
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := i + 20
|
||||
if end > minLen {
|
||||
end = minLen
|
||||
}
|
||||
|
||||
t.Logf("First difference at position %d:", i)
|
||||
t.Logf("Library: %q", librarySVG[start:end])
|
||||
t.Logf("CLI: %q", cliSVG[start:end])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCLIPNGVsLibraryOutputIdentical verifies PNG output consistency
|
||||
func TestCLIPNGVsLibraryOutputIdentical(t *testing.T) {
|
||||
// Build the CLI binary first
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-png-integration-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cliBinary := filepath.Join(tempDir, testBinaryName())
|
||||
cmd := exec.Command("go", "build", "-o", cliBinary, ".")
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("Failed to build CLI binary: %v", err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
size int
|
||||
cliArgs []string
|
||||
configFunc func() jdenticon.Config
|
||||
}{
|
||||
{
|
||||
name: "basic PNG generation",
|
||||
input: "test@example.com",
|
||||
size: 64,
|
||||
cliArgs: []string{"generate", "--format", "png", "--size", "64", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
return jdenticon.DefaultConfig()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PNG with background",
|
||||
input: "test@example.com",
|
||||
size: 64,
|
||||
cliArgs: []string{"generate", "--format", "png", "--size", "64", "--bg-color", "#ff0000", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
config := jdenticon.DefaultConfig()
|
||||
config.BackgroundColor = "#ff0000"
|
||||
return config
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Generate using library API
|
||||
config := tc.configFunc()
|
||||
libraryPNG, err := jdenticon.ToPNGWithConfig(context.Background(), tc.input, tc.size, config)
|
||||
if err != nil {
|
||||
t.Fatalf("Library PNG generation failed: %v", err)
|
||||
}
|
||||
|
||||
// Generate using CLI
|
||||
cmd := exec.Command(cliBinary, tc.cliArgs...)
|
||||
var cliOutput bytes.Buffer
|
||||
cmd.Stdout = &cliOutput
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("CLI command failed: %v", err)
|
||||
}
|
||||
|
||||
cliPNG := cliOutput.Bytes()
|
||||
|
||||
// Compare PNG outputs - they should be identical
|
||||
if !bytes.Equal(cliPNG, libraryPNG) {
|
||||
t.Errorf("CLI and library PNG outputs differ")
|
||||
t.Logf("Library PNG size: %d bytes", len(libraryPNG))
|
||||
t.Logf("CLI PNG size: %d bytes", len(cliPNG))
|
||||
|
||||
// Check PNG headers
|
||||
if len(libraryPNG) >= 8 && len(cliPNG) >= 8 {
|
||||
if !bytes.Equal(libraryPNG[:8], cliPNG[:8]) {
|
||||
t.Logf("PNG headers differ")
|
||||
t.Logf("Library: %v", libraryPNG[:8])
|
||||
t.Logf("CLI: %v", cliPNG[:8])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCLIBatchIntegration tests batch processing consistency
|
||||
func TestCLIBatchIntegration(t *testing.T) {
|
||||
// Build the CLI binary first
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-batch-integration-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cliBinary := filepath.Join(tempDir, testBinaryName())
|
||||
cmd := exec.Command("go", "build", "-o", cliBinary, ".")
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("Failed to build CLI binary: %v", err)
|
||||
}
|
||||
|
||||
// Create input file
|
||||
inputFile := filepath.Join(tempDir, "inputs.txt")
|
||||
inputs := []string{
|
||||
"user1@example.com",
|
||||
"user2@example.com",
|
||||
"test-user",
|
||||
}
|
||||
inputContent := strings.Join(inputs, "\n")
|
||||
if err := os.WriteFile(inputFile, []byte(inputContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create input file: %v", err)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(tempDir, "batch-output")
|
||||
|
||||
// Run batch command
|
||||
cmd = exec.Command(cliBinary, "batch", inputFile, "--output-dir", outputDir)
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("Batch command failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify each generated file matches library output
|
||||
config := jdenticon.DefaultConfig()
|
||||
for _, input := range inputs {
|
||||
filename := sanitizeFilename(input) + ".svg"
|
||||
filepath := filepath.Join(outputDir, filename)
|
||||
|
||||
// Read CLI-generated file
|
||||
cliContent, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read CLI-generated file for %s: %v", input, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate using library
|
||||
librarySVG, err := jdenticon.ToSVGWithConfig(context.Background(), input, 200, config)
|
||||
if err != nil {
|
||||
t.Errorf("Library generation failed for %s: %v", input, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Compare
|
||||
if string(cliContent) != librarySVG {
|
||||
t.Errorf("Batch file for %s differs from library output", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCLIErrorHandling tests that CLI properly handles error cases
|
||||
func TestCLIErrorHandling(t *testing.T) {
|
||||
// Build the CLI binary first
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-error-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cliBinary := filepath.Join(tempDir, testBinaryName())
|
||||
cmd := exec.Command("go", "build", "-o", cliBinary, ".")
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("Failed to build CLI binary: %v", err)
|
||||
}
|
||||
|
||||
errorCases := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "no arguments",
|
||||
args: []string{},
|
||||
expectError: false, // Should show help, not error
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
args: []string{"generate", "--format", "invalid", "test"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "negative size",
|
||||
args: []string{"generate", "--size", "-1", "test"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "generate no arguments",
|
||||
args: []string{"generate"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "batch missing output dir",
|
||||
args: []string{"batch", "somefile.txt"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "batch missing input file",
|
||||
args: []string{"batch", "nonexistent.txt", "--output-dir", "/tmp"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range errorCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := exec.Command(cliBinary, tc.args...)
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &output
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Errorf("Expected error but command succeeded. Output: %s", output.String())
|
||||
} else if !tc.expectError && err != nil {
|
||||
t.Errorf("Expected success but command failed: %v. Output: %s", err, output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,5 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kevin/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
value = flag.String("value", "", "Input value to generate identicon for (required)")
|
||||
size = flag.Int("size", 200, "Size of the identicon in pixels")
|
||||
format = flag.String("format", "svg", "Output format: svg or png")
|
||||
output = flag.String("output", "", "Output file (if empty, prints to stdout)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *value == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: -value is required\n")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
icon, err := jdenticon.Generate(*value, *size)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating identicon: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var result []byte
|
||||
switch *format {
|
||||
case "svg":
|
||||
svgStr, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
result = []byte(svgStr)
|
||||
case "png":
|
||||
pngBytes, err := icon.ToPNG()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating PNG: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
result = pngBytes
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid format %s (use svg or png)\n", *format)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *output != "" {
|
||||
if err := os.WriteFile(*output, result, 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Identicon saved to %s\n", *output)
|
||||
} else {
|
||||
fmt.Print(string(result))
|
||||
}
|
||||
Execute()
|
||||
}
|
||||
239
cmd/jdenticon/root.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
const (
|
||||
// generatorCacheSize defines the number of icons to cache in memory for performance.
|
||||
generatorCacheSize = 100
|
||||
)
|
||||
|
||||
var (
|
||||
// Version information - injected at build time via ldflags
|
||||
|
||||
// Version is the version string for the jdenticon CLI tool
|
||||
Version = "dev"
|
||||
|
||||
// Commit is the git commit hash for the jdenticon CLI build
|
||||
Commit = "unknown"
|
||||
|
||||
// BuildDate is the timestamp when the jdenticon CLI was built
|
||||
BuildDate = "unknown"
|
||||
|
||||
cfgFile string
|
||||
)
|
||||
|
||||
// getVersionString returns formatted version information
|
||||
func getVersionString() string {
|
||||
version := fmt.Sprintf("jdenticon version %s\n", Version)
|
||||
|
||||
if Commit != "unknown" && BuildDate != "unknown" {
|
||||
version += fmt.Sprintf("Built from commit %s on %s\n", Commit, BuildDate)
|
||||
} else if Commit != "unknown" {
|
||||
version += fmt.Sprintf("Built from commit %s\n", Commit)
|
||||
} else if BuildDate != "unknown" {
|
||||
version += fmt.Sprintf("Built on %s\n", BuildDate)
|
||||
}
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "jdenticon",
|
||||
Short: "Generate identicons from any input string",
|
||||
Long: `jdenticon is a command-line tool for generating highly recognizable identicons -
|
||||
geometric avatar images generated deterministically from any input string.
|
||||
|
||||
Generate consistent, beautiful identicons as PNG or SVG files with customizable
|
||||
color themes, padding, and styling options.`,
|
||||
Version: Version,
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
|
||||
// Set custom version template to use our detailed version info
|
||||
rootCmd.SetVersionTemplate(getVersionString())
|
||||
|
||||
// Config file flag
|
||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.jdenticon.yaml)")
|
||||
|
||||
// Basic flags shared across commands
|
||||
var formatFlag FormatFlag = FormatSVG // Default to SVG
|
||||
rootCmd.PersistentFlags().IntP("size", "s", 200, "Size of the identicon in pixels")
|
||||
rootCmd.PersistentFlags().VarP(&formatFlag, "format", "f", `Output format ("png" or "svg")`)
|
||||
rootCmd.PersistentFlags().Float64P("padding", "p", 0.08, "Padding as percentage of icon size (0.0-0.5)")
|
||||
|
||||
// Color configuration flags
|
||||
rootCmd.PersistentFlags().Float64("color-saturation", 0.5, "Saturation for colored shapes (0.0-1.0)")
|
||||
rootCmd.PersistentFlags().Float64("grayscale-saturation", 0.0, "Saturation for grayscale shapes (0.0-1.0)")
|
||||
rootCmd.PersistentFlags().String("bg-color", "", "Background color (hex format, e.g., #ffffff)")
|
||||
|
||||
// Advanced configuration flags
|
||||
rootCmd.PersistentFlags().StringSlice("hue-restrictions", nil, "Restrict hues to specific degrees (0-360), e.g., --hue-restrictions=0,120,240")
|
||||
rootCmd.PersistentFlags().String("color-lightness", "0.4,0.8", "Color lightness range as min,max (0.0-1.0)")
|
||||
rootCmd.PersistentFlags().String("grayscale-lightness", "0.3,0.9", "Grayscale lightness range as min,max (0.0-1.0)")
|
||||
rootCmd.PersistentFlags().Int("png-supersampling", 8, "PNG supersampling factor (1-16)")
|
||||
|
||||
// Bind flags to viper (errors are intentionally ignored as these bindings are non-critical)
|
||||
_ = viper.BindPFlag("size", rootCmd.PersistentFlags().Lookup("size"))
|
||||
_ = viper.BindPFlag("format", rootCmd.PersistentFlags().Lookup("format"))
|
||||
_ = viper.BindPFlag("padding", rootCmd.PersistentFlags().Lookup("padding"))
|
||||
_ = viper.BindPFlag("color-saturation", rootCmd.PersistentFlags().Lookup("color-saturation"))
|
||||
_ = viper.BindPFlag("grayscale-saturation", rootCmd.PersistentFlags().Lookup("grayscale-saturation"))
|
||||
_ = viper.BindPFlag("bg-color", rootCmd.PersistentFlags().Lookup("bg-color"))
|
||||
_ = viper.BindPFlag("hue-restrictions", rootCmd.PersistentFlags().Lookup("hue-restrictions"))
|
||||
_ = viper.BindPFlag("color-lightness", rootCmd.PersistentFlags().Lookup("color-lightness"))
|
||||
_ = viper.BindPFlag("grayscale-lightness", rootCmd.PersistentFlags().Lookup("grayscale-lightness"))
|
||||
_ = viper.BindPFlag("png-supersampling", rootCmd.PersistentFlags().Lookup("png-supersampling"))
|
||||
|
||||
// Register format flag completion
|
||||
_ = rootCmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"png", "svg"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
|
||||
// initConfig reads in config file and ENV variables if set.
|
||||
func initConfig() {
|
||||
if cfgFile != "" {
|
||||
// Use config file from the flag.
|
||||
viper.SetConfigFile(cfgFile)
|
||||
} else {
|
||||
// Find home directory.
|
||||
home, err := os.UserHomeDir()
|
||||
cobra.CheckErr(err)
|
||||
|
||||
// Search config in home directory with name ".jdenticon" (without extension).
|
||||
viper.AddConfigPath(home)
|
||||
viper.SetConfigType("yaml")
|
||||
viper.SetConfigName(".jdenticon")
|
||||
}
|
||||
|
||||
viper.AutomaticEnv() // read in environment variables that match
|
||||
|
||||
// If a config file is found, read it in.
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
// Only ignore the error if the config file doesn't exist.
|
||||
// All other errors (e.g., permission denied, malformed file) should be noted.
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
fmt.Fprintln(os.Stderr, "Error reading config file:", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
|
||||
}
|
||||
}
|
||||
|
||||
// populateConfigFromFlags creates a jdenticon.Config from viper settings and validates it
|
||||
func populateConfigFromFlags() (jdenticon.Config, int, error) {
|
||||
config := jdenticon.DefaultConfig()
|
||||
|
||||
// Get size from viper
|
||||
size := viper.GetInt("size")
|
||||
if size <= 0 {
|
||||
return config, 0, fmt.Errorf("size must be positive, got %d", size)
|
||||
}
|
||||
|
||||
// Basic configuration
|
||||
config.Padding = viper.GetFloat64("padding")
|
||||
config.ColorSaturation = viper.GetFloat64("color-saturation")
|
||||
config.GrayscaleSaturation = viper.GetFloat64("grayscale-saturation")
|
||||
config.BackgroundColor = viper.GetString("bg-color")
|
||||
config.PNGSupersampling = viper.GetInt("png-supersampling")
|
||||
|
||||
// Handle hue restrictions
|
||||
hueRestrictions := viper.GetStringSlice("hue-restrictions")
|
||||
if len(hueRestrictions) > 0 {
|
||||
hues := make([]float64, len(hueRestrictions))
|
||||
for i, hueStr := range hueRestrictions {
|
||||
var hue float64
|
||||
if _, err := fmt.Sscanf(hueStr, "%f", &hue); err != nil {
|
||||
return config, 0, fmt.Errorf("invalid hue restriction %q: %w", hueStr, err)
|
||||
}
|
||||
hues[i] = hue
|
||||
}
|
||||
config.HueRestrictions = hues
|
||||
}
|
||||
|
||||
// Handle lightness ranges
|
||||
if colorLightnessStr := viper.GetString("color-lightness"); colorLightnessStr != "" {
|
||||
parts := strings.Split(colorLightnessStr, ",")
|
||||
if len(parts) != 2 {
|
||||
return config, 0, fmt.Errorf("invalid color-lightness format: expected 'min,max', got %q", colorLightnessStr)
|
||||
}
|
||||
min, errMin := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
||||
max, errMax := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
if errMin != nil || errMax != nil {
|
||||
return config, 0, fmt.Errorf("invalid color-lightness value: %w", errors.Join(errMin, errMax))
|
||||
}
|
||||
config.ColorLightnessRange = [2]float64{min, max}
|
||||
}
|
||||
|
||||
if grayscaleLightnessStr := viper.GetString("grayscale-lightness"); grayscaleLightnessStr != "" {
|
||||
parts := strings.Split(grayscaleLightnessStr, ",")
|
||||
if len(parts) != 2 {
|
||||
return config, 0, fmt.Errorf("invalid grayscale-lightness format: expected 'min,max', got %q", grayscaleLightnessStr)
|
||||
}
|
||||
min, errMin := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
||||
max, errMax := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
if errMin != nil || errMax != nil {
|
||||
return config, 0, fmt.Errorf("invalid grayscale-lightness value: %w", errors.Join(errMin, errMax))
|
||||
}
|
||||
config.GrayscaleLightnessRange = [2]float64{min, max}
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if err := config.Validate(); err != nil {
|
||||
return config, 0, fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
return config, size, nil
|
||||
}
|
||||
|
||||
// getFormatFromViper retrieves and validates the format from Viper configuration
|
||||
func getFormatFromViper() (FormatFlag, error) {
|
||||
formatStr := viper.GetString("format")
|
||||
var format FormatFlag
|
||||
if err := format.Set(formatStr); err != nil {
|
||||
return FormatSVG, fmt.Errorf("invalid format in config: %w", err)
|
||||
}
|
||||
return format, nil
|
||||
}
|
||||
|
||||
// renderIcon converts an icon to bytes based on the specified format
|
||||
func renderIcon(icon *jdenticon.Icon, format FormatFlag) ([]byte, error) {
|
||||
switch format {
|
||||
case FormatSVG:
|
||||
svgStr, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate SVG: %w", err)
|
||||
}
|
||||
return []byte(svgStr), nil
|
||||
case FormatPNG:
|
||||
pngBytes, err := icon.ToPNG()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate PNG: %w", err)
|
||||
}
|
||||
return pngBytes, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported format: %s", format)
|
||||
}
|
||||
}
|
||||
307
cmd/jdenticon/root_test.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// TestRootCommand tests the basic structure and flags of the root command
|
||||
func TestRootCommand(t *testing.T) {
|
||||
// Reset viper for clean test state
|
||||
viper.Reset()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
validate func(t *testing.T, cmd *cobra.Command)
|
||||
}{
|
||||
{
|
||||
name: "help flag",
|
||||
args: []string{"--help"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
// Help should be available
|
||||
if !cmd.HasAvailableFlags() {
|
||||
t.Error("Expected command to have available flags")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "size flag",
|
||||
args: []string{"--size", "128"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
if viper.GetInt("size") != 128 {
|
||||
t.Errorf("Expected size=128, got %d", viper.GetInt("size"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "format flag svg",
|
||||
args: []string{"--format", "svg"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
if viper.GetString("format") != "svg" {
|
||||
t.Errorf("Expected format=svg, got %s", viper.GetString("format"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "format flag png",
|
||||
args: []string{"--format", "png"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
if viper.GetString("format") != "png" {
|
||||
t.Errorf("Expected format=png, got %s", viper.GetString("format"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
args: []string{"--format", "invalid"},
|
||||
wantErr: true,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
// Should not reach here on error
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "padding flag",
|
||||
args: []string{"--padding", "0.15"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
if viper.GetFloat64("padding") != 0.15 {
|
||||
t.Errorf("Expected padding=0.15, got %f", viper.GetFloat64("padding"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "color-saturation flag",
|
||||
args: []string{"--color-saturation", "0.8"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
if viper.GetFloat64("color-saturation") != 0.8 {
|
||||
t.Errorf("Expected color-saturation=0.8, got %f", viper.GetFloat64("color-saturation"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bg-color flag",
|
||||
args: []string{"--bg-color", "#ffffff"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
if viper.GetString("bg-color") != "#ffffff" {
|
||||
t.Errorf("Expected bg-color=#ffffff, got %s", viper.GetString("bg-color"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hue-restrictions flag",
|
||||
args: []string{"--hue-restrictions", "0,120,240"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
hues := viper.GetStringSlice("hue-restrictions")
|
||||
expected := []string{"0", "120", "240"}
|
||||
if len(hues) != len(expected) {
|
||||
t.Errorf("Expected %d hue restrictions, got %d", len(expected), len(hues))
|
||||
}
|
||||
for i, hue := range expected {
|
||||
if i >= len(hues) || hues[i] != hue {
|
||||
t.Errorf("Expected hue[%d]=%s, got %s", i, hue, hues[i])
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset viper for each test
|
||||
viper.Reset()
|
||||
|
||||
// Create a fresh root command for each test
|
||||
cmd := &cobra.Command{
|
||||
Use: "jdenticon",
|
||||
Short: "Generate identicons from any input string",
|
||||
}
|
||||
|
||||
// Re-initialize flags
|
||||
initTestFlags(cmd)
|
||||
|
||||
// Set args and execute
|
||||
cmd.SetArgs(tt.args)
|
||||
err := cmd.Execute()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && tt.validate != nil {
|
||||
tt.validate(t, cmd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// initTestFlags initializes flags for testing (similar to root init())
|
||||
func initTestFlags(cmd *cobra.Command) {
|
||||
// Basic flags shared across commands
|
||||
var formatFlag FormatFlag = FormatSVG // Default to SVG
|
||||
cmd.PersistentFlags().IntP("size", "s", 200, "Size of the identicon in pixels")
|
||||
cmd.PersistentFlags().VarP(&formatFlag, "format", "f", `Output format ("png" or "svg")`)
|
||||
cmd.PersistentFlags().Float64P("padding", "p", 0.08, "Padding as percentage of icon size (0.0-0.5)")
|
||||
|
||||
// Color configuration flags
|
||||
cmd.PersistentFlags().Float64("color-saturation", 0.5, "Saturation for colored shapes (0.0-1.0)")
|
||||
cmd.PersistentFlags().Float64("grayscale-saturation", 0.0, "Saturation for grayscale shapes (0.0-1.0)")
|
||||
cmd.PersistentFlags().String("bg-color", "", "Background color (hex format, e.g., #ffffff)")
|
||||
|
||||
// Advanced configuration flags
|
||||
cmd.PersistentFlags().StringSlice("hue-restrictions", nil, "Restrict hues to specific degrees (0-360), e.g., --hue-restrictions=0,120,240")
|
||||
cmd.PersistentFlags().String("color-lightness", "0.4,0.8", "Color lightness range as min,max (0.0-1.0)")
|
||||
cmd.PersistentFlags().String("grayscale-lightness", "0.3,0.9", "Grayscale lightness range as min,max (0.0-1.0)")
|
||||
cmd.PersistentFlags().Int("png-supersampling", 8, "PNG supersampling factor (1-16)")
|
||||
|
||||
// Bind flags to viper
|
||||
viper.BindPFlag("size", cmd.PersistentFlags().Lookup("size"))
|
||||
viper.BindPFlag("format", cmd.PersistentFlags().Lookup("format"))
|
||||
viper.BindPFlag("padding", cmd.PersistentFlags().Lookup("padding"))
|
||||
viper.BindPFlag("color-saturation", cmd.PersistentFlags().Lookup("color-saturation"))
|
||||
viper.BindPFlag("grayscale-saturation", cmd.PersistentFlags().Lookup("grayscale-saturation"))
|
||||
viper.BindPFlag("bg-color", cmd.PersistentFlags().Lookup("bg-color"))
|
||||
viper.BindPFlag("hue-restrictions", cmd.PersistentFlags().Lookup("hue-restrictions"))
|
||||
viper.BindPFlag("color-lightness", cmd.PersistentFlags().Lookup("color-lightness"))
|
||||
viper.BindPFlag("grayscale-lightness", cmd.PersistentFlags().Lookup("grayscale-lightness"))
|
||||
viper.BindPFlag("png-supersampling", cmd.PersistentFlags().Lookup("png-supersampling"))
|
||||
}
|
||||
|
||||
// TestPopulateConfigFromFlags tests the configuration building logic
|
||||
func TestPopulateConfigFromFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func()
|
||||
wantErr bool
|
||||
validate func(t *testing.T, config interface{}, size int)
|
||||
}{
|
||||
{
|
||||
name: "default config",
|
||||
setup: func() {
|
||||
viper.Reset()
|
||||
viper.Set("size", 200)
|
||||
viper.Set("format", "svg")
|
||||
viper.Set("png-supersampling", 8)
|
||||
},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, config interface{}, size int) {
|
||||
if size != 200 {
|
||||
t.Errorf("Expected size=200, got %d", size)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom config",
|
||||
setup: func() {
|
||||
viper.Reset()
|
||||
viper.Set("size", 128)
|
||||
viper.Set("padding", 0.12)
|
||||
viper.Set("color-saturation", 0.7)
|
||||
viper.Set("bg-color", "#000000")
|
||||
viper.Set("png-supersampling", 8)
|
||||
},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, config interface{}, size int) {
|
||||
if size != 128 {
|
||||
t.Errorf("Expected size=128, got %d", size)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid size",
|
||||
setup: func() {
|
||||
viper.Reset()
|
||||
viper.Set("size", -1)
|
||||
},
|
||||
wantErr: true,
|
||||
validate: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.setup()
|
||||
|
||||
config, size, err := populateConfigFromFlags()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("populateConfigFromFlags() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && tt.validate != nil {
|
||||
tt.validate(t, config, size)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFormatFromViper tests format flag validation
|
||||
func TestGetFormatFromViper(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
formatValue string
|
||||
wantFormat FormatFlag
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "svg format",
|
||||
formatValue: "svg",
|
||||
wantFormat: FormatSVG,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "png format",
|
||||
formatValue: "png",
|
||||
wantFormat: FormatPNG,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
formatValue: "invalid",
|
||||
wantFormat: FormatSVG,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
viper.Set("format", tt.formatValue)
|
||||
|
||||
format, err := getFormatFromViper()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("getFormatFromViper() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if format != tt.wantFormat {
|
||||
t.Errorf("getFormatFromViper() = %v, want %v", format, tt.wantFormat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain sets up and tears down for tests
|
||||
func TestMain(m *testing.M) {
|
||||
// Setup
|
||||
code := m.Run()
|
||||
|
||||
// Teardown
|
||||
viper.Reset()
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
35
cmd/jdenticon/types.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
// FormatFlag is a custom pflag.Value for handling --format validation and completion
|
||||
type FormatFlag string
|
||||
|
||||
const (
|
||||
// FormatPNG represents PNG output format for identicon generation
|
||||
FormatPNG FormatFlag = "png"
|
||||
|
||||
// FormatSVG represents SVG output format for identicon generation
|
||||
FormatSVG FormatFlag = "svg"
|
||||
)
|
||||
|
||||
// String is used both by pflag and fmt.Stringer
|
||||
func (f *FormatFlag) String() string {
|
||||
return string(*f)
|
||||
}
|
||||
|
||||
// Set must have pointer receiver, so it can change the value of f.
|
||||
func (f *FormatFlag) Set(v string) error {
|
||||
switch v {
|
||||
case "png", "svg":
|
||||
*f = FormatFlag(v)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf(`must be one of "png" or "svg"`)
|
||||
}
|
||||
}
|
||||
|
||||
// Type is only used in help text
|
||||
func (f *FormatFlag) Type() string {
|
||||
return "string"
|
||||
}
|
||||
110
cmd/jdenticon/types_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFormatFlag tests the custom FormatFlag type
|
||||
func TestFormatFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
expected FormatFlag
|
||||
}{
|
||||
{
|
||||
name: "valid svg",
|
||||
value: "svg",
|
||||
wantErr: false,
|
||||
expected: FormatSVG,
|
||||
},
|
||||
{
|
||||
name: "valid png",
|
||||
value: "png",
|
||||
wantErr: false,
|
||||
expected: FormatPNG,
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
value: "jpeg",
|
||||
wantErr: true,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
value: "",
|
||||
wantErr: true,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "case sensitivity",
|
||||
value: "SVG",
|
||||
wantErr: true,
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var f FormatFlag
|
||||
err := f.Set(tt.value)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("FormatFlag.Set() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && f != tt.expected {
|
||||
t.Errorf("FormatFlag.Set() = %v, want %v", f, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatFlagString tests the String() method
|
||||
func TestFormatFlagString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flag FormatFlag
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "svg format",
|
||||
flag: FormatSVG,
|
||||
expected: "svg",
|
||||
},
|
||||
{
|
||||
name: "png format",
|
||||
flag: FormatPNG,
|
||||
expected: "png",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.flag.String()
|
||||
if result != tt.expected {
|
||||
t.Errorf("FormatFlag.String() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatFlagType tests the Type() method
|
||||
func TestFormatFlagType(t *testing.T) {
|
||||
var f FormatFlag
|
||||
if f.Type() != "string" {
|
||||
t.Errorf("FormatFlag.Type() = %v, want %v", f.Type(), "string")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatFlagConstants tests that the constants are defined correctly
|
||||
func TestFormatFlagConstants(t *testing.T) {
|
||||
if FormatSVG != "svg" {
|
||||
t.Errorf("FormatSVG = %v, want %v", FormatSVG, "svg")
|
||||
}
|
||||
|
||||
if FormatPNG != "png" {
|
||||
t.Errorf("FormatPNG = %v, want %v", FormatPNG, "png")
|
||||
}
|
||||
}
|
||||
20
cmd/jdenticon/version.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version information",
|
||||
Long: "Print the version, build commit, and build date information for jdenticon CLI",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Print(getVersionString())
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
1174
coverage.txt
Normal file
@@ -1,25 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/kevin/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
testInputs := []string{"test-hash", "example1@gmail.com"}
|
||||
|
||||
for _, input := range testInputs {
|
||||
hash := jdenticon.ToHash(input)
|
||||
fmt.Printf("Input: \"%s\"\n", input)
|
||||
fmt.Printf("Go SHA1: %s\n", hash)
|
||||
|
||||
svg, err := jdenticon.ToSVG(input, 64)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("SVG length: %d\n", len(svg))
|
||||
}
|
||||
fmt.Println("---")
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
const jdenticon = require('./jdenticon-js/dist/jdenticon-node.js');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const testInputs = ['test-hash', 'example1@gmail.com'];
|
||||
|
||||
testInputs.forEach(input => {
|
||||
// Generate hash using Node.js crypto (similar to what our Go code should do)
|
||||
const nodeHash = crypto.createHash('sha1').update(input).digest('hex');
|
||||
console.log(`Input: "${input}"`);
|
||||
console.log(`Node.js SHA1: ${nodeHash}`);
|
||||
|
||||
// See what Jdenticon generates
|
||||
const svg = jdenticon.toSvg(input, 64);
|
||||
console.log(`SVG length: ${svg.length}`);
|
||||
console.log('---');
|
||||
});
|
||||
123
example_usage.go
@@ -1,123 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kevin/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Test the new public API with different input types
|
||||
|
||||
// 1. Generate SVG from email address
|
||||
fmt.Println("=== Generating SVG avatar for email ===")
|
||||
svg, err := jdenticon.ToSVG("user@example.com", 128)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("SVG length: %d characters\n", len(svg))
|
||||
fmt.Printf("SVG preview: %s...\n", svg[:100])
|
||||
|
||||
// Save SVG to file
|
||||
err = os.WriteFile("avatar_email.svg", []byte(svg), 0644)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("✅ Saved to avatar_email.svg")
|
||||
|
||||
// 2. Generate PNG from username
|
||||
fmt.Println("\n=== Generating PNG avatar for username ===")
|
||||
png, err := jdenticon.ToPNG("johndoe", 64)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("PNG size: %d bytes\n", len(png))
|
||||
|
||||
// Save PNG to file
|
||||
err = os.WriteFile("avatar_username.png", png, 0644)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("✅ Saved to avatar_username.png")
|
||||
|
||||
// 3. Generate with custom configuration
|
||||
fmt.Println("\n=== Generating with custom config ===")
|
||||
config, err := jdenticon.Configure(
|
||||
jdenticon.WithHueRestrictions([]float64{120, 240}), // Blue/green hues only
|
||||
jdenticon.WithColorSaturation(0.8),
|
||||
jdenticon.WithBackgroundColor("#ffffff"), // White background
|
||||
jdenticon.WithPadding(0.1),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
customSvg, err := jdenticon.ToSVG("custom-avatar", 96, config)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = os.WriteFile("avatar_custom.svg", []byte(customSvg), 0644)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("✅ Saved custom styled avatar to avatar_custom.svg")
|
||||
|
||||
// 4. Test different input types
|
||||
fmt.Println("\n=== Testing different input types ===")
|
||||
inputs := []interface{}{
|
||||
"hello world",
|
||||
42,
|
||||
3.14159,
|
||||
true,
|
||||
[]byte("binary data"),
|
||||
}
|
||||
|
||||
for i, input := range inputs {
|
||||
svg, err := jdenticon.ToSVG(input, 48)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
filename := fmt.Sprintf("avatar_type_%d.svg", i)
|
||||
err = os.WriteFile(filename, []byte(svg), 0644)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("✅ Generated avatar for %T: %v -> %s\n", input, input, filename)
|
||||
}
|
||||
|
||||
// 5. Show hash generation
|
||||
fmt.Println("\n=== Hash generation ===")
|
||||
testValues := []interface{}{"test", 123, []byte("data")}
|
||||
for _, val := range testValues {
|
||||
hash := jdenticon.ToHash(val)
|
||||
fmt.Printf("Hash of %v (%T): %s\n", val, val, hash)
|
||||
}
|
||||
|
||||
// 6. Generate avatars for a group of users
|
||||
fmt.Println("\n=== Group avatars ===")
|
||||
users := []string{
|
||||
"alice@company.com",
|
||||
"bob@company.com",
|
||||
"charlie@company.com",
|
||||
"diana@company.com",
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
png, err := jdenticon.ToPNG(user, 80)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("user_%s.png", user[:5]) // Use first 5 chars as filename
|
||||
err = os.WriteFile(filename, png, 0644)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("✅ Generated avatar for %s -> %s\n", user, filename)
|
||||
}
|
||||
|
||||
fmt.Println("\n🎉 All avatars generated successfully!")
|
||||
fmt.Println("Check the generated SVG and PNG files in the current directory.")
|
||||
}
|
||||
69
examples/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Go Jdenticon Examples
|
||||
|
||||
This directory contains practical examples demonstrating various usage patterns for the go-jdenticon library.
|
||||
|
||||
## Examples
|
||||
|
||||
### `concurrent-usage.go`
|
||||
|
||||
Demonstrates safe and efficient concurrent usage patterns:
|
||||
|
||||
- Package-level functions with singleton generator
|
||||
- Shared generator instances for optimal performance
|
||||
- Cache performance monitoring
|
||||
- High-throughput concurrent generation
|
||||
|
||||
**Run the example:**
|
||||
```sh
|
||||
go run examples/concurrent-usage.go
|
||||
```
|
||||
|
||||
**Run with race detection:**
|
||||
```sh
|
||||
go run -race examples/concurrent-usage.go
|
||||
```
|
||||
|
||||
The race detector confirms that all concurrent patterns are thread-safe.
|
||||
|
||||
## CLI Batch Processing
|
||||
|
||||
The CLI tool includes high-performance batch processing capabilities:
|
||||
|
||||
**Create a test input file:**
|
||||
```sh
|
||||
echo -e "alice@example.com\nbob@example.com\ncharlie@example.com" > users.txt
|
||||
```
|
||||
|
||||
**Generate icons concurrently:**
|
||||
```sh
|
||||
go run ./cmd/jdenticon batch users.txt --output-dir ./avatars --concurrency 4
|
||||
```
|
||||
|
||||
**Performance comparison:**
|
||||
```sh
|
||||
# Sequential processing
|
||||
time go run ./cmd/jdenticon batch users.txt --output-dir ./avatars --concurrency 1
|
||||
|
||||
# Concurrent processing (default: CPU count)
|
||||
time go run ./cmd/jdenticon batch users.txt --output-dir ./avatars
|
||||
```
|
||||
|
||||
The batch processing demonstrates significant performance improvements through concurrent processing.
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **All public functions are goroutine-safe** - You can call any function from multiple goroutines
|
||||
2. **Generator reuse is optimal** - Create one generator, share across goroutines
|
||||
3. **Icons are immutable** - Safe to share generated icons between goroutines
|
||||
4. **Caching improves performance** - Larger cache sizes benefit concurrent workloads
|
||||
5. **Monitor with metrics** - Use `GetCacheMetrics()` to track performance
|
||||
|
||||
## Performance Notes
|
||||
|
||||
From the concurrent usage example:
|
||||
- **Single-threaded equivalent**: ~4-15 icons/sec (race detector overhead)
|
||||
- **Concurrent (20 workers)**: ~333,000 icons/sec without cache hits
|
||||
- **Memory efficient**: ~2-6 KB per generated icon
|
||||
- **Thread-safe**: No race conditions detected
|
||||
|
||||
The library is highly optimized for concurrent workloads and scales well with the number of CPU cores.
|
||||
251
examples/concurrent-usage.go
Normal file
@@ -0,0 +1,251 @@
|
||||
// Package main demonstrates concurrent usage patterns for the go-jdenticon library.
|
||||
// This example shows safe and efficient ways to generate identicons from multiple goroutines.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Go Jdenticon - Concurrent Usage Examples")
|
||||
fmt.Println("========================================")
|
||||
|
||||
// Example 1: Using package-level functions (simplest)
|
||||
fmt.Println("\n1. Package-level functions (uses singleton generator)")
|
||||
demonstratePackageLevelConcurrency()
|
||||
|
||||
// Example 2: Shared generator instance (recommended for performance)
|
||||
fmt.Println("\n2. Shared generator instance (optimal performance)")
|
||||
demonstrateSharedGenerator()
|
||||
|
||||
// Example 3: Cache performance monitoring
|
||||
fmt.Println("\n3. Cache performance monitoring")
|
||||
demonstrateCacheMonitoring()
|
||||
|
||||
// Example 4: High-throughput concurrent generation
|
||||
fmt.Println("\n4. High-throughput concurrent generation")
|
||||
demonstrateHighThroughput()
|
||||
}
|
||||
|
||||
// demonstratePackageLevelConcurrency shows the simplest concurrent usage pattern
|
||||
func demonstratePackageLevelConcurrency() {
|
||||
userEmails := []string{
|
||||
"alice@example.com",
|
||||
"bob@example.com",
|
||||
"charlie@example.com",
|
||||
"diana@example.com",
|
||||
"eve@example.com",
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, email := range userEmails {
|
||||
wg.Add(1)
|
||||
go func(id int, userEmail string) {
|
||||
defer wg.Done()
|
||||
|
||||
// Safe: Package-level functions use internal singleton
|
||||
icon, err := jdenticon.Generate(context.Background(), userEmail, 64)
|
||||
if err != nil {
|
||||
log.Printf("Worker %d failed to generate icon: %v", id, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Icons are immutable and safe to use concurrently
|
||||
svg, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
log.Printf("Worker %d failed to generate SVG: %v", id, err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Worker %d: Generated %d-byte SVG for %s\n",
|
||||
id, len(svg), userEmail)
|
||||
}(i, email)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
fmt.Println(" ✓ All workers completed successfully")
|
||||
}
|
||||
|
||||
// demonstrateSharedGenerator shows optimal performance pattern with shared generator
|
||||
func demonstrateSharedGenerator() {
|
||||
// Create custom configuration
|
||||
config, err := jdenticon.Configure(
|
||||
jdenticon.WithColorSaturation(0.8),
|
||||
jdenticon.WithPadding(0.1),
|
||||
jdenticon.WithHueRestrictions([]float64{120, 180, 240}), // Blue/green theme
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create config: %v", err)
|
||||
}
|
||||
|
||||
// Create generator with larger cache for concurrent workload
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(config, 1000)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
const numWorkers = 10
|
||||
const iconsPerWorker = 5
|
||||
|
||||
var wg sync.WaitGroup
|
||||
start := time.Now()
|
||||
|
||||
for workerID := 0; workerID < numWorkers; workerID++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
|
||||
for i := 0; i < iconsPerWorker; i++ {
|
||||
userID := fmt.Sprintf("user-%d-%d@company.com", id, i)
|
||||
|
||||
// Safe: Multiple goroutines can use the same generator
|
||||
icon, err := generator.Generate(context.Background(), userID, 96)
|
||||
if err != nil {
|
||||
log.Printf("Worker %d failed to generate icon %d: %v", id, i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate both formats concurrently on the same icon
|
||||
var pngData []byte
|
||||
var svgData string
|
||||
var formatWg sync.WaitGroup
|
||||
|
||||
formatWg.Add(2)
|
||||
go func() {
|
||||
defer formatWg.Done()
|
||||
pngData, _ = icon.ToPNG()
|
||||
}()
|
||||
go func() {
|
||||
defer formatWg.Done()
|
||||
svgData, _ = icon.ToSVG()
|
||||
}()
|
||||
formatWg.Wait()
|
||||
|
||||
fmt.Printf(" Worker %d: Generated PNG (%d bytes) and SVG (%d bytes) for %s\n",
|
||||
id, len(pngData), len(svgData), userID)
|
||||
}
|
||||
}(workerID)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
duration := time.Since(start)
|
||||
totalIcons := numWorkers * iconsPerWorker
|
||||
|
||||
fmt.Printf(" ✓ Generated %d icons in %v (%.0f icons/sec)\n",
|
||||
totalIcons, duration, float64(totalIcons)/duration.Seconds())
|
||||
}
|
||||
|
||||
// demonstrateCacheMonitoring shows how to monitor cache performance
|
||||
func demonstrateCacheMonitoring() {
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(jdenticon.DefaultConfig(), 100)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
// Generate some icons to populate cache
|
||||
testUsers := []string{
|
||||
"user1@test.com", "user2@test.com", "user3@test.com",
|
||||
"user4@test.com", "user5@test.com",
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// First pass: populate cache
|
||||
fmt.Println(" Populating cache...")
|
||||
for _, user := range testUsers {
|
||||
wg.Add(1)
|
||||
go func(u string) {
|
||||
defer wg.Done()
|
||||
_, _ = generator.Generate(context.Background(), u, 64)
|
||||
}(user)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
hits1, misses1 := generator.GetCacheMetrics()
|
||||
fmt.Printf(" After first pass - Hits: %d, Misses: %d\n", hits1, misses1)
|
||||
|
||||
// Second pass: should hit cache
|
||||
fmt.Println(" Requesting same icons (should hit cache)...")
|
||||
for _, user := range testUsers {
|
||||
wg.Add(1)
|
||||
go func(u string) {
|
||||
defer wg.Done()
|
||||
_, _ = generator.Generate(context.Background(), u, 64)
|
||||
}(user)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
hits2, misses2 := generator.GetCacheMetrics()
|
||||
fmt.Printf(" After second pass - Hits: %d, Misses: %d\n", hits2, misses2)
|
||||
|
||||
if hits2 > hits1 {
|
||||
ratio := float64(hits2) / float64(hits2+misses2) * 100
|
||||
fmt.Printf(" ✓ Cache hit ratio: %.1f%%\n", ratio)
|
||||
}
|
||||
}
|
||||
|
||||
// demonstrateHighThroughput shows high-performance concurrent generation
|
||||
func demonstrateHighThroughput() {
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(jdenticon.DefaultConfig(), 2000)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
const numWorkers = 20
|
||||
const duration = 2 * time.Second
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stopChan := make(chan struct{})
|
||||
|
||||
// Start timer
|
||||
go func() {
|
||||
time.Sleep(duration)
|
||||
close(stopChan)
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Launch workers
|
||||
for i := 0; i < numWorkers; i++ {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
|
||||
count := 0
|
||||
for {
|
||||
select {
|
||||
case <-stopChan:
|
||||
fmt.Printf(" Worker %d generated %d icons\n", workerID, count)
|
||||
return
|
||||
default:
|
||||
userID := fmt.Sprintf("load-test-user-%d-%d", workerID, count)
|
||||
_, err := generator.Generate(context.Background(), userID, 32)
|
||||
if err != nil {
|
||||
log.Printf("Generation failed: %v", err)
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
actualDuration := time.Since(start)
|
||||
|
||||
hits, misses := generator.GetCacheMetrics()
|
||||
total := hits + misses
|
||||
throughput := float64(total) / actualDuration.Seconds()
|
||||
|
||||
fmt.Printf(" ✓ Generated %d icons in %v (%.0f icons/sec)\n",
|
||||
total, actualDuration, throughput)
|
||||
fmt.Printf(" ✓ Cache performance - Hits: %d, Misses: %d (%.1f%% hit rate)\n",
|
||||
hits, misses, float64(hits)/float64(total)*100)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kevin/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Test emails
|
||||
testEmails := []string{
|
||||
"example1@gmail.com",
|
||||
"example2@yahoo.com",
|
||||
}
|
||||
|
||||
// Test sizes
|
||||
sizes := []int{64, 128}
|
||||
|
||||
// Create go-output directory
|
||||
outDir := "./go-output"
|
||||
if _, err := os.Stat(outDir); os.IsNotExist(err) {
|
||||
os.Mkdir(outDir, 0755)
|
||||
}
|
||||
|
||||
// Generate Go versions
|
||||
for _, email := range testEmails {
|
||||
for _, size := range sizes {
|
||||
// Generate SVG
|
||||
svg, err := jdenticon.ToSVG(email, size)
|
||||
if err != nil {
|
||||
fmt.Printf("Error generating SVG for %s@%d: %v\n", email, size, err)
|
||||
continue
|
||||
}
|
||||
|
||||
svgFilename := fmt.Sprintf("%s/%s_%d.svg", outDir,
|
||||
email[0:8]+"_at_"+email[9:13]+"_com", size)
|
||||
err = os.WriteFile(svgFilename, []byte(svg), 0644)
|
||||
if err != nil {
|
||||
fmt.Printf("Error writing SVG file: %v\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Generated Go SVG: %s\n", svgFilename)
|
||||
|
||||
// Generate PNG
|
||||
pngData, err := jdenticon.ToPNG(email, size)
|
||||
if err != nil {
|
||||
fmt.Printf("Error generating PNG for %s@%d: %v\n", email, size, err)
|
||||
continue
|
||||
}
|
||||
|
||||
pngFilename := fmt.Sprintf("%s/%s_%d.png", outDir,
|
||||
email[0:8]+"_at_"+email[9:13]+"_com", size)
|
||||
err = os.WriteFile(pngFilename, pngData, 0644)
|
||||
if err != nil {
|
||||
fmt.Printf("Error writing PNG file: %v\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Generated Go PNG: %s\n", pngFilename)
|
||||
}
|
||||
}
|
||||
|
||||
// Also generate test-hash for comparison
|
||||
testSvg, err := jdenticon.ToSVG("test-hash", 64)
|
||||
if err != nil {
|
||||
fmt.Printf("Error generating test-hash SVG: %v\n", err)
|
||||
} else {
|
||||
err = os.WriteFile(outDir+"/test-hash_64.svg", []byte(testSvg), 0644)
|
||||
if err != nil {
|
||||
fmt.Printf("Error writing test-hash SVG: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Generated test-hash Go SVG")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\nGo files generated in ./go-output/ directory")
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const jdenticon = require('./jdenticon-js/dist/jdenticon-node.js');
|
||||
|
||||
// Test emails
|
||||
const testEmails = [
|
||||
'example1@gmail.com',
|
||||
'example2@yahoo.com'
|
||||
];
|
||||
|
||||
// Test sizes
|
||||
const sizes = [64, 128];
|
||||
|
||||
// Create reference directory
|
||||
const refDir = './reference';
|
||||
if (!fs.existsSync(refDir)) {
|
||||
fs.mkdirSync(refDir);
|
||||
}
|
||||
|
||||
// Generate reference SVGs and PNGs
|
||||
testEmails.forEach(email => {
|
||||
sizes.forEach(size => {
|
||||
// Generate SVG
|
||||
const svg = jdenticon.toSvg(email, size);
|
||||
const svgFilename = `${email.replace('@', '_at_').replace('.', '_')}_${size}.svg`;
|
||||
fs.writeFileSync(path.join(refDir, svgFilename), svg);
|
||||
console.log(`Generated reference SVG: ${svgFilename}`);
|
||||
|
||||
// Generate PNG (if supported)
|
||||
try {
|
||||
const pngBuffer = jdenticon.toPng(email, size);
|
||||
const pngFilename = `${email.replace('@', '_at_').replace('.', '_')}_${size}.png`;
|
||||
fs.writeFileSync(path.join(refDir, pngFilename), pngBuffer);
|
||||
console.log(`Generated reference PNG: ${pngFilename}`);
|
||||
} catch (err) {
|
||||
console.log(`PNG generation failed for ${email}@${size}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Also generate a test with fixed coordinates we can examine
|
||||
const testSvg = jdenticon.toSvg('test-hash', 64);
|
||||
fs.writeFileSync(path.join(refDir, 'test-hash_64.svg'), testSvg);
|
||||
console.log('Generated test-hash reference SVG');
|
||||
|
||||
console.log('\nReference files generated in ./reference/ directory');
|
||||
@@ -1,17 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #1 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 1
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
@@ -1,19 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #2 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 2
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This task depends on Task 1 being completed first - ensure error handling patterns are consistent.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
@@ -1,19 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #3 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 3
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This is a performance optimization task - measure before/after to ensure improvements.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
@@ -1,19 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #4 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 4
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This is a readability improvement task - replace magic numbers with named constants while keeping exact same values.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
@@ -1,19 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #5 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 5
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This is a data structure improvement task - clean up the Shape struct while maintaining all existing functionality.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
@@ -1,19 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #6 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 6
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This is a performance optimization task - replace fmt.Sprintf with more efficient string building.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
@@ -1,19 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #7 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 7
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This is a readability improvement task - simplify complex logic while maintaining exact same behavior.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
@@ -1,19 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #8 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 8
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This is a Go idioms improvement task - replace JavaScript-style patterns with idiomatic Go.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
@@ -1,19 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #9 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 9
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This task depends on Tasks 1 and 2 being completed first - build on their error handling foundations.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
@@ -1,19 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #10 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 10
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This is a performance measurement task - create benchmarks to measure icon generation speed and memory usage.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
@@ -1,19 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #11 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 11
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This is a rendering optimization task - improve polygon rendering efficiency while maintaining exact same output.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
@@ -1,21 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #12 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 12
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This task depends on Tasks 3 and 10 being completed first - ensure optimizations and benchmarks are in place.
|
||||
|
||||
⚠️ This is a concurrency feature task - add support for concurrent icon generation while maintaining thread safety.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
@@ -1,19 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #13 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 13
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This is a documentation task - create detailed documentation for public APIs and important internal functions.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
@@ -1,21 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #14 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 14
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This task depends on Task 10 being completed first - ensure benchmarks are available for CI pipeline.
|
||||
|
||||
⚠️ This is a CI/CD setup task - create automated testing pipeline for continuous quality assurance.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
@@ -1,21 +0,0 @@
|
||||
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
|
||||
|
||||
Get task #15 from taskmaster and implement the solution:
|
||||
```
|
||||
tm get-task 15
|
||||
```
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
⚠️ MUST run reference compatibility tests after any changes:
|
||||
```bash
|
||||
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
|
||||
```
|
||||
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
|
||||
|
||||
⚠️ This task depends on Tasks 1-13 being completed first - this is the final review and cleanup task.
|
||||
|
||||
⚠️ This is a comprehensive review task - perform final code review and ensure consistent style across all changes.
|
||||
|
||||
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
|
||||
|
||||
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.
|
||||
|
Before Width: | Height: | Size: 601 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><path fill="#545454" d="M64 37L37 37L37 23.5ZM10 64L10 37L23.5 37ZM37 10L64 10L64 23.5ZM37 37L37 64L23.5 64ZM37 64L10 64L10 50.5ZM37 37L37 10L50.5 10ZM10 37L37 37L37 50.5ZM64 10L64 37L50.5 37Z"/><path fill="#d19575" d="M64 50.5L64 64L50.5 64ZM91 50.5L91 64L77.5 64ZM91 77.5L91 91L77.5 91ZM64 77.5L64 91L50.5 91Z"/><path fill="#e8caba" d="M14.5 23.5a9,9 0 1,1 18,0a9,9 0 1,1 -18,0M14.5 23.5a9,9 0 1,1 18,0a9,9 0 1,1 -18,0M14.5 23.5a9,9 0 1,1 18,0a9,9 0 1,1 -18,0M14.5 23.5a9,9 0 1,1 18,0a9,9 0 1,1 -18,0"/></svg>
|
||||
|
Before Width: | Height: | Size: 598 B |
|
Before Width: | Height: | Size: 357 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path fill="#545454" d="M32 19L19 19L19 12.5ZM6 32L6 19L12.5 19ZM19 6L32 6L32 12.5ZM19 19L19 32L12.5 32ZM19 32L6 32L6 25.5ZM19 19L19 6L25.5 6ZM6 19L19 19L19 25.5ZM32 6L32 19L25.5 19Z"/><path fill="#d19575" d="M32 25.5L32 32L25.5 32ZM45 25.5L45 32L38.5 32ZM45 38.5L45 45L38.5 45ZM32 38.5L32 45L25.5 45Z"/><path fill="#e8caba" d="M8.2 12.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0M8.2 12.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0M8.2 12.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0M8.2 12.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0"/></svg>
|
||||
|
Before Width: | Height: | Size: 620 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path fill="#545454" d="M32 19L19 19L19 12.5ZM32 19L32 6L38.5 6ZM32 45L45 45L45 51.5ZM32 45L32 58L25.5 58ZM19 32L6 32L6 25.5ZM45 32L45 19L51.5 19ZM45 32L58 32L58 38.5ZM19 32L19 45L12.5 45Z"/><path fill="#d19575" d="M32 25.5L32 32L25.5 32ZM45 25.5L45 32L38.5 32ZM45 38.5L45 45L38.5 45ZM32 38.5L32 45L25.5 45Z"/><path fill="#e8caba" d="M8.2 12.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0M47.2 12.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0M47.2 51.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0M8.2 51.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0"/></svg>
|
||||
|
Before Width: | Height: | Size: 628 B |
|
Before Width: | Height: | Size: 503 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><path fill="#a83892" d="M118 37L118 64L104.5 64ZM91 37L64 37L64 23.5ZM91 64L91 37L104.5 37ZM64 10L91 10L91 23.5ZM91 10L91 37L77.5 37ZM118 64L91 64L91 50.5ZM64 37L64 10L77.5 10ZM91 37L118 37L118 50.5Z"/><path fill="#d175bf" d="M118 10L118 37L104.5 37ZM118 37L91 37L91 23.5ZM91 37L91 10L104.5 10ZM91 10L118 10L118 23.5ZM37 37L64 37L64 64L37 64ZM64 37L91 37L91 64L64 64ZM64 64L91 64L91 91L64 91ZM37 64L64 64L64 91L37 91Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 513 B |
|
Before Width: | Height: | Size: 310 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path fill="#a83892" d="M58 19L58 32L51.5 32ZM45 19L32 19L32 12.5ZM45 32L45 19L51.5 19ZM32 6L45 6L45 12.5ZM45 6L45 19L38.5 19ZM58 32L45 32L45 25.5ZM32 19L32 6L38.5 6ZM45 19L58 19L58 25.5Z"/><path fill="#d175bf" d="M58 6L58 19L51.5 19ZM58 19L45 19L45 12.5ZM45 19L45 6L51.5 6ZM45 6L58 6L58 12.5ZM19 19L32 19L32 32L19 32ZM32 19L45 19L45 32L32 32ZM32 32L45 32L45 45L32 45ZM19 32L32 32L32 45L19 45Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 485 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path fill="#a83892" d="M32 6L32 19L25.5 19ZM45 19L32 19L32 12.5ZM32 58L32 45L38.5 45ZM19 45L32 45L32 51.5ZM19 19L19 32L12.5 32ZM58 32L45 32L45 25.5ZM45 45L45 32L51.5 32ZM6 32L19 32L19 38.5Z"/><path fill="#d175bf" d="M19 6L19 19L12.5 19ZM58 19L45 19L45 12.5ZM45 58L45 45L51.5 45ZM6 45L19 45L19 51.5ZM19 19L32 19L32 32L19 32ZM23 31L31 31L31 23L23 23ZM32 19L45 19L45 32L32 32ZM36 31L44 31L44 23L36 23ZM32 32L45 32L45 45L32 45ZM36 44L44 44L44 36L36 36ZM19 32L32 32L32 45L19 45ZM23 44L31 44L31 36L23 36Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 591 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path fill="#e8e8e8" d="M19 6L32 6L32 19ZM45 6L45 19L32 19ZM45 58L32 58L32 45ZM19 58L19 45L32 45ZM6 19L19 19L19 32ZM58 19L58 32L45 32ZM58 45L45 45L45 32ZM6 45L6 32L19 32Z"/><path fill="#d175c5" d="M19 19L6 19L6 12.5ZM45 19L45 6L51.5 6ZM45 45L58 45L58 51.5ZM19 45L19 58L12.5 58ZM19 19L32 19L32 28.1L24.2 24.2L28.1 32L19 32ZM32 19L45 19L45 28.1L37.2 24.2L41.1 32L32 32ZM32 32L45 32L45 41.1L37.2 37.2L41.1 45L32 45ZM19 32L32 32L32 41.1L24.2 37.2L28.1 45L19 45Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 549 B |
34
go.mod
@@ -1,3 +1,33 @@
|
||||
module github.com/kevin/go-jdenticon
|
||||
module github.com/ungluedlabs/go-jdenticon
|
||||
|
||||
go 1.22.5
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/schollz/progressbar/v3 v3.18.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.1
|
||||
golang.org/x/sync v0.15.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.12.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/term v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
77
go.sum
Normal file
@@ -0,0 +1,77 @@
|
||||
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
||||
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
|
||||
github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
||||
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
||||
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
||||
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
22
internal/constants/limits.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package constants
|
||||
|
||||
// Default security limits for DoS protection.
|
||||
// These constants define safe default values for user inputs to prevent
|
||||
// denial of service attacks through resource exhaustion while remaining configurable.
|
||||
|
||||
// DefaultMaxIconSize is the default maximum dimension (width or height) for a generated icon.
|
||||
// A 4096x4096 RGBA image requires ~64MB of memory, which is generous for legitimate
|
||||
// use while preventing unbounded memory allocation attacks.
|
||||
// This limit is stricter than the JavaScript reference implementation for enhanced security.
|
||||
const DefaultMaxIconSize = 4096
|
||||
|
||||
// DefaultMaxInputLength is the default maximum number of bytes for the input string to be hashed.
|
||||
// 1MB is sufficient for any reasonable identifier and prevents hash computation DoS attacks.
|
||||
// Input strings longer than this are rejected before hashing begins.
|
||||
const DefaultMaxInputLength = 1 * 1024 * 1024 // 1 MB
|
||||
|
||||
// DefaultMaxComplexity is the default maximum geometric complexity score for an identicon.
|
||||
// This score is calculated as the sum of complexity points for all shapes in an identicon.
|
||||
// A complexity score of 100 allows for diverse identicons while preventing resource exhaustion.
|
||||
// This value may be adjusted based on empirical analysis of typical identicon complexity.
|
||||
const DefaultMaxComplexity = 100
|
||||
131
internal/engine/cache.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/constants"
|
||||
)
|
||||
|
||||
// CacheMetrics holds cache performance metrics using atomic operations for efficiency
|
||||
type CacheMetrics struct {
|
||||
hits int64 // Use atomic operations, no mutex needed
|
||||
misses int64 // Use atomic operations, no mutex needed
|
||||
}
|
||||
|
||||
// GetHits returns the number of cache hits
|
||||
func (m *CacheMetrics) GetHits() int64 {
|
||||
return atomic.LoadInt64(&m.hits)
|
||||
}
|
||||
|
||||
// GetMisses returns the number of cache misses
|
||||
func (m *CacheMetrics) GetMisses() int64 {
|
||||
return atomic.LoadInt64(&m.misses)
|
||||
}
|
||||
|
||||
// recordHit records a cache hit atomically
|
||||
func (m *CacheMetrics) recordHit() {
|
||||
atomic.AddInt64(&m.hits, 1)
|
||||
}
|
||||
|
||||
// recordMiss records a cache miss atomically
|
||||
func (m *CacheMetrics) recordMiss() {
|
||||
atomic.AddInt64(&m.misses, 1)
|
||||
}
|
||||
|
||||
// cacheKey generates a cache key from hash and size
|
||||
func (g *Generator) cacheKey(hash string, size float64) string {
|
||||
// Use a simple concatenation approach for better performance
|
||||
// Convert float64 size to string with appropriate precision
|
||||
return hash + ":" + strconv.FormatFloat(size, 'f', 1, 64)
|
||||
}
|
||||
|
||||
// ClearCache removes all entries from the cache and resets metrics
|
||||
func (g *Generator) ClearCache() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.cache.Purge()
|
||||
// Reset metrics
|
||||
atomic.StoreInt64(&g.metrics.hits, 0)
|
||||
atomic.StoreInt64(&g.metrics.misses, 0)
|
||||
}
|
||||
|
||||
// GetCacheSize returns the number of items currently in the cache
|
||||
func (g *Generator) GetCacheSize() int {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.cache.Len()
|
||||
}
|
||||
|
||||
// GetCacheCapacity returns the maximum number of items the cache can hold
|
||||
func (g *Generator) GetCacheCapacity() int {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
// LRU cache doesn't expose capacity, return the configured capacity from config
|
||||
return g.config.CacheSize
|
||||
}
|
||||
|
||||
// GetCacheMetrics returns the cache hit and miss counts
|
||||
func (g *Generator) GetCacheMetrics() (hits int64, misses int64) {
|
||||
return g.metrics.GetHits(), g.metrics.GetMisses()
|
||||
}
|
||||
|
||||
// SetConfig updates the generator's color configuration and clears the cache
|
||||
func (g *Generator) SetConfig(colorConfig ColorConfig) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.config.ColorConfig = colorConfig
|
||||
g.cache.Purge() // Clear cache since config changed
|
||||
}
|
||||
|
||||
// SetGeneratorConfig updates the generator's configuration, including cache size
|
||||
func (g *Generator) SetGeneratorConfig(config GeneratorConfig) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
// Validate cache size
|
||||
if config.CacheSize <= 0 {
|
||||
return fmt.Errorf("jdenticon: engine: invalid cache size: %d", config.CacheSize)
|
||||
}
|
||||
|
||||
// Create new cache with updated size if necessary
|
||||
if config.CacheSize != g.config.CacheSize {
|
||||
newCache, err := lru.New[string, *Icon](config.CacheSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jdenticon: engine: failed to create new cache: %w", err)
|
||||
}
|
||||
g.cache = newCache
|
||||
} else {
|
||||
// Same cache size, just clear existing cache
|
||||
g.cache.Purge()
|
||||
}
|
||||
|
||||
g.config = config
|
||||
|
||||
// Update resolved max icon size
|
||||
if config.MaxIconSize > 0 {
|
||||
g.maxIconSize = config.MaxIconSize
|
||||
} else {
|
||||
g.maxIconSize = constants.DefaultMaxIconSize
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConfig returns the current color configuration
|
||||
func (g *Generator) GetConfig() ColorConfig {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.config.ColorConfig
|
||||
}
|
||||
|
||||
// GetGeneratorConfig returns the current generator configuration
|
||||
func (g *Generator) GetGeneratorConfig() GeneratorConfig {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.config
|
||||
}
|
||||
449
internal/engine/cache_test.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateCaching(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
// Generate icon first time
|
||||
icon1, err := generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("First generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Check cache size
|
||||
if generator.GetCacheSize() != 1 {
|
||||
t.Errorf("Expected cache size 1, got %d", generator.GetCacheSize())
|
||||
}
|
||||
|
||||
// Generate same icon again
|
||||
icon2, err := generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Second generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Should be the same instance from cache
|
||||
if icon1 != icon2 {
|
||||
t.Error("Second generate did not return cached instance")
|
||||
}
|
||||
|
||||
// Cache size should still be 1
|
||||
if generator.GetCacheSize() != 1 {
|
||||
t.Errorf("Expected cache size 1 after second generate, got %d", generator.GetCacheSize())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCache(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
// Generate an icon to populate cache
|
||||
_, err = generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify cache has content
|
||||
if generator.GetCacheSize() == 0 {
|
||||
t.Error("Cache should not be empty after generate")
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
generator.ClearCache()
|
||||
|
||||
// Verify cache is empty
|
||||
if generator.GetCacheSize() != 0 {
|
||||
t.Errorf("Expected cache size 0 after clear, got %d", generator.GetCacheSize())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfig(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
// Generate an icon to populate cache
|
||||
_, err = generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify cache has content
|
||||
if generator.GetCacheSize() == 0 {
|
||||
t.Error("Cache should not be empty after generate")
|
||||
}
|
||||
|
||||
// Set new config
|
||||
newConfig := DefaultColorConfig()
|
||||
newConfig.IconPadding = 0.1
|
||||
generator.SetConfig(newConfig)
|
||||
|
||||
// Verify config was updated
|
||||
if generator.GetConfig().IconPadding != 0.1 {
|
||||
t.Errorf("Expected icon padding 0.1, got %f", generator.GetConfig().IconPadding)
|
||||
}
|
||||
|
||||
// Verify cache was cleared
|
||||
if generator.GetCacheSize() != 0 {
|
||||
t.Errorf("Expected cache size 0 after config change, got %d", generator.GetCacheSize())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRUCacheEviction(t *testing.T) {
|
||||
// Create generator with small cache for testing eviction
|
||||
config := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 2, // Small cache to test eviction
|
||||
}
|
||||
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewGeneratorWithConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Generate 3 different icons to trigger eviction
|
||||
hashes := []string{
|
||||
"abcdef1234567890abcdef1234567890abcdef12",
|
||||
"123456789abcdef0123456789abcdef0123456789",
|
||||
"fedcba0987654321fedcba0987654321fedcba09",
|
||||
}
|
||||
size := 64.0
|
||||
|
||||
icons := make([]*Icon, len(hashes))
|
||||
for i, hash := range hashes {
|
||||
var icon *Icon
|
||||
icon, err = generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed for hash %s: %v", hash, err)
|
||||
}
|
||||
icons[i] = icon
|
||||
}
|
||||
|
||||
// Cache should only contain 2 items (the last 2)
|
||||
if generator.GetCacheSize() != 2 {
|
||||
t.Errorf("Expected cache size 2 after eviction, got %d", generator.GetCacheSize())
|
||||
}
|
||||
|
||||
// The first icon should have been evicted, so generating it again should create a new instance
|
||||
icon1Again, err := generator.Generate(context.Background(), hashes[0], size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed for evicted hash: %v", err)
|
||||
}
|
||||
|
||||
// This should be a different instance since the first was evicted
|
||||
if icons[0] == icon1Again {
|
||||
t.Error("First icon was not evicted from cache as expected")
|
||||
}
|
||||
|
||||
// The last icon should still be cached
|
||||
icon3Again, err := generator.Generate(context.Background(), hashes[2], size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed for cached hash: %v", err)
|
||||
}
|
||||
|
||||
// This should be the same instance
|
||||
if icons[2] != icon3Again {
|
||||
t.Error("Last icon was evicted from cache unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheMetrics(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
// Initially, metrics should be zero
|
||||
hits, misses := generator.GetCacheMetrics()
|
||||
if hits != 0 || misses != 0 {
|
||||
t.Errorf("Expected initial metrics (0, 0), got (%d, %d)", hits, misses)
|
||||
}
|
||||
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
size := 64.0
|
||||
|
||||
// First generation should be a cache miss
|
||||
_, err = generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
hits, misses = generator.GetCacheMetrics()
|
||||
if hits != 0 || misses != 1 {
|
||||
t.Errorf("Expected metrics (0, 1) after first generate, got (%d, %d)", hits, misses)
|
||||
}
|
||||
|
||||
// Second generation should be a cache hit
|
||||
_, err = generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Second generate failed: %v", err)
|
||||
}
|
||||
|
||||
hits, misses = generator.GetCacheMetrics()
|
||||
if hits != 1 || misses != 1 {
|
||||
t.Errorf("Expected metrics (1, 1) after cache hit, got (%d, %d)", hits, misses)
|
||||
}
|
||||
|
||||
// Generate different icon should be another miss
|
||||
_, err = generator.Generate(context.Background(), "1234567890abcdef1234567890abcdef12345678", size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate with different hash failed: %v", err)
|
||||
}
|
||||
|
||||
hits, misses = generator.GetCacheMetrics()
|
||||
if hits != 1 || misses != 2 {
|
||||
t.Errorf("Expected metrics (1, 2) after different hash, got (%d, %d)", hits, misses)
|
||||
}
|
||||
|
||||
// Clear cache should reset metrics
|
||||
generator.ClearCache()
|
||||
hits, misses = generator.GetCacheMetrics()
|
||||
if hits != 0 || misses != 0 {
|
||||
t.Errorf("Expected metrics (0, 0) after cache clear, got (%d, %d)", hits, misses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGeneratorConfig(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
// Generate an icon to populate cache
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
_, err = generator.Generate(context.Background(), hash, 64.0)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify cache has content
|
||||
if generator.GetCacheSize() == 0 {
|
||||
t.Error("Cache should not be empty after generate")
|
||||
}
|
||||
|
||||
// Update configuration with different cache size
|
||||
newConfig := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 500,
|
||||
}
|
||||
newConfig.ColorConfig.IconPadding = 0.15
|
||||
|
||||
err = generator.SetGeneratorConfig(newConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("SetGeneratorConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify configuration was updated
|
||||
currentConfig := generator.GetGeneratorConfig()
|
||||
if currentConfig.CacheSize != 500 {
|
||||
t.Errorf("Expected cache size 500, got %d", currentConfig.CacheSize)
|
||||
}
|
||||
|
||||
if currentConfig.ColorConfig.IconPadding != 0.15 {
|
||||
t.Errorf("Expected icon padding 0.15, got %f", currentConfig.ColorConfig.IconPadding)
|
||||
}
|
||||
|
||||
// Verify cache was cleared due to config change
|
||||
if generator.GetCacheSize() != 0 {
|
||||
t.Errorf("Expected cache size 0 after config change, got %d", generator.GetCacheSize())
|
||||
}
|
||||
|
||||
// Verify cache capacity is updated
|
||||
if generator.GetCacheCapacity() != 500 {
|
||||
t.Errorf("Expected cache capacity 500, got %d", generator.GetCacheCapacity())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGeneratorConfigSameSize(t *testing.T) {
|
||||
config := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 1000, // Same as default
|
||||
}
|
||||
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewGeneratorWithConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Generate an icon to populate cache
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
_, err = generator.Generate(context.Background(), hash, 64.0)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Update configuration with same cache size but different color config
|
||||
newConfig := config
|
||||
newConfig.ColorConfig.IconPadding = 0.2
|
||||
|
||||
err = generator.SetGeneratorConfig(newConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("SetGeneratorConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Cache should be cleared even with same cache size
|
||||
if generator.GetCacheSize() != 0 {
|
||||
t.Errorf("Expected cache size 0 after config change, got %d", generator.GetCacheSize())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGeneratorConfigInvalidCacheSize(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cacheSize int
|
||||
}{
|
||||
{"Zero cache size", 0},
|
||||
{"Negative cache size", -1},
|
||||
{"Very negative cache size", -100},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
config := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: test.cacheSize,
|
||||
}
|
||||
|
||||
err := generator.SetGeneratorConfig(config)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for cache size %d, but got none", test.cacheSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentCacheAccess(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
const numGoroutines = 10
|
||||
const numGenerations = 5
|
||||
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
size := 64.0
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numGoroutines)
|
||||
|
||||
// Launch multiple goroutines that generate the same icon concurrently
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < numGenerations; j++ {
|
||||
_, err := generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
t.Errorf("Concurrent generate failed: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Cache should only contain one item since all goroutines generated the same icon
|
||||
if generator.GetCacheSize() != 1 {
|
||||
t.Errorf("Expected cache size 1 after concurrent access, got %d", generator.GetCacheSize())
|
||||
}
|
||||
|
||||
// Total cache operations should be recorded correctly (allow tolerance for singleflight deduplication)
|
||||
hits, misses := generator.GetCacheMetrics()
|
||||
totalOperations := hits + misses
|
||||
expectedOperations := int64(numGoroutines * numGenerations)
|
||||
|
||||
// Singleflight can significantly reduce counted operations when many goroutines
|
||||
// request the same key simultaneously - they share the result from one generation.
|
||||
// Allow for up to 50% reduction due to deduplication in highly concurrent scenarios.
|
||||
minExpected := expectedOperations / 2
|
||||
if totalOperations < minExpected || totalOperations > expectedOperations {
|
||||
t.Errorf("Expected %d-%d total cache operations, got %d (hits: %d, misses: %d)",
|
||||
minExpected, expectedOperations, totalOperations, hits, misses)
|
||||
}
|
||||
|
||||
// There should be at least one miss (the first generation)
|
||||
if misses < 1 {
|
||||
t.Errorf("Expected at least 1 cache miss, got %d", misses)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCacheKey(b *testing.B) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
b.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
size := 64.0
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = generator.cacheKey(hash, size)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLRUCacheHit(b *testing.B) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
b.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
size := 64.0
|
||||
|
||||
// Pre-populate cache
|
||||
_, err = generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("Pre-populate failed: %v", err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLRUCacheMiss(b *testing.B) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
b.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
size := 64.0
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Use different hash each time to ensure cache miss
|
||||
hash := fmt.Sprintf("%040x", i)
|
||||
_, err := generator.GenerateWithoutCache(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,36 +3,85 @@ package engine
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Color-related constants
|
||||
const (
|
||||
// Alpha channel constants
|
||||
defaultAlphaValue = 255 // Default alpha value for opaque colors
|
||||
|
||||
// RGB/HSL conversion constants
|
||||
rgbComponentMax = 255.0 // Maximum RGB component value
|
||||
rgbMaxValue = 255 // Maximum RGB value as integer
|
||||
hueCycle = 6.0 // Hue cycle length for HSL conversion
|
||||
hslMidpoint = 0.5 // HSL lightness midpoint
|
||||
|
||||
// Grayscale detection threshold
|
||||
grayscaleToleranceThreshold = 0.01 // Threshold for detecting grayscale colors
|
||||
|
||||
// Hue calculation constants
|
||||
hueSegmentCount = 6 // Number of hue segments for correction
|
||||
hueRounding = 0.5 // Rounding offset for hue indexing
|
||||
|
||||
// Color theme lightness values (matches JavaScript implementation)
|
||||
colorThemeDarkLightness = 0.0 // Dark color lightness value
|
||||
colorThemeMidLightness = 0.5 // Mid color lightness value
|
||||
colorThemeFullLightness = 1.0 // Full lightness value
|
||||
|
||||
// Hex color string buffer sizes
|
||||
hexColorLength = 7 // #rrggbb = 7 characters
|
||||
hexColorAlphaLength = 9 // #rrggbbaa = 9 characters
|
||||
)
|
||||
|
||||
// Lightness correctors for each hue segment (based on JavaScript implementation)
|
||||
var correctors = []float64{0.55, 0.5, 0.5, 0.46, 0.6, 0.55, 0.55}
|
||||
// These values are carefully tuned to match the JavaScript reference implementation
|
||||
var correctors = []float64{
|
||||
0.55, // Red hues (0°-60°)
|
||||
0.5, // Yellow hues (60°-120°)
|
||||
0.5, // Green hues (120°-180°)
|
||||
0.46, // Cyan hues (180°-240°)
|
||||
0.6, // Blue hues (240°-300°)
|
||||
0.55, // Magenta hues (300°-360°)
|
||||
0.55, // Wrap-around for edge cases
|
||||
}
|
||||
|
||||
// Color represents a color with both HSL and RGB representations
|
||||
// Color represents a color with HSL representation and on-demand RGB conversion
|
||||
type Color struct {
|
||||
H, S, L float64 // HSL values: H=[0,1], S=[0,1], L=[0,1]
|
||||
R, G, B uint8 // RGB values: [0,255]
|
||||
A uint8 // Alpha channel: [0,255]
|
||||
corrected bool // Whether to use corrected HSL to RGB conversion
|
||||
}
|
||||
|
||||
// ToRGB returns the RGB values computed from HSL using appropriate conversion
|
||||
func (c Color) ToRGB() (r, g, b uint8, err error) {
|
||||
if c.corrected {
|
||||
return CorrectedHSLToRGB(c.H, c.S, c.L)
|
||||
}
|
||||
return HSLToRGB(c.H, c.S, c.L)
|
||||
}
|
||||
|
||||
// ToRGBA returns the RGBA values computed from HSL using appropriate conversion
|
||||
func (c Color) ToRGBA() (r, g, b, a uint8, err error) {
|
||||
r, g, b, err = c.ToRGB()
|
||||
return r, g, b, c.A, err
|
||||
}
|
||||
|
||||
// NewColorHSL creates a new Color from HSL values
|
||||
func NewColorHSL(h, s, l float64) Color {
|
||||
r, g, b := HSLToRGB(h, s, l)
|
||||
return Color{
|
||||
H: h, S: s, L: l,
|
||||
R: r, G: g, B: b,
|
||||
A: 255,
|
||||
A: defaultAlphaValue,
|
||||
corrected: false,
|
||||
}
|
||||
}
|
||||
|
||||
// NewColorCorrectedHSL creates a new Color from HSL values with lightness correction
|
||||
func NewColorCorrectedHSL(h, s, l float64) Color {
|
||||
r, g, b := CorrectedHSLToRGB(h, s, l)
|
||||
return Color{
|
||||
H: h, S: s, L: l,
|
||||
R: r, G: g, B: b,
|
||||
A: 255,
|
||||
A: defaultAlphaValue,
|
||||
corrected: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +90,8 @@ func NewColorRGB(r, g, b uint8) Color {
|
||||
h, s, l := RGBToHSL(r, g, b)
|
||||
return Color{
|
||||
H: h, S: s, L: l,
|
||||
R: r, G: g, B: b,
|
||||
A: 255,
|
||||
A: defaultAlphaValue,
|
||||
corrected: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,56 +100,90 @@ func NewColorRGBA(r, g, b, a uint8) Color {
|
||||
h, s, l := RGBToHSL(r, g, b)
|
||||
return Color{
|
||||
H: h, S: s, L: l,
|
||||
R: r, G: g, B: b,
|
||||
A: a,
|
||||
corrected: false,
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the hex representation of the color
|
||||
func (c Color) String() string {
|
||||
if c.A == 255 {
|
||||
return RGBToHex(c.R, c.G, c.B)
|
||||
r, g, b, err := c.ToRGB()
|
||||
if err != nil {
|
||||
// Return a fallback color (black) if conversion fails
|
||||
// This maintains the string contract while indicating an error state
|
||||
r, g, b = 0, 0, 0
|
||||
}
|
||||
return fmt.Sprintf("#%02x%02x%02x%02x", c.R, c.G, c.B, c.A)
|
||||
|
||||
if c.A == defaultAlphaValue {
|
||||
return RGBToHex(r, g, b)
|
||||
}
|
||||
|
||||
// Use strings.Builder for RGBA format
|
||||
var buf strings.Builder
|
||||
buf.Grow(hexColorAlphaLength)
|
||||
|
||||
buf.WriteByte('#')
|
||||
writeHexByte(&buf, r)
|
||||
writeHexByte(&buf, g)
|
||||
writeHexByte(&buf, b)
|
||||
writeHexByte(&buf, c.A)
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// Equals compares two colors for equality
|
||||
func (c Color) Equals(other Color) bool {
|
||||
return c.R == other.R && c.G == other.G && c.B == other.B && c.A == other.A
|
||||
r1, g1, b1, err1 := c.ToRGB()
|
||||
r2, g2, b2, err2 := other.ToRGB()
|
||||
|
||||
// If either color has a conversion error, they are not equal
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return r1 == r2 && g1 == g2 && b1 == b2 && c.A == other.A
|
||||
}
|
||||
|
||||
// WithAlpha returns a new color with the specified alpha value
|
||||
func (c Color) WithAlpha(alpha uint8) Color {
|
||||
return Color{
|
||||
H: c.H, S: c.S, L: c.L,
|
||||
R: c.R, G: c.G, B: c.B,
|
||||
A: alpha,
|
||||
corrected: c.corrected,
|
||||
}
|
||||
}
|
||||
|
||||
// IsGrayscale returns true if the color is grayscale (saturation near zero)
|
||||
func (c Color) IsGrayscale() bool {
|
||||
return c.S < 0.01 // Small tolerance for floating point comparison
|
||||
return c.S < grayscaleToleranceThreshold
|
||||
}
|
||||
|
||||
// Darken returns a new color with reduced lightness
|
||||
func (c Color) Darken(amount float64) Color {
|
||||
newL := clamp(c.L-amount, 0, 1)
|
||||
return NewColorCorrectedHSL(c.H, c.S, newL)
|
||||
return Color{
|
||||
H: c.H, S: c.S, L: newL,
|
||||
A: c.A,
|
||||
corrected: c.corrected,
|
||||
}
|
||||
}
|
||||
|
||||
// Lighten returns a new color with increased lightness
|
||||
func (c Color) Lighten(amount float64) Color {
|
||||
newL := clamp(c.L+amount, 0, 1)
|
||||
return NewColorCorrectedHSL(c.H, c.S, newL)
|
||||
return Color{
|
||||
H: c.H, S: c.S, L: newL,
|
||||
A: c.A,
|
||||
corrected: c.corrected,
|
||||
}
|
||||
}
|
||||
|
||||
// RGBToHSL converts RGB values to HSL
|
||||
// Returns H=[0,1], S=[0,1], L=[0,1]
|
||||
func RGBToHSL(r, g, b uint8) (h, s, l float64) {
|
||||
rf := float64(r) / 255.0
|
||||
gf := float64(g) / 255.0
|
||||
bf := float64(b) / 255.0
|
||||
rf := float64(r) / rgbComponentMax
|
||||
gf := float64(g) / rgbComponentMax
|
||||
bf := float64(b) / rgbComponentMax
|
||||
|
||||
max := math.Max(rf, math.Max(gf, bf))
|
||||
min := math.Min(rf, math.Min(gf, bf))
|
||||
@@ -115,7 +198,7 @@ func RGBToHSL(r, g, b uint8) (h, s, l float64) {
|
||||
delta := max - min
|
||||
|
||||
// Calculate saturation
|
||||
if l > 0.5 {
|
||||
if l > hslMidpoint {
|
||||
s = delta / (2.0 - max - min)
|
||||
} else {
|
||||
s = delta / (max + min)
|
||||
@@ -124,18 +207,16 @@ func RGBToHSL(r, g, b uint8) (h, s, l float64) {
|
||||
// Calculate hue
|
||||
switch max {
|
||||
case rf:
|
||||
h = (gf-bf)/delta + (func() float64 {
|
||||
h = (gf - bf) / delta
|
||||
if gf < bf {
|
||||
return 6
|
||||
h += 6
|
||||
}
|
||||
return 0
|
||||
})()
|
||||
case gf:
|
||||
h = (bf-rf)/delta + 2
|
||||
case bf:
|
||||
h = (rf-gf)/delta + 4
|
||||
}
|
||||
h /= 6.0
|
||||
h /= hueCycle
|
||||
}
|
||||
|
||||
return h, s, l
|
||||
@@ -145,8 +226,8 @@ func RGBToHSL(r, g, b uint8) (h, s, l float64) {
|
||||
// h: hue in range [0, 1]
|
||||
// s: saturation in range [0, 1]
|
||||
// l: lightness in range [0, 1]
|
||||
// Returns RGB values in range [0, 255]
|
||||
func HSLToRGB(h, s, l float64) (r, g, b uint8) {
|
||||
// Returns RGB values in range [0, 255] and an error if conversion fails
|
||||
func HSLToRGB(h, s, l float64) (r, g, b uint8, err error) {
|
||||
// Clamp input values to valid ranges
|
||||
h = math.Mod(h, 1.0)
|
||||
if h < 0 {
|
||||
@@ -158,13 +239,13 @@ func HSLToRGB(h, s, l float64) (r, g, b uint8) {
|
||||
// Handle grayscale case (saturation = 0)
|
||||
if s == 0 {
|
||||
// All RGB components are equal for grayscale
|
||||
gray := uint8(clamp(l*255, 0, 255))
|
||||
return gray, gray, gray
|
||||
gray := uint8(clamp(l*rgbComponentMax, 0, rgbComponentMax))
|
||||
return gray, gray, gray, nil
|
||||
}
|
||||
|
||||
// Calculate intermediate values for HSL to RGB conversion
|
||||
var m2 float64
|
||||
if l <= 0.5 {
|
||||
if l <= hslMidpoint {
|
||||
m2 = l * (s + 1)
|
||||
} else {
|
||||
m2 = l + s - l*s
|
||||
@@ -172,32 +253,54 @@ func HSLToRGB(h, s, l float64) (r, g, b uint8) {
|
||||
m1 := l*2 - m2
|
||||
|
||||
// Convert each RGB component
|
||||
r = uint8(clamp(hueToRGB(m1, m2, h*6+2)*255, 0, 255))
|
||||
g = uint8(clamp(hueToRGB(m1, m2, h*6)*255, 0, 255))
|
||||
b = uint8(clamp(hueToRGB(m1, m2, h*6-2)*255, 0, 255))
|
||||
rf := hueToRGB(m1, m2, h*hueCycle+2) * rgbComponentMax
|
||||
gf := hueToRGB(m1, m2, h*hueCycle) * rgbComponentMax
|
||||
bf := hueToRGB(m1, m2, h*hueCycle-2) * rgbComponentMax
|
||||
|
||||
return r, g, b
|
||||
// Validate floating point results before conversion to uint8
|
||||
if math.IsNaN(rf) || math.IsInf(rf, 0) ||
|
||||
math.IsNaN(gf) || math.IsInf(gf, 0) ||
|
||||
math.IsNaN(bf) || math.IsInf(bf, 0) {
|
||||
return 0, 0, 0, fmt.Errorf("jdenticon: engine: HSL to RGB conversion failed: non-finite value produced during conversion")
|
||||
}
|
||||
|
||||
r = uint8(clamp(rf, 0, rgbComponentMax))
|
||||
g = uint8(clamp(gf, 0, rgbComponentMax))
|
||||
b = uint8(clamp(bf, 0, rgbComponentMax))
|
||||
|
||||
return r, g, b, nil
|
||||
}
|
||||
|
||||
// CorrectedHSLToRGB converts HSL to RGB with lightness correction for better visual perception.
|
||||
// This function adjusts the lightness based on the hue to compensate for the human eye's
|
||||
// different sensitivity to different colors.
|
||||
func CorrectedHSLToRGB(h, s, l float64) (r, g, b uint8) {
|
||||
func CorrectedHSLToRGB(h, s, l float64) (r, g, b uint8, err error) {
|
||||
// Defensive check: ensure correctors table is properly initialized
|
||||
if len(correctors) == 0 {
|
||||
return 0, 0, 0, fmt.Errorf("jdenticon: engine: corrected HSL to RGB conversion failed: color correctors table is empty or not initialized")
|
||||
}
|
||||
|
||||
// Get the corrector for the current hue
|
||||
hueIndex := int((h*6 + 0.5)) % len(correctors)
|
||||
hueIndex := int((h*hueSegmentCount + hueRounding)) % len(correctors)
|
||||
corrector := correctors[hueIndex]
|
||||
|
||||
// Adjust lightness relative to the corrector
|
||||
if l < 0.5 {
|
||||
if l < hslMidpoint {
|
||||
l = l * corrector * 2
|
||||
} else {
|
||||
l = corrector + (l-0.5)*(1-corrector)*2
|
||||
l = corrector + (l-hslMidpoint)*(1-corrector)*2
|
||||
}
|
||||
|
||||
// Clamp the corrected lightness
|
||||
l = clamp(l, 0, 1)
|
||||
|
||||
return HSLToRGB(h, s, l)
|
||||
// Call HSLToRGB and propagate any error
|
||||
r, g, b, err = HSLToRGB(h, s, l)
|
||||
if err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("jdenticon: engine: corrected HSL to RGB conversion failed: %w", err)
|
||||
}
|
||||
|
||||
return r, g, b, nil
|
||||
}
|
||||
|
||||
// hueToRGB converts a hue value to an RGB component value
|
||||
@@ -205,9 +308,9 @@ func CorrectedHSLToRGB(h, s, l float64) (r, g, b uint8) {
|
||||
func hueToRGB(m1, m2, h float64) float64 {
|
||||
// Normalize hue to [0, 6) range
|
||||
if h < 0 {
|
||||
h += 6
|
||||
} else if h > 6 {
|
||||
h -= 6
|
||||
h += hueCycle
|
||||
} else if h > hueCycle {
|
||||
h -= hueCycle
|
||||
}
|
||||
|
||||
// Calculate RGB component based on hue position
|
||||
@@ -233,67 +336,25 @@ func clamp(value, min, max float64) float64 {
|
||||
return value
|
||||
}
|
||||
|
||||
// writeHexByte writes a single byte as two hex characters to the builder
|
||||
func writeHexByte(buf *strings.Builder, b uint8) {
|
||||
const hexChars = "0123456789abcdef"
|
||||
buf.WriteByte(hexChars[b>>4])
|
||||
buf.WriteByte(hexChars[b&0x0f])
|
||||
}
|
||||
|
||||
// RGBToHex converts RGB values to a hexadecimal color string
|
||||
func RGBToHex(r, g, b uint8) string {
|
||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
// Use a strings.Builder for more efficient hex formatting
|
||||
var buf strings.Builder
|
||||
buf.Grow(hexColorLength)
|
||||
|
||||
// ParseHexColor parses a hexadecimal color string and returns RGB values
|
||||
// Supports formats: #RGB, #RRGGBB, #RRGGBBAA
|
||||
// Returns error if the format is invalid
|
||||
func ParseHexColor(color string) (r, g, b, a uint8, err error) {
|
||||
if len(color) == 0 || color[0] != '#' {
|
||||
return 0, 0, 0, 255, fmt.Errorf("invalid color format: %s", color)
|
||||
}
|
||||
buf.WriteByte('#')
|
||||
writeHexByte(&buf, r)
|
||||
writeHexByte(&buf, g)
|
||||
writeHexByte(&buf, b)
|
||||
|
||||
hex := color[1:] // Remove '#' prefix
|
||||
a = 255 // Default alpha
|
||||
|
||||
// Helper to parse a component and chain errors
|
||||
parse := func(target *uint8, hexStr string) {
|
||||
if err != nil {
|
||||
return // Don't parse if a previous component failed
|
||||
}
|
||||
*target, err = hexToByte(hexStr)
|
||||
}
|
||||
|
||||
switch len(hex) {
|
||||
case 3: // #RGB
|
||||
parse(&r, hex[0:1]+hex[0:1])
|
||||
parse(&g, hex[1:2]+hex[1:2])
|
||||
parse(&b, hex[2:3]+hex[2:3])
|
||||
case 6: // #RRGGBB
|
||||
parse(&r, hex[0:2])
|
||||
parse(&g, hex[2:4])
|
||||
parse(&b, hex[4:6])
|
||||
case 8: // #RRGGBBAA
|
||||
parse(&r, hex[0:2])
|
||||
parse(&g, hex[2:4])
|
||||
parse(&b, hex[4:6])
|
||||
parse(&a, hex[6:8])
|
||||
default:
|
||||
return 0, 0, 0, 255, fmt.Errorf("invalid hex color length: %s", color)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Return zero values for color components on error, but keep default alpha
|
||||
return 0, 0, 0, 255, fmt.Errorf("failed to parse color '%s': %w", color, err)
|
||||
}
|
||||
|
||||
return r, g, b, a, nil
|
||||
}
|
||||
|
||||
// hexToByte converts a 2-character hex string to a byte value
|
||||
func hexToByte(hex string) (uint8, error) {
|
||||
if len(hex) != 2 {
|
||||
return 0, fmt.Errorf("invalid hex string length: expected 2 characters, got %d", len(hex))
|
||||
}
|
||||
|
||||
n, err := strconv.ParseUint(hex, 16, 8)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid hex value '%s': %w", hex, err)
|
||||
}
|
||||
return uint8(n), nil
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// GenerateColor creates a color with the specified hue and configuration-based saturation and lightness
|
||||
@@ -311,7 +372,7 @@ func GenerateColor(hue float64, config ColorConfig, lightnessValue float64) Colo
|
||||
// GenerateGrayscale creates a grayscale color with configuration-based saturation and lightness
|
||||
func GenerateGrayscale(config ColorConfig, lightnessValue float64) Color {
|
||||
// For grayscale, hue doesn't matter, but we'll use 0
|
||||
hue := 0.0
|
||||
hue := colorThemeDarkLightness
|
||||
|
||||
// Get lightness from grayscale configuration range
|
||||
lightness := config.GrayscaleLightness.GetLightness(lightnessValue)
|
||||
@@ -329,18 +390,18 @@ func GenerateColorTheme(hue float64, config ColorConfig) []Color {
|
||||
|
||||
return []Color{
|
||||
// Dark gray (grayscale with lightness 0)
|
||||
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(0)),
|
||||
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(colorThemeDarkLightness)),
|
||||
|
||||
// Mid color (normal color with lightness 0.5)
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(0.5)),
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(colorThemeMidLightness)),
|
||||
|
||||
// Light gray (grayscale with lightness 1)
|
||||
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(1)),
|
||||
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(colorThemeFullLightness)),
|
||||
|
||||
// Light color (normal color with lightness 1)
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(1)),
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(colorThemeFullLightness)),
|
||||
|
||||
// Dark color (normal color with lightness 0)
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(0)),
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(colorThemeDarkLightness)),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -33,3 +34,68 @@ func BenchmarkNewColorCorrectedHSL(b *testing.B) {
|
||||
NewColorCorrectedHSL(tc.h, tc.s, tc.l)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark hex color formatting optimization
|
||||
func BenchmarkRGBToHex(b *testing.B) {
|
||||
colors := []struct {
|
||||
r, g, b uint8
|
||||
}{
|
||||
{255, 0, 0},
|
||||
{0, 255, 0},
|
||||
{0, 0, 255},
|
||||
{128, 128, 128},
|
||||
{255, 255, 255},
|
||||
{0, 0, 0},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
for _, c := range colors {
|
||||
_ = RGBToHex(c.r, c.g, c.b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark against the old fmt.Sprintf approach
|
||||
func BenchmarkRGBToHex_OldSprintf(b *testing.B) {
|
||||
colors := []struct {
|
||||
r, g, b uint8
|
||||
}{
|
||||
{255, 0, 0},
|
||||
{0, 255, 0},
|
||||
{0, 0, 255},
|
||||
{128, 128, 128},
|
||||
{255, 255, 255},
|
||||
{0, 0, 0},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
for _, c := range colors {
|
||||
_ = fmt.Sprintf("#%02x%02x%02x", c.r, c.g, c.b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark Color.String() method
|
||||
func BenchmarkColorString(b *testing.B) {
|
||||
colors := []Color{
|
||||
NewColorRGB(255, 0, 0), // Red, no alpha
|
||||
NewColorRGBA(0, 255, 0, 128), // Green with alpha
|
||||
NewColorRGB(0, 0, 255), // Blue, no alpha
|
||||
NewColorRGBA(128, 128, 128, 200), // Gray with alpha
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
for _, c := range colors {
|
||||
_ = c.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
197
internal/engine/color_graceful_degradation_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCorrectedHSLToRGB_EmptyCorrectors tests the defensive bounds checking
|
||||
// for the correctors array in CorrectedHSLToRGB
|
||||
func TestCorrectedHSLToRGB_EmptyCorrectors(t *testing.T) {
|
||||
// Save original correctors
|
||||
originalCorrectors := correctors
|
||||
defer func() { correctors = originalCorrectors }()
|
||||
|
||||
// Temporarily modify the unexported variable for this test
|
||||
correctors = []float64{}
|
||||
|
||||
// Call the function and assert that it returns the expected error
|
||||
//nolint:dogsled // We only care about the error in this test
|
||||
_, _, _, err := CorrectedHSLToRGB(0.5, 0.5, 0.5)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for empty correctors, got nil")
|
||||
}
|
||||
|
||||
// Check if error message contains expected content
|
||||
expectedMsg := "color correctors table is empty"
|
||||
if !contains(err.Error(), expectedMsg) {
|
||||
t.Errorf("expected error message to contain %q, got %q", expectedMsg, err.Error())
|
||||
}
|
||||
|
||||
t.Logf("Got expected error: %v", err)
|
||||
}
|
||||
|
||||
// TestHSLToRGB_FloatingPointValidation tests that HSLToRGB validates
|
||||
// floating point results and catches NaN/Inf values
|
||||
func TestHSLToRGB_FloatingPointValidation(t *testing.T) {
|
||||
// Test normal cases first
|
||||
testCases := []struct {
|
||||
name string
|
||||
h, s, l float64
|
||||
expectError bool
|
||||
}{
|
||||
{"normal_color", 0.5, 0.5, 0.5, false},
|
||||
{"pure_red", 0.0, 1.0, 0.5, false},
|
||||
{"white", 0.0, 0.0, 1.0, false},
|
||||
{"black", 0.0, 0.0, 0.0, false},
|
||||
{"grayscale", 0.0, 0.0, 0.5, false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r, g, b, err := HSLToRGB(tc.h, tc.s, tc.l)
|
||||
|
||||
if tc.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error for %s, got none", tc.name)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error for %s: %v", tc.name, err)
|
||||
}
|
||||
// RGB values are uint8, so they're guaranteed to be in 0-255 range
|
||||
_ = r
|
||||
_ = g
|
||||
_ = b
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestColor_ToRGB_ErrorHandling tests that Color.ToRGB properly handles
|
||||
// errors from the underlying conversion functions
|
||||
func TestColor_ToRGB_ErrorHandling(t *testing.T) {
|
||||
// Test with normal values
|
||||
color := NewColorHSL(0.5, 0.5, 0.5)
|
||||
r, g, b, err := color.ToRGB()
|
||||
if err != nil {
|
||||
t.Errorf("ToRGB failed for normal color: %v", err)
|
||||
}
|
||||
// RGB values are uint8, so they're guaranteed to be in 0-255 range
|
||||
_ = r
|
||||
_ = g
|
||||
_ = b
|
||||
|
||||
// Test corrected color conversion
|
||||
correctedColor := NewColorCorrectedHSL(0.5, 0.5, 0.5)
|
||||
r2, g2, b2, err2 := correctedColor.ToRGB()
|
||||
if err2 != nil {
|
||||
t.Errorf("ToRGB failed for corrected color: %v", err2)
|
||||
}
|
||||
// RGB values are uint8, so they're guaranteed to be in 0-255 range
|
||||
_ = r2
|
||||
_ = g2
|
||||
_ = b2
|
||||
}
|
||||
|
||||
// TestColor_String_ErrorFallback tests that Color.String() properly handles
|
||||
// conversion errors by falling back to black
|
||||
func TestColor_String_ErrorFallback(t *testing.T) {
|
||||
// Save original correctors
|
||||
originalCorrectors := correctors
|
||||
defer func() { correctors = originalCorrectors }()
|
||||
|
||||
// Create a corrected color that will fail conversion
|
||||
color := NewColorCorrectedHSL(0.5, 0.5, 0.5)
|
||||
|
||||
// Temporarily break correctors to force an error
|
||||
correctors = []float64{}
|
||||
|
||||
// String() should not panic and should return a fallback color
|
||||
result := color.String()
|
||||
|
||||
// Should return black (#000000) as fallback
|
||||
expected := "#000000"
|
||||
if result != expected {
|
||||
t.Errorf("expected fallback color %s, got %s", expected, result)
|
||||
}
|
||||
|
||||
t.Logf("String() properly fell back to: %s", result)
|
||||
}
|
||||
|
||||
// TestColor_Equals_ErrorHandling tests that Color.Equals properly handles
|
||||
// conversion errors by returning false
|
||||
func TestColor_Equals_ErrorHandling(t *testing.T) {
|
||||
// Save original correctors
|
||||
originalCorrectors := correctors
|
||||
defer func() { correctors = originalCorrectors }()
|
||||
|
||||
color1 := NewColorCorrectedHSL(0.5, 0.5, 0.5)
|
||||
color2 := NewColorCorrectedHSL(0.5, 0.5, 0.5)
|
||||
|
||||
// First test normal comparison
|
||||
if !color1.Equals(color2) {
|
||||
t.Error("identical colors should be equal")
|
||||
}
|
||||
|
||||
// Now break correctors to force conversion errors
|
||||
correctors = []float64{}
|
||||
|
||||
// Colors with conversion errors should not be equal
|
||||
if color1.Equals(color2) {
|
||||
t.Error("colors with conversion errors should not be equal")
|
||||
}
|
||||
|
||||
t.Log("Equals properly handled conversion errors")
|
||||
}
|
||||
|
||||
// TestGenerateColorTheme_Robustness tests that GenerateColorTheme always
|
||||
// returns exactly 5 colors and handles edge cases
|
||||
func TestGenerateColorTheme_Robustness(t *testing.T) {
|
||||
config := DefaultColorConfig()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
hue float64
|
||||
}{
|
||||
{"zero_hue", 0.0},
|
||||
{"mid_hue", 0.5},
|
||||
{"max_hue", 1.0},
|
||||
{"negative_hue", -0.5}, // Should be handled by hue normalization
|
||||
{"large_hue", 2.5}, // Should be handled by hue normalization
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
colors := GenerateColorTheme(tc.hue, config)
|
||||
|
||||
// Must always return exactly 5 colors
|
||||
if len(colors) != 5 {
|
||||
t.Errorf("GenerateColorTheme returned %d colors, expected 5", len(colors))
|
||||
}
|
||||
|
||||
// Each color should be valid (convertible to RGB)
|
||||
for i, color := range colors {
|
||||
_, _, _, err := color.ToRGB()
|
||||
if err != nil {
|
||||
t.Errorf("color %d in theme failed RGB conversion: %v", i, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a string contains a substring
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) &&
|
||||
(s == substr || (len(s) > len(substr) &&
|
||||
(s[:len(substr)] == substr ||
|
||||
s[len(s)-len(substr):] == substr ||
|
||||
func() bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}())))
|
||||
}
|
||||
@@ -18,12 +18,12 @@ func TestHSLToRGB(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "pure green",
|
||||
h: 1.0/3.0, s: 1.0, l: 0.5,
|
||||
h: 1.0 / 3.0, s: 1.0, l: 0.5,
|
||||
r: 0, g: 255, b: 0,
|
||||
},
|
||||
{
|
||||
name: "pure blue",
|
||||
h: 2.0/3.0, s: 1.0, l: 0.5,
|
||||
h: 2.0 / 3.0, s: 1.0, l: 0.5,
|
||||
r: 0, g: 0, b: 255,
|
||||
},
|
||||
{
|
||||
@@ -48,14 +48,17 @@ func TestHSLToRGB(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "light blue",
|
||||
h: 2.0/3.0, s: 1.0, l: 0.75,
|
||||
h: 2.0 / 3.0, s: 1.0, l: 0.75,
|
||||
r: 127, g: 127, b: 255,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r, g, b := HSLToRGB(tt.h, tt.s, tt.l)
|
||||
r, g, b, err := HSLToRGB(tt.h, tt.s, tt.l)
|
||||
if err != nil {
|
||||
t.Fatalf("HSLToRGB failed: %v", err)
|
||||
}
|
||||
|
||||
// Allow small tolerance due to floating point arithmetic
|
||||
tolerance := uint8(2)
|
||||
@@ -85,13 +88,14 @@ func TestCorrectedHSLToRGB(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r, g, b := CorrectedHSLToRGB(tc.h, tc.s, tc.l)
|
||||
|
||||
// Verify RGB values are in valid range
|
||||
if r > 255 || g > 255 || b > 255 {
|
||||
t.Errorf("CorrectedHSLToRGB(%f, %f, %f) = (%d, %d, %d), RGB values should be <= 255",
|
||||
tc.h, tc.s, tc.l, r, g, b)
|
||||
r, g, b, err := CorrectedHSLToRGB(tc.h, tc.s, tc.l)
|
||||
if err != nil {
|
||||
t.Fatalf("CorrectedHSLToRGB failed: %v", err)
|
||||
}
|
||||
// RGB values are uint8, so they're guaranteed to be in 0-255 range
|
||||
_ = r
|
||||
_ = g
|
||||
_ = b
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -120,83 +124,6 @@ func TestRGBToHex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexToByte(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected uint8
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid hex 00",
|
||||
input: "00",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "valid hex ff",
|
||||
input: "ff",
|
||||
expected: 255,
|
||||
},
|
||||
{
|
||||
name: "valid hex a5",
|
||||
input: "a5",
|
||||
expected: 165,
|
||||
},
|
||||
{
|
||||
name: "valid hex A5 uppercase",
|
||||
input: "A5",
|
||||
expected: 165,
|
||||
},
|
||||
{
|
||||
name: "invalid length - too short",
|
||||
input: "f",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid length - too long",
|
||||
input: "fff",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid character x",
|
||||
input: "fx",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid character z",
|
||||
input: "zz",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := hexToByte(tt.input)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("hexToByte(%s) expected error, got nil", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("hexToByte(%s) unexpected error: %v", tt.input, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("hexToByte(%s) = %d, want %d", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHexColor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -263,7 +190,8 @@ func TestParseHexColor(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r, g, b, a, err := ParseHexColor(tt.input)
|
||||
rgba, err := ParseHexColorToRGBA(tt.input)
|
||||
r, g, b, a := rgba.R, rgba.G, rgba.B, rgba.A
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
@@ -312,9 +240,13 @@ func TestNewColorHSL(t *testing.T) {
|
||||
color.H, color.S, color.L)
|
||||
}
|
||||
|
||||
if color.R != 255 || color.G != 0 || color.B != 0 {
|
||||
r, g, b, err := color.ToRGB()
|
||||
if err != nil {
|
||||
t.Fatalf("ToRGB failed: %v", err)
|
||||
}
|
||||
if r != 255 || g != 0 || b != 0 {
|
||||
t.Errorf("NewColorHSL(0.0, 1.0, 0.5) RGB = (%d, %d, %d), want (255, 0, 0)",
|
||||
color.R, color.G, color.B)
|
||||
r, g, b)
|
||||
}
|
||||
|
||||
if color.A != 255 {
|
||||
@@ -325,9 +257,13 @@ func TestNewColorHSL(t *testing.T) {
|
||||
func TestNewColorRGB(t *testing.T) {
|
||||
color := NewColorRGB(255, 0, 0) // Pure red
|
||||
|
||||
if color.R != 255 || color.G != 0 || color.B != 0 {
|
||||
r, g, b, err := color.ToRGB()
|
||||
if err != nil {
|
||||
t.Fatalf("ToRGB failed: %v", err)
|
||||
}
|
||||
if r != 255 || g != 0 || b != 0 {
|
||||
t.Errorf("NewColorRGB(255, 0, 0) RGB = (%d, %d, %d), want (255, 0, 0)",
|
||||
color.R, color.G, color.B)
|
||||
r, g, b)
|
||||
}
|
||||
|
||||
// HSL values should be approximately (0, 1, 0.5) for pure red
|
||||
@@ -399,7 +335,15 @@ func TestColorWithAlpha(t *testing.T) {
|
||||
}
|
||||
|
||||
// RGB and HSL should remain the same
|
||||
if newColor.R != color.R || newColor.G != color.G || newColor.B != color.B {
|
||||
newColorR, newColorG, newColorB, err1 := newColor.ToRGB()
|
||||
if err1 != nil {
|
||||
t.Fatalf("newColor.ToRGB failed: %v", err1)
|
||||
}
|
||||
colorR, colorG, colorB, err2 := color.ToRGB()
|
||||
if err2 != nil {
|
||||
t.Fatalf("color.ToRGB failed: %v", err2)
|
||||
}
|
||||
if newColorR != colorR || newColorG != colorG || newColorB != colorB {
|
||||
t.Error("WithAlpha should not change RGB values")
|
||||
}
|
||||
|
||||
@@ -460,12 +404,12 @@ func TestRGBToHSL(t *testing.T) {
|
||||
{
|
||||
name: "green",
|
||||
r: 0, g: 255, b: 0,
|
||||
h: 1.0/3.0, s: 1.0, l: 0.5,
|
||||
h: 1.0 / 3.0, s: 1.0, l: 0.5,
|
||||
},
|
||||
{
|
||||
name: "blue",
|
||||
r: 0, g: 0, b: 255,
|
||||
h: 2.0/3.0, s: 1.0, l: 0.5,
|
||||
h: 2.0 / 3.0, s: 1.0, l: 0.5,
|
||||
},
|
||||
{
|
||||
name: "white",
|
||||
@@ -619,9 +563,8 @@ func TestGenerateColorTheme(t *testing.T) {
|
||||
|
||||
func TestGenerateColorThemeWithHueRestriction(t *testing.T) {
|
||||
// Test with hue restriction
|
||||
config := NewColorConfigBuilder().
|
||||
WithHues(180). // Only allow cyan (180 degrees = 0.5 turns)
|
||||
Build()
|
||||
config := DefaultColorConfig()
|
||||
config.Hues = []float64{180} // Only allow cyan (180 degrees = 0.5 turns)
|
||||
|
||||
theme := GenerateColorTheme(0.25, config) // Request green, should get cyan
|
||||
|
||||
@@ -636,10 +579,9 @@ func TestGenerateColorThemeWithHueRestriction(t *testing.T) {
|
||||
|
||||
func TestGenerateColorWithConfiguration(t *testing.T) {
|
||||
// Test with custom configuration
|
||||
config := NewColorConfigBuilder().
|
||||
WithColorSaturation(0.8).
|
||||
WithColorLightness(0.2, 0.6).
|
||||
Build()
|
||||
config := DefaultColorConfig()
|
||||
config.ColorSaturation = 0.8
|
||||
config.ColorLightness = LightnessRange{Min: 0.2, Max: 0.6}
|
||||
|
||||
color := GenerateColor(0.33, config, 1.0) // Green hue, max lightness
|
||||
|
||||
|
||||
178
internal/engine/colorutils.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
// Compiled regex pattern for hex color validation
|
||||
hexColorRegex *regexp.Regexp
|
||||
// Initialization guard for hex color regex
|
||||
hexColorRegexOnce sync.Once
|
||||
)
|
||||
|
||||
// getHexColorRegex returns the compiled hex color regex pattern, compiling it only once.
|
||||
// Supports formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
|
||||
func getHexColorRegex() *regexp.Regexp {
|
||||
hexColorRegexOnce.Do(func() {
|
||||
hexColorRegex = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
|
||||
})
|
||||
return hexColorRegex
|
||||
}
|
||||
|
||||
// ParseHexColorToRGBA is the consolidated hex color parsing function for the entire codebase.
|
||||
// It parses a hexadecimal color string and returns color.RGBA and an error.
|
||||
// Supports formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
|
||||
// Returns error if the format is invalid.
|
||||
//
|
||||
// This function replaces all other hex color parsing implementations and provides
|
||||
// consistent error handling for all color operations, following REQ-1.3.
|
||||
func ParseHexColorToRGBA(hexStr string) (color.RGBA, error) {
|
||||
if len(hexStr) == 0 || hexStr[0] != '#' {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: invalid color format: %s", hexStr)
|
||||
}
|
||||
|
||||
// Validate the hex color format using regex
|
||||
if !getHexColorRegex().MatchString(hexStr) {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: invalid hex color format: %s", hexStr)
|
||||
}
|
||||
|
||||
hex := hexStr[1:] // Remove '#' prefix
|
||||
var r, g, b, a uint8 = 0, 0, 0, 255 // Default alpha is fully opaque
|
||||
|
||||
// Helper to parse a 2-character hex component
|
||||
parse := func(target *uint8, hexStr string) error {
|
||||
val, err := hexToByte(hexStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*target = val
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper to parse a single hex digit and expand it (e.g., 'F' -> 'FF' = 255)
|
||||
parseShort := func(target *uint8, hexChar byte) error {
|
||||
var val uint8
|
||||
if hexChar >= '0' && hexChar <= '9' {
|
||||
val = hexChar - '0'
|
||||
} else if hexChar >= 'a' && hexChar <= 'f' {
|
||||
val = hexChar - 'a' + 10
|
||||
} else if hexChar >= 'A' && hexChar <= 'F' {
|
||||
val = hexChar - 'A' + 10
|
||||
} else {
|
||||
return fmt.Errorf("jdenticon: engine: hex digit parsing failed: invalid hex character: %c", hexChar)
|
||||
}
|
||||
*target = val * 17 // Expand single digit: 0xF * 17 = 0xFF
|
||||
return nil
|
||||
}
|
||||
|
||||
switch len(hex) {
|
||||
case 3: // #RGB -> expand to #RRGGBB
|
||||
if err := parseShort(&r, hex[0]); err != nil {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse red component: %w", err)
|
||||
}
|
||||
if err := parseShort(&g, hex[1]); err != nil {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse green component: %w", err)
|
||||
}
|
||||
if err := parseShort(&b, hex[2]); err != nil {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse blue component: %w", err)
|
||||
}
|
||||
|
||||
case 4: // #RGBA -> expand to #RRGGBBAA
|
||||
if err := parseShort(&r, hex[0]); err != nil {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse red component: %w", err)
|
||||
}
|
||||
if err := parseShort(&g, hex[1]); err != nil {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse green component: %w", err)
|
||||
}
|
||||
if err := parseShort(&b, hex[2]); err != nil {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse blue component: %w", err)
|
||||
}
|
||||
if err := parseShort(&a, hex[3]); err != nil {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse alpha component: %w", err)
|
||||
}
|
||||
|
||||
case 6: // #RRGGBB
|
||||
if err := parse(&r, hex[0:2]); err != nil {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse red component: %w", err)
|
||||
}
|
||||
if err := parse(&g, hex[2:4]); err != nil {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse green component: %w", err)
|
||||
}
|
||||
if err := parse(&b, hex[4:6]); err != nil {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse blue component: %w", err)
|
||||
}
|
||||
|
||||
case 8: // #RRGGBBAA
|
||||
if err := parse(&r, hex[0:2]); err != nil {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse red component: %w", err)
|
||||
}
|
||||
if err := parse(&g, hex[2:4]); err != nil {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse green component: %w", err)
|
||||
}
|
||||
if err := parse(&b, hex[4:6]); err != nil {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse blue component: %w", err)
|
||||
}
|
||||
if err := parse(&a, hex[6:8]); err != nil {
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse alpha component: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
// This case should be unreachable due to the regex validation above.
|
||||
// Return an error instead of panicking to ensure library never panics.
|
||||
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: unsupported color format with length %d", len(hex))
|
||||
}
|
||||
|
||||
return color.RGBA{R: r, G: g, B: b, A: a}, nil
|
||||
}
|
||||
|
||||
// ValidateHexColor validates that a color string is a valid hex color format.
|
||||
// Returns nil if valid, error if invalid.
|
||||
func ValidateHexColor(hexStr string) error {
|
||||
if !getHexColorRegex().MatchString(hexStr) {
|
||||
return fmt.Errorf("jdenticon: engine: hex color validation failed: color must be a hex color like #fff, #ffffff, or #ffffff80")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseHexColorToEngine parses a hex color string and returns an engine.Color.
|
||||
// This is a convenience function for converting hex colors to the engine's internal Color type.
|
||||
func ParseHexColorToEngine(hexStr string) (Color, error) {
|
||||
rgba, err := ParseHexColorToRGBA(hexStr)
|
||||
if err != nil {
|
||||
return Color{}, err
|
||||
}
|
||||
return NewColorRGBA(rgba.R, rgba.G, rgba.B, rgba.A), nil
|
||||
}
|
||||
|
||||
// ParseHexColorForRenderer parses a hex color for use in renderers.
|
||||
// Returns color.RGBA with the specified opacity applied.
|
||||
// This function provides compatibility with the fast PNG renderer's parseColor function.
|
||||
func ParseHexColorForRenderer(hexStr string, opacity float64) (color.RGBA, error) {
|
||||
rgba, err := ParseHexColorToRGBA(hexStr)
|
||||
if err != nil {
|
||||
return color.RGBA{}, err
|
||||
}
|
||||
|
||||
// Apply opacity to the alpha channel
|
||||
rgba.A = uint8(float64(rgba.A) * opacity)
|
||||
return rgba, nil
|
||||
}
|
||||
|
||||
// hexToByte converts a 2-character hex string to a byte value.
|
||||
// This is a helper function used by ParseHexColor.
|
||||
func hexToByte(hex string) (uint8, error) {
|
||||
if len(hex) != 2 {
|
||||
return 0, fmt.Errorf("jdenticon: engine: hex byte parsing failed: invalid hex string length: expected 2 characters, got %d", len(hex))
|
||||
}
|
||||
|
||||
n, err := strconv.ParseUint(hex, 16, 8)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("jdenticon: engine: hex byte parsing failed: invalid hex value '%s': %w", hex, err)
|
||||
}
|
||||
return uint8(n), nil
|
||||
}
|
||||
229
internal/engine/colorutils_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseHexColorToRGBA(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected color.RGBA
|
||||
hasError bool
|
||||
}{
|
||||
// Valid cases
|
||||
{"RGB short form", "#000", color.RGBA{0, 0, 0, 255}, false},
|
||||
{"RGB short form white", "#fff", color.RGBA{255, 255, 255, 255}, false},
|
||||
{"RGB short form mixed", "#f0a", color.RGBA{255, 0, 170, 255}, false},
|
||||
{"RGBA short form", "#f0a8", color.RGBA{255, 0, 170, 136}, false},
|
||||
{"RRGGBB full form", "#000000", color.RGBA{0, 0, 0, 255}, false},
|
||||
{"RRGGBB full form white", "#ffffff", color.RGBA{255, 255, 255, 255}, false},
|
||||
{"RRGGBB full form mixed", "#ff00aa", color.RGBA{255, 0, 170, 255}, false},
|
||||
{"RRGGBBAA full form", "#ff00aa80", color.RGBA{255, 0, 170, 128}, false},
|
||||
{"RRGGBBAA full form transparent", "#ff00aa00", color.RGBA{255, 0, 170, 0}, false},
|
||||
{"RRGGBBAA full form opaque", "#ff00aaff", color.RGBA{255, 0, 170, 255}, false},
|
||||
|
||||
// Invalid cases
|
||||
{"Empty string", "", color.RGBA{}, true},
|
||||
{"No hash prefix", "ffffff", color.RGBA{}, true},
|
||||
{"Invalid length", "#12", color.RGBA{}, true},
|
||||
{"Invalid length", "#12345", color.RGBA{}, true},
|
||||
{"Invalid length", "#1234567", color.RGBA{}, true},
|
||||
{"Invalid hex character", "#gggggg", color.RGBA{}, true},
|
||||
{"Invalid hex character short", "#ggg", color.RGBA{}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ParseHexColorToRGBA(tt.input)
|
||||
|
||||
if tt.hasError {
|
||||
if err == nil {
|
||||
t.Errorf("ParseHexColorToRGBA(%q) expected error, got nil", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("ParseHexColorToRGBA(%q) unexpected error: %v", tt.input, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("ParseHexColorToRGBA(%q) = %+v, expected %+v", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateHexColor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
hasError bool
|
||||
}{
|
||||
// Valid cases
|
||||
{"RGB short", "#000", false},
|
||||
{"RGB short uppercase", "#FFF", false},
|
||||
{"RGB short mixed case", "#f0A", false},
|
||||
{"RGBA short", "#f0a8", false},
|
||||
{"RRGGBB", "#000000", false},
|
||||
{"RRGGBB uppercase", "#FFFFFF", false},
|
||||
{"RRGGBB mixed case", "#Ff00Aa", false},
|
||||
{"RRGGBBAA", "#ff00aa80", false},
|
||||
{"RRGGBBAA uppercase", "#FF00AA80", false},
|
||||
|
||||
// Invalid cases
|
||||
{"Empty string", "", true},
|
||||
{"No hash", "ffffff", true},
|
||||
{"Too short", "#12", true},
|
||||
{"Invalid length", "#12345", true},
|
||||
{"Too long", "#123456789", true},
|
||||
{"Invalid character", "#gggggg", true},
|
||||
{"Invalid character short", "#ggg", true},
|
||||
{"Space", "#fff fff", true},
|
||||
{"Special character", "#fff@ff", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateHexColor(tt.input)
|
||||
|
||||
if tt.hasError && err == nil {
|
||||
t.Errorf("ValidateHexColor(%q) expected error, got nil", tt.input)
|
||||
} else if !tt.hasError && err != nil {
|
||||
t.Errorf("ValidateHexColor(%q) unexpected error: %v", tt.input, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHexColorToEngine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected Color
|
||||
hasError bool
|
||||
}{
|
||||
{"RGB short", "#000", NewColorRGBA(0, 0, 0, 255), false},
|
||||
{"RGB short white", "#fff", NewColorRGBA(255, 255, 255, 255), false},
|
||||
{"RRGGBB", "#ff00aa", NewColorRGBA(255, 0, 170, 255), false},
|
||||
{"RRGGBBAA", "#ff00aa80", NewColorRGBA(255, 0, 170, 128), false},
|
||||
{"Invalid", "#invalid", Color{}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ParseHexColorToEngine(tt.input)
|
||||
|
||||
if tt.hasError {
|
||||
if err == nil {
|
||||
t.Errorf("ParseHexColorToEngine(%q) expected error, got nil", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("ParseHexColorToEngine(%q) unexpected error: %v", tt.input, err)
|
||||
return
|
||||
}
|
||||
|
||||
resultR, resultG, resultB, err1 := result.ToRGB()
|
||||
if err1 != nil {
|
||||
t.Fatalf("result.ToRGB failed: %v", err1)
|
||||
}
|
||||
expectedR, expectedG, expectedB, err2 := tt.expected.ToRGB()
|
||||
if err2 != nil {
|
||||
t.Fatalf("expected.ToRGB failed: %v", err2)
|
||||
}
|
||||
if resultR != expectedR || resultG != expectedG ||
|
||||
resultB != expectedB || result.A != tt.expected.A {
|
||||
t.Errorf("ParseHexColorToEngine(%q) = R:%d G:%d B:%d A:%d, expected R:%d G:%d B:%d A:%d",
|
||||
tt.input, resultR, resultG, resultB, result.A,
|
||||
expectedR, expectedG, expectedB, tt.expected.A)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHexColorForRenderer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
opacity float64
|
||||
expected color.RGBA
|
||||
hasError bool
|
||||
}{
|
||||
{"RGB with opacity 1.0", "#ff0000", 1.0, color.RGBA{255, 0, 0, 255}, false},
|
||||
{"RGB with opacity 0.5", "#ff0000", 0.5, color.RGBA{255, 0, 0, 127}, false},
|
||||
{"RGB with opacity 0.0", "#ff0000", 0.0, color.RGBA{255, 0, 0, 0}, false},
|
||||
{"RGBA with opacity 1.0", "#ff000080", 1.0, color.RGBA{255, 0, 0, 128}, false},
|
||||
{"RGBA with opacity 0.5", "#ff000080", 0.5, color.RGBA{255, 0, 0, 64}, false},
|
||||
{"Invalid color", "#invalid", 1.0, color.RGBA{}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ParseHexColorForRenderer(tt.input, tt.opacity)
|
||||
|
||||
if tt.hasError {
|
||||
if err == nil {
|
||||
t.Errorf("ParseHexColorForRenderer(%q, %f) expected error, got nil", tt.input, tt.opacity)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("ParseHexColorForRenderer(%q, %f) unexpected error: %v", tt.input, tt.opacity, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("ParseHexColorForRenderer(%q, %f) = %+v, expected %+v", tt.input, tt.opacity, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexToByte(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected uint8
|
||||
hasError bool
|
||||
}{
|
||||
{"Zero", "00", 0, false},
|
||||
{"Max", "ff", 255, false},
|
||||
{"Max uppercase", "FF", 255, false},
|
||||
{"Mixed case", "Ff", 255, false},
|
||||
{"Middle value", "80", 128, false},
|
||||
{"Small value", "0a", 10, false},
|
||||
{"Invalid length short", "f", 0, true},
|
||||
{"Invalid length long", "fff", 0, true},
|
||||
{"Invalid character", "gg", 0, true},
|
||||
{"Empty string", "", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := hexToByte(tt.input)
|
||||
|
||||
if tt.hasError {
|
||||
if err == nil {
|
||||
t.Errorf("hexToByte(%q) expected error, got nil", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("hexToByte(%q) unexpected error: %v", tt.input, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("hexToByte(%q) = %d, expected %d", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,30 @@
|
||||
package engine
|
||||
|
||||
import "math"
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Default configuration constants matching JavaScript implementation
|
||||
const (
|
||||
// Default saturation values
|
||||
defaultColorSaturation = 0.5 // Default saturation for colored shapes
|
||||
defaultGrayscaleSaturation = 0.0 // Default saturation for grayscale shapes
|
||||
|
||||
// Default lightness range boundaries
|
||||
defaultColorLightnessMin = 0.4 // Default minimum lightness for colors
|
||||
defaultColorLightnessMax = 0.8 // Default maximum lightness for colors
|
||||
defaultGrayscaleLightnessMin = 0.3 // Default minimum lightness for grayscale
|
||||
defaultGrayscaleLightnessMax = 0.9 // Default maximum lightness for grayscale
|
||||
|
||||
// Default padding
|
||||
defaultIconPadding = 0.08 // Default padding as percentage of icon size
|
||||
|
||||
// Hue calculation constants
|
||||
hueIndexNormalizationFactor = 0.999 // Factor to normalize hue to [0,1) range for indexing
|
||||
degreesToTurns = 360.0 // Conversion factor from degrees to turns
|
||||
)
|
||||
|
||||
// ColorConfig represents the configuration for color generation
|
||||
type ColorConfig struct {
|
||||
@@ -44,13 +68,13 @@ func (lr LightnessRange) GetLightness(value float64) float64 {
|
||||
// DefaultColorConfig returns the default configuration matching the JavaScript implementation
|
||||
func DefaultColorConfig() ColorConfig {
|
||||
return ColorConfig{
|
||||
ColorSaturation: 0.5,
|
||||
GrayscaleSaturation: 0.0,
|
||||
ColorLightness: LightnessRange{Min: 0.4, Max: 0.8},
|
||||
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
|
||||
ColorSaturation: defaultColorSaturation,
|
||||
GrayscaleSaturation: defaultGrayscaleSaturation,
|
||||
ColorLightness: LightnessRange{Min: defaultColorLightnessMin, Max: defaultColorLightnessMax},
|
||||
GrayscaleLightness: LightnessRange{Min: defaultGrayscaleLightnessMin, Max: defaultGrayscaleLightnessMax},
|
||||
Hues: nil, // No hue restriction
|
||||
BackColor: nil, // Transparent background
|
||||
IconPadding: 0.08,
|
||||
IconPadding: defaultIconPadding,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +95,7 @@ func (c ColorConfig) RestrictHue(originalHue float64) float64 {
|
||||
// Find the closest allowed hue
|
||||
// originalHue is in range [0, 1], multiply by 0.999 to get range [0, 1)
|
||||
// then truncate to get index
|
||||
index := int((0.999 * hue * float64(len(c.Hues))))
|
||||
index := int((hueIndexNormalizationFactor * hue * float64(len(c.Hues))))
|
||||
if index >= len(c.Hues) {
|
||||
index = len(c.Hues) - 1
|
||||
}
|
||||
@@ -80,7 +104,7 @@ func (c ColorConfig) RestrictHue(originalHue float64) float64 {
|
||||
|
||||
// Convert from degrees to turns in range [0, 1)
|
||||
// Handle any turn - e.g. 746° is valid
|
||||
result := math.Mod(restrictedHue/360.0, 1.0)
|
||||
result := math.Mod(restrictedHue/degreesToTurns, 1.0)
|
||||
if result < 0 {
|
||||
result += 1.0
|
||||
}
|
||||
@@ -88,13 +112,61 @@ func (c ColorConfig) RestrictHue(originalHue float64) float64 {
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidateConfig validates and corrects a ColorConfig to ensure all values are within valid ranges
|
||||
func (c *ColorConfig) Validate() {
|
||||
// Validate validates a ColorConfig to ensure all values are within valid ranges
|
||||
// Returns an error if any validation issues are found without correcting the values
|
||||
func (c *ColorConfig) Validate() error {
|
||||
var validationErrors []string
|
||||
|
||||
// Validate saturation values
|
||||
if c.ColorSaturation < 0 || c.ColorSaturation > 1 {
|
||||
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: color saturation out of range: value %f not in [0, 1]", c.ColorSaturation))
|
||||
}
|
||||
|
||||
if c.GrayscaleSaturation < 0 || c.GrayscaleSaturation > 1 {
|
||||
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: grayscale saturation out of range: value %f not in [0, 1]", c.GrayscaleSaturation))
|
||||
}
|
||||
|
||||
// Validate lightness ranges
|
||||
if c.ColorLightness.Min < 0 || c.ColorLightness.Min > 1 {
|
||||
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: color lightness minimum out of range: value %f not in [0, 1]", c.ColorLightness.Min))
|
||||
}
|
||||
if c.ColorLightness.Max < 0 || c.ColorLightness.Max > 1 {
|
||||
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: color lightness maximum out of range: value %f not in [0, 1]", c.ColorLightness.Max))
|
||||
}
|
||||
if c.ColorLightness.Min > c.ColorLightness.Max {
|
||||
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: color lightness range invalid: minimum %f greater than maximum %f", c.ColorLightness.Min, c.ColorLightness.Max))
|
||||
}
|
||||
|
||||
if c.GrayscaleLightness.Min < 0 || c.GrayscaleLightness.Min > 1 {
|
||||
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: grayscale lightness minimum out of range: value %f not in [0, 1]", c.GrayscaleLightness.Min))
|
||||
}
|
||||
if c.GrayscaleLightness.Max < 0 || c.GrayscaleLightness.Max > 1 {
|
||||
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: grayscale lightness maximum out of range: value %f not in [0, 1]", c.GrayscaleLightness.Max))
|
||||
}
|
||||
if c.GrayscaleLightness.Min > c.GrayscaleLightness.Max {
|
||||
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: grayscale lightness range invalid: minimum %f greater than maximum %f", c.GrayscaleLightness.Min, c.GrayscaleLightness.Max))
|
||||
}
|
||||
|
||||
// Validate icon padding
|
||||
if c.IconPadding < 0 || c.IconPadding > 1 {
|
||||
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: icon padding out of range: value %f not in [0, 1]", c.IconPadding))
|
||||
}
|
||||
|
||||
if len(validationErrors) > 0 {
|
||||
return fmt.Errorf("jdenticon: engine: validation failed: configuration invalid: %s", strings.Join(validationErrors, "; "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize validates and corrects a ColorConfig to ensure all values are within valid ranges
|
||||
// This method provides backward compatibility by applying corrections for invalid values
|
||||
func (c *ColorConfig) Normalize() {
|
||||
// Clamp saturation values
|
||||
c.ColorSaturation = clamp(c.ColorSaturation, 0, 1)
|
||||
c.GrayscaleSaturation = clamp(c.GrayscaleSaturation, 0, 1)
|
||||
|
||||
// Validate lightness ranges
|
||||
// Validate and fix lightness ranges
|
||||
c.ColorLightness.Min = clamp(c.ColorLightness.Min, 0, 1)
|
||||
c.ColorLightness.Max = clamp(c.ColorLightness.Max, 0, 1)
|
||||
if c.ColorLightness.Min > c.ColorLightness.Max {
|
||||
@@ -109,67 +181,4 @@ func (c *ColorConfig) Validate() {
|
||||
|
||||
// Clamp icon padding
|
||||
c.IconPadding = clamp(c.IconPadding, 0, 1)
|
||||
|
||||
// Validate hues (no need to clamp as RestrictHue handles normalization)
|
||||
}
|
||||
|
||||
// ColorConfigBuilder provides a fluent interface for building ColorConfig
|
||||
type ColorConfigBuilder struct {
|
||||
config ColorConfig
|
||||
}
|
||||
|
||||
// NewColorConfigBuilder creates a new builder with default values
|
||||
func NewColorConfigBuilder() *ColorConfigBuilder {
|
||||
return &ColorConfigBuilder{
|
||||
config: DefaultColorConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
// WithColorSaturation sets the color saturation
|
||||
func (b *ColorConfigBuilder) WithColorSaturation(saturation float64) *ColorConfigBuilder {
|
||||
b.config.ColorSaturation = saturation
|
||||
return b
|
||||
}
|
||||
|
||||
// WithGrayscaleSaturation sets the grayscale saturation
|
||||
func (b *ColorConfigBuilder) WithGrayscaleSaturation(saturation float64) *ColorConfigBuilder {
|
||||
b.config.GrayscaleSaturation = saturation
|
||||
return b
|
||||
}
|
||||
|
||||
// WithColorLightness sets the color lightness range
|
||||
func (b *ColorConfigBuilder) WithColorLightness(min, max float64) *ColorConfigBuilder {
|
||||
b.config.ColorLightness = LightnessRange{Min: min, Max: max}
|
||||
return b
|
||||
}
|
||||
|
||||
// WithGrayscaleLightness sets the grayscale lightness range
|
||||
func (b *ColorConfigBuilder) WithGrayscaleLightness(min, max float64) *ColorConfigBuilder {
|
||||
b.config.GrayscaleLightness = LightnessRange{Min: min, Max: max}
|
||||
return b
|
||||
}
|
||||
|
||||
// WithHues sets the allowed hues in degrees
|
||||
func (b *ColorConfigBuilder) WithHues(hues ...float64) *ColorConfigBuilder {
|
||||
b.config.Hues = make([]float64, len(hues))
|
||||
copy(b.config.Hues, hues)
|
||||
return b
|
||||
}
|
||||
|
||||
// WithBackColor sets the background color
|
||||
func (b *ColorConfigBuilder) WithBackColor(color Color) *ColorConfigBuilder {
|
||||
b.config.BackColor = &color
|
||||
return b
|
||||
}
|
||||
|
||||
// WithIconPadding sets the icon padding
|
||||
func (b *ColorConfigBuilder) WithIconPadding(padding float64) *ColorConfigBuilder {
|
||||
b.config.IconPadding = padding
|
||||
return b
|
||||
}
|
||||
|
||||
// Build returns the configured ColorConfig after validation
|
||||
func (b *ColorConfigBuilder) Build() ColorConfig {
|
||||
b.config.Validate()
|
||||
return b.config
|
||||
}
|
||||
@@ -5,6 +5,101 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestColorConfigValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config ColorConfig
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid default config",
|
||||
config: DefaultColorConfig(),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid color saturation < 0",
|
||||
config: ColorConfig{
|
||||
ColorSaturation: -0.1,
|
||||
GrayscaleSaturation: 0.0,
|
||||
ColorLightness: LightnessRange{Min: 0.4, Max: 0.8},
|
||||
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
|
||||
IconPadding: 0.08,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "color saturation out of range",
|
||||
},
|
||||
{
|
||||
name: "invalid grayscale saturation > 1",
|
||||
config: ColorConfig{
|
||||
ColorSaturation: 0.5,
|
||||
GrayscaleSaturation: 1.5,
|
||||
ColorLightness: LightnessRange{Min: 0.4, Max: 0.8},
|
||||
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
|
||||
IconPadding: 0.08,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "grayscale saturation out of range",
|
||||
},
|
||||
{
|
||||
name: "invalid color lightness min > max",
|
||||
config: ColorConfig{
|
||||
ColorSaturation: 0.5,
|
||||
GrayscaleSaturation: 0.0,
|
||||
ColorLightness: LightnessRange{Min: 0.8, Max: 0.4},
|
||||
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
|
||||
IconPadding: 0.08,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "color lightness range invalid",
|
||||
},
|
||||
{
|
||||
name: "invalid icon padding > 1",
|
||||
config: ColorConfig{
|
||||
ColorSaturation: 0.5,
|
||||
GrayscaleSaturation: 0.0,
|
||||
ColorLightness: LightnessRange{Min: 0.4, Max: 0.8},
|
||||
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
|
||||
IconPadding: 1.5,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "icon padding out of range",
|
||||
},
|
||||
{
|
||||
name: "multiple validation errors",
|
||||
config: ColorConfig{
|
||||
ColorSaturation: -0.1, // Invalid
|
||||
GrayscaleSaturation: 1.5, // Invalid
|
||||
ColorLightness: LightnessRange{Min: 0.4, Max: 0.8},
|
||||
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
|
||||
IconPadding: 0.08,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "color saturation out of range",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for config validation, got none")
|
||||
return
|
||||
}
|
||||
if tt.errMsg != "" && !containsString(err.Error(), tt.errMsg) {
|
||||
t.Errorf("Expected error message to contain '%s', got '%s'", tt.errMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultColorConfig(t *testing.T) {
|
||||
config := DefaultColorConfig()
|
||||
|
||||
@@ -119,8 +214,8 @@ func TestConfigRestrictHue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
// Test that validation corrects invalid values
|
||||
func TestConfigNormalize(t *testing.T) {
|
||||
// Test that Normalize corrects invalid values
|
||||
config := ColorConfig{
|
||||
ColorSaturation: -0.5, // Invalid: below 0
|
||||
GrayscaleSaturation: 1.5, // Invalid: above 1
|
||||
@@ -129,7 +224,7 @@ func TestConfigValidate(t *testing.T) {
|
||||
IconPadding: 2.0, // Invalid: above 1
|
||||
}
|
||||
|
||||
config.Validate()
|
||||
config.Normalize()
|
||||
|
||||
if config.ColorSaturation != 0.0 {
|
||||
t.Errorf("ColorSaturation after validation = %f, want 0.0", config.ColorSaturation)
|
||||
@@ -159,15 +254,14 @@ func TestConfigValidate(t *testing.T) {
|
||||
func TestColorConfigBuilder(t *testing.T) {
|
||||
redColor := NewColorRGB(255, 0, 0)
|
||||
|
||||
config := NewColorConfigBuilder().
|
||||
WithColorSaturation(0.7).
|
||||
WithGrayscaleSaturation(0.1).
|
||||
WithColorLightness(0.2, 0.8).
|
||||
WithGrayscaleLightness(0.1, 0.9).
|
||||
WithHues(0, 120, 240).
|
||||
WithBackColor(redColor).
|
||||
WithIconPadding(0.1).
|
||||
Build()
|
||||
config := DefaultColorConfig()
|
||||
config.ColorSaturation = 0.7
|
||||
config.GrayscaleSaturation = 0.1
|
||||
config.ColorLightness = LightnessRange{Min: 0.2, Max: 0.8}
|
||||
config.GrayscaleLightness = LightnessRange{Min: 0.1, Max: 0.9}
|
||||
config.Hues = []float64{0, 120, 240}
|
||||
config.BackColor = &redColor
|
||||
config.IconPadding = 0.1
|
||||
|
||||
if config.ColorSaturation != 0.7 {
|
||||
t.Errorf("ColorSaturation = %f, want 0.7", config.ColorSaturation)
|
||||
@@ -200,19 +294,30 @@ func TestColorConfigBuilder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorConfigBuilderValidation(t *testing.T) {
|
||||
// Test that builder validates configuration
|
||||
config := NewColorConfigBuilder().
|
||||
WithColorSaturation(-0.5). // Invalid
|
||||
WithGrayscaleSaturation(1.5). // Invalid
|
||||
Build()
|
||||
func TestColorConfigValidation(t *testing.T) {
|
||||
// Test direct config validation
|
||||
config := DefaultColorConfig()
|
||||
config.ColorSaturation = -0.5 // Invalid
|
||||
config.GrayscaleSaturation = 1.5 // Invalid
|
||||
|
||||
// Should be corrected by validation
|
||||
if config.ColorSaturation != 0.0 {
|
||||
t.Errorf("ColorSaturation = %f, want 0.0 (corrected)", config.ColorSaturation)
|
||||
err := config.Validate()
|
||||
|
||||
// Should return validation error for invalid values
|
||||
if err == nil {
|
||||
t.Error("Expected validation error for invalid configuration, got nil")
|
||||
}
|
||||
|
||||
if config.GrayscaleSaturation != 1.0 {
|
||||
t.Errorf("GrayscaleSaturation = %f, want 1.0 (corrected)", config.GrayscaleSaturation)
|
||||
if !containsString(err.Error(), "color saturation out of range") {
|
||||
t.Errorf("Expected error to mention color saturation validation, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// containsString checks if a string contains a substring
|
||||
func containsString(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
51
internal/engine/doc.go
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Package engine contains the core, format-agnostic logic for generating Jdenticon
|
||||
identicons. It is responsible for translating an input hash into a structured,
|
||||
intermediate representation of the final image.
|
||||
|
||||
This package is internal to the jdenticon library and its API is not guaranteed
|
||||
to be stable. Do not use it directly.
|
||||
|
||||
# Architectural Overview
|
||||
|
||||
The generation process follows a clear pipeline:
|
||||
|
||||
1. Hashing: An input value (e.g., a username) is hashed into a byte slice. This
|
||||
is handled by the public `jdenticon` package.
|
||||
|
||||
2. Generator: The `Generator` struct is the heart of the engine. It consumes the
|
||||
hash to deterministically select shapes, colors, and their transformations
|
||||
(rotation, position).
|
||||
|
||||
3. Shape Selection: Based on bytes from the hash, specific shapes are chosen from
|
||||
the predefined shape catalog in `shapes.go`.
|
||||
|
||||
4. Transform & Positioning: The `transform.go` file defines how shapes are
|
||||
positioned and rotated within the icon's grid. The center shape is
|
||||
handled separately from the outer shapes.
|
||||
|
||||
5. Colorization: `color.go` uses the hash and the `Config` to determine the
|
||||
final hue, saturation, and lightness of the icon's foreground color.
|
||||
|
||||
The output of this engine is a `[]RenderedElement`, which is a list of
|
||||
geometries and their associated colors. This intermediate representation is then
|
||||
passed to a renderer (see the `internal/renderer` package) to produce the final
|
||||
output (e.g., SVG or PNG). This separation of concerns allows the core generation
|
||||
logic to remain independent of the output format.
|
||||
|
||||
# Key Components
|
||||
|
||||
- generator.go: Main generation algorithm and core deterministic logic
|
||||
- shapes.go: Shape definitions and rendering with coordinate transformations
|
||||
- color.go: Color theme generation using HSL color space
|
||||
- config.go: Internal configuration structures and validation
|
||||
- transform.go: Coordinate transformation utilities
|
||||
|
||||
# Hash-Based Determinism
|
||||
|
||||
The engine ensures deterministic output by using specific positions within the
|
||||
input hash to drive shape selection, color generation, and transformations.
|
||||
This guarantees that identical inputs always produce identical identicons while
|
||||
maintaining visual variety across different inputs.
|
||||
*/
|
||||
package engine
|
||||
412
internal/engine/fuzz_test.go
Normal file
@@ -0,0 +1,412 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/util"
|
||||
)
|
||||
|
||||
// FuzzGeneratorGenerate tests the internal engine generator with arbitrary inputs
|
||||
func FuzzGeneratorGenerate(f *testing.F) {
|
||||
// Seed with known hash patterns and sizes
|
||||
f.Add("abcdef1234567890", 64.0)
|
||||
f.Add("", 32.0)
|
||||
f.Add("0123456789abcdef", 128.0)
|
||||
f.Add("ffffffffffffffff", 256.0)
|
||||
f.Add("0000000000000000", 1.0)
|
||||
|
||||
f.Fuzz(func(t *testing.T, hash string, size float64) {
|
||||
// Test invalid sizes for proper error handling
|
||||
if size <= 0 || math.IsNaN(size) || math.IsInf(size, 0) {
|
||||
// Create a generator with default config
|
||||
config := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 100,
|
||||
}
|
||||
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create generator: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = generator.Generate(context.Background(), hash, size)
|
||||
if err == nil {
|
||||
t.Errorf("Generate with invalid size %f should have returned an error", size)
|
||||
}
|
||||
return // Stop further processing for invalid inputs
|
||||
}
|
||||
if size > 10000 {
|
||||
return // Avoid resource exhaustion
|
||||
}
|
||||
|
||||
// Create a generator with default config
|
||||
config := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 100,
|
||||
}
|
||||
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create generator: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate should never panic, regardless of hash input
|
||||
icon, err := generator.Generate(context.Background(), hash, size)
|
||||
|
||||
// We don't require success for all inputs, but we require no crashes
|
||||
if err != nil {
|
||||
// Check that error is reasonable
|
||||
_ = err
|
||||
return
|
||||
}
|
||||
|
||||
if icon == nil {
|
||||
t.Errorf("Generate(%q, %f) returned nil icon without error", hash, size)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify icon has reasonable properties
|
||||
if icon.Size != size {
|
||||
t.Errorf("Generated icon size %f does not match requested size %f", icon.Size, size)
|
||||
}
|
||||
|
||||
if len(icon.Shapes) == 0 {
|
||||
t.Errorf("Generated icon has no shapes")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzColorConfigValidation tests color configuration validation
|
||||
func FuzzColorConfigValidation(f *testing.F) {
|
||||
// Seed with various color configuration patterns
|
||||
f.Add(0.5, 0.5, 0.4, 0.8, 0.3, 0.9, 0.08)
|
||||
f.Add(-1.0, 2.0, -0.5, 1.5, -0.1, 1.1, -0.1)
|
||||
f.Add(0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0)
|
||||
|
||||
f.Fuzz(func(t *testing.T, colorSat, grayscaleSat, colorLightMin, colorLightMax,
|
||||
grayscaleLightMin, grayscaleLightMax, padding float64) {
|
||||
config := ColorConfig{
|
||||
ColorSaturation: colorSat,
|
||||
GrayscaleSaturation: grayscaleSat,
|
||||
ColorLightness: LightnessRange{
|
||||
Min: colorLightMin,
|
||||
Max: colorLightMax,
|
||||
},
|
||||
GrayscaleLightness: LightnessRange{
|
||||
Min: grayscaleLightMin,
|
||||
Max: grayscaleLightMax,
|
||||
},
|
||||
IconPadding: padding,
|
||||
}
|
||||
|
||||
// Validation should never panic
|
||||
err := config.Validate()
|
||||
_ = err
|
||||
|
||||
// If validation passes, test that we can create a generator
|
||||
if err == nil {
|
||||
genConfig := GeneratorConfig{
|
||||
ColorConfig: config,
|
||||
CacheSize: 10,
|
||||
}
|
||||
|
||||
generator, genErr := NewGeneratorWithConfig(genConfig)
|
||||
if genErr == nil && generator != nil {
|
||||
// Try to generate an icon
|
||||
icon, iconErr := generator.Generate(context.Background(), "test-hash", 64.0)
|
||||
if iconErr == nil && icon != nil {
|
||||
// Verify the icon has valid properties
|
||||
if icon.Size != 64.0 {
|
||||
t.Errorf("Icon size mismatch: expected 64.0, got %f", icon.Size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzParseHex tests the hex parsing function with arbitrary inputs
|
||||
func FuzzParseHex(f *testing.F) {
|
||||
// Seed with various hex patterns
|
||||
f.Add("abcdef123456", 0, 1)
|
||||
f.Add("0123456789", 5, 2)
|
||||
f.Add("", 0, 1)
|
||||
f.Add("xyz", 0, 1)
|
||||
f.Add("ffffffffff", 10, 5)
|
||||
|
||||
f.Fuzz(func(t *testing.T, hash string, position, octets int) {
|
||||
// ParseHex should never panic, even with invalid inputs
|
||||
result, err := util.ParseHex(hash, position, octets)
|
||||
|
||||
// Determine the actual slice being parsed (mimic ParseHex logic)
|
||||
startPosition := position
|
||||
if startPosition < 0 {
|
||||
startPosition = len(hash) + startPosition
|
||||
}
|
||||
|
||||
// Only check substring if it would be valid to parse
|
||||
if startPosition >= 0 && startPosition < len(hash) {
|
||||
end := len(hash)
|
||||
if octets > 0 {
|
||||
end = startPosition + octets
|
||||
if end > len(hash) {
|
||||
end = len(hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the substring that ParseHex would actually process
|
||||
if startPosition < end {
|
||||
substr := hash[startPosition:end]
|
||||
|
||||
// Check if the relevant substring contains invalid hex characters
|
||||
isInvalidHex := containsNonHex(substr)
|
||||
if isInvalidHex && err == nil {
|
||||
t.Errorf("ParseHex should have returned an error for invalid hex substring %q, but didn't", substr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for position out of bounds (after negative position handling)
|
||||
if startPosition >= len(hash) && len(hash) > 0 && err == nil {
|
||||
t.Errorf("ParseHex should return error for position %d >= hash length %d", startPosition, len(hash))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return // Correctly returned an error
|
||||
}
|
||||
|
||||
// On success, verify the result is reasonable
|
||||
_ = result // Result could be any valid integer
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzColorGeneration tests color generation with arbitrary hue values
|
||||
func FuzzColorGeneration(f *testing.F) {
|
||||
// Seed with various hue values
|
||||
f.Add(0.0, 0.5)
|
||||
f.Add(0.5, 0.7)
|
||||
f.Add(1.0, 0.3)
|
||||
f.Add(-0.1, 0.9)
|
||||
f.Add(1.1, 0.1)
|
||||
|
||||
f.Fuzz(func(t *testing.T, hue, lightnessValue float64) {
|
||||
// Skip extreme values that might cause issues
|
||||
if math.IsNaN(hue) || math.IsInf(hue, 0) || math.IsNaN(lightnessValue) || math.IsInf(lightnessValue, 0) {
|
||||
return
|
||||
}
|
||||
|
||||
config := DefaultColorConfig()
|
||||
|
||||
// Test actual production color generation functions
|
||||
color := GenerateColor(hue, config, lightnessValue)
|
||||
|
||||
// Verify color has reasonable RGB values (0-255)
|
||||
r, g, b, err := color.ToRGB()
|
||||
if err != nil {
|
||||
t.Errorf("color.ToRGB failed: %v", err)
|
||||
return
|
||||
}
|
||||
// RGB values are uint8, so they're guaranteed to be in 0-255 range
|
||||
_, _, _ = r, g, b
|
||||
|
||||
// Test grayscale generation as well
|
||||
grayscale := GenerateGrayscale(config, lightnessValue)
|
||||
gr, gg, gb, err := grayscale.ToRGB()
|
||||
if err != nil {
|
||||
t.Errorf("grayscale.ToRGB failed: %v", err)
|
||||
return
|
||||
}
|
||||
// RGB values are uint8, so they're guaranteed to be in 0-255 range
|
||||
_, _, _ = gr, gg, gb
|
||||
|
||||
// Test color theme generation
|
||||
theme := GenerateColorTheme(hue, config)
|
||||
if len(theme) != 5 {
|
||||
t.Errorf("GenerateColorTheme should return 5 colors, got %d", len(theme))
|
||||
}
|
||||
for _, themeColor := range theme {
|
||||
tr, tg, tb, err := themeColor.ToRGB()
|
||||
if err != nil {
|
||||
t.Errorf("themeColor.ToRGB failed: %v", err)
|
||||
continue
|
||||
}
|
||||
// RGB values are uint8, so they're guaranteed to be in 0-255 range
|
||||
_, _, _ = tr, tg, tb
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzHexColorParsing tests hex color parsing with arbitrary strings
|
||||
func FuzzHexColorParsing(f *testing.F) {
|
||||
// Seed with various hex color patterns
|
||||
f.Add("#ffffff")
|
||||
f.Add("#000000")
|
||||
f.Add("#fff")
|
||||
f.Add("#12345678")
|
||||
f.Add("invalid")
|
||||
f.Add("")
|
||||
f.Add("#")
|
||||
f.Add("#gggggg")
|
||||
|
||||
f.Fuzz(func(t *testing.T, colorStr string) {
|
||||
// ValidateHexColor should never panic
|
||||
err := ValidateHexColor(colorStr)
|
||||
_ = err
|
||||
|
||||
// If validation passes, try parsing
|
||||
if err == nil {
|
||||
color, parseErr := ParseHexColorToEngine(colorStr)
|
||||
if parseErr == nil {
|
||||
// Verify parsed color has valid properties
|
||||
r, g, b, err := color.ToRGB()
|
||||
if err != nil {
|
||||
t.Errorf("color.ToRGB failed: %v", err)
|
||||
return
|
||||
}
|
||||
// RGB and alpha values are uint8, so they're guaranteed to be in 0-255 range
|
||||
_, _, _ = r, g, b
|
||||
_ = color.A
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzGeneratorCaching tests generator caching behavior with arbitrary inputs
|
||||
func FuzzGeneratorCaching(f *testing.F) {
|
||||
// Seed with various cache scenarios
|
||||
f.Add("hash1", 64.0, "hash2", 128.0)
|
||||
f.Add("same", 64.0, "same", 64.0)
|
||||
f.Add("", 1.0, "different", 1.0)
|
||||
|
||||
f.Fuzz(func(t *testing.T, hash1 string, size1 float64, hash2 string, size2 float64) {
|
||||
// Skip invalid sizes
|
||||
if size1 <= 0 || size1 > 1000 || size2 <= 0 || size2 > 1000 {
|
||||
return
|
||||
}
|
||||
if math.IsNaN(size1) || math.IsInf(size1, 0) || math.IsNaN(size2) || math.IsInf(size2, 0) {
|
||||
return
|
||||
}
|
||||
|
||||
config := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 10,
|
||||
}
|
||||
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate first icon
|
||||
icon1, err1 := generator.Generate(context.Background(), hash1, size1)
|
||||
if err1 != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check cache metrics after first generation
|
||||
initialHits, initialMisses := generator.GetCacheMetrics()
|
||||
|
||||
// Generate second icon (might be cache hit if same)
|
||||
icon2, err2 := generator.Generate(context.Background(), hash2, size2)
|
||||
if err2 != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify cache behavior
|
||||
finalSize := generator.GetCacheSize()
|
||||
finalHits, finalMisses := generator.GetCacheMetrics()
|
||||
|
||||
// Cache size should not exceed capacity
|
||||
if finalSize > generator.GetCacheCapacity() {
|
||||
t.Errorf("Cache size %d exceeds capacity %d", finalSize, generator.GetCacheCapacity())
|
||||
}
|
||||
|
||||
// Metrics should increase appropriately
|
||||
if finalHits < initialHits || finalMisses < initialMisses {
|
||||
t.Errorf("Cache metrics decreased: hits %d->%d, misses %d->%d",
|
||||
initialHits, finalHits, initialMisses, finalMisses)
|
||||
}
|
||||
|
||||
// If same hash and size, should be cache hit
|
||||
if hash1 == hash2 && size1 == size2 && icon1 != nil && icon2 != nil {
|
||||
if finalHits <= initialHits {
|
||||
t.Errorf("Expected cache hit for identical inputs, but hits did not increase. Initial: %d, Final: %d", initialHits, finalHits)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cache should not panic and should reset metrics
|
||||
generator.ClearCache()
|
||||
clearedSize := generator.GetCacheSize()
|
||||
clearedHits, clearedMisses := generator.GetCacheMetrics()
|
||||
|
||||
if clearedSize != 0 {
|
||||
t.Errorf("Cache size after clear: expected 0, got %d", clearedSize)
|
||||
}
|
||||
if clearedHits != 0 || clearedMisses != 0 {
|
||||
t.Errorf("Metrics after clear: expected 0,0 got %d,%d", clearedHits, clearedMisses)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzLightnessRangeOperations tests lightness range calculations
|
||||
func FuzzLightnessRangeOperations(f *testing.F) {
|
||||
// Seed with various lightness range values
|
||||
f.Add(0.0, 1.0, 0.5)
|
||||
f.Add(0.4, 0.8, 0.7)
|
||||
f.Add(-0.1, 1.1, 0.5)
|
||||
f.Add(0.9, 0.1, 0.5) // Invalid range (min > max)
|
||||
|
||||
f.Fuzz(func(t *testing.T, min, max, value float64) {
|
||||
// Skip NaN and infinite values
|
||||
if math.IsNaN(min) || math.IsInf(min, 0) ||
|
||||
math.IsNaN(max) || math.IsInf(max, 0) ||
|
||||
math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return
|
||||
}
|
||||
|
||||
lightnessRange := LightnessRange{Min: min, Max: max}
|
||||
|
||||
// Test actual production LightnessRange.GetLightness method
|
||||
result := lightnessRange.GetLightness(value)
|
||||
|
||||
// GetLightness should never panic and should return a valid result
|
||||
if math.IsNaN(result) || math.IsInf(result, 0) {
|
||||
t.Errorf("GetLightness(%f) with range [%f, %f] returned invalid result: %f", value, min, max, result)
|
||||
}
|
||||
|
||||
// Result should be clamped to [0, 1] range
|
||||
if result < 0 || result > 1 {
|
||||
t.Errorf("GetLightness(%f) with range [%f, %f] returned out-of-range result: %f", value, min, max, result)
|
||||
}
|
||||
|
||||
// If input range is valid and value is in [0,1], result should be in range
|
||||
if min >= 0 && max <= 1 && min <= max && value >= 0 && value <= 1 {
|
||||
expectedMin := math.Min(min, max)
|
||||
expectedMax := math.Max(min, max)
|
||||
if result < expectedMin || result > expectedMax {
|
||||
// Allow for floating point precision issues
|
||||
if math.Abs(result-expectedMin) > 1e-10 && math.Abs(result-expectedMax) > 1e-10 {
|
||||
t.Errorf("GetLightness(%f) with valid range [%f, %f] returned result %f outside expected range [%f, %f]",
|
||||
value, min, max, result, expectedMin, expectedMax)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// containsNonHex checks if a string contains non-hexadecimal characters
|
||||
// Note: strconv.ParseInt allows negative hex numbers, so '-' is valid at the start
|
||||
func containsNonHex(s string) bool {
|
||||
for i, r := range s {
|
||||
isHexDigit := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
|
||||
isValidMinus := (r == '-' && i == 0) // Minus only valid at start
|
||||
if !isHexDigit && !isValidMinus {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,10 +1,60 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/kevin/go-jdenticon/internal/util"
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/constants"
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/util"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// Hash position constants for extracting values from the hash string
|
||||
const (
|
||||
// Shape type selection positions
|
||||
hashPosSideShape = 2 // Position for side shape selection
|
||||
hashPosCornerShape = 4 // Position for corner shape selection
|
||||
hashPosCenterShape = 1 // Position for center shape selection
|
||||
|
||||
// Rotation positions
|
||||
hashPosSideRotation = 3 // Position for side shape rotation
|
||||
hashPosCornerRotation = 5 // Position for corner shape rotation
|
||||
hashPosCenterRotation = -1 // Center shapes use incremental rotation (no hash position)
|
||||
|
||||
// Color selection positions
|
||||
hashPosColorStart = 8 // Starting position for color selection (8, 9, 10)
|
||||
|
||||
// Hue extraction
|
||||
hashPosHueStart = -7 // Start position for hue extraction (last 7 chars)
|
||||
hashPosHueLength = 7 // Number of characters for hue
|
||||
hueMaxValue = 0xfffffff // Maximum hue value for normalization
|
||||
)
|
||||
|
||||
// Grid and layout constants
|
||||
const (
|
||||
gridSize = 4 // Standard 4x4 grid for jdenticon layout
|
||||
paddingMultiple = 2 // Padding is applied on both sides (2x)
|
||||
)
|
||||
|
||||
// Color conflict resolution constants
|
||||
const (
|
||||
colorDarkGray = 0 // Index for dark gray color
|
||||
colorDarkMain = 4 // Index for dark main color
|
||||
colorLightGray = 2 // Index for light gray color
|
||||
colorLightMain = 3 // Index for light main color
|
||||
colorMidFallback = 1 // Fallback color index for conflicts
|
||||
)
|
||||
|
||||
// Shape rendering constants
|
||||
const (
|
||||
shapeColorIndexSides = 0 // Color index for side shapes
|
||||
shapeColorIndexCorners = 1 // Color index for corner shapes
|
||||
shapeColorIndexCenter = 2 // Color index for center shapes
|
||||
|
||||
numColorSelections = 3 // Total number of color selections needed
|
||||
)
|
||||
|
||||
// Icon represents a generated jdenticon with its configuration and geometry
|
||||
@@ -37,86 +87,111 @@ type Shape struct {
|
||||
CircleSize float64
|
||||
}
|
||||
|
||||
// Generator encapsulates the icon generation logic and provides caching
|
||||
type Generator struct {
|
||||
config ColorConfig
|
||||
cache map[string]*Icon
|
||||
mu sync.RWMutex
|
||||
// GeneratorConfig holds configuration for the generator including cache settings
|
||||
type GeneratorConfig struct {
|
||||
ColorConfig ColorConfig
|
||||
CacheSize int // Maximum number of items in the LRU cache (default: 1000)
|
||||
MaxComplexity int // Maximum geometric complexity score (-1 to disable, 0 for default)
|
||||
MaxIconSize int // Maximum allowed icon size in pixels (0 for default from constants.DefaultMaxIconSize)
|
||||
}
|
||||
|
||||
// NewGenerator creates a new Generator with the specified configuration
|
||||
func NewGenerator(config ColorConfig) *Generator {
|
||||
config.Validate()
|
||||
// DefaultGeneratorConfig returns the default generator configuration
|
||||
func DefaultGeneratorConfig() GeneratorConfig {
|
||||
return GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 1000,
|
||||
MaxComplexity: 0, // Use default from constants
|
||||
MaxIconSize: 0, // Use default from constants.DefaultMaxIconSize
|
||||
}
|
||||
}
|
||||
|
||||
// Generator encapsulates the icon generation logic and provides caching
|
||||
type Generator struct {
|
||||
config GeneratorConfig
|
||||
cache *lru.Cache[string, *Icon]
|
||||
mu sync.RWMutex
|
||||
metrics CacheMetrics
|
||||
sf singleflight.Group // Prevents thundering herd on cache misses
|
||||
maxIconSize int // Resolved maximum icon size (from config or default)
|
||||
}
|
||||
|
||||
// NewGenerator creates a new Generator with the specified color configuration
|
||||
// and default cache size of 1000 entries
|
||||
func NewGenerator(colorConfig ColorConfig) (*Generator, error) {
|
||||
generatorConfig := GeneratorConfig{
|
||||
ColorConfig: colorConfig,
|
||||
CacheSize: 1000,
|
||||
}
|
||||
return NewGeneratorWithConfig(generatorConfig)
|
||||
}
|
||||
|
||||
// NewGeneratorWithConfig creates a new Generator with the specified configuration
|
||||
func NewGeneratorWithConfig(config GeneratorConfig) (*Generator, error) {
|
||||
if config.CacheSize <= 0 {
|
||||
return nil, fmt.Errorf("jdenticon: engine: cache initialization failed: invalid cache size: %d", config.CacheSize)
|
||||
}
|
||||
|
||||
config.ColorConfig.Normalize()
|
||||
|
||||
// Resolve the effective maximum icon size
|
||||
maxIconSize := config.MaxIconSize
|
||||
if maxIconSize == 0 || (maxIconSize < 0 && maxIconSize != -1) {
|
||||
maxIconSize = constants.DefaultMaxIconSize
|
||||
}
|
||||
// If maxIconSize is -1, keep it as -1 to disable the limit
|
||||
|
||||
// Create LRU cache with specified size
|
||||
cache, err := lru.New[string, *Icon](config.CacheSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jdenticon: engine: cache initialization failed: %w", err)
|
||||
}
|
||||
|
||||
return &Generator{
|
||||
config: config,
|
||||
cache: make(map[string]*Icon),
|
||||
}
|
||||
cache: cache,
|
||||
metrics: CacheMetrics{},
|
||||
maxIconSize: maxIconSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewDefaultGenerator creates a new Generator with default configuration
|
||||
func NewDefaultGenerator() *Generator {
|
||||
return NewGenerator(DefaultColorConfig())
|
||||
func NewDefaultGenerator() (*Generator, error) {
|
||||
generator, err := NewGeneratorWithConfig(DefaultGeneratorConfig())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jdenticon: engine: default generator creation failed: %w", err)
|
||||
}
|
||||
return generator, nil
|
||||
}
|
||||
|
||||
// Generate creates an icon from a hash string using the configured settings
|
||||
func (g *Generator) Generate(hash string, size float64) (*Icon, error) {
|
||||
if hash == "" {
|
||||
return nil, fmt.Errorf("hash cannot be empty")
|
||||
}
|
||||
|
||||
if size <= 0 {
|
||||
return nil, fmt.Errorf("size must be positive, got %f", size)
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
cacheKey := g.cacheKey(hash, size)
|
||||
g.mu.RLock()
|
||||
if cached, exists := g.cache[cacheKey]; exists {
|
||||
g.mu.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
g.mu.RUnlock()
|
||||
|
||||
// Validate hash format
|
||||
if !util.IsValidHash(hash) {
|
||||
return nil, fmt.Errorf("invalid hash format: %s", hash)
|
||||
}
|
||||
|
||||
// Generate new icon
|
||||
icon, err := g.generateIcon(hash, size)
|
||||
if err != nil {
|
||||
// generateIcon performs the actual icon generation with context support and complexity checking
|
||||
func (g *Generator) generateIcon(ctx context.Context, hash string, size float64) (*Icon, error) {
|
||||
// Check for cancellation before expensive operations
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
g.mu.Lock()
|
||||
g.cache[cacheKey] = icon
|
||||
g.mu.Unlock()
|
||||
// Complexity validation is now handled at the jdenticon package level
|
||||
// to ensure proper structured error types are returned
|
||||
|
||||
return icon, nil
|
||||
}
|
||||
|
||||
// generateIcon performs the actual icon generation
|
||||
func (g *Generator) generateIcon(hash string, size float64) (*Icon, error) {
|
||||
// Calculate padding and round to nearest integer (matching JavaScript)
|
||||
padding := int((0.5 + size*g.config.IconPadding))
|
||||
iconSize := size - float64(padding*2)
|
||||
padding := int((0.5 + size*g.config.ColorConfig.IconPadding))
|
||||
iconSize := size - float64(padding*paddingMultiple)
|
||||
|
||||
// Calculate cell size and ensure it is an integer (matching JavaScript)
|
||||
cell := int(iconSize / 4)
|
||||
cell := int(iconSize / gridSize)
|
||||
|
||||
// Since the cell size is integer based, the actual icon will be slightly smaller than specified => center icon
|
||||
x := int(float64(padding) + iconSize/2 - float64(cell*2))
|
||||
y := int(float64(padding) + iconSize/2 - float64(cell*2))
|
||||
x := int(float64(padding) + iconSize/2 - float64(cell*paddingMultiple))
|
||||
y := int(float64(padding) + iconSize/2 - float64(cell*paddingMultiple))
|
||||
|
||||
// Extract hue from hash (last 7 characters)
|
||||
hue, err := g.extractHue(hash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generateIcon: %w", err)
|
||||
return nil, fmt.Errorf("jdenticon: engine: icon generation failed: %w", err)
|
||||
}
|
||||
|
||||
// Generate color theme
|
||||
availableColors := GenerateColorTheme(hue, g.config)
|
||||
availableColors := GenerateColorTheme(hue, g.config.ColorConfig)
|
||||
|
||||
// Select colors for each shape layer
|
||||
selectedColorIndexes, err := g.selectColors(hash, availableColors)
|
||||
@@ -125,48 +200,56 @@ func (g *Generator) generateIcon(hash string, size float64) (*Icon, error) {
|
||||
}
|
||||
|
||||
// Generate shape groups in exact JavaScript order
|
||||
shapeGroups := make([]ShapeGroup, 0, 3)
|
||||
shapeGroups := make([]ShapeGroup, 0, numColorSelections)
|
||||
|
||||
// Check for cancellation before rendering shapes
|
||||
if err = ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 1. Sides (outer edges) - renderShape(0, outerShape, 2, 3, [[1, 0], [2, 0], [2, 3], [1, 3], [0, 1], [3, 1], [3, 2], [0, 2]]);
|
||||
sideShapes, err := g.renderShape(hash, 0, 2, 3,
|
||||
var sideShapes []Shape
|
||||
err = g.renderShape(ctx, hash, shapeColorIndexSides, hashPosSideShape, hashPosSideRotation,
|
||||
[][]int{{1, 0}, {2, 0}, {2, 3}, {1, 3}, {0, 1}, {3, 1}, {3, 2}, {0, 2}},
|
||||
x, y, cell, true)
|
||||
x, y, cell, true, &sideShapes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generateIcon: failed to render side shapes: %w", err)
|
||||
return nil, fmt.Errorf("jdenticon: engine: icon generation failed: side shapes rendering failed: %w", err)
|
||||
}
|
||||
if len(sideShapes) > 0 {
|
||||
shapeGroups = append(shapeGroups, ShapeGroup{
|
||||
Color: availableColors[selectedColorIndexes[0]],
|
||||
Color: availableColors[selectedColorIndexes[shapeColorIndexSides]],
|
||||
Shapes: sideShapes,
|
||||
ShapeType: "sides",
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Corners - renderShape(1, outerShape, 4, 5, [[0, 0], [3, 0], [3, 3], [0, 3]]);
|
||||
cornerShapes, err := g.renderShape(hash, 1, 4, 5,
|
||||
var cornerShapes []Shape
|
||||
err = g.renderShape(ctx, hash, shapeColorIndexCorners, hashPosCornerShape, hashPosCornerRotation,
|
||||
[][]int{{0, 0}, {3, 0}, {3, 3}, {0, 3}},
|
||||
x, y, cell, true)
|
||||
x, y, cell, true, &cornerShapes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generateIcon: failed to render corner shapes: %w", err)
|
||||
return nil, fmt.Errorf("jdenticon: engine: icon generation failed: corner shapes rendering failed: %w", err)
|
||||
}
|
||||
if len(cornerShapes) > 0 {
|
||||
shapeGroups = append(shapeGroups, ShapeGroup{
|
||||
Color: availableColors[selectedColorIndexes[1]],
|
||||
Color: availableColors[selectedColorIndexes[shapeColorIndexCorners]],
|
||||
Shapes: cornerShapes,
|
||||
ShapeType: "corners",
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Center - renderShape(2, centerShape, 1, null, [[1, 1], [2, 1], [2, 2], [1, 2]]);
|
||||
centerShapes, err := g.renderShape(hash, 2, 1, -1,
|
||||
var centerShapes []Shape
|
||||
err = g.renderShape(ctx, hash, shapeColorIndexCenter, hashPosCenterShape, hashPosCenterRotation,
|
||||
[][]int{{1, 1}, {2, 1}, {2, 2}, {1, 2}},
|
||||
x, y, cell, false)
|
||||
x, y, cell, false, ¢erShapes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generateIcon: failed to render center shapes: %w", err)
|
||||
return nil, fmt.Errorf("jdenticon: engine: icon generation failed: center shapes rendering failed: %w", err)
|
||||
}
|
||||
if len(centerShapes) > 0 {
|
||||
shapeGroups = append(shapeGroups, ShapeGroup{
|
||||
Color: availableColors[selectedColorIndexes[2]],
|
||||
Color: availableColors[selectedColorIndexes[shapeColorIndexCenter]],
|
||||
Shapes: centerShapes,
|
||||
ShapeType: "center",
|
||||
})
|
||||
@@ -175,7 +258,7 @@ func (g *Generator) generateIcon(hash string, size float64) (*Icon, error) {
|
||||
return &Icon{
|
||||
Hash: hash,
|
||||
Size: size,
|
||||
Config: g.config,
|
||||
Config: g.config.ColorConfig,
|
||||
Shapes: shapeGroups,
|
||||
}, nil
|
||||
}
|
||||
@@ -183,32 +266,43 @@ func (g *Generator) generateIcon(hash string, size float64) (*Icon, error) {
|
||||
// extractHue extracts the hue value from the hash string
|
||||
func (g *Generator) extractHue(hash string) (float64, error) {
|
||||
// Use the last 7 characters of the hash to determine hue
|
||||
hueValue, err := util.ParseHex(hash, -7, 7)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("extractHue: %w", err)
|
||||
if len(hash) < hashPosHueLength {
|
||||
return 0, fmt.Errorf("jdenticon: engine: hue extraction failed: hash too short for hue extraction")
|
||||
}
|
||||
return float64(hueValue) / 0xfffffff, nil
|
||||
hueStr := hash[len(hash)-hashPosHueLength:]
|
||||
hueValue64, err := strconv.ParseInt(hueStr, 16, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("jdenticon: engine: hue extraction failed: failed to parse hue '%s': %w", hueStr, err)
|
||||
}
|
||||
hueValue := int(hueValue64)
|
||||
return float64(hueValue) / hueMaxValue, nil
|
||||
}
|
||||
|
||||
// selectColors selects 3 colors from the available color palette
|
||||
func (g *Generator) selectColors(hash string, availableColors []Color) ([]int, error) {
|
||||
if len(availableColors) == 0 {
|
||||
return nil, fmt.Errorf("no available colors")
|
||||
return nil, fmt.Errorf("jdenticon: engine: color selection failed: no available colors")
|
||||
}
|
||||
|
||||
selectedIndexes := make([]int, 3)
|
||||
selectedIndexes := make([]int, numColorSelections)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
indexValue, err := util.ParseHex(hash, 8+i, 1)
|
||||
for i := 0; i < numColorSelections; i++ {
|
||||
indexValue, err := util.ParseHex(hash, hashPosColorStart+i, 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selectColors: failed to parse color index at position %d: %w", 8+i, err)
|
||||
return nil, fmt.Errorf("jdenticon: engine: color selection failed: failed to parse color index at position %d: %w", hashPosColorStart+i, err)
|
||||
}
|
||||
// Defensive check: ensure availableColors is not empty before modulo operation
|
||||
// This should never happen due to the check at the start of the function,
|
||||
// but provides additional safety for future modifications
|
||||
if len(availableColors) == 0 {
|
||||
return nil, fmt.Errorf("jdenticon: engine: color selection failed: available colors became empty during selection")
|
||||
}
|
||||
index := indexValue % len(availableColors)
|
||||
|
||||
// Apply color conflict resolution rules from JavaScript implementation
|
||||
if g.isDuplicateColor(index, selectedIndexes[:i], []int{0, 4}) || // Disallow dark gray and dark color combo
|
||||
g.isDuplicateColor(index, selectedIndexes[:i], []int{2, 3}) { // Disallow light gray and light color combo
|
||||
index = 1 // Use mid color as fallback
|
||||
if g.isDuplicateColor(index, selectedIndexes[:i], []int{colorDarkGray, colorDarkMain}) || // Disallow dark gray and dark color combo
|
||||
g.isDuplicateColor(index, selectedIndexes[:i], []int{colorLightGray, colorLightMain}) { // Disallow light gray and light color combo
|
||||
index = colorMidFallback // Use mid color as fallback
|
||||
}
|
||||
|
||||
selectedIndexes[i] = index
|
||||
@@ -217,35 +311,35 @@ func (g *Generator) selectColors(hash string, availableColors []Color) ([]int, e
|
||||
return selectedIndexes, nil
|
||||
}
|
||||
|
||||
// contains checks if a slice contains a specific value
|
||||
func contains(slice []int, value int) bool {
|
||||
for _, item := range slice {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isDuplicateColor checks for problematic color combinations
|
||||
func (g *Generator) isDuplicateColor(index int, selected []int, forbidden []int) bool {
|
||||
if !contains(forbidden, index) {
|
||||
if !isColorInForbiddenSet(index, forbidden) {
|
||||
return false
|
||||
}
|
||||
return hasSelectedColorInForbiddenSet(selected, forbidden)
|
||||
}
|
||||
|
||||
// isColorInForbiddenSet checks if the given color index is in the forbidden set
|
||||
func isColorInForbiddenSet(index int, forbidden []int) bool {
|
||||
return util.ContainsInt(forbidden, index)
|
||||
}
|
||||
|
||||
// hasSelectedColorInForbiddenSet checks if any selected color is in the forbidden set
|
||||
func hasSelectedColorInForbiddenSet(selected []int, forbidden []int) bool {
|
||||
for _, s := range selected {
|
||||
if contains(forbidden, s) {
|
||||
if util.ContainsInt(forbidden, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// renderShape implements the JavaScript renderShape function exactly
|
||||
func (g *Generator) renderShape(hash string, colorIndex, shapeHashIndex, rotationHashIndex int, positions [][]int, x, y, cell int, isOuter bool) ([]Shape, error) {
|
||||
// renderShape implements the JavaScript renderShape function exactly with context support
|
||||
// Shapes are appended directly to the provided destination slice to avoid intermediate allocations
|
||||
func (g *Generator) renderShape(ctx context.Context, hash string, colorIndex, shapeHashIndex, rotationHashIndex int, positions [][]int, x, y, cell int, isOuter bool, dest *[]Shape) error { //nolint:unparam // colorIndex is passed for API consistency with JavaScript implementation
|
||||
shapeIndexValue, err := util.ParseHex(hash, shapeHashIndex, 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("renderShape: failed to parse shape index at position %d: %w", shapeHashIndex, err)
|
||||
return fmt.Errorf("jdenticon: engine: shape rendering failed: failed to parse shape index at position %d: %w", shapeHashIndex, err)
|
||||
}
|
||||
shapeIndex := shapeIndexValue
|
||||
|
||||
@@ -253,29 +347,36 @@ func (g *Generator) renderShape(hash string, colorIndex, shapeHashIndex, rotatio
|
||||
if rotationHashIndex >= 0 {
|
||||
rotationValue, err := util.ParseHex(hash, rotationHashIndex, 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("renderShape: failed to parse rotation at position %d: %w", rotationHashIndex, err)
|
||||
return fmt.Errorf("jdenticon: engine: shape rendering failed: failed to parse rotation at position %d: %w", rotationHashIndex, err)
|
||||
}
|
||||
rotation = rotationValue
|
||||
}
|
||||
|
||||
shapes := make([]Shape, 0, len(positions))
|
||||
|
||||
for i, pos := range positions {
|
||||
// Check for cancellation in the rendering loop
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate transform exactly like JavaScript: new Transform(x + positions[i][0] * cell, y + positions[i][1] * cell, cell, r++ % 4)
|
||||
transformX := float64(x + pos[0]*cell)
|
||||
transformY := float64(y + pos[1]*cell)
|
||||
var transformRotation int
|
||||
if rotationHashIndex >= 0 {
|
||||
transformRotation = (rotation + i) % 4
|
||||
transformRotation = (rotation + i) % gridSize
|
||||
} else {
|
||||
// For center shapes (rotationIndex is null), r starts at 0 and increments
|
||||
transformRotation = i % 4
|
||||
transformRotation = i % gridSize
|
||||
}
|
||||
|
||||
transform := NewTransform(transformX, transformY, float64(cell), transformRotation)
|
||||
|
||||
// Create shape using graphics with transform
|
||||
graphics := NewGraphicsWithTransform(&shapeCollector{}, transform)
|
||||
// Get a collector from the pool and reset it
|
||||
collector := shapeCollectorPool.Get().(*shapeCollector)
|
||||
collector.Reset()
|
||||
|
||||
// Create shape using graphics with pooled collector
|
||||
graphics := NewGraphicsWithTransform(collector, transform)
|
||||
|
||||
if isOuter {
|
||||
RenderOuterShape(graphics, shapeIndex, float64(cell))
|
||||
@@ -283,13 +384,20 @@ func (g *Generator) renderShape(hash string, colorIndex, shapeHashIndex, rotatio
|
||||
RenderCenterShape(graphics, shapeIndex, float64(cell), float64(i))
|
||||
}
|
||||
|
||||
collector := graphics.renderer.(*shapeCollector)
|
||||
for _, shape := range collector.shapes {
|
||||
shapes = append(shapes, shape)
|
||||
}
|
||||
// Append shapes directly to destination slice and return collector to pool
|
||||
*dest = append(*dest, collector.shapes...)
|
||||
shapeCollectorPool.Put(collector)
|
||||
}
|
||||
|
||||
return shapes, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// shapeCollectorPool provides pooled shapeCollector instances for efficient reuse
|
||||
var shapeCollectorPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
// Pre-allocate with reasonable capacity - typical identicon has 4-8 shapes per collector
|
||||
return &shapeCollector{shapes: make([]Shape, 0, 8)}
|
||||
},
|
||||
}
|
||||
|
||||
// shapeCollector implements Renderer interface to collect shapes during generation
|
||||
@@ -297,6 +405,12 @@ type shapeCollector struct {
|
||||
shapes []Shape
|
||||
}
|
||||
|
||||
// Reset clears the shape collector for reuse while preserving capacity
|
||||
func (sc *shapeCollector) Reset() {
|
||||
// Keep capacity but reset length to 0 for efficient reuse
|
||||
sc.shapes = sc.shapes[:0]
|
||||
}
|
||||
|
||||
func (sc *shapeCollector) AddPolygon(points []Point) {
|
||||
sc.shapes = append(sc.shapes, Shape{
|
||||
Type: "polygon",
|
||||
@@ -315,39 +429,89 @@ func (sc *shapeCollector) AddCircle(topLeft Point, size float64, invert bool) {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
// cacheKey generates a cache key for the given parameters
|
||||
func (g *Generator) cacheKey(hash string, size float64) string {
|
||||
return fmt.Sprintf("%s:%.2f", hash, size)
|
||||
func getOuterShapeComplexity(shapeIndex int) int {
|
||||
index := shapeIndex % 4
|
||||
switch index {
|
||||
case 0: // Triangle
|
||||
return 3
|
||||
case 1: // Triangle (different orientation)
|
||||
return 3
|
||||
case 2: // Rhombus (diamond)
|
||||
return 4
|
||||
case 3: // Circle
|
||||
return 5 // Circles are more expensive to render
|
||||
default:
|
||||
return 1 // Fallback for unknown shapes
|
||||
}
|
||||
}
|
||||
|
||||
// ClearCache clears the internal cache
|
||||
func (g *Generator) ClearCache() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.cache = make(map[string]*Icon)
|
||||
// getCenterShapeComplexity returns the complexity score for a center shape type.
|
||||
// Scoring accounts for multiple geometric elements and cutouts.
|
||||
func getCenterShapeComplexity(shapeIndex int) int {
|
||||
index := shapeIndex % 14
|
||||
switch index {
|
||||
case 0: // Asymmetric polygon (5 points)
|
||||
return 5
|
||||
case 1: // Triangle
|
||||
return 3
|
||||
case 2: // Rectangle
|
||||
return 4
|
||||
case 3: // Nested rectangles (2 rectangles)
|
||||
return 8
|
||||
case 4: // Circle
|
||||
return 5
|
||||
case 5: // Rectangle with triangular cutout (rect + inverted triangle)
|
||||
return 7
|
||||
case 6: // Complex polygon (6 points)
|
||||
return 6
|
||||
case 7: // Small triangle
|
||||
return 3
|
||||
case 8: // Composite shape (2 rectangles + 1 triangle)
|
||||
return 11
|
||||
case 9: // Rectangle with rectangular cutout (rect + inverted rect)
|
||||
return 8
|
||||
case 10: // Rectangle with circular cutout (rect + inverted circle)
|
||||
return 9
|
||||
case 11: // Small triangle (same as 7)
|
||||
return 3
|
||||
case 12: // Rectangle with rhombus cutout (rect + inverted rhombus)
|
||||
return 8
|
||||
case 13: // Large circle (conditional rendering)
|
||||
return 5
|
||||
default:
|
||||
return 1 // Fallback for unknown shapes
|
||||
}
|
||||
}
|
||||
|
||||
// GetCacheSize returns the number of cached icons
|
||||
func (g *Generator) GetCacheSize() int {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return len(g.cache)
|
||||
}
|
||||
// CalculateComplexity calculates the total geometric complexity for an identicon
|
||||
// based on the hash string. This provides a fast complexity assessment before
|
||||
// any expensive rendering operations.
|
||||
func (g *Generator) CalculateComplexity(hash string) (int, error) {
|
||||
totalComplexity := 0
|
||||
|
||||
// SetConfig updates the generator configuration and clears cache
|
||||
func (g *Generator) SetConfig(config ColorConfig) {
|
||||
config.Validate()
|
||||
g.mu.Lock()
|
||||
g.config = config
|
||||
g.cache = make(map[string]*Icon)
|
||||
g.mu.Unlock()
|
||||
}
|
||||
// Calculate complexity for side shapes (8 positions)
|
||||
sideShapeIndexValue, err := util.ParseHex(hash, hashPosSideShape, 1)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse side shape index: %w", err)
|
||||
}
|
||||
sideShapeComplexity := getOuterShapeComplexity(sideShapeIndexValue)
|
||||
totalComplexity += sideShapeComplexity * 8 // 8 side positions
|
||||
|
||||
// GetConfig returns a copy of the current configuration
|
||||
func (g *Generator) GetConfig() ColorConfig {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.config
|
||||
// Calculate complexity for corner shapes (4 positions)
|
||||
cornerShapeIndexValue, err := util.ParseHex(hash, hashPosCornerShape, 1)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse corner shape index: %w", err)
|
||||
}
|
||||
cornerShapeComplexity := getOuterShapeComplexity(cornerShapeIndexValue)
|
||||
totalComplexity += cornerShapeComplexity * 4 // 4 corner positions
|
||||
|
||||
// Calculate complexity for center shapes (4 positions)
|
||||
centerShapeIndexValue, err := util.ParseHex(hash, hashPosCenterShape, 1)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse center shape index: %w", err)
|
||||
}
|
||||
centerShapeComplexity := getCenterShapeComplexity(centerShapeIndexValue)
|
||||
totalComplexity += centerShapeComplexity * 4 // 4 center positions
|
||||
|
||||
return totalComplexity, nil
|
||||
}
|
||||
413
internal/engine/generator_bench_test.go
Normal file
@@ -0,0 +1,413 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/util"
|
||||
)
|
||||
|
||||
var benchmarkHashes = []string{
|
||||
"7c4a8d09ca3762af61e59520943dc26494f8941b", // test-hash
|
||||
"b36d9b6a07d0b5bfb7e0e77a7f8d1e5e6f7a8b9c", // example1@gmail.com
|
||||
"a9d8e7f6c5b4a3d2e1f0e9d8c7b6a5d4e3f2a1b0", // example2@yahoo.com
|
||||
"1234567890abcdef1234567890abcdef12345678",
|
||||
"fedcba0987654321fedcba0987654321fedcba09",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"0000000000000000000000000000000000000000",
|
||||
"ffffffffffffffffffffffffffffffffffffffffffff",
|
||||
}
|
||||
|
||||
var benchmarkSizesFloat = []float64{
|
||||
16.0, 32.0, 64.0, 128.0, 256.0, 512.0,
|
||||
}
|
||||
|
||||
// Benchmark core generator creation
|
||||
func BenchmarkNewGeneratorWithConfig(b *testing.B) {
|
||||
config := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 1000,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
|
||||
}
|
||||
_ = generator
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark icon generation without cache (per size)
|
||||
func BenchmarkGenerateWithoutCachePerSize(b *testing.B) {
|
||||
config := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 1000,
|
||||
}
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
|
||||
}
|
||||
|
||||
for _, size := range benchmarkSizesFloat {
|
||||
b.Run(fmt.Sprintf("size-%.0f", size), func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
hash := benchmarkHashes[i%len(benchmarkHashes)]
|
||||
_, err := generator.GenerateWithoutCache(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("GenerateWithoutCache failed: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark icon generation with cache (different from generator_test.go)
|
||||
func BenchmarkGenerateWithCacheHeavy(b *testing.B) {
|
||||
config := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 100,
|
||||
}
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Use limited set of hashes to test cache hits
|
||||
hash := benchmarkHashes[i%3] // Only use first 3 hashes
|
||||
size := 64.0
|
||||
_, err := generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark hash parsing functions
|
||||
func BenchmarkParseHex(b *testing.B) {
|
||||
hash := "7c4a8d09ca3762af61e59520943dc26494f8941b"
|
||||
|
||||
b.Run("offset2_len1", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = util.ParseHex(hash, 2, 1)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("offset4_len1", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = util.ParseHex(hash, 4, 1)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("offset1_len1", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = util.ParseHex(hash, 1, 1)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("offset8_len3", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = util.ParseHex(hash, 8, 3)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark hue extraction
|
||||
func BenchmarkExtractHue(b *testing.B) {
|
||||
config := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 1,
|
||||
}
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
hash := benchmarkHashes[i%len(benchmarkHashes)]
|
||||
_, _ = generator.extractHue(hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark shape selection
|
||||
func BenchmarkShapeSelection(b *testing.B) {
|
||||
hash := "7c4a8d09ca3762af61e59520943dc26494f8941b"
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Simulate shape selection process using util.ParseHex
|
||||
sideShapeIndex, _ := util.ParseHex(hash, hashPosSideShape, 1)
|
||||
cornerShapeIndex, _ := util.ParseHex(hash, hashPosCornerShape, 1)
|
||||
centerShapeIndex, _ := util.ParseHex(hash, hashPosCenterShape, 1)
|
||||
|
||||
// Use modulo with arbitrary shape counts (simulating actual shape arrays)
|
||||
sideShapeIndex = sideShapeIndex % 16 // Assume 16 outer shapes
|
||||
cornerShapeIndex = cornerShapeIndex % 16
|
||||
centerShapeIndex = centerShapeIndex % 8 // Assume 8 center shapes
|
||||
|
||||
_, _, _ = sideShapeIndex, cornerShapeIndex, centerShapeIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark color theme generation
|
||||
func BenchmarkGenerateColorTheme(b *testing.B) {
|
||||
config := DefaultColorConfig()
|
||||
generator, err := NewGeneratorWithConfig(GeneratorConfig{
|
||||
ColorConfig: config,
|
||||
CacheSize: 1,
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
hash := benchmarkHashes[i%len(benchmarkHashes)]
|
||||
hue, _ := generator.extractHue(hash)
|
||||
_ = GenerateColorTheme(hue, config)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark position computation
|
||||
func BenchmarkComputePositions(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Test both side and corner positions
|
||||
_ = getSidePositions()
|
||||
_ = getCornerPositions()
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark transform applications
|
||||
func BenchmarkTransformApplication(b *testing.B) {
|
||||
transform := Transform{
|
||||
x: 1.0,
|
||||
y: 2.0,
|
||||
size: 64.0,
|
||||
rotation: 1,
|
||||
}
|
||||
|
||||
b.Run("center_point", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = transform.TransformIconPoint(0.5, 0.5, 0, 0)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("corner_point", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = transform.TransformIconPoint(1.0, 1.0, 0, 0)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("origin_point", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = transform.TransformIconPoint(0.0, 0.0, 0, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark icon size calculations
|
||||
func BenchmarkIconSizeCalculations(b *testing.B) {
|
||||
sizes := benchmarkSizesFloat
|
||||
padding := 0.1
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
size := sizes[i%len(sizes)]
|
||||
// Simulate size calculations from generator
|
||||
paddingPixels := size * padding * paddingMultiple
|
||||
iconSize := size - paddingPixels
|
||||
cellSize := iconSize / gridSize
|
||||
|
||||
_, _, _ = paddingPixels, iconSize, cellSize
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark cache key generation
|
||||
func BenchmarkCacheKeyGeneration(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
hash := benchmarkHashes[i%len(benchmarkHashes)]
|
||||
size := benchmarkSizesFloat[i%len(benchmarkSizesFloat)]
|
||||
_ = benchmarkCacheKey(hash, size)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to simulate cache key generation
|
||||
func benchmarkCacheKey(hash string, size float64) string {
|
||||
return hash + ":" + fmt.Sprintf("%.0f", size)
|
||||
}
|
||||
|
||||
// Benchmark full icon generation pipeline
|
||||
func BenchmarkFullGenerationPipeline(b *testing.B) {
|
||||
config := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 1, // Minimal cache to avoid cache hits
|
||||
}
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
hash := benchmarkHashes[i%len(benchmarkHashes)]
|
||||
size := 64.0
|
||||
|
||||
// This tests the full pipeline: hash parsing, color generation,
|
||||
// shape selection, positioning, and rendering preparation
|
||||
_, err := generator.GenerateWithoutCache(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("GenerateWithoutCache failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark different grid sizes (theoretical)
|
||||
func BenchmarkGridSizeCalculations(b *testing.B) {
|
||||
sizes := benchmarkSizesFloat
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
size := sizes[i%len(sizes)]
|
||||
padding := 0.1
|
||||
|
||||
// Test calculations for different theoretical grid sizes
|
||||
for gridSizeTest := 3; gridSizeTest <= 6; gridSizeTest++ {
|
||||
paddingPixels := size * padding * paddingMultiple
|
||||
iconSize := size - paddingPixels
|
||||
cellSize := iconSize / float64(gridSizeTest)
|
||||
_ = cellSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark color conflict resolution
|
||||
func BenchmarkColorConflictResolution(b *testing.B) {
|
||||
config := DefaultColorConfig()
|
||||
generator, err := NewGeneratorWithConfig(GeneratorConfig{
|
||||
ColorConfig: config,
|
||||
CacheSize: 1,
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
hash := benchmarkHashes[i%len(benchmarkHashes)]
|
||||
hue, _ := generator.extractHue(hash)
|
||||
colorTheme := GenerateColorTheme(hue, config)
|
||||
|
||||
// Simulate color conflict resolution
|
||||
for j := 0; j < 5; j++ {
|
||||
colorHash, _ := util.ParseHex(hash, hashPosColorStart+j%3, 1)
|
||||
selectedColor := colorTheme[colorHash%len(colorTheme)]
|
||||
_ = selectedColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get side positions (matching generator logic)
|
||||
func getSidePositions() [][]int {
|
||||
return [][]int{{1, 0}, {2, 0}, {2, 3}, {1, 3}, {0, 1}, {3, 1}, {3, 2}, {0, 2}}
|
||||
}
|
||||
|
||||
// Helper function to get corner positions (matching generator logic)
|
||||
func getCornerPositions() [][]int {
|
||||
return [][]int{{0, 0}, {3, 0}, {3, 3}, {0, 3}}
|
||||
}
|
||||
|
||||
// Benchmark concurrent icon generation for high-traffic scenarios
|
||||
func BenchmarkGenerateWithoutCacheParallel(b *testing.B) {
|
||||
config := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 1, // Minimal cache to avoid cache effects
|
||||
}
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
|
||||
}
|
||||
|
||||
for _, size := range []float64{64.0, 128.0, 256.0} {
|
||||
b.Run(fmt.Sprintf("size-%.0f", size), func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
hash := benchmarkHashes[i%len(benchmarkHashes)]
|
||||
_, err := generator.GenerateWithoutCache(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
b.Errorf("GenerateWithoutCache failed: %v", err)
|
||||
}
|
||||
i++
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark concurrent cached generation
|
||||
func BenchmarkGenerateWithCacheParallel(b *testing.B) {
|
||||
config := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 100, // Shared cache for concurrent access
|
||||
}
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
// Use limited set of hashes to test cache hits under concurrency
|
||||
hash := benchmarkHashes[i%3] // Only use first 3 hashes
|
||||
size := 64.0
|
||||
_, err := generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
b.Errorf("Generate failed: %v", err)
|
||||
}
|
||||
i++
|
||||
}
|
||||
})
|
||||
}
|
||||
635
internal/engine/generator_core_test.go
Normal file
@@ -0,0 +1,635 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/util"
|
||||
)
|
||||
|
||||
func TestNewGenerator(t *testing.T) {
|
||||
config := DefaultColorConfig()
|
||||
generator, err := NewGenerator(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
if generator == nil {
|
||||
t.Fatal("NewGenerator returned nil")
|
||||
}
|
||||
|
||||
if generator.config.ColorConfig.IconPadding != config.IconPadding {
|
||||
t.Errorf("Expected icon padding %f, got %f", config.IconPadding, generator.config.ColorConfig.IconPadding)
|
||||
}
|
||||
|
||||
if generator.cache == nil {
|
||||
t.Error("Generator cache was not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefaultGenerator(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
if generator == nil {
|
||||
t.Fatal("NewDefaultGenerator returned nil")
|
||||
}
|
||||
|
||||
expectedConfig := DefaultColorConfig()
|
||||
if generator.config.ColorConfig.IconPadding != expectedConfig.IconPadding {
|
||||
t.Errorf("Expected icon padding %f, got %f", expectedConfig.IconPadding, generator.config.ColorConfig.IconPadding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGeneratorWithConfig(t *testing.T) {
|
||||
config := GeneratorConfig{
|
||||
ColorConfig: DefaultColorConfig(),
|
||||
CacheSize: 500,
|
||||
}
|
||||
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewGeneratorWithConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if generator == nil {
|
||||
t.Fatal("NewGeneratorWithConfig returned nil")
|
||||
}
|
||||
|
||||
if generator.config.CacheSize != 500 {
|
||||
t.Errorf("Expected cache size 500, got %d", generator.config.CacheSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultGeneratorConfig(t *testing.T) {
|
||||
config := DefaultGeneratorConfig()
|
||||
|
||||
if config.CacheSize != 1000 {
|
||||
t.Errorf("Expected default cache size 1000, got %d", config.CacheSize)
|
||||
}
|
||||
|
||||
if config.MaxComplexity != 0 {
|
||||
t.Errorf("Expected default max complexity 0, got %d", config.MaxComplexity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHue(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hash string
|
||||
expectedHue float64
|
||||
expectsError bool
|
||||
}{
|
||||
{
|
||||
name: "Valid 40-character hash",
|
||||
hash: "abcdef1234567890abcdef1234567890abcdef12",
|
||||
expectedHue: float64(0xbcdef12) / float64(0xfffffff),
|
||||
expectsError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid hash with different values",
|
||||
hash: "1234567890abcdef1234567890abcdef12345678",
|
||||
expectedHue: float64(0x2345678) / float64(0xfffffff),
|
||||
expectsError: false,
|
||||
},
|
||||
{
|
||||
name: "Hash too short",
|
||||
hash: "abc",
|
||||
expectedHue: 0,
|
||||
expectsError: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid hex characters",
|
||||
hash: "abcdef1234567890abcdef1234567890abcdefgh",
|
||||
expectedHue: 0,
|
||||
expectsError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
hue, err := generator.extractHue(test.hash)
|
||||
|
||||
if test.expectsError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for hash %s, but got none", test.hash)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for hash %s: %v", test.hash, err)
|
||||
return
|
||||
}
|
||||
|
||||
if fmt.Sprintf("%.6f", hue) != fmt.Sprintf("%.6f", test.expectedHue) {
|
||||
t.Errorf("Expected hue %.6f, got %.6f", test.expectedHue, hue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectColors(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
availableColors := []Color{
|
||||
{H: 0.0, S: 1.0, L: 0.5, A: 255}, // Red
|
||||
{H: 0.33, S: 1.0, L: 0.5, A: 255}, // Green
|
||||
{H: 0.67, S: 1.0, L: 0.5, A: 255}, // Blue
|
||||
{H: 0.17, S: 1.0, L: 0.5, A: 255}, // Yellow
|
||||
{H: 0.0, S: 0.0, L: 0.5, A: 255}, // Gray
|
||||
}
|
||||
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
selectedIndexes, err := generator.selectColors(hash, availableColors)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("selectColors failed: %v", err)
|
||||
}
|
||||
|
||||
if len(selectedIndexes) != 3 {
|
||||
t.Errorf("Expected 3 selected color indexes, got %d", len(selectedIndexes))
|
||||
}
|
||||
|
||||
for i, index := range selectedIndexes {
|
||||
if index < 0 || index >= len(availableColors) {
|
||||
t.Errorf("Selected index %d at position %d is out of range [0, %d)", index, i, len(availableColors))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectColorsEmptyPalette(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
_, err = generator.selectColors(hash, []Color{})
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty color palette, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsistentGeneration(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
size := 64.0
|
||||
|
||||
icon1, err := generator.GenerateWithoutCache(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("First generation failed: %v", err)
|
||||
}
|
||||
|
||||
icon2, err := generator.GenerateWithoutCache(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Second generation failed: %v", err)
|
||||
}
|
||||
|
||||
if icon1.Hash != icon2.Hash {
|
||||
t.Error("Icons have different hashes")
|
||||
}
|
||||
|
||||
if icon1.Size != icon2.Size {
|
||||
t.Error("Icons have different sizes")
|
||||
}
|
||||
|
||||
if len(icon1.Shapes) != len(icon2.Shapes) {
|
||||
t.Errorf("Icons have different number of shape groups: %d vs %d", len(icon1.Shapes), len(icon2.Shapes))
|
||||
}
|
||||
|
||||
for i, group1 := range icon1.Shapes {
|
||||
group2 := icon2.Shapes[i]
|
||||
if len(group1.Shapes) != len(group2.Shapes) {
|
||||
t.Errorf("Shape group %d has different number of shapes: %d vs %d", i, len(group1.Shapes), len(group2.Shapes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsColorInForbiddenSet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
index int
|
||||
forbidden []int
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Index in forbidden set",
|
||||
index: 2,
|
||||
forbidden: []int{0, 2, 4},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Index not in forbidden set",
|
||||
index: 1,
|
||||
forbidden: []int{0, 2, 4},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty forbidden set",
|
||||
index: 1,
|
||||
forbidden: []int{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Single element forbidden set - match",
|
||||
index: 5,
|
||||
forbidden: []int{5},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Single element forbidden set - no match",
|
||||
index: 3,
|
||||
forbidden: []int{5},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result := isColorInForbiddenSet(test.index, test.forbidden)
|
||||
if result != test.expected {
|
||||
t.Errorf("Expected %v, got %v", test.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasSelectedColorInForbiddenSet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
selected []int
|
||||
forbidden []int
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "No overlap",
|
||||
selected: []int{1, 3, 5},
|
||||
forbidden: []int{0, 2, 4},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Partial overlap",
|
||||
selected: []int{1, 2, 5},
|
||||
forbidden: []int{0, 2, 4},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Complete overlap",
|
||||
selected: []int{0, 2, 4},
|
||||
forbidden: []int{0, 2, 4},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Empty selected",
|
||||
selected: []int{},
|
||||
forbidden: []int{0, 2, 4},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty forbidden",
|
||||
selected: []int{1, 3, 5},
|
||||
forbidden: []int{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Both empty",
|
||||
selected: []int{},
|
||||
forbidden: []int{},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result := hasSelectedColorInForbiddenSet(test.selected, test.forbidden)
|
||||
if result != test.expected {
|
||||
t.Errorf("Expected %v, got %v", test.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDuplicateColorRefactored(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
index int
|
||||
selected []int
|
||||
forbidden []int
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Index not in forbidden set",
|
||||
index: 1,
|
||||
selected: []int{0, 4},
|
||||
forbidden: []int{0, 4},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Index in forbidden set, no selected colors in forbidden set",
|
||||
index: 0,
|
||||
selected: []int{1, 3},
|
||||
forbidden: []int{0, 4},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Index in forbidden set, has selected colors in forbidden set",
|
||||
index: 0,
|
||||
selected: []int{1, 4},
|
||||
forbidden: []int{0, 4},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Dark gray and dark main conflict",
|
||||
index: colorDarkGray,
|
||||
selected: []int{colorDarkMain},
|
||||
forbidden: []int{colorDarkGray, colorDarkMain},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Light gray and light main conflict",
|
||||
index: colorLightGray,
|
||||
selected: []int{colorLightMain},
|
||||
forbidden: []int{colorLightGray, colorLightMain},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result := generator.isDuplicateColor(test.index, test.selected, test.forbidden)
|
||||
if result != test.expected {
|
||||
t.Errorf("Expected %v, got %v", test.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShapeCollector(t *testing.T) {
|
||||
collector := &shapeCollector{}
|
||||
|
||||
// Test initial state
|
||||
if len(collector.shapes) != 0 {
|
||||
t.Error("Expected empty shapes slice initially")
|
||||
}
|
||||
|
||||
// Test AddPolygon
|
||||
points := []Point{{X: 0, Y: 0}, {X: 10, Y: 0}, {X: 5, Y: 10}}
|
||||
collector.AddPolygon(points)
|
||||
|
||||
if len(collector.shapes) != 1 {
|
||||
t.Errorf("Expected 1 shape after AddPolygon, got %d", len(collector.shapes))
|
||||
}
|
||||
|
||||
shape := collector.shapes[0]
|
||||
if shape.Type != "polygon" {
|
||||
t.Errorf("Expected shape type 'polygon', got '%s'", shape.Type)
|
||||
}
|
||||
|
||||
if len(shape.Points) != 3 {
|
||||
t.Errorf("Expected 3 points, got %d", len(shape.Points))
|
||||
}
|
||||
|
||||
// Test AddCircle
|
||||
collector.AddCircle(Point{X: 5, Y: 5}, 20, false)
|
||||
|
||||
if len(collector.shapes) != 2 {
|
||||
t.Errorf("Expected 2 shapes after AddCircle, got %d", len(collector.shapes))
|
||||
}
|
||||
|
||||
circleShape := collector.shapes[1]
|
||||
if circleShape.Type != "circle" {
|
||||
t.Errorf("Expected shape type 'circle', got '%s'", circleShape.Type)
|
||||
}
|
||||
|
||||
if circleShape.CircleX != 5 {
|
||||
t.Errorf("Expected CircleX 5, got %f", circleShape.CircleX)
|
||||
}
|
||||
|
||||
if circleShape.CircleY != 5 {
|
||||
t.Errorf("Expected CircleY 5, got %f", circleShape.CircleY)
|
||||
}
|
||||
|
||||
if circleShape.CircleSize != 20 {
|
||||
t.Errorf("Expected CircleSize 20, got %f", circleShape.CircleSize)
|
||||
}
|
||||
|
||||
if circleShape.Invert != false {
|
||||
t.Errorf("Expected Invert false, got %v", circleShape.Invert)
|
||||
}
|
||||
|
||||
// Test Reset
|
||||
collector.Reset()
|
||||
if len(collector.shapes) != 0 {
|
||||
t.Errorf("Expected empty shapes slice after Reset, got %d", len(collector.shapes))
|
||||
}
|
||||
|
||||
// Test that we can add shapes again after reset
|
||||
collector.AddPolygon([]Point{{X: 1, Y: 1}})
|
||||
if len(collector.shapes) != 1 {
|
||||
t.Errorf("Expected 1 shape after Reset and AddPolygon, got %d", len(collector.shapes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidHash(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hash string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Valid 40-character hex hash",
|
||||
hash: "abcdef1234567890abcdef1234567890abcdef12",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid 32-character hex hash",
|
||||
hash: "abcdef1234567890abcdef1234567890",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Empty hash",
|
||||
hash: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Hash too short",
|
||||
hash: "abc",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Hash with invalid characters",
|
||||
hash: "abcdef1234567890abcdef1234567890abcdefgh",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Hash with uppercase letters",
|
||||
hash: "ABCDEF1234567890ABCDEF1234567890ABCDEF12",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Mixed case hash",
|
||||
hash: "AbCdEf1234567890aBcDeF1234567890AbCdEf12",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Hash with spaces",
|
||||
hash: "abcdef12 34567890abcdef1234567890abcdef12",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "All zeros",
|
||||
hash: "0000000000000000000000000000000000000000",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "All f's",
|
||||
hash: "ffffffffffffffffffffffffffffffffffffffff",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result := util.IsValidHash(test.hash)
|
||||
if result != test.expected {
|
||||
t.Errorf("Expected %v for hash '%s', got %v", test.expected, test.hash, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hash string
|
||||
position int
|
||||
octets int
|
||||
expected int
|
||||
expectsError bool
|
||||
}{
|
||||
{
|
||||
name: "Valid single octet",
|
||||
hash: "abcdef1234567890",
|
||||
position: 0,
|
||||
octets: 1,
|
||||
expected: 0xa,
|
||||
expectsError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid two octets",
|
||||
hash: "abcdef1234567890",
|
||||
position: 1,
|
||||
octets: 2,
|
||||
expected: 0xbc,
|
||||
expectsError: false,
|
||||
},
|
||||
{
|
||||
name: "Position at end of hash",
|
||||
hash: "abcdef12",
|
||||
position: 7,
|
||||
octets: 1,
|
||||
expected: 0x2,
|
||||
expectsError: false,
|
||||
},
|
||||
{
|
||||
name: "Position beyond hash length",
|
||||
hash: "abc",
|
||||
position: 5,
|
||||
octets: 1,
|
||||
expected: 0,
|
||||
expectsError: true,
|
||||
},
|
||||
{
|
||||
name: "Octets extend beyond hash",
|
||||
hash: "abcdef12",
|
||||
position: 6,
|
||||
octets: 3,
|
||||
expected: 0x12, // Should read to end of hash
|
||||
expectsError: false,
|
||||
},
|
||||
{
|
||||
name: "Zero octets",
|
||||
hash: "abcdef12",
|
||||
position: 0,
|
||||
octets: 0,
|
||||
expected: 0xabcdef12, // Should read to end when octets is 0
|
||||
expectsError: false,
|
||||
},
|
||||
{
|
||||
name: "Negative position",
|
||||
hash: "abcdef12",
|
||||
position: -1,
|
||||
octets: 1,
|
||||
expected: 0x2, // Should read from end
|
||||
expectsError: false,
|
||||
},
|
||||
{
|
||||
name: "Empty hash",
|
||||
hash: "",
|
||||
position: 0,
|
||||
octets: 1,
|
||||
expected: 0,
|
||||
expectsError: true,
|
||||
},
|
||||
{
|
||||
name: "All f's",
|
||||
hash: "ffffffff",
|
||||
position: 0,
|
||||
octets: 4,
|
||||
expected: 0xffff,
|
||||
expectsError: false,
|
||||
},
|
||||
{
|
||||
name: "Mixed case",
|
||||
hash: "AbCdEf12",
|
||||
position: 2,
|
||||
octets: 2,
|
||||
expected: 0xcd,
|
||||
expectsError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := util.ParseHex(test.hash, test.position, test.octets)
|
||||
|
||||
if test.expectsError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for ParseHex(%s, %d, %d), but got none", test.hash, test.position, test.octets)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for ParseHex(%s, %d, %d): %v", test.hash, test.position, test.octets, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != test.expected {
|
||||
t.Errorf("Expected %d (0x%x), got %d (0x%x)", test.expected, test.expected, result, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
160
internal/engine/generator_graceful_degradation_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSelectColors_EmptyColors tests the defensive check for empty available colors
|
||||
func TestSelectColors_EmptyColors(t *testing.T) {
|
||||
// Create a generator for testing
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
// Test with empty available colors slice
|
||||
hash := "1234567890abcdef"
|
||||
emptyColors := []Color{}
|
||||
|
||||
_, err = generator.selectColors(hash, emptyColors)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty available colors, got nil")
|
||||
}
|
||||
|
||||
expectedMsg := "no available colors"
|
||||
if !contains(err.Error(), expectedMsg) {
|
||||
t.Errorf("expected error message to contain %q, got %q", expectedMsg, err.Error())
|
||||
}
|
||||
|
||||
t.Logf("Got expected error: %v", err)
|
||||
}
|
||||
|
||||
// TestSelectColors_ValidColors tests that selectColors works correctly with valid input
|
||||
func TestSelectColors_ValidColors(t *testing.T) {
|
||||
// Create a generator for testing
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
// Create a sample set of colors (similar to what GenerateColorTheme returns)
|
||||
config := DefaultColorConfig()
|
||||
availableColors := GenerateColorTheme(0.5, config)
|
||||
|
||||
if len(availableColors) == 0 {
|
||||
t.Fatal("GenerateColorTheme returned empty colors")
|
||||
}
|
||||
|
||||
hash := "1234567890abcdef"
|
||||
selectedIndexes, err := generator.selectColors(hash, availableColors)
|
||||
if err != nil {
|
||||
t.Fatalf("selectColors failed with valid input: %v", err)
|
||||
}
|
||||
|
||||
// Should return exactly numColorSelections (3) color indexes
|
||||
if len(selectedIndexes) != numColorSelections {
|
||||
t.Errorf("expected %d selected colors, got %d", numColorSelections, len(selectedIndexes))
|
||||
}
|
||||
|
||||
// All indexes should be valid (within bounds of available colors)
|
||||
for i, index := range selectedIndexes {
|
||||
if index < 0 || index >= len(availableColors) {
|
||||
t.Errorf("selected index %d at position %d is out of bounds (0-%d)", index, i, len(availableColors)-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerator_GenerateIcon_RobustnessChecks tests that generateIcon handles edge cases gracefully
|
||||
func TestGenerator_GenerateIcon_RobustnessChecks(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
hash string
|
||||
size float64
|
||||
expectError bool
|
||||
}{
|
||||
{"valid_input", "1234567890abcdef12345", 64.0, false},
|
||||
{"minimum_size", "1234567890abcdef12345", 1.0, false},
|
||||
{"large_size", "1234567890abcdef12345", 1024.0, false},
|
||||
{"zero_size", "1234567890abcdef12345", 0.0, false}, // generateIcon doesn't validate size
|
||||
{"negative_size", "1234567890abcdef12345", -10.0, false}, // generateIcon doesn't validate size
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
icon, err := generator.generateIcon(context.Background(), tc.hash, tc.size)
|
||||
|
||||
if tc.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error for %s, got none", tc.name)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error for %s: %v", tc.name, err)
|
||||
}
|
||||
|
||||
if icon == nil {
|
||||
t.Errorf("got nil icon for valid input %s", tc.name)
|
||||
}
|
||||
|
||||
// Validate icon properties
|
||||
if icon != nil {
|
||||
if icon.Size != tc.size {
|
||||
t.Errorf("icon size mismatch: expected %f, got %f", tc.size, icon.Size)
|
||||
}
|
||||
|
||||
if icon.Hash != tc.hash {
|
||||
t.Errorf("icon hash mismatch: expected %s, got %s", tc.hash, icon.Hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHueExtraction_EdgeCases tests hue extraction with edge case inputs
|
||||
func TestHueExtraction_EdgeCases(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
hash string
|
||||
expectError bool
|
||||
}{
|
||||
{"valid_hash", "1234567890abcdef12345", false},
|
||||
{"minimum_length", "1234567890a", false}, // Exactly 11 characters
|
||||
{"hex_only", "abcdefabcdefabcdef123", false},
|
||||
{"numbers_only", "12345678901234567890", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
hue, err := generator.extractHue(tc.hash)
|
||||
|
||||
if tc.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error for %s, got none", tc.name)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error for %s: %v", tc.name, err)
|
||||
}
|
||||
|
||||
// Hue should be in range [0, 1]
|
||||
if hue < 0 || hue > 1 {
|
||||
t.Errorf("hue out of range for %s: %f (should be 0-1)", tc.name, hue)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a string contains a substring (defined in color_graceful_degradation_test.go)
|
||||
@@ -1,517 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kevin/go-jdenticon/internal/util"
|
||||
)
|
||||
|
||||
func TestNewGenerator(t *testing.T) {
|
||||
config := DefaultColorConfig()
|
||||
generator := NewGenerator(config)
|
||||
|
||||
if generator == nil {
|
||||
t.Fatal("NewGenerator returned nil")
|
||||
}
|
||||
|
||||
if generator.config.IconPadding != config.IconPadding {
|
||||
t.Errorf("Expected icon padding %f, got %f", config.IconPadding, generator.config.IconPadding)
|
||||
}
|
||||
|
||||
if generator.cache == nil {
|
||||
t.Error("Generator cache was not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefaultGenerator(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
|
||||
if generator == nil {
|
||||
t.Fatal("NewDefaultGenerator returned nil")
|
||||
}
|
||||
|
||||
expectedConfig := DefaultColorConfig()
|
||||
if generator.config.IconPadding != expectedConfig.IconPadding {
|
||||
t.Errorf("Expected icon padding %f, got %f", expectedConfig.IconPadding, generator.config.IconPadding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateValidHash(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
icon, err := generator.Generate(hash, size)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed with error: %v", err)
|
||||
}
|
||||
|
||||
if icon == nil {
|
||||
t.Fatal("Generate returned nil icon")
|
||||
}
|
||||
|
||||
if icon.Hash != hash {
|
||||
t.Errorf("Expected hash %s, got %s", hash, icon.Hash)
|
||||
}
|
||||
|
||||
if icon.Size != size {
|
||||
t.Errorf("Expected size %f, got %f", size, icon.Size)
|
||||
}
|
||||
|
||||
if len(icon.Shapes) == 0 {
|
||||
t.Error("Generated icon has no shapes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateInvalidInputs(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hash string
|
||||
size float64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty hash",
|
||||
hash: "",
|
||||
size: 64.0,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "zero size",
|
||||
hash: "abcdef123456789",
|
||||
size: 0.0,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negative size",
|
||||
hash: "abcdef123456789",
|
||||
size: -10.0,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "short hash",
|
||||
hash: "abc",
|
||||
size: 64.0,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid hex characters",
|
||||
hash: "xyz123456789abc",
|
||||
size: 64.0,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := generator.Generate(tt.hash, tt.size)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Generate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCaching(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
// Generate icon first time
|
||||
icon1, err := generator.Generate(hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("First generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Check cache size
|
||||
if generator.GetCacheSize() != 1 {
|
||||
t.Errorf("Expected cache size 1, got %d", generator.GetCacheSize())
|
||||
}
|
||||
|
||||
// Generate same icon again
|
||||
icon2, err := generator.Generate(hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Second generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Should be the same instance from cache
|
||||
if icon1 != icon2 {
|
||||
t.Error("Second generate did not return cached instance")
|
||||
}
|
||||
|
||||
// Cache size should still be 1
|
||||
if generator.GetCacheSize() != 1 {
|
||||
t.Errorf("Expected cache size 1 after second generate, got %d", generator.GetCacheSize())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCache(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
// Generate an icon to populate cache
|
||||
_, err := generator.Generate(hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify cache has content
|
||||
if generator.GetCacheSize() == 0 {
|
||||
t.Error("Cache should not be empty after generate")
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
generator.ClearCache()
|
||||
|
||||
// Verify cache is empty
|
||||
if generator.GetCacheSize() != 0 {
|
||||
t.Errorf("Expected cache size 0 after clear, got %d", generator.GetCacheSize())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfig(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
// Generate an icon to populate cache
|
||||
_, err := generator.Generate(hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify cache has content
|
||||
if generator.GetCacheSize() == 0 {
|
||||
t.Error("Cache should not be empty after generate")
|
||||
}
|
||||
|
||||
// Set new config
|
||||
newConfig := DefaultColorConfig()
|
||||
newConfig.IconPadding = 0.1
|
||||
generator.SetConfig(newConfig)
|
||||
|
||||
// Verify config was updated
|
||||
if generator.GetConfig().IconPadding != 0.1 {
|
||||
t.Errorf("Expected icon padding 0.1, got %f", generator.GetConfig().IconPadding)
|
||||
}
|
||||
|
||||
// Verify cache was cleared
|
||||
if generator.GetCacheSize() != 0 {
|
||||
t.Errorf("Expected cache size 0 after config change, got %d", generator.GetCacheSize())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHue(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hash string
|
||||
expected float64
|
||||
tolerance float64
|
||||
}{
|
||||
{
|
||||
name: "all zeros",
|
||||
hash: "0000000000000000000",
|
||||
expected: 0.0,
|
||||
tolerance: 0.0001,
|
||||
},
|
||||
{
|
||||
name: "all fs",
|
||||
hash: "ffffffffffffffffff",
|
||||
expected: 1.0,
|
||||
tolerance: 0.0001,
|
||||
},
|
||||
{
|
||||
name: "half value",
|
||||
hash: "000000000007ffffff",
|
||||
expected: 0.5,
|
||||
tolerance: 0.001, // Allow small floating point variance
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := generator.extractHue(tt.hash)
|
||||
if err != nil {
|
||||
t.Fatalf("extractHue failed: %v", err)
|
||||
}
|
||||
diff := result - tt.expected
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > tt.tolerance {
|
||||
t.Errorf("Expected hue %f, got %f (tolerance %f)", tt.expected, result, tt.tolerance)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectColors(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "123456789abcdef"
|
||||
|
||||
// Create test color palette
|
||||
availableColors := []Color{
|
||||
NewColorRGB(50, 50, 50), // 0: Dark gray
|
||||
NewColorRGB(100, 100, 200), // 1: Mid color
|
||||
NewColorRGB(200, 200, 200), // 2: Light gray
|
||||
NewColorRGB(150, 150, 255), // 3: Light color
|
||||
NewColorRGB(25, 25, 100), // 4: Dark color
|
||||
}
|
||||
|
||||
selectedIndexes, err := generator.selectColors(hash, availableColors)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("selectColors failed: %v", err)
|
||||
}
|
||||
|
||||
if len(selectedIndexes) != 3 {
|
||||
t.Fatalf("Expected 3 selected colors, got %d", len(selectedIndexes))
|
||||
}
|
||||
|
||||
for i, index := range selectedIndexes {
|
||||
if index < 0 || index >= len(availableColors) {
|
||||
t.Errorf("Color index %d at position %d is out of range [0, %d)", index, i, len(availableColors))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectColorsEmptyPalette(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "123456789abcdef"
|
||||
|
||||
_, err := generator.selectColors(hash, []Color{})
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty color palette")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidHash(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hash string
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
name: "valid hash",
|
||||
hash: "abcdef123456789",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "too short",
|
||||
hash: "abc",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "invalid characters",
|
||||
hash: "xyz123456789abc",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "uppercase valid",
|
||||
hash: "ABCDEF123456789",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "mixed case valid",
|
||||
hash: "AbCdEf123456789",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
hash: "",
|
||||
valid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := util.IsValidHash(tt.hash)
|
||||
if result != tt.valid {
|
||||
t.Errorf("Expected isValidHash(%s) = %v, got %v", tt.hash, tt.valid, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHex(t *testing.T) {
|
||||
hash := "123456789abcdef"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start int
|
||||
octets int
|
||||
expected int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "single character",
|
||||
start: 0,
|
||||
octets: 1,
|
||||
expected: 1,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "two characters",
|
||||
start: 1,
|
||||
octets: 2,
|
||||
expected: 0x23,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "negative index",
|
||||
start: -1,
|
||||
octets: 1,
|
||||
expected: 0xf,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "out of bounds",
|
||||
start: 100,
|
||||
octets: 1,
|
||||
expected: 0,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := util.ParseHex(hash, tt.start, tt.octets)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected an error, but got nil")
|
||||
}
|
||||
return // Test is done for error cases
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseHex failed unexpectedly: %v", err)
|
||||
}
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %d, got %d", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShapeCollector(t *testing.T) {
|
||||
collector := &shapeCollector{}
|
||||
|
||||
// Test AddPolygon
|
||||
points := []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 0, Y: 1}}
|
||||
collector.AddPolygon(points)
|
||||
|
||||
if len(collector.shapes) != 1 {
|
||||
t.Fatalf("Expected 1 shape after AddPolygon, got %d", len(collector.shapes))
|
||||
}
|
||||
|
||||
shape := collector.shapes[0]
|
||||
if shape.Type != "polygon" {
|
||||
t.Errorf("Expected shape type 'polygon', got '%s'", shape.Type)
|
||||
}
|
||||
|
||||
if len(shape.Points) != len(points) {
|
||||
t.Errorf("Expected %d points, got %d", len(points), len(shape.Points))
|
||||
}
|
||||
|
||||
// Test AddCircle
|
||||
center := Point{X: 5, Y: 5}
|
||||
radius := 2.5
|
||||
collector.AddCircle(center, radius, false)
|
||||
|
||||
if len(collector.shapes) != 2 {
|
||||
t.Fatalf("Expected 2 shapes after AddCircle, got %d", len(collector.shapes))
|
||||
}
|
||||
|
||||
circleShape := collector.shapes[1]
|
||||
if circleShape.Type != "circle" {
|
||||
t.Errorf("Expected shape type 'circle', got '%s'", circleShape.Type)
|
||||
}
|
||||
|
||||
// Verify circle fields are set correctly
|
||||
if circleShape.CircleX != center.X {
|
||||
t.Errorf("Expected CircleX %f, got %f", center.X, circleShape.CircleX)
|
||||
}
|
||||
if circleShape.CircleY != center.Y {
|
||||
t.Errorf("Expected CircleY %f, got %f", center.Y, circleShape.CircleY)
|
||||
}
|
||||
if circleShape.CircleSize != radius {
|
||||
t.Errorf("Expected CircleSize %f, got %f", radius, circleShape.CircleSize)
|
||||
}
|
||||
if circleShape.Invert != false {
|
||||
t.Errorf("Expected Invert false, got %t", circleShape.Invert)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerate(b *testing.B) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := generator.Generate(hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerateWithCache(b *testing.B) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
// Pre-populate cache
|
||||
_, err := generator.Generate(hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("Initial generate failed: %v", err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := generator.Generate(hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsistentGeneration(t *testing.T) {
|
||||
generator1 := NewDefaultGenerator()
|
||||
generator2 := NewDefaultGenerator()
|
||||
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
icon1, err := generator1.Generate(hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generator1 failed: %v", err)
|
||||
}
|
||||
|
||||
icon2, err := generator2.Generate(hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generator2 failed: %v", err)
|
||||
}
|
||||
|
||||
// Icons should have same number of shape groups
|
||||
if len(icon1.Shapes) != len(icon2.Shapes) {
|
||||
t.Errorf("Different number of shape groups: %d vs %d", len(icon1.Shapes), len(icon2.Shapes))
|
||||
}
|
||||
|
||||
// Colors should be the same
|
||||
for i := range icon1.Shapes {
|
||||
if i >= len(icon2.Shapes) {
|
||||
break
|
||||
}
|
||||
if !icon1.Shapes[i].Color.Equals(icon2.Shapes[i].Color) {
|
||||
t.Errorf("Different colors at group %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package engine
|
||||
|
||||
// Grid represents a 4x4 layout grid for positioning shapes in a jdenticon
|
||||
type Grid struct {
|
||||
Size float64
|
||||
Cell int
|
||||
X int
|
||||
Y int
|
||||
Padding int
|
||||
}
|
||||
|
||||
// Position represents an x, y coordinate pair
|
||||
type Position struct {
|
||||
X, Y int
|
||||
}
|
||||
|
||||
// NewGrid creates a new Grid with the specified icon size and padding ratio
|
||||
func NewGrid(iconSize float64, paddingRatio float64) *Grid {
|
||||
// Calculate padding and round to nearest integer (matches JS: (0.5 + size * parsedConfig.iconPadding) | 0)
|
||||
padding := int(0.5 + iconSize*paddingRatio)
|
||||
size := iconSize - float64(padding*2)
|
||||
|
||||
// Calculate cell size and ensure it is an integer (matches JS: 0 | (size / 4))
|
||||
cell := int(size / 4)
|
||||
|
||||
// Center the icon since cell size is integer-based (matches JS implementation)
|
||||
// Since the cell size is integer based, the actual icon will be slightly smaller than specified => center icon
|
||||
x := padding + int((size - float64(cell*4))/2)
|
||||
y := padding + int((size - float64(cell*4))/2)
|
||||
|
||||
return &Grid{
|
||||
Size: size,
|
||||
Cell: cell,
|
||||
X: x,
|
||||
Y: y,
|
||||
Padding: padding,
|
||||
}
|
||||
}
|
||||
|
||||
// CellToCoordinate converts a grid cell position to actual coordinates
|
||||
func (g *Grid) CellToCoordinate(cellX, cellY int) (x, y float64) {
|
||||
return float64(g.X + cellX*g.Cell), float64(g.Y + cellY*g.Cell)
|
||||
}
|
||||
|
||||
// GetCellSize returns the size of each cell in the grid
|
||||
func (g *Grid) GetCellSize() float64 {
|
||||
return float64(g.Cell)
|
||||
}
|
||||
|
||||
// LayoutEngine manages the overall layout and positioning of icon elements
|
||||
type LayoutEngine struct {
|
||||
grid *Grid
|
||||
}
|
||||
|
||||
// NewLayoutEngine creates a new LayoutEngine with the specified parameters
|
||||
func NewLayoutEngine(iconSize float64, paddingRatio float64) *LayoutEngine {
|
||||
return &LayoutEngine{
|
||||
grid: NewGrid(iconSize, paddingRatio),
|
||||
}
|
||||
}
|
||||
|
||||
// Grid returns the underlying grid
|
||||
func (le *LayoutEngine) Grid() *Grid {
|
||||
return le.grid
|
||||
}
|
||||
|
||||
// GetShapePositions returns the positions for different shape types based on the jdenticon pattern
|
||||
func (le *LayoutEngine) GetShapePositions(shapeType string) []Position {
|
||||
switch shapeType {
|
||||
case "sides":
|
||||
// Sides: positions around the perimeter (8 positions)
|
||||
return []Position{
|
||||
{1, 0}, {2, 0}, {2, 3}, {1, 3}, // top and bottom
|
||||
{0, 1}, {3, 1}, {3, 2}, {0, 2}, // left and right
|
||||
}
|
||||
case "corners":
|
||||
// Corners: four corner positions
|
||||
return []Position{
|
||||
{0, 0}, {3, 0}, {3, 3}, {0, 3},
|
||||
}
|
||||
case "center":
|
||||
// Center: four center positions
|
||||
return []Position{
|
||||
{1, 1}, {2, 1}, {2, 2}, {1, 2},
|
||||
}
|
||||
default:
|
||||
return []Position{}
|
||||
}
|
||||
}
|
||||
|
||||
// ApplySymmetry applies symmetrical transformations to position indices
|
||||
// This ensures the icon has the characteristic jdenticon symmetry
|
||||
func ApplySymmetry(positions []Position, index int) []Position {
|
||||
if index >= len(positions) {
|
||||
return positions
|
||||
}
|
||||
|
||||
// For jdenticon, we apply rotational symmetry
|
||||
// The pattern is designed to be symmetrical, so we don't need to modify positions
|
||||
// The symmetry is achieved through the predefined position arrays
|
||||
return positions
|
||||
}
|
||||
|
||||
// GetTransformedPosition applies rotation and returns the final position
|
||||
func (le *LayoutEngine) GetTransformedPosition(cellX, cellY int, rotation int) (x, y float64, cellSize float64) {
|
||||
// Apply rotation if needed (rotation is 0-3 for 0°, 90°, 180°, 270°)
|
||||
switch rotation % 4 {
|
||||
case 0: // 0°
|
||||
// No rotation
|
||||
case 1: // 90° clockwise
|
||||
cellX, cellY = cellY, 3-cellX
|
||||
case 2: // 180°
|
||||
cellX, cellY = 3-cellX, 3-cellY
|
||||
case 3: // 270° clockwise (90° counter-clockwise)
|
||||
cellX, cellY = 3-cellY, cellX
|
||||
}
|
||||
|
||||
x, y = le.grid.CellToCoordinate(cellX, cellY)
|
||||
cellSize = le.grid.GetCellSize()
|
||||
return
|
||||
}
|
||||
|
||||
// ValidateGrid checks if the grid configuration is valid
|
||||
func (g *Grid) ValidateGrid() bool {
|
||||
return g.Cell > 0 && g.Size > 0 && g.Padding >= 0
|
||||
}
|
||||
|
||||
// GetIconBounds returns the bounds of the icon within the grid
|
||||
func (g *Grid) GetIconBounds() (x, y, width, height float64) {
|
||||
return float64(g.X), float64(g.Y), float64(g.Cell * 4), float64(g.Cell * 4)
|
||||
}
|
||||
|
||||
// GetCenterOffset returns the offset needed to center content within a cell
|
||||
func (g *Grid) GetCenterOffset() (dx, dy float64) {
|
||||
return float64(g.Cell) / 2, float64(g.Cell) / 2
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewGrid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
iconSize float64
|
||||
paddingRatio float64
|
||||
wantPadding int
|
||||
wantCell int
|
||||
}{
|
||||
{
|
||||
name: "standard 64px icon with 8% padding",
|
||||
iconSize: 64.0,
|
||||
paddingRatio: 0.08,
|
||||
wantPadding: 5, // 0.5 + 64 * 0.08 = 5.62, rounded to 5
|
||||
wantCell: 13, // (64 - 5*2) / 4 = 54/4 = 13.5, truncated to 13
|
||||
},
|
||||
{
|
||||
name: "large 256px icon with 10% padding",
|
||||
iconSize: 256.0,
|
||||
paddingRatio: 0.10,
|
||||
wantPadding: 26, // 0.5 + 256 * 0.10 = 26.1, rounded to 26
|
||||
wantCell: 51, // (256 - 26*2) / 4 = 204/4 = 51
|
||||
},
|
||||
{
|
||||
name: "small 32px icon with 5% padding",
|
||||
iconSize: 32.0,
|
||||
paddingRatio: 0.05,
|
||||
wantPadding: 2, // 0.5 + 32 * 0.05 = 2.1, rounded to 2
|
||||
wantCell: 7, // (32 - 2*2) / 4 = 28/4 = 7
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
grid := NewGrid(tt.iconSize, tt.paddingRatio)
|
||||
|
||||
if grid.Padding != tt.wantPadding {
|
||||
t.Errorf("NewGrid() padding = %v, want %v", grid.Padding, tt.wantPadding)
|
||||
}
|
||||
|
||||
if grid.Cell != tt.wantCell {
|
||||
t.Errorf("NewGrid() cell = %v, want %v", grid.Cell, tt.wantCell)
|
||||
}
|
||||
|
||||
// Verify that the grid is centered
|
||||
expectedSize := tt.iconSize - float64(tt.wantPadding*2)
|
||||
if math.Abs(grid.Size-expectedSize) > 0.1 {
|
||||
t.Errorf("NewGrid() size = %v, want %v", grid.Size, expectedSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridCellToCoordinate(t *testing.T) {
|
||||
grid := NewGrid(64.0, 0.08)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cellX int
|
||||
cellY int
|
||||
wantX float64
|
||||
wantY float64
|
||||
}{
|
||||
{
|
||||
name: "origin cell (0,0)",
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
wantX: float64(grid.X),
|
||||
wantY: float64(grid.Y),
|
||||
},
|
||||
{
|
||||
name: "center cell (1,1)",
|
||||
cellX: 1,
|
||||
cellY: 1,
|
||||
wantX: float64(grid.X + grid.Cell),
|
||||
wantY: float64(grid.Y + grid.Cell),
|
||||
},
|
||||
{
|
||||
name: "corner cell (3,3)",
|
||||
cellX: 3,
|
||||
cellY: 3,
|
||||
wantX: float64(grid.X + 3*grid.Cell),
|
||||
wantY: float64(grid.Y + 3*grid.Cell),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotX, gotY := grid.CellToCoordinate(tt.cellX, tt.cellY)
|
||||
|
||||
if gotX != tt.wantX {
|
||||
t.Errorf("CellToCoordinate() x = %v, want %v", gotX, tt.wantX)
|
||||
}
|
||||
|
||||
if gotY != tt.wantY {
|
||||
t.Errorf("CellToCoordinate() y = %v, want %v", gotY, tt.wantY)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutEngineGetShapePositions(t *testing.T) {
|
||||
le := NewLayoutEngine(64.0, 0.08)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shapeType string
|
||||
wantLen int
|
||||
wantFirst Position
|
||||
wantLast Position
|
||||
}{
|
||||
{
|
||||
name: "sides positions",
|
||||
shapeType: "sides",
|
||||
wantLen: 8,
|
||||
wantFirst: Position{1, 0},
|
||||
wantLast: Position{0, 2},
|
||||
},
|
||||
{
|
||||
name: "corners positions",
|
||||
shapeType: "corners",
|
||||
wantLen: 4,
|
||||
wantFirst: Position{0, 0},
|
||||
wantLast: Position{0, 3},
|
||||
},
|
||||
{
|
||||
name: "center positions",
|
||||
shapeType: "center",
|
||||
wantLen: 4,
|
||||
wantFirst: Position{1, 1},
|
||||
wantLast: Position{1, 2},
|
||||
},
|
||||
{
|
||||
name: "invalid shape type",
|
||||
shapeType: "invalid",
|
||||
wantLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
positions := le.GetShapePositions(tt.shapeType)
|
||||
|
||||
if len(positions) != tt.wantLen {
|
||||
t.Errorf("GetShapePositions() len = %v, want %v", len(positions), tt.wantLen)
|
||||
}
|
||||
|
||||
if tt.wantLen > 0 {
|
||||
if positions[0] != tt.wantFirst {
|
||||
t.Errorf("GetShapePositions() first = %v, want %v", positions[0], tt.wantFirst)
|
||||
}
|
||||
|
||||
if positions[len(positions)-1] != tt.wantLast {
|
||||
t.Errorf("GetShapePositions() last = %v, want %v", positions[len(positions)-1], tt.wantLast)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutEngineGetTransformedPosition(t *testing.T) {
|
||||
le := NewLayoutEngine(64.0, 0.08)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cellX int
|
||||
cellY int
|
||||
rotation int
|
||||
wantX int // Expected cell X after rotation
|
||||
wantY int // Expected cell Y after rotation
|
||||
}{
|
||||
{
|
||||
name: "no rotation",
|
||||
cellX: 1,
|
||||
cellY: 0,
|
||||
rotation: 0,
|
||||
wantX: 1,
|
||||
wantY: 0,
|
||||
},
|
||||
{
|
||||
name: "90 degree rotation",
|
||||
cellX: 1,
|
||||
cellY: 0,
|
||||
rotation: 1,
|
||||
wantX: 0,
|
||||
wantY: 2, // 3-1 = 2
|
||||
},
|
||||
{
|
||||
name: "180 degree rotation",
|
||||
cellX: 1,
|
||||
cellY: 0,
|
||||
rotation: 2,
|
||||
wantX: 2, // 3-1 = 2
|
||||
wantY: 3, // 3-0 = 3
|
||||
},
|
||||
{
|
||||
name: "270 degree rotation",
|
||||
cellX: 1,
|
||||
cellY: 0,
|
||||
rotation: 3,
|
||||
wantX: 3, // 3-0 = 3
|
||||
wantY: 1,
|
||||
},
|
||||
{
|
||||
name: "rotation overflow (4 = 0)",
|
||||
cellX: 1,
|
||||
cellY: 0,
|
||||
rotation: 4,
|
||||
wantX: 1,
|
||||
wantY: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotX, gotY, gotCellSize := le.GetTransformedPosition(tt.cellX, tt.cellY, tt.rotation)
|
||||
|
||||
// Convert back to cell coordinates to verify rotation
|
||||
expectedX, expectedY := le.grid.CellToCoordinate(tt.wantX, tt.wantY)
|
||||
|
||||
if gotX != expectedX {
|
||||
t.Errorf("GetTransformedPosition() x = %v, want %v", gotX, expectedX)
|
||||
}
|
||||
|
||||
if gotY != expectedY {
|
||||
t.Errorf("GetTransformedPosition() y = %v, want %v", gotY, expectedY)
|
||||
}
|
||||
|
||||
if gotCellSize != float64(le.grid.Cell) {
|
||||
t.Errorf("GetTransformedPosition() cellSize = %v, want %v", gotCellSize, float64(le.grid.Cell))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySymmetry(t *testing.T) {
|
||||
positions := []Position{{0, 0}, {1, 0}, {2, 0}, {3, 0}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
index int
|
||||
want int // expected length
|
||||
}{
|
||||
{
|
||||
name: "valid index",
|
||||
index: 1,
|
||||
want: 4,
|
||||
},
|
||||
{
|
||||
name: "index out of bounds",
|
||||
index: 10,
|
||||
want: 4,
|
||||
},
|
||||
{
|
||||
name: "negative index",
|
||||
index: -1,
|
||||
want: 4,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ApplySymmetry(positions, tt.index)
|
||||
|
||||
if len(result) != tt.want {
|
||||
t.Errorf("ApplySymmetry() len = %v, want %v", len(result), tt.want)
|
||||
}
|
||||
|
||||
// Verify that the positions are unchanged (current implementation)
|
||||
for i, pos := range result {
|
||||
if pos != positions[i] {
|
||||
t.Errorf("ApplySymmetry() changed position at index %d: got %v, want %v", i, pos, positions[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridValidateGrid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
grid *Grid
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "valid grid",
|
||||
grid: &Grid{Size: 64, Cell: 16, Padding: 4},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero cell size",
|
||||
grid: &Grid{Size: 64, Cell: 0, Padding: 4},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "zero size",
|
||||
grid: &Grid{Size: 0, Cell: 16, Padding: 4},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "negative padding",
|
||||
grid: &Grid{Size: 64, Cell: 16, Padding: -1},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.grid.ValidateGrid(); got != tt.want {
|
||||
t.Errorf("ValidateGrid() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridGetIconBounds(t *testing.T) {
|
||||
grid := NewGrid(64.0, 0.08)
|
||||
|
||||
x, y, width, height := grid.GetIconBounds()
|
||||
|
||||
expectedX := float64(grid.X)
|
||||
expectedY := float64(grid.Y)
|
||||
expectedWidth := float64(grid.Cell * 4)
|
||||
expectedHeight := float64(grid.Cell * 4)
|
||||
|
||||
if x != expectedX {
|
||||
t.Errorf("GetIconBounds() x = %v, want %v", x, expectedX)
|
||||
}
|
||||
|
||||
if y != expectedY {
|
||||
t.Errorf("GetIconBounds() y = %v, want %v", y, expectedY)
|
||||
}
|
||||
|
||||
if width != expectedWidth {
|
||||
t.Errorf("GetIconBounds() width = %v, want %v", width, expectedWidth)
|
||||
}
|
||||
|
||||
if height != expectedHeight {
|
||||
t.Errorf("GetIconBounds() height = %v, want %v", height, expectedHeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridGetCenterOffset(t *testing.T) {
|
||||
grid := NewGrid(64.0, 0.08)
|
||||
|
||||
dx, dy := grid.GetCenterOffset()
|
||||
|
||||
expected := float64(grid.Cell) / 2
|
||||
|
||||
if dx != expected {
|
||||
t.Errorf("GetCenterOffset() dx = %v, want %v", dx, expected)
|
||||
}
|
||||
|
||||
if dy != expected {
|
||||
t.Errorf("GetCenterOffset() dy = %v, want %v", dy, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLayoutEngine(t *testing.T) {
|
||||
le := NewLayoutEngine(64.0, 0.08)
|
||||
|
||||
if le.grid == nil {
|
||||
t.Error("NewLayoutEngine() grid is nil")
|
||||
}
|
||||
|
||||
if le.Grid() != le.grid {
|
||||
t.Error("NewLayoutEngine() Grid() does not return internal grid")
|
||||
}
|
||||
|
||||
// Verify grid configuration
|
||||
if !le.grid.ValidateGrid() {
|
||||
t.Error("NewLayoutEngine() created invalid grid")
|
||||
}
|
||||
}
|
||||
294
internal/engine/security_memory_test.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/constants"
|
||||
)
|
||||
|
||||
// TestResourceExhaustionProtection tests that the generator properly blocks
|
||||
// attempts to create extremely large icons that could cause memory exhaustion.
|
||||
func TestResourceExhaustionProtection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
maxIconSize int
|
||||
requestedSize float64
|
||||
expectError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "valid size within default limit",
|
||||
maxIconSize: 0, // Use default
|
||||
requestedSize: 1024,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid size at exact default limit",
|
||||
maxIconSize: 0, // Use default
|
||||
requestedSize: constants.DefaultMaxIconSize,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid size exceeds default limit by 1",
|
||||
maxIconSize: 0, // Use default
|
||||
requestedSize: constants.DefaultMaxIconSize + 1,
|
||||
expectError: true,
|
||||
errorContains: "exceeds maximum allowed size",
|
||||
},
|
||||
{
|
||||
name: "extremely large size should be blocked",
|
||||
maxIconSize: 0, // Use default
|
||||
requestedSize: 100000,
|
||||
expectError: true,
|
||||
errorContains: "exceeds maximum allowed size",
|
||||
},
|
||||
{
|
||||
name: "custom limit - valid size",
|
||||
maxIconSize: 1000,
|
||||
requestedSize: 1000,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "custom limit - invalid size",
|
||||
maxIconSize: 1000,
|
||||
requestedSize: 1001,
|
||||
expectError: true,
|
||||
errorContains: "exceeds maximum allowed size",
|
||||
},
|
||||
{
|
||||
name: "disabled limit allows oversized requests",
|
||||
maxIconSize: -1, // Disabled
|
||||
requestedSize: constants.DefaultMaxIconSize + 1000,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := DefaultGeneratorConfig()
|
||||
config.MaxIconSize = tt.maxIconSize
|
||||
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use a simple hash for testing
|
||||
testHash := "7b824bb99b5b4a4b7b824bb99b5b4a4b7b824bb99b5b4a4b"
|
||||
|
||||
icon, err := generator.Generate(ctx, testHash, tt.requestedSize)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for size %f, but got none", tt.requestedSize)
|
||||
return
|
||||
}
|
||||
if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
|
||||
t.Errorf("Expected error to contain '%s', but got: %v", tt.errorContains, err)
|
||||
}
|
||||
if icon != nil {
|
||||
t.Errorf("Expected nil icon when error occurs, but got non-nil")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for size %f: %v", tt.requestedSize, err)
|
||||
return
|
||||
}
|
||||
if icon == nil {
|
||||
t.Errorf("Expected non-nil icon for valid size %f", tt.requestedSize)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryUsageDoesNotSpikeOnRejection verifies that memory usage doesn't
|
||||
// spike when oversized icon requests are rejected, proving that the validation
|
||||
// happens before any memory allocation.
|
||||
func TestMemoryUsageDoesNotSpikeOnRejection(t *testing.T) {
|
||||
generator, err := NewGeneratorWithConfig(DefaultGeneratorConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
// Force garbage collection and get baseline memory stats
|
||||
runtime.GC()
|
||||
runtime.GC() // Run twice to ensure clean baseline
|
||||
|
||||
var m1 runtime.MemStats
|
||||
runtime.ReadMemStats(&m1)
|
||||
baselineAlloc := m1.Alloc
|
||||
|
||||
ctx := context.Background()
|
||||
testHash := "7b824bb99b5b4a4b7b824bb99b5b4a4b7b824bb99b5b4a4b"
|
||||
|
||||
// Attempt to generate an extremely large icon (should be rejected)
|
||||
oversizedRequest := float64(constants.DefaultMaxIconSize * 10) // 10x the limit
|
||||
|
||||
icon, err := generator.Generate(ctx, testHash, oversizedRequest)
|
||||
|
||||
// Verify the request was properly rejected
|
||||
if err == nil {
|
||||
t.Fatalf("Expected error for oversized request, but got none")
|
||||
}
|
||||
if icon != nil {
|
||||
t.Fatalf("Expected nil icon for oversized request, but got non-nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exceeds maximum allowed size") {
|
||||
t.Fatalf("Expected specific error message, got: %v", err)
|
||||
}
|
||||
|
||||
// Check memory usage after the rejected request
|
||||
runtime.GC()
|
||||
var m2 runtime.MemStats
|
||||
runtime.ReadMemStats(&m2)
|
||||
postRejectionAlloc := m2.Alloc
|
||||
|
||||
// Calculate memory increase (allow for some variance due to test overhead)
|
||||
memoryIncrease := postRejectionAlloc - baselineAlloc
|
||||
maxAcceptableIncrease := uint64(1024 * 1024) // 1MB tolerance for test overhead
|
||||
|
||||
if memoryIncrease > maxAcceptableIncrease {
|
||||
t.Errorf("Memory usage spiked by %d bytes after rejection (baseline: %d, post: %d). "+
|
||||
"This suggests memory allocation occurred before validation.",
|
||||
memoryIncrease, baselineAlloc, postRejectionAlloc)
|
||||
}
|
||||
|
||||
t.Logf("Memory baseline: %d bytes, post-rejection: %d bytes, increase: %d bytes",
|
||||
baselineAlloc, postRejectionAlloc, memoryIncrease)
|
||||
}
|
||||
|
||||
// TestConfigurationDefaults verifies that the default MaxIconSize is properly applied
|
||||
// when not explicitly set in the configuration.
|
||||
func TestConfigurationDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configSize int
|
||||
expectedMax int
|
||||
}{
|
||||
{
|
||||
name: "zero config uses default",
|
||||
configSize: 0,
|
||||
expectedMax: constants.DefaultMaxIconSize,
|
||||
},
|
||||
{
|
||||
name: "other negative config uses default",
|
||||
configSize: -5,
|
||||
expectedMax: constants.DefaultMaxIconSize,
|
||||
},
|
||||
{
|
||||
name: "custom config is respected",
|
||||
configSize: 2000,
|
||||
expectedMax: 2000,
|
||||
},
|
||||
{
|
||||
name: "disabled config is respected",
|
||||
configSize: -1,
|
||||
expectedMax: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := DefaultGeneratorConfig()
|
||||
config.MaxIconSize = tt.configSize
|
||||
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
// Check that the effective max size was set correctly
|
||||
if generator.maxIconSize != tt.expectedMax {
|
||||
t.Errorf("Expected maxIconSize to be %d, but got %d", tt.expectedMax, generator.maxIconSize)
|
||||
}
|
||||
|
||||
// Verify the limit is enforced (skip if disabled)
|
||||
if tt.expectedMax > 0 {
|
||||
ctx := context.Background()
|
||||
testHash := "7b824bb99b5b4a4b7b824bb99b5b4a4b7b824bb99b5b4a4b"
|
||||
|
||||
// Try a size just over the limit
|
||||
oversizedRequest := float64(tt.expectedMax + 1)
|
||||
icon, err := generator.Generate(ctx, testHash, oversizedRequest)
|
||||
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for size %f (limit: %d), but got none", oversizedRequest, tt.expectedMax)
|
||||
}
|
||||
if icon != nil {
|
||||
t.Errorf("Expected nil icon for oversized request")
|
||||
}
|
||||
} else if tt.expectedMax == -1 {
|
||||
// Test that disabled limit allows large sizes
|
||||
ctx := context.Background()
|
||||
testHash := "7b824bb99b5b4a4b7b824bb99b5b4a4b7b824bb99b5b4a4b"
|
||||
|
||||
// Try a very large size that would normally be blocked
|
||||
largeRequest := float64(constants.DefaultMaxIconSize + 1000)
|
||||
icon, err := generator.Generate(ctx, testHash, largeRequest)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for large size with disabled limit: %v", err)
|
||||
}
|
||||
if icon == nil {
|
||||
t.Errorf("Expected non-nil icon for large size with disabled limit")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoundaryConditions tests edge cases around the size limit boundaries
|
||||
func TestBoundaryConditions(t *testing.T) {
|
||||
config := DefaultGeneratorConfig()
|
||||
config.MaxIconSize = 1000
|
||||
|
||||
generator, err := NewGeneratorWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
testHash := "7b824bb99b5b4a4b7b824bb99b5b4a4b7b824bb99b5b4a4b"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
size float64
|
||||
expectError bool
|
||||
}{
|
||||
{"size at exact limit", 1000, false},
|
||||
{"size just under limit", 999, false},
|
||||
{"size just over limit", 1001, true},
|
||||
{"floating point at limit", 1000.0, false},
|
||||
{"floating point just over", 1001.0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
icon, err := generator.Generate(ctx, testHash, tt.size)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for size %f, but got none", tt.size)
|
||||
}
|
||||
if icon != nil {
|
||||
t.Errorf("Expected nil icon for oversized request")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for size %f: %v", tt.size, err)
|
||||
}
|
||||
if icon == nil {
|
||||
t.Errorf("Expected non-nil icon for valid size %f", tt.size)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,50 @@ package engine
|
||||
|
||||
import "math"
|
||||
|
||||
// Shape rendering constants for visual proportions
|
||||
const (
|
||||
// Center shape proportions - these ratios determine the visual appearance
|
||||
centerShapeAsymmetricCornerRatio = 0.42 // Shape 0: corner cut proportion
|
||||
centerShapeTriangleWidthRatio = 0.5 // Shape 1: triangle width relative to cell
|
||||
centerShapeTriangleHeightRatio = 0.8 // Shape 1: triangle height relative to cell
|
||||
|
||||
centerShapeInnerMarginRatio = 0.1 // Shape 3,5,9,10: inner margin ratio
|
||||
centerShapeOuterMarginRatio = 0.25 // Shape 3: outer margin ratio for large cells
|
||||
centerShapeOuterMarginRatio9 = 0.35 // Shape 9: outer margin ratio for large cells
|
||||
centerShapeOuterMarginRatio10 = 0.12 // Shape 10: inner ratio for circular cutout
|
||||
|
||||
centerShapeCircleMarginRatio = 0.15 // Shape 4: circle margin ratio
|
||||
centerShapeCircleWidthRatio = 0.5 // Shape 4: circle width ratio
|
||||
|
||||
// Shape 6 complex polygon proportions
|
||||
centerShapeComplexHeight1Ratio = 0.7 // First height point
|
||||
centerShapeComplexPoint1XRatio = 0.4 // First point X ratio
|
||||
centerShapeComplexPoint1YRatio = 0.4 // First point Y ratio
|
||||
centerShapeComplexPoint2XRatio = 0.7 // Second point X ratio
|
||||
|
||||
// Shape 9 rectangular cutout proportions
|
||||
centerShapeRect9InnerRatio = 0.14 // Shape 9: inner rectangle ratio
|
||||
|
||||
// Shape 12 rhombus cutout proportion
|
||||
centerShapeRhombusCutoutRatio = 0.25 // Shape 12: rhombus cutout margin
|
||||
|
||||
// Shape 13 large circle proportions (only for center position)
|
||||
centerShapeLargeCircleMarginRatio = 0.4 // Shape 13: circle margin ratio
|
||||
centerShapeLargeCircleWidthRatio = 1.2 // Shape 13: circle width ratio
|
||||
|
||||
// Outer shape proportions
|
||||
outerShapeCircleMarginRatio = 1.0 / 6.0 // Shape 3: circle margin (1/6 of cell)
|
||||
|
||||
// Size thresholds for conditional rendering
|
||||
smallCellThreshold4 = 4 // Threshold for shape 3,9 outer margin calculation
|
||||
smallCellThreshold6 = 6 // Threshold for shape 3 outer margin calculation
|
||||
smallCellThreshold8 = 8 // Threshold for shape 3,9 inner margin floor calculation
|
||||
|
||||
// Multipliers for margin calculations
|
||||
innerOuterMultiplier5 = 4 // Shape 5: inner to outer multiplier
|
||||
innerOuterMultiplier10 = 3 // Shape 10: inner to outer multiplier
|
||||
)
|
||||
|
||||
// Point represents a 2D point
|
||||
type Point struct {
|
||||
X, Y float64
|
||||
@@ -108,7 +152,7 @@ func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64)
|
||||
switch index {
|
||||
case 0:
|
||||
// Shape 0: Asymmetric polygon
|
||||
k := cell * 0.42
|
||||
k := cell * centerShapeAsymmetricCornerRatio
|
||||
points := []Point{
|
||||
{X: 0, Y: 0},
|
||||
{X: cell, Y: 0},
|
||||
@@ -120,8 +164,8 @@ func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64)
|
||||
|
||||
case 1:
|
||||
// Shape 1: Triangle
|
||||
w := math.Floor(cell * 0.5)
|
||||
h := math.Floor(cell * 0.8)
|
||||
w := math.Floor(cell * centerShapeTriangleWidthRatio)
|
||||
h := math.Floor(cell * centerShapeTriangleHeightRatio)
|
||||
g.AddTriangle(cell-w, 0, w, h, 2, false)
|
||||
|
||||
case 2:
|
||||
@@ -131,14 +175,14 @@ func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64)
|
||||
|
||||
case 3:
|
||||
// Shape 3: Nested rectangles
|
||||
inner := cell * 0.1
|
||||
inner := cell * centerShapeInnerMarginRatio
|
||||
var outer float64
|
||||
if cell < 6 {
|
||||
if cell < smallCellThreshold6 {
|
||||
outer = 1
|
||||
} else if cell < 8 {
|
||||
} else if cell < smallCellThreshold8 {
|
||||
outer = 2
|
||||
} else {
|
||||
outer = math.Floor(cell * 0.25)
|
||||
outer = math.Floor(cell * centerShapeOuterMarginRatio)
|
||||
}
|
||||
|
||||
if inner > 1 {
|
||||
@@ -151,14 +195,14 @@ func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64)
|
||||
|
||||
case 4:
|
||||
// Shape 4: Circle
|
||||
m := math.Floor(cell * 0.15)
|
||||
w := math.Floor(cell * 0.5)
|
||||
m := math.Floor(cell * centerShapeCircleMarginRatio)
|
||||
w := math.Floor(cell * centerShapeCircleWidthRatio)
|
||||
g.AddCircle(cell-w-m, cell-w-m, w, false)
|
||||
|
||||
case 5:
|
||||
// Shape 5: Rectangle with triangular cutout
|
||||
inner := cell * 0.1
|
||||
outer := inner * 4
|
||||
inner := cell * centerShapeInnerMarginRatio
|
||||
outer := inner * innerOuterMultiplier5
|
||||
|
||||
if outer > 3 {
|
||||
outer = math.Floor(outer)
|
||||
@@ -177,9 +221,9 @@ func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64)
|
||||
points := []Point{
|
||||
{X: 0, Y: 0},
|
||||
{X: cell, Y: 0},
|
||||
{X: cell, Y: cell * 0.7},
|
||||
{X: cell * 0.4, Y: cell * 0.4},
|
||||
{X: cell * 0.7, Y: cell},
|
||||
{X: cell, Y: cell * centerShapeComplexHeight1Ratio},
|
||||
{X: cell * centerShapeComplexPoint1XRatio, Y: cell * centerShapeComplexPoint1YRatio},
|
||||
{X: cell * centerShapeComplexPoint2XRatio, Y: cell},
|
||||
{X: 0, Y: cell},
|
||||
}
|
||||
g.AddPolygon(points, false)
|
||||
@@ -196,17 +240,17 @@ func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64)
|
||||
|
||||
case 9:
|
||||
// Shape 9: Rectangle with rectangular cutout
|
||||
inner := cell * 0.14
|
||||
inner := cell * centerShapeRect9InnerRatio
|
||||
var outer float64
|
||||
if cell < 4 {
|
||||
if cell < smallCellThreshold4 {
|
||||
outer = 1
|
||||
} else if cell < 6 {
|
||||
} else if cell < smallCellThreshold6 {
|
||||
outer = 2
|
||||
} else {
|
||||
outer = math.Floor(cell * 0.35)
|
||||
outer = math.Floor(cell * centerShapeOuterMarginRatio9)
|
||||
}
|
||||
|
||||
if cell >= 8 {
|
||||
if cell >= smallCellThreshold8 {
|
||||
inner = math.Floor(inner)
|
||||
}
|
||||
|
||||
@@ -215,8 +259,8 @@ func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64)
|
||||
|
||||
case 10:
|
||||
// Shape 10: Rectangle with circular cutout
|
||||
inner := cell * 0.12
|
||||
outer := inner * 3
|
||||
inner := cell * centerShapeOuterMarginRatio10
|
||||
outer := inner * innerOuterMultiplier10
|
||||
|
||||
g.AddRectangle(0, 0, cell, cell, false)
|
||||
g.AddCircle(outer, outer, cell-inner-outer, true)
|
||||
@@ -227,15 +271,15 @@ func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64)
|
||||
|
||||
case 12:
|
||||
// Shape 12: Rectangle with rhombus cutout
|
||||
m := cell * 0.25
|
||||
m := cell * centerShapeRhombusCutoutRatio
|
||||
g.AddRectangle(0, 0, cell, cell, false)
|
||||
g.AddRhombus(m, m, cell-m, cell-m, true)
|
||||
|
||||
case 13:
|
||||
// Shape 13: Large circle (only for position 0)
|
||||
if positionIndex == 0 {
|
||||
m := cell * 0.4
|
||||
w := cell * 1.2
|
||||
m := cell * centerShapeLargeCircleMarginRatio
|
||||
w := cell * centerShapeLargeCircleWidthRatio
|
||||
g.AddCircle(m, m, w, false)
|
||||
}
|
||||
}
|
||||
@@ -260,7 +304,7 @@ func RenderOuterShape(g *Graphics, shapeIndex int, cell float64) {
|
||||
|
||||
case 3:
|
||||
// Shape 3: Circle
|
||||
m := cell / 6
|
||||
m := cell * outerShapeCircleMarginRatio
|
||||
g.AddCircle(m, m, cell-2*m, false)
|
||||
}
|
||||
}
|
||||
103
internal/engine/singleflight.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/util"
|
||||
)
|
||||
|
||||
// Generate creates an identicon with the specified hash and size
|
||||
// This method includes caching and singleflight to prevent duplicate work
|
||||
func (g *Generator) Generate(ctx context.Context, hash string, size float64) (*Icon, error) {
|
||||
// Basic validation
|
||||
if hash == "" {
|
||||
return nil, fmt.Errorf("jdenticon: engine: generation failed: hash cannot be empty")
|
||||
}
|
||||
if size <= 0 {
|
||||
return nil, fmt.Errorf("jdenticon: engine: generation failed: invalid size: %f", size)
|
||||
}
|
||||
|
||||
// Check icon size limits
|
||||
if g.maxIconSize > 0 && int(size) > g.maxIconSize {
|
||||
return nil, fmt.Errorf("jdenticon: engine: generation failed: icon size %d exceeds maximum allowed size %d", int(size), g.maxIconSize)
|
||||
}
|
||||
|
||||
// Validate hash format
|
||||
if !util.IsValidHash(hash) {
|
||||
return nil, fmt.Errorf("jdenticon: engine: generation failed: invalid hash format: %s", hash)
|
||||
}
|
||||
|
||||
// Check for context cancellation before proceeding
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate cache key
|
||||
key := g.cacheKey(hash, size)
|
||||
|
||||
// Check cache first (with read lock)
|
||||
g.mu.RLock()
|
||||
if cached, ok := g.cache.Get(key); ok {
|
||||
g.mu.RUnlock()
|
||||
g.metrics.recordHit()
|
||||
return cached, nil
|
||||
}
|
||||
g.mu.RUnlock()
|
||||
|
||||
// Use singleflight to prevent multiple concurrent generations for the same key
|
||||
result, err, _ := g.sf.Do(key, func() (interface{}, error) {
|
||||
// Check cache again inside singleflight (another goroutine might have populated it)
|
||||
g.mu.RLock()
|
||||
if cached, ok := g.cache.Get(key); ok {
|
||||
g.mu.RUnlock()
|
||||
g.metrics.recordHit()
|
||||
return cached, nil
|
||||
}
|
||||
g.mu.RUnlock()
|
||||
|
||||
// Generate the icon
|
||||
icon, err := g.generateIcon(ctx, hash, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store in cache (with write lock)
|
||||
g.mu.Lock()
|
||||
g.cache.Add(key, icon)
|
||||
g.mu.Unlock()
|
||||
|
||||
g.metrics.recordMiss()
|
||||
return icon, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result.(*Icon), nil
|
||||
}
|
||||
|
||||
// GenerateWithoutCache creates an identicon without using cache
|
||||
// This method is useful for testing or when caching is not desired
|
||||
func (g *Generator) GenerateWithoutCache(ctx context.Context, hash string, size float64) (*Icon, error) {
|
||||
// Basic validation
|
||||
if hash == "" {
|
||||
return nil, fmt.Errorf("jdenticon: engine: generation failed: hash cannot be empty")
|
||||
}
|
||||
if size <= 0 {
|
||||
return nil, fmt.Errorf("jdenticon: engine: generation failed: invalid size: %f", size)
|
||||
}
|
||||
|
||||
// Validate hash format
|
||||
if !util.IsValidHash(hash) {
|
||||
return nil, fmt.Errorf("jdenticon: engine: generation failed: invalid hash format: %s", hash)
|
||||
}
|
||||
|
||||
// Check for context cancellation
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return g.generateIcon(ctx, hash, size)
|
||||
}
|
||||
415
internal/engine/singleflight_test.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateValidHash(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
icon, err := generator.Generate(context.Background(), hash, size)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed with error: %v", err)
|
||||
}
|
||||
|
||||
if icon == nil {
|
||||
t.Fatal("Generate returned nil icon")
|
||||
}
|
||||
|
||||
if icon.Hash != hash {
|
||||
t.Errorf("Expected hash %s, got %s", hash, icon.Hash)
|
||||
}
|
||||
|
||||
if icon.Size != size {
|
||||
t.Errorf("Expected size %f, got %f", size, icon.Size)
|
||||
}
|
||||
|
||||
if len(icon.Shapes) == 0 {
|
||||
t.Error("Generated icon has no shapes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateInvalidInputs(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hash string
|
||||
size float64
|
||||
}{
|
||||
{
|
||||
name: "Empty hash",
|
||||
hash: "",
|
||||
size: 64.0,
|
||||
},
|
||||
{
|
||||
name: "Zero size",
|
||||
hash: "abcdef1234567890",
|
||||
size: 0.0,
|
||||
},
|
||||
{
|
||||
name: "Negative size",
|
||||
hash: "abcdef1234567890",
|
||||
size: -10.0,
|
||||
},
|
||||
{
|
||||
name: "Invalid hash format",
|
||||
hash: "invalid_hash_format",
|
||||
size: 64.0,
|
||||
},
|
||||
{
|
||||
name: "Hash too short",
|
||||
hash: "abc",
|
||||
size: 64.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := generator.Generate(context.Background(), test.hash, test.size)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for %s, but got none", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithoutCache(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
size := 64.0
|
||||
|
||||
// Generate without cache
|
||||
icon1, err := generator.GenerateWithoutCache(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithoutCache failed: %v", err)
|
||||
}
|
||||
|
||||
// Generate again without cache - should be different instances
|
||||
icon2, err := generator.GenerateWithoutCache(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Second GenerateWithoutCache failed: %v", err)
|
||||
}
|
||||
|
||||
// Should be different instances
|
||||
if icon1 == icon2 {
|
||||
t.Error("GenerateWithoutCache returned same instance - should be different")
|
||||
}
|
||||
|
||||
// But should have same content
|
||||
if icon1.Hash != icon2.Hash {
|
||||
t.Error("Icons have different hashes")
|
||||
}
|
||||
|
||||
if icon1.Size != icon2.Size {
|
||||
t.Error("Icons have different sizes")
|
||||
}
|
||||
|
||||
// Cache should remain empty
|
||||
if generator.GetCacheSize() != 0 {
|
||||
t.Errorf("Expected cache size 0 after GenerateWithoutCache, got %d", generator.GetCacheSize())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithCancellation(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
size := 64.0
|
||||
|
||||
// Create canceled context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
_, err = generator.Generate(ctx, hash, size)
|
||||
if err == nil {
|
||||
t.Error("Expected error for canceled context, but got none")
|
||||
}
|
||||
|
||||
if err != context.Canceled {
|
||||
t.Errorf("Expected context.Canceled error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithTimeout(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
size := 64.0
|
||||
|
||||
// Create context with very short timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||
defer cancel()
|
||||
|
||||
// Sleep to ensure timeout
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
_, err = generator.Generate(ctx, hash, size)
|
||||
if err == nil {
|
||||
t.Error("Expected timeout error, but got none")
|
||||
}
|
||||
|
||||
if err != context.DeadlineExceeded {
|
||||
t.Errorf("Expected context.DeadlineExceeded error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentGenerate(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
size := 64.0
|
||||
|
||||
const numGoroutines = 20
|
||||
icons := make([]*Icon, numGoroutines)
|
||||
errors := make([]error, numGoroutines)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numGoroutines)
|
||||
|
||||
// Start multiple goroutines that generate the same icon concurrently
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
icon, genErr := generator.Generate(context.Background(), hash, size)
|
||||
icons[index] = icon
|
||||
errors[index] = genErr
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Check that all generations succeeded
|
||||
for i, err := range errors {
|
||||
if err != nil {
|
||||
t.Errorf("Goroutine %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// All icons should be identical (same instance due to singleflight)
|
||||
firstIcon := icons[0]
|
||||
for i, icon := range icons[1:] {
|
||||
if icon != firstIcon {
|
||||
t.Errorf("Icon %d is different instance from first icon", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache should contain exactly one item
|
||||
if generator.GetCacheSize() != 1 {
|
||||
t.Errorf("Expected cache size 1, got %d", generator.GetCacheSize())
|
||||
}
|
||||
|
||||
// Should have exactly one cache miss (the actual generation)
|
||||
// Note: With singleflight, concurrent requests share the result directly from singleflight,
|
||||
// not from the cache. Cache hits only occur for requests that arrive AFTER the initial
|
||||
// generation completes. So we only verify the miss count is 1.
|
||||
_, misses := generator.GetCacheMetrics()
|
||||
if misses != 1 {
|
||||
t.Errorf("Expected exactly 1 cache miss due to singleflight, got %d", misses)
|
||||
}
|
||||
|
||||
// Verify subsequent requests DO get cache hits
|
||||
_, err = generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Subsequent Generate failed: %v", err)
|
||||
}
|
||||
hits, _ := generator.GetCacheMetrics()
|
||||
if hits == 0 {
|
||||
t.Error("Expected cache hit for subsequent request, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentGenerateDifferentHashes(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
const numGoroutines = 10
|
||||
size := 64.0
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numGoroutines)
|
||||
|
||||
icons := make([]*Icon, numGoroutines)
|
||||
errors := make([]error, numGoroutines)
|
||||
|
||||
// Start multiple goroutines that generate different icons concurrently
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
hash := fmt.Sprintf("%032x", index)
|
||||
icon, err := generator.Generate(context.Background(), hash, size)
|
||||
icons[index] = icon
|
||||
errors[index] = err
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Check that all generations succeeded
|
||||
for i, err := range errors {
|
||||
if err != nil {
|
||||
t.Errorf("Goroutine %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// All icons should be different instances
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
for j := i + 1; j < numGoroutines; j++ {
|
||||
if icons[i] == icons[j] {
|
||||
t.Errorf("Icons %d and %d are the same instance - should be different", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache should contain all generated icons
|
||||
if generator.GetCacheSize() != numGoroutines {
|
||||
t.Errorf("Expected cache size %d, got %d", numGoroutines, generator.GetCacheSize())
|
||||
}
|
||||
|
||||
// Should have exactly numGoroutines cache misses and no hits
|
||||
hits, misses := generator.GetCacheMetrics()
|
||||
if misses != int64(numGoroutines) {
|
||||
t.Errorf("Expected %d cache misses, got %d", numGoroutines, misses)
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Errorf("Expected 0 cache hits, got %d", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleflightDeduplication(t *testing.T) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
size := 64.0
|
||||
|
||||
const numGoroutines = 50
|
||||
|
||||
// Use a channel to coordinate goroutine starts
|
||||
start := make(chan struct{})
|
||||
icons := make([]*Icon, numGoroutines)
|
||||
errors := make([]error, numGoroutines)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numGoroutines)
|
||||
|
||||
// Start all goroutines and have them wait for the signal
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
<-start // Wait for start signal
|
||||
icon, genErr := generator.Generate(context.Background(), hash, size)
|
||||
icons[index] = icon
|
||||
errors[index] = genErr
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Signal all goroutines to start at once
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
// Check that all generations succeeded
|
||||
for i, err := range errors {
|
||||
if err != nil {
|
||||
t.Errorf("Goroutine %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// All icons should be the exact same instance due to singleflight
|
||||
firstIcon := icons[0]
|
||||
for i, icon := range icons[1:] {
|
||||
if icon != firstIcon {
|
||||
t.Errorf("Icon %d is different instance - singleflight deduplication failed", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// Should have exactly one cache miss due to singleflight deduplication
|
||||
// Note: Singleflight shares results directly with waiting goroutines, so they don't
|
||||
// hit the cache. Cache hits only occur for requests that arrive AFTER generation completes.
|
||||
_, misses := generator.GetCacheMetrics()
|
||||
if misses != 1 {
|
||||
t.Errorf("Expected exactly 1 cache miss due to singleflight deduplication, got %d", misses)
|
||||
}
|
||||
|
||||
// Verify subsequent requests DO get cache hits
|
||||
_, err = generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Subsequent Generate failed: %v", err)
|
||||
}
|
||||
hits, _ := generator.GetCacheMetrics()
|
||||
if hits == 0 {
|
||||
t.Error("Expected cache hit for subsequent request, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerate(b *testing.B) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
b.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
size := 64.0
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerateWithCache(b *testing.B) {
|
||||
generator, err := NewDefaultGenerator()
|
||||
if err != nil {
|
||||
b.Fatalf("NewDefaultGenerator failed: %v", err)
|
||||
}
|
||||
|
||||
hash := "abcdef1234567890abcdef1234567890abcdef12"
|
||||
size := 64.0
|
||||
|
||||
// Pre-populate cache
|
||||
_, err = generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("Pre-populate failed: %v", err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := generator.Generate(context.Background(), hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||