diff --git a/Common/Client/go/wshub/calibration.go b/Common/Client/go/wshub/calibration.go index 981afce..08a1683 100644 --- a/Common/Client/go/wshub/calibration.go +++ b/Common/Client/go/wshub/calibration.go @@ -4,11 +4,18 @@ import ( "encoding/json" "log" "math" + "regexp" "sort" "strings" "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 // the C++ StreamHub and MAX_UNIT_LEN in the SPA's calibration.js. const maxUnitLen = 16 @@ -37,6 +44,10 @@ func calKey(source, signal string) string { return source + "\x00" + signal } func (c *CalConfig) Normalise() bool { c.Source = strings.TrimSpace(c.Source) 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 == "" { return false } @@ -49,6 +60,16 @@ func (c *CalConfig) Normalise() bool { c.Unit = strings.TrimSpace(c.Unit) if len(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 } diff --git a/Common/Client/go/wshub/calibration_test.go b/Common/Client/go/wshub/calibration_test.go index 5ac3afe..b808e11 100644 --- a/Common/Client/go/wshub/calibration_test.go +++ b/Common/Client/go/wshub/calibration_test.go @@ -2,7 +2,9 @@ package wshub import ( "math" + "strings" "testing" + "unicode/utf8" ) func TestCalConfigNormalise(t *testing.T) { @@ -24,6 +26,13 @@ func TestCalConfigNormalise(t *testing.T) { {"negScaleOK", CalConfig{Source: "wave", Signal: "Adc", Scale: -1}, true, ""}, {"longUnit", CalConfig{Source: "wave", Signal: "Adc", Scale: 1, 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 { got := c.in @@ -38,6 +47,51 @@ func TestCalConfigNormalise(t *testing.T) { if len("0123456789abcdef") != 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) {