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>
238 lines
8.0 KiB
Go
238 lines
8.0 KiB
Go
package wshub
|
|
|
|
import (
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
func TestCalConfigNormalise(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in CalConfig
|
|
want bool
|
|
wantUnit string
|
|
}{
|
|
{"plain", CalConfig{Source: "wave", Signal: "Adc", Scale: 2, Offset: -1, Unit: "V"}, true, "V"},
|
|
{"trims", CalConfig{Source: " wave ", Signal: " Adc ", Scale: 1, Unit: " V "}, true, "V"},
|
|
{"emptySource", CalConfig{Signal: "Adc", Scale: 1}, false, ""},
|
|
{"emptySignal", CalConfig{Source: "wave", Scale: 1}, false, ""},
|
|
{"zeroScale", CalConfig{Source: "wave", Signal: "Adc", Scale: 0}, false, ""},
|
|
{"nanScale", CalConfig{Source: "wave", Signal: "Adc", Scale: math.NaN()}, false, ""},
|
|
{"infScale", CalConfig{Source: "wave", Signal: "Adc", Scale: math.Inf(1)}, false, ""},
|
|
{"nanOffset", CalConfig{Source: "wave", Signal: "Adc", Scale: 1, Offset: math.NaN()}, false, ""},
|
|
{"infOffset", CalConfig{Source: "wave", Signal: "Adc", Scale: 1, Offset: math.Inf(-1)}, false, ""},
|
|
{"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
|
|
if ok := got.Normalise(); ok != c.want {
|
|
t.Errorf("%s: Normalise() = %v, want %v", c.name, ok, c.want)
|
|
continue
|
|
}
|
|
if c.want && got.Unit != c.wantUnit {
|
|
t.Errorf("%s: Unit = %q, want %q", c.name, got.Unit, c.wantUnit)
|
|
}
|
|
}
|
|
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) {
|
|
tab := newCalTable()
|
|
if !tab.Set(CalConfig{Source: "b", Signal: "Y", Scale: 3, Offset: 1, Unit: "A"}) {
|
|
t.Fatal("Set(b/Y) rejected")
|
|
}
|
|
if !tab.Set(CalConfig{Source: "a", Signal: "X", Scale: 2}) {
|
|
t.Fatal("Set(a/X) rejected")
|
|
}
|
|
if tab.Set(CalConfig{Source: "a", Signal: "Z", Scale: 0}) {
|
|
t.Error("Set with scale=0 accepted, want rejected")
|
|
}
|
|
got := tab.List()
|
|
if len(got) != 2 {
|
|
t.Fatalf("List() = %d entries, want 2", len(got))
|
|
}
|
|
// Sorted by source then signal.
|
|
if got[0].Source != "a" || got[1].Source != "b" {
|
|
t.Errorf("List() order = %q,%q, want a,b", got[0].Source, got[1].Source)
|
|
}
|
|
// An identity entry removes the stored one.
|
|
if !tab.Set(CalConfig{Source: "a", Signal: "X", Scale: 1, Offset: 0, Unit: ""}) {
|
|
t.Fatal("identity Set rejected")
|
|
}
|
|
if got := tab.List(); len(got) != 1 || got[0].Source != "b" {
|
|
t.Errorf("after identity Set, List() = %+v, want only b/Y", got)
|
|
}
|
|
}
|
|
|
|
func TestCalTableReplace(t *testing.T) {
|
|
tab := newCalTable()
|
|
tab.Set(CalConfig{Source: "old", Signal: "X", Scale: 5})
|
|
tab.Replace([]CalConfig{
|
|
{Source: "new", Signal: "Y", Scale: 2},
|
|
{Source: "bad", Signal: "Z", Scale: 0}, // invalid → dropped
|
|
{Source: "id", Signal: "W", Scale: 1, Offset: 0, Unit: ""}, // identity → dropped
|
|
})
|
|
got := tab.List()
|
|
if len(got) != 1 || got[0].Source != "new" {
|
|
t.Fatalf("List() = %+v, want only new/Y", got)
|
|
}
|
|
}
|
|
|
|
func TestParseConfigFileCurrentFormat(t *testing.T) {
|
|
// A file written by the current binaries — sources only, spaces after colons.
|
|
data := []byte(`[
|
|
{
|
|
"label": "wave",
|
|
"addr": "127.0.0.1:44500"
|
|
},
|
|
{
|
|
"label": "mc",
|
|
"addr": "127.0.0.1:44501",
|
|
"multicastGroup": "239.0.0.1",
|
|
"dataPort": 44502
|
|
}
|
|
]`)
|
|
srcs, cals, err := parseConfigFile(data)
|
|
if err != nil {
|
|
t.Fatalf("parseConfigFile: %v", err)
|
|
}
|
|
if len(srcs) != 2 || len(cals) != 0 {
|
|
t.Fatalf("got %d sources / %d cals, want 2 / 0", len(srcs), len(cals))
|
|
}
|
|
if srcs[1].MulticastGroup != "239.0.0.1" || srcs[1].DataPort != 44502 {
|
|
t.Errorf("multicast source = %+v", srcs[1])
|
|
}
|
|
}
|
|
|
|
func TestParseConfigFileMixed(t *testing.T) {
|
|
data := []byte(`[
|
|
{"label":"wave","addr":"127.0.0.1:44500"},
|
|
{"source":"wave","signal":"Adc","scale":0.00030518,"offset":-1.25,"unit":"V"},
|
|
{"source":"wave","signal":"Bare"},
|
|
{"source":"wave","signal":"Bad","scale":0},
|
|
{"nonsense":true}
|
|
]`)
|
|
srcs, cals, err := parseConfigFile(data)
|
|
if err != nil {
|
|
t.Fatalf("parseConfigFile: %v", err)
|
|
}
|
|
if len(srcs) != 1 {
|
|
t.Fatalf("got %d sources, want 1", len(srcs))
|
|
}
|
|
if len(cals) != 2 {
|
|
t.Fatalf("got %d cals, want 2 (Adc and Bare; Bad is invalid)", len(cals))
|
|
}
|
|
if cals[0].Scale != 0.00030518 || cals[0].Offset != -1.25 || cals[0].Unit != "V" {
|
|
t.Errorf("Adc = %+v", cals[0])
|
|
}
|
|
// Absent scale/offset default to the identity values, not to zero.
|
|
if cals[1].Signal != "Bare" || cals[1].Scale != 1 || cals[1].Offset != 0 {
|
|
t.Errorf("Bare = %+v, want scale 1 / offset 0", cals[1])
|
|
}
|
|
}
|
|
|
|
func TestParseConfigFileMalformed(t *testing.T) {
|
|
if _, _, err := parseConfigFile([]byte("not json")); err == nil {
|
|
t.Error("parseConfigFile(garbage) = nil error, want error")
|
|
}
|
|
}
|
|
|
|
func TestEncodeConfigFileRoundTrip(t *testing.T) {
|
|
srcs := []SourceConfig{
|
|
{Label: "wave", Addr: "127.0.0.1:44500"},
|
|
{Label: "mc", Addr: "127.0.0.1:44501", MulticastGroup: "239.0.0.1", DataPort: 44502},
|
|
}
|
|
cals := []CalConfig{
|
|
{Source: "wave", Signal: "Adc", Scale: 0.5, Offset: 0, Unit: "V"},
|
|
}
|
|
data, err := encodeConfigFile(srcs, cals)
|
|
if err != nil {
|
|
t.Fatalf("encodeConfigFile: %v", err)
|
|
}
|
|
gotSrcs, gotCals, err := parseConfigFile(data)
|
|
if err != nil {
|
|
t.Fatalf("parseConfigFile(encoded): %v\n%s", err, data)
|
|
}
|
|
if len(gotSrcs) != 2 || len(gotCals) != 1 {
|
|
t.Fatalf("round-trip gave %d sources / %d cals, want 2 / 1\n%s",
|
|
len(gotSrcs), len(gotCals), data)
|
|
}
|
|
if gotSrcs[1] != srcs[1] {
|
|
t.Errorf("source round-trip: got %+v, want %+v", gotSrcs[1], srcs[1])
|
|
}
|
|
if gotCals[0] != cals[0] {
|
|
t.Errorf("cal round-trip: got %+v, want %+v", gotCals[0], cals[0])
|
|
}
|
|
// offset 0 must survive as an explicit field, not be dropped by omitempty.
|
|
if !bytesContains(data, []byte(`"offset": 0`)) {
|
|
t.Errorf("encoded file lost the zero offset:\n%s", data)
|
|
}
|
|
}
|
|
|
|
func bytesContains(hay, needle []byte) bool {
|
|
for i := 0; i+len(needle) <= len(hay); i++ {
|
|
if string(hay[i:i+len(needle)]) == string(needle) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|