diff --git a/CHANGELOG.md b/CHANGELOG.md index db518b1..4f5a768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ For more details or to discuss releases, please visit the ## [Unreleased] +## [0.9.4] - 2026-07-16 + +- Parts whose variation codes a value rather than a plain number, such as + `ICS-0047-02V5` for 2.5 V or `RES-0008-8R3m` for 8.3 mOhm, are now accepted + everywhere an IPN is read. Releases of these parts previously failed, the + KiCad HTTP API served them without a category, and the TUI could suggest a + next available IPN that collided with one of them. + ## [0.9.3] - 2026-07-15 - KiCad HTTP API: parts are now named by their IPN, so a placed symbol's library diff --git a/CLAUDE.md b/CLAUDE.md index 332a8e3..5c2fb7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,11 @@ and ASY have recursive BOMs. Purchased: `RES`, `CAP`, `DIO`, `LED`, etc. -Format: `CCC-NNN-VVVV` where N is 3-4 digits, V is always 4 digits. +Format: `CCC-NNN-VVVV` where N is 3-4 digits, and V is 4 alphanumeric +characters. V often codes a value rather than a plain number, such as `02V5` for +2.5 V or `047n` for 47 nH, so its case is significant. `reIpn` in `ipn.go` is +the single definition of this format -- parse IPNs through the `ipn` type rather +than matching the format again elsewhere. ### Data Flow for Release Processing diff --git a/csv_data_test.go b/csv_data_test.go new file mode 100644 index 0000000..257065f --- /dev/null +++ b/csv_data_test.go @@ -0,0 +1,60 @@ +package main + +import "testing" + +// The highest N in a category may belong to a part whose variation codes a +// value, such as the 02V5 (2.5 V) below. Those parts still have to count +// towards the max N, otherwise the next IPN collides with an existing part. +func TestNextAvailableIPN(t *testing.T) { + tests := []struct { + name string + rows [][]string + want string + }{ + { + name: "digit variations", + rows: [][]string{ + {"ICS-0045-0001"}, + {"ICS-0046-0000"}, + }, + want: "ICS-0047-0001", + }, + { + name: "max N has a value coded variation", + rows: [][]string{ + {"ICS-0045-0001"}, + {"ICS-0046-0000"}, + {"ICS-0047-02V5"}, + }, + want: "ICS-0048-0001", + }, + { + name: "3 digit N is preserved", + rows: [][]string{ + {"PCB-001-0500"}, + }, + want: "PCB-002-0001", + }, + { + name: "rows that are not IPNs are ignored", + rows: [][]string{ + {"ICS-0045-0001"}, + {"not an ipn"}, + {""}, + }, + want: "ICS-0046-0001", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := nextAvailableIPN(test.rows, 0) + if err != nil { + t.Fatalf("nextAvailableIPN() error: %v", err) + } + if got != test.want { + t.Errorf("nextAvailableIPN() = %q, want %q", got, test.want) + } + }) + } +} diff --git a/ipn.go b/ipn.go index c02e02f..f5f6bba 100644 --- a/ipn.go +++ b/ipn.go @@ -11,60 +11,38 @@ import ( type ipn string -var reIpn = regexp.MustCompile(`^([A-Z][A-Z][A-Z])-(\d{3,4})-(\d\d\d\d)$`) -var reC = regexp.MustCompile(`^[A-Z][A-Z][A-Z]$`) +// reIpn is the single definition of the IPN format: CCC-NNNN-VVVV, and +// CCC-NNN-VVVV for legacy 3-digit N. +// +// VVVV codes a variation and is alphanumeric rather than digits only, because +// it often encodes a value: 02V5 denotes 2.5 V, and 047n denotes 47 nH. Case is +// significant, since SI prefixes such as the m in 8R3m (8.3 mOhm) rely on it. +var reIpn = regexp.MustCompile(`^([A-Z][A-Z][A-Z])-(\d{3,4})-([0-9A-Za-z]{4})$`) func newIpn(s string) (ipn, error) { _, _, _, err := ipn(s).parse() return ipn(s), err } -func newIpnParts(c string, n, v int) (ipn, error) { - if n < 0 || n > 9999 { - return "", errors.New("N out of range") - } - - if v < 0 || v > 9999 { - return "", errors.New("V out of range") - } - - if len(c) != 3 { - return "", errors.New("C must be 3 chars") - } - - if reC.FindString(c) == "" { - return "", errors.New("C must be in format CCC") - } - - nFmt := "%03v" - if n > 999 { - nFmt = "%04v" - } - return ipn(fmt.Sprintf("%v-"+nFmt+"-%04v", c, n, v)), nil -} - func (i ipn) String() string { return string(i) } -// parse() returns C (category), N (number), V (variation) -func (i ipn) parse() (string, int, int, error) { +// parse() returns C (category), N (number), V (variation). V is returned as a +// string because it is not always a number. +func (i ipn) parse() (string, int, string, error) { groups := reIpn.FindStringSubmatch(string(i)) if len(groups) < 4 { - return "", 0, 0, errors.New("Error parsing ipn") + return "", 0, "", errors.New("Error parsing ipn") } c := groups[1] n, err := strconv.Atoi(groups[2]) if err != nil { - return "", 0, 0, fmt.Errorf("Error parsing N: %v", err) - } - v, err := strconv.Atoi(groups[3]) - if err != nil { - return "", 0, 0, fmt.Errorf("Error parsing V: %v", err) + return "", 0, "", fmt.Errorf("Error parsing N: %v", err) } - return c, n, v, nil + return c, n, groups[3], nil } // nWidth returns the number of digits in the N segment of the IPN @@ -95,11 +73,6 @@ func (i ipn) n() (int, error) { return n, err } -func (i ipn) v() (int, error) { - _, _, v, err := i.parse() - return v, err -} - var ourIPNs = []string{"PCA", "PCB", "ASY", "DOC", "DFW", "DSW", "DCL", "FIX"} func (i ipn) isOurIPN() (bool, error) { diff --git a/ipn_test.go b/ipn_test.go index 3307bed..a42b6de 100644 --- a/ipn_test.go +++ b/ipn_test.go @@ -6,19 +6,26 @@ type ipnTest struct { ipn string c string n int - v int + v string valid bool } func TestIpn(t *testing.T) { tests := []ipnTest{ - {"PCB-001-0500", "PCB", 1, 500, true}, - {"ASY-200-1000", "ASY", 200, 1000, true}, - {"SY-200-1000", "", 0, 0, false}, - {"ASY-0001-0001", "ASY", 1, 1, true}, // 4-digit N - {"PCB-0123-0500", "PCB", 123, 500, true}, // 4-digit N - {"ASY-20-1000", "", 0, 0, false}, - {"ASY-200-100", "", 0, 0, false}, + {"PCB-001-0500", "PCB", 1, "0500", true}, + {"ASY-200-1000", "ASY", 200, "1000", true}, + {"SY-200-1000", "", 0, "", false}, + {"ASY-0001-0001", "ASY", 1, "0001", true}, // 4-digit N + {"PCB-0123-0500", "PCB", 123, "0500", true}, // 4-digit N + {"ICS-0047-02V5", "ICS", 47, "02V5", true}, // V codes 2.5 V + {"CAP-0006-04R7", "CAP", 6, "04R7", true}, // R as a decimal point + {"IND-0005-047n", "IND", 5, "047n", true}, // n codes nH + {"RES-0008-8R3m", "RES", 8, "8R3m", true}, // 8.3 mOhm + {"ASY-20-1000", "", 0, "", false}, // N too short + {"ASY-200-100", "", 0, "", false}, // V too short + {"ASY-200-10000", "", 0, "", false}, // V too long + {"ics-0047-0000", "", 0, "", false}, // C must be upper case + {"ICS-0047-02_5", "", 0, "", false}, // V is alphanumeric only } for _, test := range tests { diff --git a/kicad_api.go b/kicad_api.go index 9067d66..9926332 100644 --- a/kicad_api.go +++ b/kicad_api.go @@ -6,7 +6,6 @@ import ( "log" "net/http" "path/filepath" - "regexp" "sort" "strings" "sync" @@ -238,15 +237,14 @@ func (s *KiCadServer) findColumnIndex(file *CSVFile, columnName string) int { return -1 } -// extractCategory extracts the CCC component from an IPN +// extractCategory extracts the CCC component from an IPN, or returns an empty +// string if the IPN is not in the format described on the ipn type. func (s *KiCadServer) extractCategory(ipnStr string) string { - // IPN format: CCC-NNNN-VVVV (also supports CCC-NNN-VVVV for legacy) - re := regexp.MustCompile(`^([A-Z][A-Z][A-Z])-(\d{3,4})-(\d{4})$`) - matches := re.FindStringSubmatch(ipnStr) - if len(matches) >= 2 { - return matches[1] + c, err := ipn(ipnStr).c() + if err != nil { + return "" } - return "" + return c } // getCategoryDisplayName returns a human-readable name for a category diff --git a/kicad_api_test.go b/kicad_api_test.go new file mode 100644 index 0000000..4b81ba7 --- /dev/null +++ b/kicad_api_test.go @@ -0,0 +1,42 @@ +package main + +import "testing" + +type extractCategoryTest struct { + ipn string + category string +} + +func TestExtractCategory(t *testing.T) { + tests := []extractCategoryTest{ + {"ICS-0046-0000", "ICS"}, + {"PCB-001-0500", "PCB"}, + {"ASY-0001-0001", "ASY"}, + // VVVV may encode a value such as voltage, so it is alphanumeric + {"ICS-0047-02V5", "ICS"}, + {"REG-0006-03V3", "REG"}, + {"CAP-0006-04R7", "CAP"}, + {"CAP-0003-220M", "CAP"}, + // SI prefixes in the variation are lower case + {"IND-0005-047n", "IND"}, + {"RES-0008-8R3m", "RES"}, + // Malformed IPNs have no category + {"SY-200-1000", ""}, + {"ASY-20-1000", ""}, + {"ASY-200-100", ""}, + {"ASY-200-10000", ""}, + {"ics-0047-0000", ""}, + {"ICS-0047-02V", ""}, + {"ICS-0047-02_5", ""}, + {"", ""}, + } + + s := &KiCadServer{} + + for _, test := range tests { + got := s.extractCategory(test.ipn) + if got != test.category { + t.Errorf("extractCategory(%q) = %q, want %q", test.ipn, got, test.category) + } + } +} diff --git a/release.go b/release.go index 0dc99f7..de69d71 100644 --- a/release.go +++ b/release.go @@ -21,7 +21,7 @@ func processRelease(relPn string, relLog *strings.Builder, pmDir string) (string } relPnBase := relIpn.base() - relPnBaseWithVar := fmt.Sprintf("%v-%02v", relPnBase, v/100) // First two digits of variation + relPnBaseWithVar := fmt.Sprintf("%v-%v", relPnBase, v[:2]) // First two characters of variation bomFile := relPnBase + ".csv" bomFileWithVar := relPnBaseWithVar + ".csv"