diff --git a/.planning/phases/04-usb-manager-label-printing/04-02-SUMMARY.md b/.planning/phases/04-usb-manager-label-printing/04-02-SUMMARY.md new file mode 100644 index 0000000..84f71db --- /dev/null +++ b/.planning/phases/04-usb-manager-label-printing/04-02-SUMMARY.md @@ -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* diff --git a/go.mod b/go.mod index b06c2f1..adddea1 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 37ae9fd..a5ba97a 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/labels/cable.go b/internal/labels/cable.go new file mode 100644 index 0000000..d164c74 --- /dev/null +++ b/internal/labels/cable.go @@ -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") +} diff --git a/internal/labels/cable_test.go b/internal/labels/cable_test.go new file mode 100644 index 0000000..ae4ab07 --- /dev/null +++ b/internal/labels/cable_test.go @@ -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") + } +} diff --git a/internal/labels/renderer.go b/internal/labels/renderer.go new file mode 100644 index 0000000..2df09aa --- /dev/null +++ b/internal/labels/renderer.go @@ -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]) + "…" +} diff --git a/internal/labels/renderer_test.go b/internal/labels/renderer_test.go new file mode 100644 index 0000000..fc957a3 --- /dev/null +++ b/internal/labels/renderer_test.go @@ -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) + } +}