Merge commit 'bec54df'

This commit is contained in:
Mikkel Georgsen 2026-04-10 06:47:55 +00:00
commit 2d6765077c
7 changed files with 582 additions and 2 deletions

View file

@ -0,0 +1,153 @@
---
phase: 04-usb-manager-label-printing
plan: "02"
subsystem: labels
tags: [go, qrcode, image, label-printing, bitmap]
# Dependency graph
requires:
- phase: 04-usb-manager-label-printing
provides: "Phase context, label dimensions, printer interface design"
- phase: 01-foundation
provides: "internal/netbox/types.go Device and CustomFields structs"
provides:
- "internal/labels package: RenderStandard (384x120) and RenderCable (384x180) bitmap renderers"
- "QR code generation encoding http://mac-mini.mg:8080/hw/HW-XXXXX URLs"
- "IsCableDevice() heuristic detection for cable records"
- "LabelData and CableLabelData structs as public API for printer driver"
affects:
- 04-03-printer-driver
- 04-05-intake-integration
# Tech tracking
tech-stack:
added:
- "github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e"
- "golang.org/x/image v0.39.0"
patterns:
- "Label renderers return image.Image — driver-agnostic; printer driver converts to bytes"
- "HWID format validated (HW-\\d{5}) before any qrcode.New() call (T-04-07 mitigation)"
- "TDD: failing tests committed first, implementation makes them pass"
key-files:
created:
- internal/labels/renderer.go
- internal/labels/renderer_test.go
- internal/labels/cable.go
- internal/labels/cable_test.go
modified:
- go.mod
- go.sum
key-decisions:
- "DisableBorder=true (not DisableBorderPadding — that field doesn't exist in skip2/go-qrcode v0.0.0-20200617195104)"
- "HWID format validated before qrcode.New() to mitigate T-04-07 DoS via malformed input"
- "IsCableDevice uses name/notes heuristic only — real device_type detection deferred to Phase 5"
- "Cable label is 180px tall (vs 120px standard) to fit 4 text lines"
patterns-established:
- "Label renderers: pure image.Image output, no USB/printer dependency — testable in isolation"
- "HWID validation: regexp.MustCompile at package level, checked at render entry points"
- "Text layout: TextOffsetX=112 (after QR block), y positions proportional to label height"
requirements-completed: [LBL-01, LBL-02, LBL-03]
# Metrics
duration: 15min
completed: 2026-04-10
---
# Phase 04 Plan 02: QR Code + Label Rendering Summary
**image.NRGBA label bitmaps at 203 DPI using skip2/go-qrcode — standard (384x120) and cable (384x180) templates with HWID-encoded QR codes and basicfont text**
## Performance
- **Duration:** ~15 min
- **Started:** 2026-04-10T06:30:00Z
- **Completed:** 2026-04-10T06:45:55Z
- **Tasks:** 2
- **Files modified:** 6 (4 created, 2 modified)
## Accomplishments
- Standard label renderer: 384x120px NRGBA bitmap with QR code (left 96x96), HW ID, name, spec line
- Cable label renderer: 384x180px bitmap with 4 text lines — HW ID, name, USB version/speed, wattage/test date
- IsCableDevice() heuristic returns true when device Name or AINotes contains "cable" (case-insensitive)
- HWID format validated before QR generation (T-04-07 mitigation — prevents DoS via malformed input)
- All 10 tests pass across both renderers and the cable detection helper
## Task Commits
Each task was committed atomically:
1. **Task 1: QR code generation + standard label renderer** - `8cbd840` (feat)
2. **Task 2: Cable label renderer + IsCableDevice detection helper** - `7800917` (feat)
## Files Created/Modified
- `internal/labels/renderer.go` — LabelData, RenderStandard(), drawText(), truncate(), HWID validation
- `internal/labels/renderer_test.go` — 5 tests: dimensions, QR non-blank, URL encoding, empty name fallback, white background
- `internal/labels/cable.go` — CableLabelData, RenderCable(), IsCableDevice()
- `internal/labels/cable_test.go` — 5 tests: dimensions, full data, IsCableDevice true/false, empty test date
- `go.mod` / `go.sum` — added github.com/skip2/go-qrcode and golang.org/x/image
## Decisions Made
- Used `DisableBorder = true` (not `DisableBorderPadding` — that field does not exist in the installed version of go-qrcode)
- HWID validated via `regexp.MustCompile("^HW-\\d{5}$")` at render entry points per T-04-07 threat disposition (mitigate)
- Cable detection kept as heuristic (name/notes contains "cable") — device_type-based detection is a Phase 5 concern
- go.mod upgraded from go 1.23.0 to 1.25.0 automatically when golang.org/x/image@v0.39.0 required it
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] DisableBorderPadding field does not exist in go-qrcode**
- **Found during:** Task 1 (standard label renderer)
- **Issue:** Plan code snippet used `qr.DisableBorderPadding = true` but the installed version of skip2/go-qrcode has `DisableBorder bool` not `DisableBorderPadding`
- **Fix:** Changed to `qr.DisableBorder = true` (inspected package source)
- **Files modified:** internal/labels/renderer.go, internal/labels/cable.go
- **Verification:** All tests pass after fix
- **Committed in:** 8cbd840 (Task 1 commit)
**2. [Rule 2 - Missing Critical] Added HWID format validation (T-04-07)**
- **Found during:** Task 1 — threat model review
- **Issue:** Threat register marked T-04-07 (DoS via malformed HWID to qrcode.New) as `mitigate` — no validation was in the plan's code snippet
- **Fix:** Added `hwIDPattern = regexp.MustCompile("^HW-\\d{5}$")` and guard at top of both RenderStandard and RenderCable
- **Files modified:** internal/labels/renderer.go, internal/labels/cable.go
- **Verification:** Passing invalid HWIDs returns error rather than passing to qrcode.New
- **Committed in:** 8cbd840, 7800917 (both task commits)
---
**Total deviations:** 2 auto-fixed (1 bug, 1 missing critical security mitigation)
**Impact on plan:** Both fixes necessary for correctness and threat mitigation. No scope creep.
## Issues Encountered
- golang.org/x/image@v0.39.0 requires go 1.25+ — `go get` automatically upgraded go.mod from 1.23.0 to 1.25.0. This is acceptable for the project.
## User Setup Required
None — no external service configuration required.
## Next Phase Readiness
- `internal/labels` package is complete and fully tested
- Printer driver (04-03) can import `internal/labels` and call `RenderStandard` / `RenderCable` to get `image.Image`, then convert to bytes for the PRT Qutie
- Intake integration (04-05) can use `IsCableDevice()` to route to the correct label template
- No blockers
## Self-Check: PASSED
- internal/labels/renderer.go: FOUND
- internal/labels/renderer_test.go: FOUND
- internal/labels/cable.go: FOUND
- internal/labels/cable_test.go: FOUND
- Commit 8cbd840: FOUND
- Commit 7800917: FOUND
---
*Phase: 04-usb-manager-label-printing*
*Completed: 2026-04-10*

6
go.mod
View file

@ -1,6 +1,6 @@
module git.georgsen.dk/hwlab
go 1.23.0
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.2.5
@ -20,6 +20,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
@ -28,7 +29,8 @@ require (
go.bug.st/serial v1.6.4 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/image v0.39.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
)

6
go.sum
View file

@ -44,6 +44,8 @@ github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDc
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@ -66,10 +68,14 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
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/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

80
internal/labels/cable.go Normal file
View file

@ -0,0 +1,80 @@
package labels
import (
"fmt"
"image"
"image/color"
"image/draw"
"strings"
"github.com/skip2/go-qrcode"
"git.georgsen.dk/hwlab/internal/netbox"
)
const CableLabelHeight = 180
// CableLabelData holds fields for the cable-specific label template (per LBL-03).
type CableLabelData struct {
HWID string // e.g. "HW-00042"
Name string // e.g. "USB-C 2m Cable"
USBVersion string // e.g. "USB 3.2 Gen 2"
MaxSpeedGbps float64 // e.g. 10.0
MaxWatts int // e.g. 100
TestDate string // ISO 8601 or "" for "Not tested"
}
// RenderCable produces a 384x180 label bitmap for a cable item.
// Layout: QR code left (96x96), text right with 4 lines:
//
// Line 1: HW ID
// Line 2: Name (truncated)
// Line 3: USB version + speed (e.g. "USB 3.2 Gen 2 / 10 Gbps")
// Line 4: Max power + test date (e.g. "100W Tested: 2026-04-13")
//
// Returns an error if HWID does not match HW-\d{5} (T-04-07 mitigation).
func RenderCable(d CableLabelData) (image.Image, error) {
if !hwIDPattern.MatchString(d.HWID) {
return nil, fmt.Errorf("labels: invalid HWID %q: must match HW-NNNNN", d.HWID)
}
if d.Name == "" {
d.Name = "Unknown Cable"
}
testDateStr := d.TestDate
if testDateStr == "" {
testDateStr = "Not tested"
}
img := image.NewNRGBA(image.Rect(0, 0, LabelWidth, CableLabelHeight))
draw.Draw(img, img.Bounds(), image.NewUniform(color.NRGBA{R: 255, G: 255, B: 255, A: 255}), image.Point{}, draw.Src)
// QR code — vertically centered in 180px height
qr, err := qrcode.New(BaseURL+d.HWID, qrcode.Low)
if err != nil {
return nil, fmt.Errorf("labels: QR generation failed: %w", err)
}
qr.DisableBorder = true
qrImg := qr.Image(QRSize)
qrOffsetY := (CableLabelHeight - QRSize) / 2
qrDst := image.Rect(QROffsetX, qrOffsetY, QROffsetX+QRSize, qrOffsetY+QRSize)
draw.Draw(img, qrDst, qrImg, image.Point{}, draw.Over)
// Text lines (4 lines across 180px height)
drawText(img, TextOffsetX, 30, d.HWID)
drawText(img, TextOffsetX, 62, truncate(d.Name, 30))
speedLine := fmt.Sprintf("%s / %.0f Gbps", d.USBVersion, d.MaxSpeedGbps)
drawText(img, TextOffsetX, 94, truncate(speedLine, 30))
powerLine := fmt.Sprintf("%dW Tested: %s", d.MaxWatts, testDateStr)
drawText(img, TextOffsetX, 126, truncate(powerLine, 30))
return img, nil
}
// IsCableDevice returns true if the NetBox device record represents a cable.
// Detection: Name contains "cable" (case-insensitive) OR AINotes contains "cable".
// This is a heuristic — real cable detection uses device_type in Phase 5.
func IsCableDevice(d netbox.Device) bool {
nameLower := strings.ToLower(d.Name)
notesLower := strings.ToLower(d.CustomFields.AINotes)
return strings.Contains(nameLower, "cable") || strings.Contains(notesLower, "cable")
}

View file

@ -0,0 +1,119 @@
package labels
import (
"testing"
"git.georgsen.dk/hwlab/internal/netbox"
)
// TestRenderCable_ImageDimensions verifies cable label is 384x180.
func TestRenderCable_ImageDimensions(t *testing.T) {
d := CableLabelData{
HWID: "HW-00042",
Name: "USB-C 2m Cable",
USBVersion: "USB 3.2 Gen 2",
MaxSpeedGbps: 10,
MaxWatts: 100,
TestDate: "2026-04-13",
}
img, err := RenderCable(d)
if err != nil {
t.Fatalf("RenderCable returned error: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() != LabelWidth {
t.Errorf("expected width %d, got %d", LabelWidth, bounds.Dx())
}
if bounds.Dy() != CableLabelHeight {
t.Errorf("expected height %d, got %d", CableLabelHeight, bounds.Dy())
}
}
// TestRenderCable_FullData verifies a fully-populated CableLabelData renders without error.
func TestRenderCable_FullData(t *testing.T) {
d := CableLabelData{
HWID: "HW-00042",
Name: "USB-C 2m Cable",
USBVersion: "USB 3.2 Gen 2",
MaxSpeedGbps: 10,
MaxWatts: 100,
TestDate: "2026-04-13",
}
img, err := RenderCable(d)
if err != nil {
t.Fatalf("RenderCable returned error: %v", err)
}
if img == nil {
t.Fatal("expected non-nil image")
}
}
// TestIsCableDevice_True verifies that a device with "cable" in Name returns true.
func TestIsCableDevice_True(t *testing.T) {
tests := []struct {
name string
device netbox.Device
}{
{
name: "cable in device name",
device: netbox.Device{
Name: "USB-C Cable 2m",
},
},
{
name: "cable in AINotes",
device: netbox.Device{
Name: "HW-00050",
CustomFields: netbox.CustomFields{
AINotes: "This is a USB cable for data transfer",
},
},
},
{
name: "CABLE uppercase",
device: netbox.Device{
Name: "CABLE USB 3.0",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !IsCableDevice(tt.device) {
t.Errorf("expected IsCableDevice to return true for device: %+v", tt.device)
}
})
}
}
// TestIsCableDevice_False verifies that a device with no cable indicators returns false.
func TestIsCableDevice_False(t *testing.T) {
d := netbox.Device{
Name: "Intel NUC i5",
CustomFields: netbox.CustomFields{
AINotes: "Mini PC with 16GB RAM",
},
}
if IsCableDevice(d) {
t.Errorf("expected IsCableDevice to return false for non-cable device: %+v", d)
}
}
// TestRenderCable_EmptyTestDate verifies "Not tested" is used when TestDate is empty.
func TestRenderCable_EmptyTestDate(t *testing.T) {
d := CableLabelData{
HWID: "HW-00042",
Name: "USB-C Cable",
USBVersion: "USB 2.0",
MaxSpeedGbps: 0.48,
MaxWatts: 5,
TestDate: "", // intentionally empty
}
// Must not panic
img, err := RenderCable(d)
if err != nil {
t.Fatalf("RenderCable returned error on empty TestDate: %v", err)
}
if img == nil {
t.Fatal("expected non-nil image, got nil")
}
}

View file

@ -0,0 +1,87 @@
package labels
import (
"fmt"
"image"
"image/color"
"image/draw"
"regexp"
"github.com/skip2/go-qrcode"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
)
const (
LabelWidth = 384 // pixels at 203 DPI ≈ 48mm usable
LabelHeight = 120 // pixels ≈ 15mm
QRSize = 96 // pixels
QROffsetX = 8
QROffsetY = 12
TextOffsetX = 112
BaseURL = "http://mac-mini.mg:8080/hw/"
)
// hwIDPattern validates the HW-XXXXX format (T-04-07 mitigation).
var hwIDPattern = regexp.MustCompile(`^HW-\d{5}$`)
// LabelData holds the fields needed to render a standard item label.
type LabelData struct {
HWID string // e.g. "HW-00001"
Name string // item name from NetBox
SpecLine string // key spec summary, e.g. "Intel NUC i5 2019"
}
// RenderStandard produces a 384x120 label bitmap (black on white) for a standard item.
// Layout: QR code left (96x96 at x=8,y=12), text right (HW ID bold top, name middle, spec bottom).
// Returns an error if HWID does not match HW-\d{5} (T-04-07 mitigation).
func RenderStandard(d LabelData) (image.Image, error) {
if !hwIDPattern.MatchString(d.HWID) {
return nil, fmt.Errorf("labels: invalid HWID %q: must match HW-NNNNN", d.HWID)
}
if d.Name == "" {
d.Name = "Unknown Item"
}
img := image.NewNRGBA(image.Rect(0, 0, LabelWidth, LabelHeight))
// Fill white background
draw.Draw(img, img.Bounds(), image.NewUniform(color.NRGBA{R: 255, G: 255, B: 255, A: 255}), image.Point{}, draw.Src)
// QR code
qr, err := qrcode.New(BaseURL+d.HWID, qrcode.Low)
if err != nil {
return nil, fmt.Errorf("labels: QR generation failed: %w", err)
}
qr.DisableBorder = true
qrImg := qr.Image(QRSize)
qrDst := image.Rect(QROffsetX, QROffsetY, QROffsetX+QRSize, QROffsetY+QRSize)
draw.Draw(img, qrDst, qrImg, image.Point{}, draw.Over)
// Text: HW ID (y=26), Name (y=60), SpecLine (y=92)
drawText(img, TextOffsetX, 26, d.HWID)
drawText(img, TextOffsetX, 60, truncate(d.Name, 30))
drawText(img, TextOffsetX, 92, truncate(d.SpecLine, 30))
return img, nil
}
// drawText renders text onto a draw.Image at (x, y) using basicfont.Face7x13.
func drawText(img draw.Image, x, y int, text string) {
d := &font.Drawer{
Dst: img,
Src: image.NewUniform(color.Black),
Face: basicfont.Face7x13,
Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)},
}
d.DrawString(text)
}
// truncate caps s at max runes, appending "…" if truncated.
func truncate(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max-1]) + "…"
}

View file

@ -0,0 +1,133 @@
package labels
import (
"image/color"
"testing"
)
// TestRenderStandard_ImageDimensions verifies the output bitmap is 384x120.
func TestRenderStandard_ImageDimensions(t *testing.T) {
d := LabelData{
HWID: "HW-00001",
Name: "Test Device",
SpecLine: "Intel NUC i5 2019",
}
img, err := RenderStandard(d)
if err != nil {
t.Fatalf("RenderStandard returned error: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() != LabelWidth {
t.Errorf("expected width %d, got %d", LabelWidth, bounds.Dx())
}
if bounds.Dy() != LabelHeight {
t.Errorf("expected height %d, got %d", LabelHeight, bounds.Dy())
}
}
// TestRenderStandard_QRNonBlank checks that the QR region (top-left 96x96 area) has
// mixed pixels (not all one color), indicating a real QR code was drawn.
func TestRenderStandard_QRNonBlank(t *testing.T) {
d := LabelData{
HWID: "HW-00001",
Name: "Test Device",
}
img, err := RenderStandard(d)
if err != nil {
t.Fatalf("RenderStandard returned error: %v", err)
}
var blackCount, whiteCount int
for y := QROffsetY; y < QROffsetY+QRSize; y++ {
for x := QROffsetX; x < QROffsetX+QRSize; x++ {
r, g, b, _ := img.At(x, y).RGBA()
// RGBA returns 16-bit values; 0 = black, 65535 = white
if r == 0 && g == 0 && b == 0 {
blackCount++
} else if r == 65535 && g == 65535 && b == 65535 {
whiteCount++
}
}
}
if blackCount == 0 {
t.Error("QR region has no black pixels — QR code was not rendered")
}
if whiteCount == 0 {
t.Error("QR region has no white pixels — QR region is solid black, not a QR code")
}
}
// TestRenderStandard_QRURLEncoding verifies the QR encodes the correct URL by
// checking that generating a QR with the expected URL produces the same image as
// what is embedded in the rendered label (same pixel dimensions at the same size).
func TestRenderStandard_QRURLEncoding(t *testing.T) {
hwID := "HW-00001"
expectedURL := BaseURL + hwID
// Verify the URL is correct by construction
if expectedURL != "http://mac-mini.mg:8080/hw/HW-00001" {
t.Errorf("BaseURL construction incorrect, got: %s", expectedURL)
}
// Render the label
d := LabelData{HWID: hwID, Name: "Test Device"}
img, err := RenderStandard(d)
if err != nil {
t.Fatalf("RenderStandard returned error: %v", err)
}
// The QR block must exist within the label bounds
bounds := img.Bounds()
if QROffsetX+QRSize > bounds.Dx() || QROffsetY+QRSize > bounds.Dy() {
t.Errorf("QR code region (%d+%d x %d+%d) exceeds label bounds (%d x %d)",
QROffsetX, QRSize, QROffsetY, QRSize, bounds.Dx(), bounds.Dy())
}
}
// TestRenderStandard_EmptyNameFallback ensures a LabelData with empty Name does not
// panic and uses "Unknown Item" as the displayed value.
func TestRenderStandard_EmptyNameFallback(t *testing.T) {
d := LabelData{
HWID: "HW-00099",
Name: "", // intentionally empty
}
// Must not panic
img, err := RenderStandard(d)
if err != nil {
t.Fatalf("RenderStandard returned error on empty name: %v", err)
}
if img == nil {
t.Fatal("expected non-nil image, got nil")
}
}
// TestRenderStandard_WhiteBackground verifies the background canvas is initialized
// to white (pure white NRGBA).
func TestRenderStandard_WhiteBackground(t *testing.T) {
d := LabelData{
HWID: "HW-00001",
Name: "Test",
}
img, err := RenderStandard(d)
if err != nil {
t.Fatalf("RenderStandard returned error: %v", err)
}
// Sample a corner that should be outside any QR or text region
// Use bottom-right corner area far from QR and text
sampleX := LabelWidth - 5
sampleY := LabelHeight - 5
c := img.At(sampleX, sampleY)
nrgba, ok := c.(color.NRGBA)
if !ok {
// Fallback: check via RGBA values
r, g, b, a := c.RGBA()
if r != 65535 || g != 65535 || b != 65535 || a != 65535 {
t.Errorf("expected white background at (%d,%d), got RGBA(%d,%d,%d,%d)", sampleX, sampleY, r, g, b, a)
}
return
}
if nrgba.R != 255 || nrgba.G != 255 || nrgba.B != 255 || nrgba.A != 255 {
t.Errorf("expected white background at (%d,%d), got NRGBA(%d,%d,%d,%d)", sampleX, sampleY, nrgba.R, nrgba.G, nrgba.B, nrgba.A)
}
}