wshub: fix CalConfig.Normalise parity gaps vs C++ twin and JS client
Finding 1: strip trailing [digits] array-element suffix from Signal so one calibration entry covers an entire array signal, matching the C++ strchr truncation and the JS equivalent. Finding 2: after the 16-byte Unit truncation, drop any trailing partial UTF-8 rune so json.Marshal never emits replacement characters; keeps byte limit in sync with C++ strncpy(u, unit, kMaxUnitLen). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
47f1567a26
commit
66efd74dd5
@@ -4,11 +4,18 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// arrayIndexSuffix matches a trailing "[digits]" at the very end of a signal
|
||||||
|
// name, used to strip array-element suffixes so one entry covers the whole
|
||||||
|
// array. Mirrors the C++ `strchr(signal,'[')` truncation and the JS equivalent.
|
||||||
|
var arrayIndexSuffix = regexp.MustCompile(`\[\d+\]$`)
|
||||||
|
|
||||||
// maxUnitLen bounds the calibration unit override. Mirrored by kMaxUnitLen in
|
// maxUnitLen bounds the calibration unit override. Mirrored by kMaxUnitLen in
|
||||||
// the C++ StreamHub and MAX_UNIT_LEN in the SPA's calibration.js.
|
// the C++ StreamHub and MAX_UNIT_LEN in the SPA's calibration.js.
|
||||||
const maxUnitLen = 16
|
const maxUnitLen = 16
|
||||||
@@ -37,6 +44,10 @@ func calKey(source, signal string) string { return source + "\x00" + signal }
|
|||||||
func (c *CalConfig) Normalise() bool {
|
func (c *CalConfig) Normalise() bool {
|
||||||
c.Source = strings.TrimSpace(c.Source)
|
c.Source = strings.TrimSpace(c.Source)
|
||||||
c.Signal = strings.TrimSpace(c.Signal)
|
c.Signal = strings.TrimSpace(c.Signal)
|
||||||
|
// Strip a trailing "[digits]" suffix so one entry covers an entire array
|
||||||
|
// signal. "Adc[3]" → "Adc". Must run before the empty check below so
|
||||||
|
// that "[0]" → "" → rejected, matching C++ and JS behaviour.
|
||||||
|
c.Signal = arrayIndexSuffix.ReplaceAllString(c.Signal, "")
|
||||||
if c.Source == "" || c.Signal == "" {
|
if c.Source == "" || c.Signal == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -49,6 +60,16 @@ func (c *CalConfig) Normalise() bool {
|
|||||||
c.Unit = strings.TrimSpace(c.Unit)
|
c.Unit = strings.TrimSpace(c.Unit)
|
||||||
if len(c.Unit) > maxUnitLen {
|
if len(c.Unit) > maxUnitLen {
|
||||||
c.Unit = c.Unit[:maxUnitLen]
|
c.Unit = c.Unit[:maxUnitLen]
|
||||||
|
// The byte cut may land mid-rune. Drop any trailing partial rune so
|
||||||
|
// the result is always valid UTF-8; json.Marshal would otherwise emit
|
||||||
|
// replacement characters and break the save→load round-trip.
|
||||||
|
for {
|
||||||
|
r, size := utf8.DecodeLastRuneInString(c.Unit)
|
||||||
|
if r != utf8.RuneError || size != 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c.Unit = c.Unit[:len(c.Unit)-1]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package wshub
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCalConfigNormalise(t *testing.T) {
|
func TestCalConfigNormalise(t *testing.T) {
|
||||||
@@ -24,6 +26,13 @@ func TestCalConfigNormalise(t *testing.T) {
|
|||||||
{"negScaleOK", CalConfig{Source: "wave", Signal: "Adc", Scale: -1}, true, ""},
|
{"negScaleOK", CalConfig{Source: "wave", Signal: "Adc", Scale: -1}, true, ""},
|
||||||
{"longUnit", CalConfig{Source: "wave", Signal: "Adc", Scale: 1,
|
{"longUnit", CalConfig{Source: "wave", Signal: "Adc", Scale: 1,
|
||||||
Unit: "0123456789abcdefGHIJ"}, true, "0123456789abcdef"},
|
Unit: "0123456789abcdefGHIJ"}, true, "0123456789abcdef"},
|
||||||
|
// Finding 1: array-element suffix stripping for cross-implementation parity.
|
||||||
|
{"arrayIndex3", CalConfig{Source: "wave", Signal: "Adc[3]", Scale: 1}, true, ""},
|
||||||
|
{"arrayIndex12", CalConfig{Source: "wave", Signal: "Adc[12]", Scale: 1}, true, ""},
|
||||||
|
{"arrayNoSuffix", CalConfig{Source: "wave", Signal: "Adc", Scale: 1}, true, ""},
|
||||||
|
{"arrayMidBracket", CalConfig{Source: "wave", Signal: "A[1]B", Scale: 1}, true, ""},
|
||||||
|
{"arrayNonNumeric", CalConfig{Source: "wave", Signal: "Adc[x]", Scale: 1}, true, ""},
|
||||||
|
{"arrayZeroOnly", CalConfig{Source: "wave", Signal: "[0]", Scale: 1}, false, ""},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
got := c.in
|
got := c.in
|
||||||
@@ -38,6 +47,51 @@ func TestCalConfigNormalise(t *testing.T) {
|
|||||||
if len("0123456789abcdef") != maxUnitLen {
|
if len("0123456789abcdef") != maxUnitLen {
|
||||||
t.Fatalf("test assumes maxUnitLen == 16, got %d", maxUnitLen)
|
t.Fatalf("test assumes maxUnitLen == 16, got %d", maxUnitLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify stripped Signal values for array-index cases.
|
||||||
|
arraySignalCases := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Adc[3]", "Adc"},
|
||||||
|
{"Adc[12]", "Adc"},
|
||||||
|
{"Adc", "Adc"},
|
||||||
|
{"A[1]B", "A[1]B"},
|
||||||
|
{"Adc[x]", "Adc[x]"},
|
||||||
|
}
|
||||||
|
for _, ac := range arraySignalCases {
|
||||||
|
got := CalConfig{Source: "wave", Signal: ac.input, Scale: 1}
|
||||||
|
got.Normalise()
|
||||||
|
if got.Signal != ac.want {
|
||||||
|
t.Errorf("Signal strip %q: got %q, want %q", ac.input, got.Signal, ac.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finding 2: UTF-8 unit truncation must not split a multi-byte rune.
|
||||||
|
// "°" is U+00B0, encoded as 2 bytes in UTF-8.
|
||||||
|
degree := "°"
|
||||||
|
if len(degree) != 2 {
|
||||||
|
t.Fatalf("test expects '°' to be 2 bytes, got %d", len(degree))
|
||||||
|
}
|
||||||
|
unit16 := strings.Repeat(degree, 8) // exactly 16 bytes — must survive intact
|
||||||
|
c8 := CalConfig{Source: "wave", Signal: "Adc", Scale: 1, Unit: unit16}
|
||||||
|
c8.Normalise()
|
||||||
|
if c8.Unit != unit16 {
|
||||||
|
t.Errorf("16-byte degree unit mangled: got %q, want %q", c8.Unit, unit16)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(c8.Unit) {
|
||||||
|
t.Errorf("16-byte degree unit is not valid UTF-8: %q", c8.Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
unit18 := strings.Repeat(degree, 9) // 18 bytes — must truncate to 8 degrees (16 bytes), not 16 bytes with a broken half-rune
|
||||||
|
c9 := CalConfig{Source: "wave", Signal: "Adc", Scale: 1, Unit: unit18}
|
||||||
|
c9.Normalise()
|
||||||
|
if c9.Unit != unit16 {
|
||||||
|
t.Errorf("18-byte degree unit truncated to %q, want %q", c9.Unit, unit16)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(c9.Unit) {
|
||||||
|
t.Errorf("truncated degree unit is not valid UTF-8: %q", c9.Unit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCalTableSetListAndIdentityRemoval(t *testing.T) {
|
func TestCalTableSetListAndIdentityRemoval(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user