diff --git a/cmd/boardgen/README.md b/cmd/boardgen/README.md new file mode 100644 index 0000000..b10c5d9 --- /dev/null +++ b/cmd/boardgen/README.md @@ -0,0 +1,149 @@ +# boardgen + +`boardgen` is a Go utility that generates SVG and JSON files for adding a new +board to the TinyGo Playground simulator [parts library](../../parts/). + +The generated files follow the style conventions documented in +[parts/README.md](../../parts/README.md): hand-written SVG style, millimeter +units (1px == 1mm), absolute SVG path commands, and consistent pin/LED shapes +across boards. + +## Installation + +```bash +cd ./cmd/boardgen +go install . +``` + +### Examples + +**Pico-like board** (horizontal, 20 pins top/bottom, USB on the left, built-in LED): + +```bash +boardgen \ + -name my-pico \ + -human "My Pico Board" \ + -pins-top 20 -pins-bottom 20 \ + -usb left -led \ + -output ./parts +``` + +**Vertical board** (15 pins on each side, USB on top): + +```bash +boardgen \ + -name my-nano \ + -pins-left 15 -pins-right 15 \ + -usb top -led \ + -orientation vertical \ + -output ./parts +``` + +**Four-sided board** (pins on all edges, custom PCB color): + +```bash +boardgen \ + -name my-devkit \ + -pins-top 10 -pins-bottom 10 \ + -pins-left 8 -pins-right 8 \ + -usb left -led \ + -color "#10A2A1" \ + -output ./parts +``` + +**Minimal board** (only bottom pins, no USB, no LED): + +```bash +go run ./cmd/boardgen \ + -name my-breakout \ + -pins-bottom 8 \ + -output ./parts +``` + +**Fixed-size board** (explicit dimensions, rectangular GND pads): + +```bash +boardgen \ + -name my-wide-board \ + -pins-top 10 -pins-bottom 10 \ + -width 60 -height 30 \ + -rect-gnd \ + -usb left -led \ + -output ./parts +``` + +## Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `-name` | Board name — used for filenames (`.svg`, `.json`) | *required* | +| `-human` | Human-readable board name shown in the UI | title-cased `-name` | +| `-orientation` | Board orientation: `horizontal` or `vertical` | `horizontal` | +| `-pins-top` | Number of pins on the top edge | `0` | +| `-pins-bottom` | Number of pins on the bottom edge | `0` | +| `-pins-left` | Number of pins on the left edge | `0` | +| `-pins-right` | Number of pins on the right edge | `0` | +| `-usb` | Side for the USB port: `left`, `right`, `top`, or `bottom` | *(none)* | +| `-led` | Include a built-in LED (placed next to the USB port) | `false` | +| `-color` | PCB color as a hex string | `#006837` | +| `-width` | Board width in mm (0 = auto-sized from pins) | `0` | +| `-height` | Board height in mm (0 = auto-sized from pins) | `0` | +| `-rect-gnd` | Use rectangular pad shape for GND pins (taller, rounded corners) | `false` | +| `-format` | Firmware format string (e.g. `uf2`, `hex`) | `uf2` | +| `-output` | Output directory for generated files | `.` | + +## What it generates + +### SVG file (`.svg`) + +A board image that matches the project's visual conventions: + +- **Millimeter units** — `viewBox` matches the physical board size so that + 1px == 1mm. +- **Castellated pads** — half-circle pad shapes matching the Raspberry Pi Pico + style. When `-rect-gnd` is set, GND pads use a distinct taller rectangular + shape with small rounded corners; otherwise all pads share the same + half-circle shape. +- **Pin attributes** — each pad has `data-pin` (and `data-title` where the + display name differs) for the simulator to connect wires. +- **USB port** — a `#ccc` rectangle protruding 1.3mm past the board edge on the + specified side. +- **LED** — an animated `data-part="led"` element using CSS custom properties + (`--color`, `--shadow`) for the simulator to control brightness. + +### JSON file (`.json`) + +A complete board configuration for the simulator: + +- **MCU part** with `GPIO0`–`GPIOn` pin mapping. +- **LED part** (when `-led` is set) with color and current values. +- **Wire table** connecting SVG `data-pin` names to MCU GPIOs, power (`vcc`), + and ground (`gnd`). +- **`firmwareFormat`** and **`baseCurrent`** fields. + +## Pin assignment + +Pins are assigned automatically: + +1. **GPIO pins** are numbered sequentially starting from `GP0`, distributed + across sides in reading order: top → right → bottom → left. +2. **GND pins** are auto-inserted after every 5 GPIO pins on each side. +3. **Power pins** — when `-usb` is specified, the first three positions on the + USB side are reserved for `VBUS`, `GND`, and `3V3`. +4. The **LED** (when enabled) is wired to the last GPIO pin. + +## Board sizing + +Board dimensions are computed automatically from the pin counts: + +- Each pin occupies one **pitch** unit (2.54mm). +- Pad-depth zones (3.22mm) are added at edges that have pins. +- Corner gaps prevent overlapping when perpendicular sides both have pins. +- A minimum board dimension of 12mm is enforced. +- When USB is present, the `viewBox` extends to include the protruding port. + +You can override the auto-computed size with `-width` and/or `-height` (in mm). +This is useful when you want a specific physical board size, or need extra space +between pin rows. The pin rows are still positioned based on the computed +pad-depth offsets, so the board will simply be larger in the overridden +dimension(s). diff --git a/cmd/boardgen/go.mod b/cmd/boardgen/go.mod new file mode 100644 index 0000000..fa6183d --- /dev/null +++ b/cmd/boardgen/go.mod @@ -0,0 +1,3 @@ +module github.com/tinygo-org/playground/cmd/boardgen + +go 1.21 diff --git a/cmd/boardgen/main.go b/cmd/boardgen/main.go new file mode 100644 index 0000000..d83f0f6 --- /dev/null +++ b/cmd/boardgen/main.go @@ -0,0 +1,623 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "math" + "os" + "path/filepath" + "strings" +) + +// All measurements in millimeters, matching existing board files. +const ( + pitch = 2.54 // standard pin pitch + padDepth = 3.22 // depth of castellated pad clickable area + padInset = 0.47 // visual pad inset inside the pad area + + usbLongDim = 8.0 // USB port long dimension + usbShortDim = 6.0 // USB port short dimension + usbProtrude = 1.3 // USB protrusion past board edge + + ledW = 2.0 // LED rectangle width + ledH = 1.2 // LED rectangle height + + gndEvery = 5 // insert a GND pin after every N GPIO pins + cornerGap = 1.0 // gap between perpendicular pin rows at corners + minBoardDim = 12.0 // minimum board width or height +) + +// SVG pad path templates derived from parts/pico.svg. +// Regular pads: half-circle (castellated) end facing into the board. +// GND pads: taller rectangle with small rounded corners. + +// Top pads (flat top edge at y=0, shape grows downward). +var topPaths = [2]string{ + `M 0,0 H 1.6 V 1.61 A 0.8 0.8 0 0 1 0 1.61 Z`, + `M 0,0 H 1.6 V 2.21 a 0.2 0.2 0 0 1 -0.2 0.2 h -1.2 a 0.2 0.2 0 0 1 -0.2 -0.2 Z`, +} + +// Bottom pads (flat bottom edge, shape grows upward). +var bottomPaths = [2]string{ + `M 0,0 H 1.6 V -1.61 A 0.8 0.8 0 0 0 0 -1.61 Z`, + `M 0,0 H 1.6 V -2.21 a 0.2 0.2 0 0 0 -0.2 -0.2 H 0.2 a 0.2 0.2 0 0 0 -0.2 0.2 Z`, +} + +// Left pads (flat left edge at x=0, shape grows rightward). +var leftPaths = [2]string{ + `M 0,0 V 1.6 H 1.61 A 0.8 0.8 0 0 0 1.61 0 Z`, + `M 0,0 V 1.6 H 2.21 a 0.2 0.2 0 0 0 0.2 -0.2 V 0.2 a 0.2 0.2 0 0 0 -0.2 -0.2 Z`, +} + +// Right pads (flat right edge, shape grows leftward). +var rightPaths = [2]string{ + `M 0,0 V 1.6 H -1.61 A 0.8 0.8 0 0 1 -1.61 0 Z`, + `M 0,0 V 1.6 H -2.21 a 0.2 0.2 0 0 1 -0.2 -0.2 V 0.2 a 0.2 0.2 0 0 1 0.2 -0.2 Z`, +} + +// BoardConfig holds all user-specified parameters. +type BoardConfig struct { + Name string + HumanName string + Orientation string + PinsTop int + PinsBottom int + PinsLeft int + PinsRight int + HasUSB bool + USBSide string + HasLED bool + OutputDir string + PCBColor string + FirmwareFormat string + Width float64 // explicit board width in mm (0 = auto) + Height float64 // explicit board height in mm (0 = auto) + RectGnd bool // use rectangular pad shape for GND +} + +// Pin represents a single pin on the board. +type Pin struct { + Name string + Title string + Type string // "gpio", "gnd", "vcc", "3v3" + GPIONum int + Side string // "top", "bottom", "left", "right" + Index int +} + +// Layout holds computed board dimensions and pin assignments. +type Layout struct { + BoardW, BoardH float64 + ViewX, ViewY float64 + ViewW, ViewH float64 + TopStartX, BottomStartX float64 + LeftStartY, RightStartY float64 + HasUSBRect bool + USBX, USBY float64 + USBW, USBH float64 + HasLED bool + LEDX, LEDY float64 + RectGnd bool + Pins []Pin + NumGPIO int + LEDPin int +} + +// JSON output types. + +type BoardJSON struct { + Name string `json:"name"` + HumanName string `json:"humanName"` + FirmwareFormat string `json:"firmwareFormat"` + SVG string `json:"svg"` + MainPart string `json:"mainPart"` + BaseCurrent float64 `json:"baseCurrent"` + Parts []PartJSON `json:"parts"` + Wires []WireJSON `json:"wires"` +} + +type PartJSON struct { + ID string `json:"id"` + Type string `json:"type"` + HumanName string `json:"humanName,omitempty"` + Pins json.RawMessage `json:"pins,omitempty"` + Color []int `json:"color,omitempty"` + Current float64 `json:"current,omitempty"` +} + +type WireJSON struct { + From string `json:"from"` + To string `json:"to"` +} + +func main() { + cfg := parseFlags() + if err := validate(cfg); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + flag.Usage() + os.Exit(1) + } + + layout := computeLayout(cfg) + svgContent := generateSVG(cfg, layout) + jsonContent := generateJSON(cfg, layout) + + if err := os.MkdirAll(cfg.OutputDir, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err) + os.Exit(1) + } + + svgPath := filepath.Join(cfg.OutputDir, cfg.Name+".svg") + jsonPath := filepath.Join(cfg.OutputDir, cfg.Name+".json") + + if err := os.WriteFile(svgPath, []byte(svgContent), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "Error writing SVG: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(jsonPath, []byte(jsonContent), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "Error writing JSON: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Generated:\n %s\n %s\n", svgPath, jsonPath) + fmt.Printf("Board: %s (%s)\n", cfg.HumanName, cfg.Name) + fmt.Printf("Size: %.1f x %.1f mm\n", layout.BoardW, layout.BoardH) + fmt.Printf("GPIO pins: %d\n", layout.NumGPIO) + if layout.HasLED { + fmt.Printf("LED on GPIO%d\n", layout.LEDPin) + } +} + +func parseFlags() BoardConfig { + var cfg BoardConfig + flag.StringVar(&cfg.Name, "name", "", "Board name (required; used for filenames)") + flag.StringVar(&cfg.HumanName, "human", "", "Human-readable board name (default: title-cased -name)") + flag.StringVar(&cfg.Orientation, "orientation", "horizontal", "Board orientation: horizontal or vertical") + flag.IntVar(&cfg.PinsTop, "pins-top", 0, "Number of pins on the top edge") + flag.IntVar(&cfg.PinsBottom, "pins-bottom", 0, "Number of pins on the bottom edge") + flag.IntVar(&cfg.PinsLeft, "pins-left", 0, "Number of pins on the left edge") + flag.IntVar(&cfg.PinsRight, "pins-right", 0, "Number of pins on the right edge") + flag.StringVar(&cfg.USBSide, "usb", "", "Side for USB port: left, right, top, bottom (omit for no USB)") + flag.BoolVar(&cfg.HasLED, "led", false, "Include a built-in LED (placed next to USB port)") + flag.StringVar(&cfg.OutputDir, "output", ".", "Output directory for generated files") + flag.StringVar(&cfg.PCBColor, "color", "#006837", "PCB color as hex (default: TinyGo green)") + flag.StringVar(&cfg.FirmwareFormat, "format", "uf2", "Firmware format string (e.g. uf2, hex)") + flag.Float64Var(&cfg.Width, "width", 0, "Board width in mm (0 = auto-sized from pins)") + flag.Float64Var(&cfg.Height, "height", 0, "Board height in mm (0 = auto-sized from pins)") + flag.BoolVar(&cfg.RectGnd, "rect-gnd", false, "Use rectangular pad shape for GND pins") + flag.Parse() + + cfg.HasUSB = cfg.USBSide != "" + if cfg.HumanName == "" && cfg.Name != "" { + cfg.HumanName = titleCase(strings.ReplaceAll(cfg.Name, "-", " ")) + } + return cfg +} + +func validate(cfg BoardConfig) error { + if cfg.Name == "" { + return fmt.Errorf("-name is required") + } + if cfg.PinsTop+cfg.PinsBottom+cfg.PinsLeft+cfg.PinsRight == 0 { + return fmt.Errorf("at least one side must have pins") + } + if cfg.HasUSB { + switch cfg.USBSide { + case "left", "right", "top", "bottom": + default: + return fmt.Errorf("invalid -usb value %q; must be left, right, top, or bottom", cfg.USBSide) + } + } + switch cfg.Orientation { + case "horizontal", "vertical": + default: + return fmt.Errorf("invalid -orientation %q; must be horizontal or vertical", cfg.Orientation) + } + for _, v := range []struct { + n string + v int + }{ + {"pins-top", cfg.PinsTop}, {"pins-bottom", cfg.PinsBottom}, + {"pins-left", cfg.PinsLeft}, {"pins-right", cfg.PinsRight}, + } { + if v.v < 0 { + return fmt.Errorf("-%s must be >= 0", v.n) + } + } + if cfg.Width < 0 { + return fmt.Errorf("-width must be >= 0") + } + if cfg.Height < 0 { + return fmt.Errorf("-height must be >= 0") + } + return nil +} + +// assignPins distributes GPIO, GND, and power pins across the four sides. +func assignPins(cfg BoardConfig) ([]Pin, int) { + var pins []Pin + gpioNum := 0 + gndNum := 0 + + processSide := func(side string, count int) { + if count == 0 { + return + } + isUSB := cfg.HasUSB && side == cfg.USBSide + gpioOnSide := 0 + startIdx := 0 + + if isUSB && count >= 3 { + pins = append(pins, Pin{Name: "VBUS", Type: "vcc", GPIONum: -1, Side: side, Index: 0}) + gndNum++ + pins = append(pins, Pin{ + Name: fmt.Sprintf("GND#%d", gndNum), Title: "GND", + Type: "gnd", GPIONum: -1, Side: side, Index: 1, + }) + pins = append(pins, Pin{Name: "3V3", Title: "3.3V", Type: "3v3", GPIONum: -1, Side: side, Index: 2}) + startIdx = 3 + } + + lastWasGND := false + for i := startIdx; i < count; i++ { + if gpioOnSide > 0 && gpioOnSide%gndEvery == 0 && !lastWasGND { + gndNum++ + pins = append(pins, Pin{ + Name: fmt.Sprintf("GND#%d", gndNum), Title: "GND", + Type: "gnd", GPIONum: -1, Side: side, Index: i, + }) + lastWasGND = true + } else { + pins = append(pins, Pin{ + Name: fmt.Sprintf("GP%d", gpioNum), Type: "gpio", + GPIONum: gpioNum, Side: side, Index: i, + }) + gpioNum++ + gpioOnSide++ + lastWasGND = false + } + } + } + + processSide("top", cfg.PinsTop) + processSide("right", cfg.PinsRight) + processSide("bottom", cfg.PinsBottom) + processSide("left", cfg.PinsLeft) + + return pins, gpioNum +} + +func computeLayout(cfg BoardConfig) Layout { + pins, numGPIO := assignPins(cfg) + + hasTop := cfg.PinsTop > 0 + hasBottom := cfg.PinsBottom > 0 + hasLeft := cfg.PinsLeft > 0 + hasRight := cfg.PinsRight > 0 + + topPad := condF(hasTop, padDepth, 0) + bottomPad := condF(hasBottom, padDepth, 0) + leftPad := condF(hasLeft, padDepth, 0) + rightPad := condF(hasRight, padDepth, 0) + + cgl := condF(hasLeft && (hasTop || hasBottom), cornerGap, 0) + cgr := condF(hasRight && (hasTop || hasBottom), cornerGap, 0) + cgt := condF(hasTop && (hasLeft || hasRight), cornerGap, 0) + cgb := condF(hasBottom && (hasLeft || hasRight), cornerGap, 0) + + hPins := maxI(cfg.PinsTop, cfg.PinsBottom) + vPins := maxI(cfg.PinsLeft, cfg.PinsRight) + hSpan := float64(hPins) * pitch + vSpan := float64(vPins) * pitch + + contentW := math.Max(hSpan+cgl+cgr, minBoardDim) + contentH := math.Max(vSpan+cgt+cgb, minBoardDim) + if hasTop && hasBottom { + contentH = math.Max(contentH, 14.0) + } + if hasLeft && hasRight { + contentW = math.Max(contentW, 14.0) + } + + boardW := leftPad + contentW + rightPad + boardH := topPad + contentH + bottomPad + + // Apply explicit size overrides if provided. + if cfg.Width > 0 { + boardW = cfg.Width + } + if cfg.Height > 0 { + boardH = cfg.Height + } + + lay := Layout{ + BoardW: boardW, + BoardH: boardH, + ViewX: 0, + ViewY: 0, + ViewW: boardW, + ViewH: boardH, + TopStartX: leftPad + cgl, + BottomStartX: leftPad + cgl, + LeftStartY: topPad + cgt, + RightStartY: topPad + cgt, + RectGnd: cfg.RectGnd, + Pins: pins, + NumGPIO: numGPIO, + LEDPin: -1, + } + + if cfg.HasUSB { + lay.HasUSBRect = true + switch cfg.USBSide { + case "left": + lay.USBW = usbShortDim + lay.USBH = usbLongDim + lay.USBX = -usbProtrude + lay.USBY = (boardH - usbLongDim) / 2 + lay.ViewX = -usbProtrude + lay.ViewW = boardW + usbProtrude + case "right": + lay.USBW = usbShortDim + lay.USBH = usbLongDim + lay.USBX = boardW - usbShortDim + usbProtrude + lay.USBY = (boardH - usbLongDim) / 2 + lay.ViewW = boardW + usbProtrude + case "top": + lay.USBW = usbLongDim + lay.USBH = usbShortDim + lay.USBX = (boardW - usbLongDim) / 2 + lay.USBY = -usbProtrude + lay.ViewY = -usbProtrude + lay.ViewH = boardH + usbProtrude + case "bottom": + lay.USBW = usbLongDim + lay.USBH = usbShortDim + lay.USBX = (boardW - usbLongDim) / 2 + lay.USBY = boardH - usbShortDim + usbProtrude + lay.ViewH = boardH + usbProtrude + } + } + + if cfg.HasLED && numGPIO > 0 { + lay.HasLED = true + lay.LEDPin = numGPIO - 1 + switch cfg.USBSide { + case "left": + lay.LEDX = usbShortDim - usbProtrude + 1.0 + lay.LEDY = (boardH+usbLongDim)/2 + 1.0 + case "right": + lay.LEDX = boardW - usbShortDim + usbProtrude - ledW - 1.0 + lay.LEDY = (boardH+usbLongDim)/2 + 1.0 + case "top": + lay.LEDX = (boardW+usbLongDim)/2 + 1.0 + lay.LEDY = usbShortDim - usbProtrude + 1.0 + case "bottom": + lay.LEDX = (boardW+usbLongDim)/2 + 1.0 + lay.LEDY = boardH - usbShortDim + usbProtrude - ledH - 1.0 + default: + lay.LEDX = boardW/2 - ledW/2 + lay.LEDY = boardH/2 - ledH/2 + } + lay.LEDX = math.Max(1, math.Min(lay.LEDX, boardW-ledW-1)) + lay.LEDY = math.Max(1, math.Min(lay.LEDY, boardH-ledH-1)) + } + + return lay +} + +func generateSVG(cfg BoardConfig, lay Layout) string { + var b strings.Builder + w := func(f string, a ...any) { fmt.Fprintf(&b, f, a...) } + + w("\n") + w("\n") + w("\n", + ff(lay.ViewX), ff(lay.ViewY), ff(lay.ViewW), ff(lay.ViewH), + ff(lay.ViewW), ff(lay.ViewH)) + + w("\n \n") + w(" \n", + ff(lay.BoardW), ff(lay.BoardH), cfg.PCBColor) + + if lay.HasUSBRect { + w("\n \n") + w(" \n", + ff(lay.USBX), ff(lay.USBY), ff(lay.USBW), ff(lay.USBH)) + } + + if lay.HasLED { + w("\n \n") + w(" \n", ff(lay.LEDX), ff(lay.LEDY)) + w(" \n", ff(ledW), ff(ledH)) + w(" \n", + ff(ledW), ff(ledH)) + w(" \n") + } + + writePinRow(&b, "top", lay) + writePinRow(&b, "bottom", lay) + writePinRow(&b, "left", lay) + writePinRow(&b, "right", lay) + + w("\n") + return b.String() +} + +func writePinRow(b *strings.Builder, side string, lay Layout) { + var sidePins []Pin + for _, p := range lay.Pins { + if p.Side == side { + sidePins = append(sidePins, p) + } + } + if len(sidePins) == 0 { + return + } + + w := func(f string, a ...any) { fmt.Fprintf(b, f, a...) } + + var gx, gy float64 + switch side { + case "top": + gx, gy = lay.TopStartX, 0 + case "bottom": + gx, gy = lay.BottomStartX, lay.BoardH-padDepth + case "left": + gx, gy = 0, lay.LeftStartY + case "right": + gx, gy = lay.BoardW-padDepth, lay.RightStartY + } + + w("\n \n", side) + w(" \n", ff(gx), ff(gy)) + + for _, pin := range sidePins { + attr := fmt.Sprintf("data-pin=\"%s\"", pin.Name) + if pin.Title != "" { + attr += fmt.Sprintf(" data-title=\"%s\"", pin.Title) + } + gndIdx := 0 + if pin.Type == "gnd" && lay.RectGnd { + gndIdx = 1 + } + + switch side { + case "top": + x := float64(pin.Index) * pitch + w(" \n", attr, ff(x)) + w(" \n", + ff(padInset), topPaths[gndIdx]) + w(" \n", ff(pitch), ff(padDepth)) + w(" \n") + + case "bottom": + x := float64(pin.Index) * pitch + w(" \n", attr, ff(x)) + w(" \n", + ff(padInset), ff(padDepth), bottomPaths[gndIdx]) + w(" \n", ff(pitch), ff(padDepth)) + w(" \n") + + case "left": + y := float64(pin.Index) * pitch + w(" \n", attr, ff(y)) + w(" \n", + ff(padInset), leftPaths[gndIdx]) + w(" \n", ff(padDepth), ff(pitch)) + w(" \n") + + case "right": + y := float64(pin.Index) * pitch + w(" \n", attr, ff(y)) + w(" \n", + ff(padDepth), ff(padInset), rightPaths[gndIdx]) + w(" \n", ff(padDepth), ff(pitch)) + w(" \n") + } + } + + w(" \n") +} + +func generateJSON(cfg BoardConfig, lay Layout) string { + var pinsJSON strings.Builder + pinsJSON.WriteString("{\n") + for i := 0; i < lay.NumGPIO; i++ { + if i > 0 { + pinsJSON.WriteString(",\n") + } + fmt.Fprintf(&pinsJSON, " \"GPIO%d\": %d", i, i) + } + pinsJSON.WriteString("\n }") + + board := BoardJSON{ + Name: cfg.Name, + HumanName: cfg.HumanName, + FirmwareFormat: cfg.FirmwareFormat, + SVG: cfg.Name + ".svg", + MainPart: "mcu", + BaseCurrent: 0.020, + Parts: []PartJSON{ + { + ID: "mcu", + Type: "mcu", + Pins: json.RawMessage(pinsJSON.String()), + }, + }, + } + + if lay.HasLED { + board.Parts = append(board.Parts, PartJSON{ + ID: "led", + Type: "led", + HumanName: "LED", + Color: []int{196, 255, 0}, + Current: 0.0024, + }) + } + + for _, pin := range lay.Pins { + switch pin.Type { + case "gpio": + board.Wires = append(board.Wires, WireJSON{ + From: fmt.Sprintf("mcu.GPIO%d", pin.GPIONum), + To: pin.Name, + }) + case "gnd": + board.Wires = append(board.Wires, WireJSON{From: "gnd", To: pin.Name}) + case "vcc": + board.Wires = append(board.Wires, WireJSON{From: "vcc", To: pin.Name}) + case "3v3": + board.Wires = append(board.Wires, WireJSON{From: "vcc", To: pin.Name}) + } + } + + if lay.HasLED && lay.LEDPin >= 0 { + board.Wires = append(board.Wires, WireJSON{ + From: fmt.Sprintf("mcu.GPIO%d", lay.LEDPin), + To: "led.anode", + }) + } + + data, err := json.MarshalIndent(board, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err) + os.Exit(1) + } + return string(data) + "\n" +} + +func ff(f float64) string { + if f == math.Trunc(f) { + return fmt.Sprintf("%.0f", f) + } + s := fmt.Sprintf("%.2f", f) + s = strings.TrimRight(s, "0") + return s +} + +func titleCase(s string) string { + words := strings.Fields(s) + for i, w := range words { + if len(w) > 0 { + words[i] = strings.ToUpper(w[:1]) + w[1:] + } + } + return strings.Join(words, " ") +} + +func maxI(a, b int) int { + if a > b { + return a + } + return b +} + +func condF(c bool, t, f float64) float64 { + if c { + return t + } + return f +} diff --git a/cmd/boardgen/main_test.go b/cmd/boardgen/main_test.go new file mode 100644 index 0000000..cffb2a4 --- /dev/null +++ b/cmd/boardgen/main_test.go @@ -0,0 +1,960 @@ +package main + +import ( +"encoding/json" +"encoding/xml" +"fmt" +"math" +"strings" +"testing" +) + +// --------------------------------------------------------------------------- +// validate +// --------------------------------------------------------------------------- + +func TestValidate_NameRequired(t *testing.T) { +err := validate(BoardConfig{PinsTop: 5}) +if err == nil || !strings.Contains(err.Error(), "-name") { +t.Fatalf("expected name-required error, got %v", err) +} +} + +func TestValidate_NoPins(t *testing.T) { +err := validate(BoardConfig{Name: "x"}) +if err == nil || !strings.Contains(err.Error(), "pins") { +t.Fatalf("expected no-pins error, got %v", err) +} +} + +func TestValidate_InvalidUSBSide(t *testing.T) { +err := validate(BoardConfig{Name: "x", PinsTop: 1, USBSide: "front", HasUSB: true}) +if err == nil || !strings.Contains(err.Error(), "invalid -usb") { +t.Fatalf("expected invalid usb error, got %v", err) +} +} + +func TestValidate_InvalidOrientation(t *testing.T) { +err := validate(BoardConfig{Name: "x", PinsTop: 1, Orientation: "diagonal"}) +if err == nil || !strings.Contains(err.Error(), "orientation") { +t.Fatalf("expected orientation error, got %v", err) +} +} + +func TestValidate_NegativePins(t *testing.T) { +err := validate(BoardConfig{Name: "x", PinsTop: -1, Orientation: "horizontal"}) +if err == nil || !strings.Contains(err.Error(), "pins-top") { +t.Fatalf("expected negative-pins error, got %v", err) +} +} + +func TestValidate_NegativeWidth(t *testing.T) { +err := validate(BoardConfig{Name: "x", PinsTop: 5, Orientation: "horizontal", Width: -10}) +if err == nil || !strings.Contains(err.Error(), "-width") { +t.Fatalf("expected negative width error, got %v", err) +} +} + +func TestValidate_NegativeHeight(t *testing.T) { +err := validate(BoardConfig{Name: "x", PinsTop: 5, Orientation: "horizontal", Height: -5}) +if err == nil || !strings.Contains(err.Error(), "-height") { +t.Fatalf("expected negative height error, got %v", err) +} +} + +func TestValidate_ValidConfigs(t *testing.T) { +cases := []struct { +name string +cfg BoardConfig +}{ +{ +name: "minimal", +cfg: BoardConfig{Name: "b", PinsBottom: 4, Orientation: "horizontal"}, +}, +{ +name: "all sides", +cfg: BoardConfig{ +Name: "b", PinsTop: 5, PinsBottom: 5, PinsLeft: 3, PinsRight: 3, +Orientation: "horizontal", +}, +}, +{ +name: "with usb", +cfg: BoardConfig{ +Name: "b", PinsTop: 10, PinsBottom: 10, +HasUSB: true, USBSide: "left", +Orientation: "vertical", +}, +}, +{ +name: "explicit size", +cfg: BoardConfig{ +Name: "b", PinsTop: 5, Orientation: "horizontal", +Width: 60, Height: 30, +}, +}, +} +for _, tc := range cases { +t.Run(tc.name, func(t *testing.T) { +if err := validate(tc.cfg); err != nil { +t.Fatalf("unexpected error: %v", err) +} +}) +} +} + +// --------------------------------------------------------------------------- +// assignPins +// --------------------------------------------------------------------------- + +func TestAssignPins_SingleSide(t *testing.T) { +cfg := BoardConfig{PinsBottom: 4} +pins, numGPIO := assignPins(cfg) +if numGPIO != 4 { +t.Fatalf("expected 4 GPIOs, got %d", numGPIO) +} +if len(pins) != 4 { +t.Fatalf("expected 4 pins, got %d", len(pins)) +} +for i, p := range pins { +if p.Side != "bottom" { +t.Errorf("pin %d: expected side bottom, got %s", i, p.Side) +} +if p.Type != "gpio" { +t.Errorf("pin %d: expected type gpio, got %s", i, p.Type) +} +if p.GPIONum != i { +t.Errorf("pin %d: expected GPIONum %d, got %d", i, i, p.GPIONum) +} +} +} + +func TestAssignPins_GNDInsertion(t *testing.T) { +// With 7 pins on one side, after 5 GPIOs a GND should be inserted. +// Expect: GP0 GP1 GP2 GP3 GP4 GND#1 GP5 +cfg := BoardConfig{PinsTop: 7} +pins, numGPIO := assignPins(cfg) +if numGPIO != 6 { +t.Fatalf("expected 6 GPIOs, got %d", numGPIO) +} +if len(pins) != 7 { +t.Fatalf("expected 7 pins, got %d", len(pins)) +} +if pins[5].Type != "gnd" { +t.Errorf("expected pin 5 to be GND, got %s (%s)", pins[5].Type, pins[5].Name) +} +if pins[5].Title != "GND" { +t.Errorf("expected GND title, got %q", pins[5].Title) +} +} + +func TestAssignPins_USBSidePowerPins(t *testing.T) { +cfg := BoardConfig{ +PinsLeft: 8, +HasUSB: true, +USBSide: "left", +} +pins, _ := assignPins(cfg) +if len(pins) < 3 { +t.Fatalf("expected at least 3 pins, got %d", len(pins)) +} +if pins[0].Name != "VBUS" || pins[0].Type != "vcc" { +t.Errorf("first pin should be VBUS, got %s (%s)", pins[0].Name, pins[0].Type) +} +if pins[1].Type != "gnd" || pins[1].Title != "GND" { +t.Errorf("second pin should be GND, got %s (%s)", pins[1].Name, pins[1].Type) +} +if pins[2].Name != "3V3" || pins[2].Type != "3v3" { +t.Errorf("third pin should be 3V3, got %s (%s)", pins[2].Name, pins[2].Type) +} +// Remaining 5 should be GPIO pins. +gpioCount := 0 +for _, p := range pins[3:] { +if p.Type == "gpio" { +gpioCount++ +} +} +if gpioCount != 5 { +t.Errorf("expected 5 GPIO pins after power, got %d", gpioCount) +} +} + +func TestAssignPins_MultipleSides(t *testing.T) { +cfg := BoardConfig{ +PinsTop: 3, +PinsRight: 2, +PinsBottom: 3, +PinsLeft: 2, +} +pins, numGPIO := assignPins(cfg) +if numGPIO != 10 { +t.Fatalf("expected 10 GPIOs, got %d", numGPIO) +} +gpios := 0 +for _, p := range pins { +if p.Type == "gpio" { +gpios++ +} +} +if gpios != 10 { +t.Errorf("expected 10 GPIOs, got %d", gpios) +} +// Verify side order: top, right, bottom, left +sides := make(map[string]int) +for _, p := range pins { +sides[p.Side]++ +} +if sides["top"] != 3 || sides["right"] != 2 || sides["bottom"] != 3 || sides["left"] != 2 { +t.Errorf("unexpected side distribution: %v", sides) +} +} + +func TestAssignPins_GPIONumberingSequential(t *testing.T) { +cfg := BoardConfig{PinsTop: 4, PinsBottom: 4} +pins, _ := assignPins(cfg) +gpioNum := 0 +for _, p := range pins { +if p.Type == "gpio" { +if p.GPIONum != gpioNum { +t.Errorf("expected GPIO%d, got GPIO%d (pin %s)", gpioNum, p.GPIONum, p.Name) +} +gpioNum++ +} +} +} + +// --------------------------------------------------------------------------- +// computeLayout +// --------------------------------------------------------------------------- + +func TestComputeLayout_MinimumSize(t *testing.T) { +cfg := BoardConfig{Name: "x", PinsBottom: 2, Orientation: "horizontal"} +lay := computeLayout(cfg) +if lay.BoardW < minBoardDim { +t.Errorf("expected min width >= %.1f, got %.1f", minBoardDim, lay.BoardW) +} +if lay.BoardH < minBoardDim { +t.Errorf("expected min height >= %.1f, got %.1f", minBoardDim, lay.BoardH) +} +} + +func TestComputeLayout_PadDepth(t *testing.T) { +cfg := BoardConfig{Name: "x", PinsTop: 5, PinsBottom: 5, Orientation: "horizontal"} +lay := computeLayout(cfg) +// Board height should include top and bottom pad depths. +if lay.BoardH < 2*padDepth { +t.Errorf("expected board height >= 2*padDepth (%.2f), got %.2f", 2*padDepth, lay.BoardH) +} +} + +func TestComputeLayout_ExplicitDimensions(t *testing.T) { +cfg := BoardConfig{ +Name: "x", PinsTop: 5, PinsBottom: 5, Orientation: "horizontal", +Width: 60, Height: 30, +} +lay := computeLayout(cfg) +if lay.BoardW != 60 { +t.Errorf("expected width 60, got %.1f", lay.BoardW) +} +if lay.BoardH != 30 { +t.Errorf("expected height 30, got %.1f", lay.BoardH) +} +} + +func TestComputeLayout_USB(t *testing.T) { +sides := []string{"left", "right", "top", "bottom"} +for _, side := range sides { +t.Run(side, func(t *testing.T) { +cfg := BoardConfig{ +Name: "x", PinsTop: 10, PinsBottom: 10, +Orientation: "horizontal", HasUSB: true, USBSide: side, +} +lay := computeLayout(cfg) +if !lay.HasUSBRect { +t.Fatal("expected USB rect") +} +switch side { +case "left": +if lay.ViewX >= 0 { +t.Error("expected negative ViewX for left USB") +} +case "right": +if lay.ViewW <= lay.BoardW { +t.Error("expected ViewW > BoardW for right USB") +} +case "top": +if lay.ViewY >= 0 { +t.Error("expected negative ViewY for top USB") +} +case "bottom": +if lay.ViewH <= lay.BoardH { +t.Error("expected ViewH > BoardH for bottom USB") +} +} +}) +} +} + +func TestComputeLayout_LED(t *testing.T) { +cfg := BoardConfig{ +Name: "x", PinsTop: 10, PinsBottom: 10, +Orientation: "horizontal", HasUSB: true, USBSide: "left", +HasLED: true, +} +lay := computeLayout(cfg) +if !lay.HasLED { +t.Fatal("expected LED") +} +if lay.LEDPin < 0 { +t.Fatal("expected valid LED pin") +} +if lay.LEDX < 0 || lay.LEDX+ledW > lay.BoardW { +t.Errorf("LED X out of board bounds: %.2f (board width %.2f)", lay.LEDX, lay.BoardW) +} +if lay.LEDY < 0 || lay.LEDY+ledH > lay.BoardH { +t.Errorf("LED Y out of board bounds: %.2f (board height %.2f)", lay.LEDY, lay.BoardH) +} +} + +func TestComputeLayout_NoUSBNoLED(t *testing.T) { +cfg := BoardConfig{Name: "x", PinsBottom: 6, Orientation: "horizontal"} +lay := computeLayout(cfg) +if lay.HasUSBRect { +t.Error("expected no USB rect") +} +if lay.HasLED { +t.Error("expected no LED") +} +} + +func TestComputeLayout_CornerGaps(t *testing.T) { +cfg := BoardConfig{ +Name: "x", PinsTop: 4, PinsBottom: 4, PinsLeft: 4, PinsRight: 4, +Orientation: "horizontal", +} +lay := computeLayout(cfg) +expectedStartX := padDepth + cornerGap +if math.Abs(lay.TopStartX-expectedStartX) > 0.01 { +t.Errorf("expected TopStartX ~%.2f, got %.2f", expectedStartX, lay.TopStartX) +} +expectedStartY := padDepth + cornerGap +if math.Abs(lay.LeftStartY-expectedStartY) > 0.01 { +t.Errorf("expected LeftStartY ~%.2f, got %.2f", expectedStartY, lay.LeftStartY) +} +} + +func TestComputeLayout_RectGndPassedThrough(t *testing.T) { +cfg := BoardConfig{Name: "x", PinsTop: 5, Orientation: "horizontal", RectGnd: true} +lay := computeLayout(cfg) +if !lay.RectGnd { +t.Error("expected RectGnd to be true in layout") +} +} + +// --------------------------------------------------------------------------- +// generateSVG +// --------------------------------------------------------------------------- + +func TestGenerateSVG_WellFormedXML(t *testing.T) { +cfg := BoardConfig{ +Name: "test", HumanName: "Test", PinsTop: 10, PinsBottom: 10, +Orientation: "horizontal", HasUSB: true, USBSide: "left", +HasLED: true, PCBColor: "#006837", +} +lay := computeLayout(cfg) +svg := generateSVG(cfg, lay) + +d := xml.NewDecoder(strings.NewReader(svg)) +for { +_, err := d.Token() +if err != nil { +if err.Error() == "EOF" { +break +} +t.Fatalf("invalid XML: %v", err) +} +} +} + +func TestGenerateSVG_ContainsPCBRect(t *testing.T) { +cfg := BoardConfig{ +Name: "test", HumanName: "Test", PinsBottom: 6, +Orientation: "horizontal", PCBColor: "#ff0000", +} +lay := computeLayout(cfg) +svg := generateSVG(cfg, lay) +if !strings.Contains(svg, `fill="#ff0000"`) { +t.Error("SVG should contain the PCB fill color") +} +} + +func TestGenerateSVG_ContainsUSB(t *testing.T) { +cfg := BoardConfig{ +Name: "test", HumanName: "Test", PinsTop: 5, PinsBottom: 5, +Orientation: "horizontal", HasUSB: true, USBSide: "left", PCBColor: "#006837", +} +lay := computeLayout(cfg) +svg := generateSVG(cfg, lay) +if !strings.Contains(svg, `fill="#ccc"`) { +t.Error("SVG should contain USB port rectangle") +} +if !strings.Contains(svg, "USB port") { +t.Error("SVG should contain USB port comment") +} +} + +func TestGenerateSVG_NoUSBWhenDisabled(t *testing.T) { +cfg := BoardConfig{ +Name: "test", HumanName: "Test", PinsBottom: 5, +Orientation: "horizontal", PCBColor: "#006837", +} +lay := computeLayout(cfg) +svg := generateSVG(cfg, lay) +if strings.Contains(svg, "USB port") { +t.Error("SVG should not contain USB port when disabled") +} +} + +func TestGenerateSVG_ContainsLED(t *testing.T) { +cfg := BoardConfig{ +Name: "test", HumanName: "Test", PinsTop: 10, PinsBottom: 10, +Orientation: "horizontal", HasUSB: true, USBSide: "left", +HasLED: true, PCBColor: "#006837", +} +lay := computeLayout(cfg) +svg := generateSVG(cfg, lay) +if !strings.Contains(svg, `data-part="led"`) { +t.Error("SVG should contain LED element") +} +} + +func TestGenerateSVG_NoLEDWhenDisabled(t *testing.T) { +cfg := BoardConfig{ +Name: "test", HumanName: "Test", PinsBottom: 5, +Orientation: "horizontal", PCBColor: "#006837", +} +lay := computeLayout(cfg) +svg := generateSVG(cfg, lay) +if strings.Contains(svg, `data-part="led"`) { +t.Error("SVG should not contain LED element when disabled") +} +} + +func TestGenerateSVG_PinAttributes(t *testing.T) { +cfg := BoardConfig{ +Name: "test", HumanName: "Test", PinsTop: 3, +Orientation: "horizontal", PCBColor: "#006837", +} +lay := computeLayout(cfg) +svg := generateSVG(cfg, lay) +for i := 0; i < 3; i++ { +attr := fmt.Sprintf(`data-pin="GP%d"`, i) +if !strings.Contains(svg, attr) { +t.Errorf("SVG should contain %s", attr) +} +} +} + +func TestGenerateSVG_GNDPinTitle(t *testing.T) { +cfg := BoardConfig{ +Name: "test", HumanName: "Test", PinsTop: 7, +Orientation: "horizontal", PCBColor: "#006837", +} +lay := computeLayout(cfg) +svg := generateSVG(cfg, lay) +if !strings.Contains(svg, `data-title="GND"`) { +t.Error("SVG should contain GND title attribute") +} +} + +func TestGenerateSVG_USBSidePowerPins(t *testing.T) { +cfg := BoardConfig{ +Name: "test", HumanName: "Test", PinsLeft: 8, +Orientation: "horizontal", HasUSB: true, USBSide: "left", +PCBColor: "#006837", +} +lay := computeLayout(cfg) +svg := generateSVG(cfg, lay) +for _, name := range []string{"VBUS", "3V3"} { +attr := fmt.Sprintf(`data-pin="%s"`, name) +if !strings.Contains(svg, attr) { +t.Errorf("SVG should contain %s", attr) +} +} +} + +func TestGenerateSVG_RectGndUsesSpecialPath(t *testing.T) { +cfgDefault := BoardConfig{ +Name: "test", HumanName: "Test", PinsTop: 7, +Orientation: "horizontal", PCBColor: "#006837", +} +layDefault := computeLayout(cfgDefault) +svgDefault := generateSVG(cfgDefault, layDefault) + +cfgRect := BoardConfig{ +Name: "test", HumanName: "Test", PinsTop: 7, +Orientation: "horizontal", PCBColor: "#006837", RectGnd: true, +} +layRect := computeLayout(cfgRect) +svgRect := generateSVG(cfgRect, layRect) + +// Default (RectGnd=false): GND pads use regular half-circle path (no "2.21"). +if strings.Contains(svgDefault, "2.21") { +t.Error("default GND pads should use regular path (no 2.21)") +} +// RectGnd=true: GND pads use taller rectangular path (contains "2.21"). +if !strings.Contains(svgRect, "2.21") { +t.Error("rect-gnd GND pads should use taller rectangular path (containing 2.21)") +} +} + +func TestGenerateSVG_ViewBox(t *testing.T) { +cfg := BoardConfig{ +Name: "test", HumanName: "Test", PinsTop: 10, PinsBottom: 10, +Orientation: "horizontal", PCBColor: "#006837", +} +lay := computeLayout(cfg) +svg := generateSVG(cfg, lay) +viewBox := fmt.Sprintf(`viewBox="0 0 %s %s"`, ff(lay.ViewW), ff(lay.ViewH)) +if !strings.Contains(svg, viewBox) { +t.Errorf("SVG should contain viewBox %q", viewBox) +} +} + +func TestGenerateSVG_AllFourSides(t *testing.T) { +cfg := BoardConfig{ +Name: "test", HumanName: "Test", +PinsTop: 3, PinsBottom: 3, PinsLeft: 3, PinsRight: 3, +Orientation: "horizontal", PCBColor: "#006837", +} +lay := computeLayout(cfg) +svg := generateSVG(cfg, lay) +for _, side := range []string{"top", "bottom", "left", "right"} { +comment := fmt.Sprintf("pads on the %s", side) +if !strings.Contains(svg, comment) { +t.Errorf("SVG should contain pads section for %s", side) +} +} +} + +// --------------------------------------------------------------------------- +// generateJSON +// --------------------------------------------------------------------------- + +func TestGenerateJSON_ValidJSON(t *testing.T) { +cfg := BoardConfig{ +Name: "test-board", HumanName: "Test Board", +PinsTop: 10, PinsBottom: 10, +Orientation: "horizontal", HasUSB: true, USBSide: "left", +HasLED: true, FirmwareFormat: "uf2", +} +lay := computeLayout(cfg) +jsonStr := generateJSON(cfg, lay) + +var parsed BoardJSON +if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil { +t.Fatalf("generated JSON is invalid: %v", err) +} +} + +func TestGenerateJSON_Fields(t *testing.T) { +cfg := BoardConfig{ +Name: "test-board", HumanName: "Test Board", +PinsTop: 5, PinsBottom: 5, +Orientation: "horizontal", FirmwareFormat: "hex", +} +lay := computeLayout(cfg) +jsonStr := generateJSON(cfg, lay) + +var parsed BoardJSON +if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil { +t.Fatalf("invalid JSON: %v", err) +} +if parsed.Name != "test-board" { +t.Errorf("expected name test-board, got %s", parsed.Name) +} +if parsed.HumanName != "Test Board" { +t.Errorf("expected humanName 'Test Board', got %s", parsed.HumanName) +} +if parsed.FirmwareFormat != "hex" { +t.Errorf("expected firmwareFormat hex, got %s", parsed.FirmwareFormat) +} +if parsed.SVG != "test-board.svg" { +t.Errorf("expected svg test-board.svg, got %s", parsed.SVG) +} +if parsed.MainPart != "mcu" { +t.Errorf("expected mainPart mcu, got %s", parsed.MainPart) +} +} + +func TestGenerateJSON_MCUPart(t *testing.T) { +cfg := BoardConfig{ +Name: "b", HumanName: "B", PinsBottom: 4, +Orientation: "horizontal", FirmwareFormat: "uf2", +} +lay := computeLayout(cfg) +jsonStr := generateJSON(cfg, lay) + +var parsed BoardJSON +json.Unmarshal([]byte(jsonStr), &parsed) + +if len(parsed.Parts) < 1 { +t.Fatal("expected at least one part") +} +mcu := parsed.Parts[0] +if mcu.ID != "mcu" || mcu.Type != "mcu" { +t.Errorf("expected mcu part, got id=%s type=%s", mcu.ID, mcu.Type) +} +var pinMap map[string]int +if err := json.Unmarshal(mcu.Pins, &pinMap); err != nil { +t.Fatalf("could not parse MCU pins: %v", err) +} +if len(pinMap) != lay.NumGPIO { +t.Errorf("expected %d GPIO entries in MCU pins, got %d", lay.NumGPIO, len(pinMap)) +} +for i := 0; i < lay.NumGPIO; i++ { +key := fmt.Sprintf("GPIO%d", i) +val, ok := pinMap[key] +if !ok { +t.Errorf("missing MCU pin %s", key) +} else if val != i { +t.Errorf("expected pin %s=%d, got %d", key, i, val) +} +} +} + +func TestGenerateJSON_LEDPart(t *testing.T) { +cfg := BoardConfig{ +Name: "b", HumanName: "B", PinsTop: 10, PinsBottom: 10, +Orientation: "horizontal", HasUSB: true, USBSide: "left", +HasLED: true, FirmwareFormat: "uf2", +} +lay := computeLayout(cfg) +jsonStr := generateJSON(cfg, lay) + +var parsed BoardJSON +json.Unmarshal([]byte(jsonStr), &parsed) + +if len(parsed.Parts) < 2 { +t.Fatal("expected at least 2 parts (mcu + led)") +} +led := parsed.Parts[1] +if led.ID != "led" || led.Type != "led" { +t.Errorf("expected led part, got id=%s type=%s", led.ID, led.Type) +} +if led.HumanName != "LED" { +t.Errorf("expected humanName LED, got %s", led.HumanName) +} +} + +func TestGenerateJSON_NoLEDPart(t *testing.T) { +cfg := BoardConfig{ +Name: "b", HumanName: "B", PinsBottom: 4, +Orientation: "horizontal", FirmwareFormat: "uf2", +} +lay := computeLayout(cfg) +jsonStr := generateJSON(cfg, lay) + +var parsed BoardJSON +json.Unmarshal([]byte(jsonStr), &parsed) + +if len(parsed.Parts) != 1 { +t.Errorf("expected 1 part (mcu only), got %d", len(parsed.Parts)) +} +} + +func TestGenerateJSON_Wires(t *testing.T) { +cfg := BoardConfig{ +Name: "b", HumanName: "B", PinsBottom: 4, +Orientation: "horizontal", FirmwareFormat: "uf2", +} +lay := computeLayout(cfg) +jsonStr := generateJSON(cfg, lay) + +var parsed BoardJSON +json.Unmarshal([]byte(jsonStr), &parsed) + +gpioWires := 0 +for _, w := range parsed.Wires { +if strings.HasPrefix(w.From, "mcu.GPIO") { +gpioWires++ +} +} +if gpioWires != lay.NumGPIO { +t.Errorf("expected %d GPIO wires, got %d", lay.NumGPIO, gpioWires) +} +} + +func TestGenerateJSON_PowerWires(t *testing.T) { +cfg := BoardConfig{ +Name: "b", HumanName: "B", PinsLeft: 8, +Orientation: "horizontal", HasUSB: true, USBSide: "left", +FirmwareFormat: "uf2", +} +lay := computeLayout(cfg) +jsonStr := generateJSON(cfg, lay) + +var parsed BoardJSON +json.Unmarshal([]byte(jsonStr), &parsed) + +hasVCC := false +hasGND := false +for _, w := range parsed.Wires { +if w.From == "vcc" && w.To == "VBUS" { +hasVCC = true +} +if w.From == "gnd" { +hasGND = true +} +} +if !hasVCC { +t.Error("expected vcc wire for VBUS") +} +if !hasGND { +t.Error("expected gnd wire") +} +} + +func TestGenerateJSON_LEDWire(t *testing.T) { +cfg := BoardConfig{ +Name: "b", HumanName: "B", PinsTop: 10, PinsBottom: 10, +Orientation: "horizontal", HasUSB: true, USBSide: "left", +HasLED: true, FirmwareFormat: "uf2", +} +lay := computeLayout(cfg) +jsonStr := generateJSON(cfg, lay) + +var parsed BoardJSON +json.Unmarshal([]byte(jsonStr), &parsed) + +hasLEDWire := false +for _, w := range parsed.Wires { +if w.To == "led.anode" { +hasLEDWire = true +expected := fmt.Sprintf("mcu.GPIO%d", lay.LEDPin) +if w.From != expected { +t.Errorf("LED wire from %s, expected %s", w.From, expected) +} +} +} +if !hasLEDWire { +t.Error("expected LED anode wire") +} +} + +// --------------------------------------------------------------------------- +// ff (float formatter) +// --------------------------------------------------------------------------- + +func TestFF(t *testing.T) { +cases := []struct { +in float64 +want string +}{ +{0, "0"}, +{1, "1"}, +{10, "10"}, +{1.5, "1.5"}, +{1.55, "1.55"}, +{1.50, "1.5"}, +{2.54, "2.54"}, +{-1.3, "-1.3"}, +{100.0, "100"}, +{3.10, "3.1"}, +} +for _, tc := range cases { +got := ff(tc.in) +if got != tc.want { +t.Errorf("ff(%v) = %q, want %q", tc.in, got, tc.want) +} +} +} + +// --------------------------------------------------------------------------- +// titleCase +// --------------------------------------------------------------------------- + +func TestTitleCase(t *testing.T) { +cases := []struct { +in, want string +}{ +{"hello world", "Hello World"}, +{"my pico board", "My Pico Board"}, +{"a", "A"}, +{"", ""}, +{"already Title", "Already Title"}, +} +for _, tc := range cases { +got := titleCase(tc.in) +if got != tc.want { +t.Errorf("titleCase(%q) = %q, want %q", tc.in, got, tc.want) +} +} +} + +// --------------------------------------------------------------------------- +// maxI +// --------------------------------------------------------------------------- + +func TestMaxI(t *testing.T) { +if maxI(3, 5) != 5 { +t.Error("maxI(3, 5) should be 5") +} +if maxI(5, 3) != 5 { +t.Error("maxI(5, 3) should be 5") +} +if maxI(4, 4) != 4 { +t.Error("maxI(4, 4) should be 4") +} +} + +// --------------------------------------------------------------------------- +// condF +// --------------------------------------------------------------------------- + +func TestCondF(t *testing.T) { +if condF(true, 1.0, 2.0) != 1.0 { +t.Error("condF(true, ...) should return first value") +} +if condF(false, 1.0, 2.0) != 2.0 { +t.Error("condF(false, ...) should return second value") +} +} + +// --------------------------------------------------------------------------- +// Integration: full pipeline produces consistent SVG+JSON +// --------------------------------------------------------------------------- + +func TestIntegration_PicoLikeBoard(t *testing.T) { +cfg := BoardConfig{ +Name: "pico-test", HumanName: "Pico Test", +PinsTop: 20, PinsBottom: 20, +Orientation: "horizontal", HasUSB: true, USBSide: "left", +HasLED: true, PCBColor: "#006837", FirmwareFormat: "uf2", +} +if err := validate(cfg); err != nil { +t.Fatalf("validation failed: %v", err) +} +lay := computeLayout(cfg) +svg := generateSVG(cfg, lay) +jsonStr := generateJSON(cfg, lay) + +// SVG is well-formed XML. +d := xml.NewDecoder(strings.NewReader(svg)) +for { +_, err := d.Token() +if err != nil { +if err.Error() == "EOF" { +break +} +t.Fatalf("SVG is not valid XML: %v", err) +} +} + +// JSON is valid. +var parsed BoardJSON +if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil { +t.Fatalf("JSON is invalid: %v", err) +} + +// Every GPIO wire references a pin that exists in the SVG. +for _, w := range parsed.Wires { +if strings.HasPrefix(w.From, "mcu.GPIO") && !strings.Contains(w.To, ".") { +attr := fmt.Sprintf(`data-pin="%s"`, w.To) +if !strings.Contains(svg, attr) { +t.Errorf("wire to %s but SVG has no %s", w.To, attr) +} +} +} + +// LED anode wire exists. +hasLED := false +for _, w := range parsed.Wires { +if w.To == "led.anode" { +hasLED = true +} +} +if !hasLED { +t.Error("expected LED wire in JSON") +} +if !strings.Contains(svg, `data-part="led"`) { +t.Error("expected LED in SVG") +} +} + +func TestIntegration_MinimalBoard(t *testing.T) { +cfg := BoardConfig{ +Name: "minimal", HumanName: "Minimal", +PinsBottom: 4, +Orientation: "horizontal", PCBColor: "#006837", FirmwareFormat: "uf2", +} +if err := validate(cfg); err != nil { +t.Fatalf("validation failed: %v", err) +} +lay := computeLayout(cfg) +svg := generateSVG(cfg, lay) +jsonStr := generateJSON(cfg, lay) + +if strings.Contains(svg, "USB port") { +t.Error("minimal board should not have USB") +} +if strings.Contains(svg, `data-part="led"`) { +t.Error("minimal board should not have LED") +} + +var parsed BoardJSON +json.Unmarshal([]byte(jsonStr), &parsed) +if len(parsed.Parts) != 1 { +t.Errorf("expected 1 part for minimal board, got %d", len(parsed.Parts)) +} +} + +func TestIntegration_VerticalBoard(t *testing.T) { +cfg := BoardConfig{ +Name: "vert", HumanName: "Vertical", +PinsLeft: 15, PinsRight: 15, +Orientation: "vertical", HasUSB: true, USBSide: "top", +HasLED: true, PCBColor: "#006837", FirmwareFormat: "uf2", +} +if err := validate(cfg); err != nil { +t.Fatalf("validation failed: %v", err) +} +lay := computeLayout(cfg) +svg := generateSVG(cfg, lay) +jsonStr := generateJSON(cfg, lay) + +if !strings.Contains(svg, "pads on the left") { +t.Error("expected left pads in SVG") +} +if !strings.Contains(svg, "pads on the right") { +t.Error("expected right pads in SVG") +} + +var parsed BoardJSON +json.Unmarshal([]byte(jsonStr), &parsed) +if parsed.Name != "vert" { +t.Errorf("expected name 'vert', got %s", parsed.Name) +} +} + +func TestIntegration_FixedSizeWithRectGnd(t *testing.T) { +cfg := BoardConfig{ +Name: "wide", HumanName: "Wide", +PinsTop: 10, PinsBottom: 10, +Orientation: "horizontal", HasUSB: true, USBSide: "left", +HasLED: true, PCBColor: "#006837", FirmwareFormat: "uf2", +Width: 60, Height: 30, RectGnd: true, +} +if err := validate(cfg); err != nil { +t.Fatalf("validation failed: %v", err) +} +lay := computeLayout(cfg) +if lay.BoardW != 60 || lay.BoardH != 30 { +t.Errorf("expected 60x30, got %.0fx%.0f", lay.BoardW, lay.BoardH) +} + +svg := generateSVG(cfg, lay) +if !strings.Contains(svg, "2.21") { +t.Error("rect-gnd should have taller rectangular GND paths (containing 2.21)") +} +}