562 lines
16 KiB
Go
562 lines
16 KiB
Go
// Command streamhub-e2e is an end-to-end test client for the C++ StreamHub.
|
|
//
|
|
// It connects to a running StreamHub WebSocket endpoint (with at least one
|
|
// connected UDPStreamer source, e.g. the stack launched by run_e2e_test.sh)
|
|
// and verifies the full protocol:
|
|
//
|
|
// 1. "sources" event with at least one connected source
|
|
// 2. "config" event per source with at least one signal
|
|
// 3. binary v1 data pushes: parseable, per-signal monotonic time,
|
|
// timestamps within a few seconds of wall clock (Unix time base)
|
|
// 4. "stats" event with a positive receive rate
|
|
// 5. WS zoom round-trip: reqId echoed, points returned in [t0,t1]
|
|
// 6. hub-side trigger: setTrigger+arm → triggerState(armed) → binary v2
|
|
// capture frame with the latched pre/post window
|
|
//
|
|
// Exit code 0 on success; 1 with a FAIL message otherwise.
|
|
package main
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
var hub = flag.String("hub", "127.0.0.1:8090", "StreamHub host:port")
|
|
var timeout = flag.Duration("timeout", 30*time.Second, "overall test timeout")
|
|
var verbose = flag.Bool("v", false, "log every received event")
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wire types (subset of the StreamHub JSON protocol)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type sourceInfo struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Addr string `json:"addr"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
type signalInfo struct {
|
|
Name string `json:"name"`
|
|
TypeCode uint32 `json:"typeCode"`
|
|
NumRows uint32 `json:"numRows"`
|
|
NumCols uint32 `json:"numCols"`
|
|
TimeMode int `json:"timeMode"`
|
|
Rate float64 `json:"samplingRate"`
|
|
}
|
|
|
|
type statInfo struct {
|
|
State string `json:"state"`
|
|
TotalReceived uint64 `json:"totalReceived"`
|
|
RateHz float64 `json:"rateHz"`
|
|
CycleHist []f64 `json:"cycleHist"`
|
|
}
|
|
|
|
type f64 = float64
|
|
|
|
type zoomPoints struct {
|
|
T []float64 `json:"t"`
|
|
V []float64 `json:"v"`
|
|
}
|
|
|
|
type historyInfoMsg struct {
|
|
Enabled bool `json:"enabled"`
|
|
DurationHours float64 `json:"durationHours"`
|
|
Decimation uint32 `json:"decimation"`
|
|
Signals map[string]struct {
|
|
T0 float64 `json:"t0"`
|
|
T1 float64 `json:"t1"`
|
|
Count uint32 `json:"count"`
|
|
Capacity uint32 `json:"capacity"`
|
|
} `json:"signals"`
|
|
}
|
|
|
|
type event struct {
|
|
Type string `json:"type"`
|
|
Sources json.RawMessage `json:"sources"`
|
|
SourceID string `json:"sourceId"`
|
|
Signals json.RawMessage `json:"signals"`
|
|
ReqID uint32 `json:"reqId"`
|
|
State string `json:"state"`
|
|
TrigTime float64 `json:"trigTime"`
|
|
}
|
|
|
|
// Parsed binary v1 push frame: sourceId → signal → samples.
|
|
type pushFrame struct {
|
|
sourceID string
|
|
signals map[string]zoomPoints
|
|
}
|
|
|
|
// Parsed binary v2 capture frame.
|
|
type captureFrame struct {
|
|
trigTime, preSec, postSec float64
|
|
signals map[string]zoomPoints
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Binary parsers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func parsePush(b []byte) (*pushFrame, error) {
|
|
if len(b) < 2 || b[0] != 1 {
|
|
return nil, fmt.Errorf("not a v1 frame")
|
|
}
|
|
idLen := int(b[1])
|
|
off := 2
|
|
if len(b) < off+idLen+4 {
|
|
return nil, fmt.Errorf("truncated header")
|
|
}
|
|
f := &pushFrame{sourceID: string(b[off : off+idLen]),
|
|
signals: map[string]zoomPoints{}}
|
|
off += idLen
|
|
nSig := int(binary.LittleEndian.Uint32(b[off:]))
|
|
off += 4
|
|
for s := 0; s < nSig; s++ {
|
|
if len(b) < off+2 {
|
|
return nil, fmt.Errorf("truncated keyLen (sig %d)", s)
|
|
}
|
|
keyLen := int(binary.LittleEndian.Uint16(b[off:]))
|
|
off += 2
|
|
if len(b) < off+keyLen+4 {
|
|
return nil, fmt.Errorf("truncated key (sig %d)", s)
|
|
}
|
|
key := string(b[off : off+keyLen])
|
|
off += keyLen
|
|
n := int(binary.LittleEndian.Uint32(b[off:]))
|
|
off += 4
|
|
if len(b) < off+16*n {
|
|
return nil, fmt.Errorf("truncated data (sig %s n=%d)", key, n)
|
|
}
|
|
pts := zoomPoints{T: make([]float64, n), V: make([]float64, n)}
|
|
for i := 0; i < n; i++ {
|
|
pts.T[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:]))
|
|
}
|
|
off += 8 * n
|
|
for i := 0; i < n; i++ {
|
|
pts.V[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:]))
|
|
}
|
|
off += 8 * n
|
|
f.signals[key] = pts
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
func parseCapture(b []byte) (*captureFrame, error) {
|
|
if len(b) < 1+24+4 || b[0] != 2 {
|
|
return nil, fmt.Errorf("not a v2 frame")
|
|
}
|
|
rdF64 := func(off int) float64 {
|
|
return math.Float64frombits(binary.LittleEndian.Uint64(b[off:]))
|
|
}
|
|
f := &captureFrame{
|
|
trigTime: rdF64(1), preSec: rdF64(9), postSec: rdF64(17),
|
|
signals: map[string]zoomPoints{},
|
|
}
|
|
off := 25
|
|
nSig := int(binary.LittleEndian.Uint32(b[off:]))
|
|
off += 4
|
|
for s := 0; s < nSig; s++ {
|
|
keyLen := int(binary.LittleEndian.Uint16(b[off:]))
|
|
off += 2
|
|
key := string(b[off : off+keyLen])
|
|
off += keyLen
|
|
n := int(binary.LittleEndian.Uint32(b[off:]))
|
|
off += 4
|
|
if len(b) < off+16*n {
|
|
return nil, fmt.Errorf("truncated capture (sig %s n=%d)", key, n)
|
|
}
|
|
pts := zoomPoints{T: make([]float64, n), V: make([]float64, n)}
|
|
for i := 0; i < n; i++ {
|
|
pts.T[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:]))
|
|
}
|
|
off += 8 * n
|
|
for i := 0; i < n; i++ {
|
|
pts.V[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:]))
|
|
}
|
|
off += 8 * n
|
|
f.signals[key] = pts
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test driver
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type client struct {
|
|
ws *websocket.Conn
|
|
deadline time.Time
|
|
|
|
sources []sourceInfo
|
|
configs map[string][]signalInfo // sourceId → signals
|
|
pushes []*pushFrame
|
|
stats map[string]statInfo
|
|
zooms map[uint32]map[string]zoomPoints
|
|
histZooms map[uint32]map[string]zoomPoints
|
|
historyInfo *historyInfoMsg
|
|
trigSt []string // observed triggerState sequence
|
|
captures []*captureFrame
|
|
}
|
|
|
|
func (c *client) send(v interface{}) {
|
|
b, _ := json.Marshal(v)
|
|
if err := c.ws.WriteMessage(websocket.TextMessage, b); err != nil {
|
|
fail("ws write: %v", err)
|
|
}
|
|
}
|
|
|
|
// pump reads one WS message (with a short read deadline) and dispatches it.
|
|
func (c *client) pump() {
|
|
c.ws.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
|
mt, data, err := c.ws.ReadMessage()
|
|
if err != nil {
|
|
if websocket.IsUnexpectedCloseError(err) {
|
|
fail("ws closed: %v", err)
|
|
}
|
|
return // read timeout — fine
|
|
}
|
|
switch mt {
|
|
case websocket.BinaryMessage:
|
|
if len(data) == 0 {
|
|
return
|
|
}
|
|
switch data[0] {
|
|
case 1:
|
|
if f, err := parsePush(data); err == nil {
|
|
c.pushes = append(c.pushes, f)
|
|
} else {
|
|
fail("bad v1 frame: %v", err)
|
|
}
|
|
case 2:
|
|
if f, err := parseCapture(data); err == nil {
|
|
c.captures = append(c.captures, f)
|
|
} else {
|
|
fail("bad v2 frame: %v", err)
|
|
}
|
|
default:
|
|
fail("unknown binary frame version %d", data[0])
|
|
}
|
|
case websocket.TextMessage:
|
|
var ev event
|
|
if err := json.Unmarshal(data, &ev); err != nil {
|
|
fail("bad JSON event: %v (%.120s)", err, data)
|
|
}
|
|
if *verbose {
|
|
log.Printf("event %-12s %.160s", ev.Type, data)
|
|
}
|
|
switch ev.Type {
|
|
case "sources":
|
|
var srcs []sourceInfo
|
|
if err := json.Unmarshal(ev.Sources, &srcs); err == nil {
|
|
c.sources = srcs
|
|
}
|
|
case "config":
|
|
var sigs []signalInfo
|
|
if err := json.Unmarshal(ev.Signals, &sigs); err == nil {
|
|
c.configs[ev.SourceID] = sigs
|
|
} else {
|
|
log.Printf("config parse error: %v (%.200s)", err, data)
|
|
}
|
|
case "stats":
|
|
var st map[string]statInfo
|
|
if err := json.Unmarshal(ev.Sources, &st); err == nil {
|
|
c.stats = st
|
|
}
|
|
case "zoom":
|
|
var body struct {
|
|
Signals map[string]zoomPoints `json:"signals"`
|
|
}
|
|
if err := json.Unmarshal(data, &body); err == nil {
|
|
c.zooms[ev.ReqID] = body.Signals
|
|
}
|
|
case "historyZoom":
|
|
var body struct {
|
|
Signals map[string]zoomPoints `json:"signals"`
|
|
}
|
|
if err := json.Unmarshal(data, &body); err == nil {
|
|
c.histZooms[ev.ReqID] = body.Signals
|
|
}
|
|
case "historyInfo":
|
|
var hi historyInfoMsg
|
|
if err := json.Unmarshal(data, &hi); err == nil {
|
|
c.historyInfo = &hi
|
|
}
|
|
case "triggerState":
|
|
c.trigSt = append(c.trigSt, ev.State)
|
|
}
|
|
}
|
|
}
|
|
|
|
// waitFor pumps messages until cond() or the step deadline expires.
|
|
func (c *client) waitFor(what string, d time.Duration, cond func() bool) {
|
|
end := time.Now().Add(d)
|
|
if end.After(c.deadline) {
|
|
end = c.deadline
|
|
}
|
|
for time.Now().Before(end) {
|
|
if cond() {
|
|
log.Printf("OK %s", what)
|
|
return
|
|
}
|
|
c.pump()
|
|
}
|
|
fail("timeout waiting for %s", what)
|
|
}
|
|
|
|
func fail(format string, args ...interface{}) {
|
|
fmt.Printf("FAIL "+format+"\n", args...)
|
|
os.Exit(1)
|
|
}
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
url := "ws://" + *hub + "/ws"
|
|
log.Printf("connecting to %s", url)
|
|
|
|
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
|
|
if err != nil {
|
|
fail("dial %s: %v", url, err)
|
|
}
|
|
defer ws.Close()
|
|
|
|
c := &client{
|
|
ws: ws,
|
|
deadline: time.Now().Add(*timeout),
|
|
configs: map[string][]signalInfo{},
|
|
zooms: map[uint32]map[string]zoomPoints{},
|
|
histZooms: map[uint32]map[string]zoomPoints{},
|
|
}
|
|
|
|
// ── 1. sources ────────────────────────────────────────────────────────
|
|
c.send(map[string]interface{}{"type": "getSources"})
|
|
c.waitFor("sources event with a connected source", 10*time.Second, func() bool {
|
|
for _, s := range c.sources {
|
|
if s.State == "connected" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
|
|
// ── 2. config per connected source ───────────────────────────────────
|
|
for _, s := range c.sources {
|
|
log.Printf("source %s (%s): state=%s", s.ID, s.Label, s.State)
|
|
c.send(map[string]interface{}{"type": "getConfig", "sourceId": s.ID})
|
|
}
|
|
c.waitFor("config with signals for every connected source", 10*time.Second, func() bool {
|
|
for _, s := range c.sources {
|
|
if s.State != "connected" {
|
|
continue
|
|
}
|
|
if len(c.configs[s.ID]) == 0 {
|
|
return false
|
|
}
|
|
}
|
|
return len(c.sources) > 0
|
|
})
|
|
|
|
// ── 3. binary pushes: wall-clock time base + monotonicity ────────────
|
|
c.waitFor("binary v1 data pushes (>=10 frames)", 10*time.Second, func() bool {
|
|
return len(c.pushes) >= 10
|
|
})
|
|
now := float64(time.Now().UnixNano()) / 1e9
|
|
seen := map[string][]float64{} // last times per src:sig
|
|
for _, f := range c.pushes {
|
|
for key, pts := range f.signals {
|
|
full := f.sourceID + ":" + key
|
|
for i, t := range pts.T {
|
|
if math.Abs(t-now) > 30.0 {
|
|
fail("timestamp not wall-clock: %s t=%.3f now=%.3f", full, t, now)
|
|
}
|
|
prev := seen[full]
|
|
if len(prev) > 0 && t < prev[len(prev)-1]-1e-9 {
|
|
fail("non-monotonic time on %s: %.9f after %.9f (i=%d)",
|
|
full, t, prev[len(prev)-1], i)
|
|
}
|
|
seen[full] = append(seen[full], t)
|
|
}
|
|
}
|
|
}
|
|
if len(seen) == 0 {
|
|
fail("pushes contained no signal data")
|
|
}
|
|
log.Printf("OK wall-clock & monotonic time on %d signal streams", len(seen))
|
|
|
|
// ── 4. stats ──────────────────────────────────────────────────────────
|
|
c.send(map[string]interface{}{"type": "getStats"})
|
|
c.waitFor("stats with positive rate", 10*time.Second, func() bool {
|
|
for _, st := range c.stats {
|
|
if st.State == "connected" && st.RateHz > 0 && st.TotalReceived > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
|
|
// ── 5. zoom round-trip ───────────────────────────────────────────────
|
|
// Use the busiest streamed signal and the time range we actually saw.
|
|
var zoomKey string
|
|
var zMax int
|
|
for k, ts := range seen {
|
|
if len(ts) > zMax {
|
|
zMax, zoomKey = len(ts), k
|
|
}
|
|
}
|
|
ts := seen[zoomKey]
|
|
t1 := ts[len(ts)-1]
|
|
t0 := t1 - 0.5
|
|
const reqID = 4242
|
|
c.send(map[string]interface{}{
|
|
"type": "zoom", "reqId": reqID, "t0": t0, "t1": t1, "n": 200,
|
|
"signals": zoomKey,
|
|
})
|
|
c.waitFor(fmt.Sprintf("zoom reply (reqId=%d, %s)", reqID, zoomKey),
|
|
10*time.Second, func() bool {
|
|
sigs, ok := c.zooms[reqID]
|
|
if !ok {
|
|
return false
|
|
}
|
|
pts, ok := sigs[zoomKey]
|
|
if !ok || len(pts.T) < 2 {
|
|
fail("zoom reply missing %s (got %d signals)", zoomKey, len(sigs))
|
|
}
|
|
for _, t := range pts.T {
|
|
if t < t0-1e-6 || t > t1+1e-6 {
|
|
fail("zoom point outside range: t=%.9f not in [%.9f,%.9f]", t, t0, t1)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
|
|
// ── 5b. historyInfo — check the hub broadcast it on connect ─────────
|
|
if c.historyInfo != nil && c.historyInfo.Enabled {
|
|
log.Printf("OK historyInfo: enabled, %.1fh, decimation=%d, %d signals",
|
|
c.historyInfo.DurationHours, c.historyInfo.Decimation,
|
|
len(c.historyInfo.Signals))
|
|
|
|
// ── 5c. historyZoom round-trip ──────────────────────────────────
|
|
const hReqID = 4243
|
|
c.send(map[string]interface{}{
|
|
"type": "historyZoom", "reqId": hReqID,
|
|
"t0": t0, "t1": t1, "n": 200,
|
|
"signals": zoomKey,
|
|
})
|
|
c.waitFor(fmt.Sprintf("historyZoom reply (reqId=%d, %s)", hReqID, zoomKey),
|
|
10*time.Second, func() bool {
|
|
sigs, ok := c.histZooms[hReqID]
|
|
if !ok {
|
|
return false
|
|
}
|
|
pts, ok := sigs[zoomKey]
|
|
if !ok || len(pts.T) < 1 {
|
|
// History data may still be sparse right after startup
|
|
return true
|
|
}
|
|
for _, ht := range pts.T {
|
|
if ht < t0-1e-6 || ht > t1+1e-6 {
|
|
fail("historyZoom point outside range: t=%.9f not in [%.9f,%.9f]", ht, t0, t1)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
} else {
|
|
log.Println(" (history not enabled — skipping historyZoom test)")
|
|
}
|
|
|
|
// ── 6. trigger: arm → capture ────────────────────────────────────────
|
|
// Trigger on an *oscillating* signal at its mean observed value: a
|
|
// monotonic ramp (counter, time array) crosses its past mean only once,
|
|
// before arming, so a rising edge would never fire on it. Pick the
|
|
// busiest signal whose last push frame is non-monotonic (a sine).
|
|
lastVals := map[string][]float64{}
|
|
for _, f := range c.pushes {
|
|
for name, pts := range f.signals {
|
|
if len(pts.V) >= 4 {
|
|
lastVals[f.sourceID+":"+name] = pts.V
|
|
}
|
|
}
|
|
}
|
|
trigKey := ""
|
|
tMaxPts := 0
|
|
for k, vs := range lastVals {
|
|
monotonic := true
|
|
for i := 1; i < len(vs); i++ {
|
|
if vs[i] < vs[i-1] {
|
|
monotonic = false
|
|
break
|
|
}
|
|
}
|
|
if !monotonic && len(seen[k]) > tMaxPts {
|
|
tMaxPts, trigKey = len(seen[k]), k
|
|
}
|
|
}
|
|
if trigKey == "" {
|
|
fail("no oscillating signal found for trigger test")
|
|
}
|
|
vals := lastVals[trigKey]
|
|
mean := 0.0
|
|
for _, v := range vals {
|
|
mean += v
|
|
}
|
|
mean /= float64(len(vals))
|
|
log.Printf(" trigger signal %s, threshold %.6g", trigKey, mean)
|
|
|
|
c.send(map[string]interface{}{
|
|
"type": "setTrigger", "signal": trigKey, "edge": "rising",
|
|
"threshold": mean, "windowSec": 0.1, "prePercent": 20.0,
|
|
"mode": "single",
|
|
})
|
|
c.send(map[string]interface{}{"type": "arm"})
|
|
// The trigger can fire within microseconds of arming (5 MS/s sine), so
|
|
// the broadcast emitted by the arm command may already say "collecting"
|
|
// or even "triggered" — any of these proves the arm was accepted.
|
|
c.waitFor("triggerState: armed/collecting/triggered", 5*time.Second, func() bool {
|
|
for _, s := range c.trigSt {
|
|
if s == "armed" || s == "collecting" || s == "triggered" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
c.waitFor("binary v2 capture frame", 15*time.Second, func() bool {
|
|
return len(c.captures) > 0
|
|
})
|
|
|
|
cap0 := c.captures[0]
|
|
if math.Abs(cap0.preSec-0.02) > 1e-9 || math.Abs(cap0.postSec-0.08) > 1e-9 {
|
|
fail("capture window mismatch: pre=%.6f post=%.6f (want 0.02/0.08)",
|
|
cap0.preSec, cap0.postSec)
|
|
}
|
|
pts, ok := cap0.signals[trigKey]
|
|
if !ok || len(pts.T) == 0 {
|
|
fail("capture missing trigger signal %s (%d signals)", trigKey, len(cap0.signals))
|
|
}
|
|
for _, t := range pts.T {
|
|
if t < cap0.trigTime-cap0.preSec-1e-3 || t > cap0.trigTime+cap0.postSec+1e-3 {
|
|
fail("capture point outside window: t=%.9f trig=%.9f", t, cap0.trigTime)
|
|
}
|
|
}
|
|
log.Printf("OK capture: trig=%.6f pre=%.3fs post=%.3fs %d signals",
|
|
cap0.trigTime, cap0.preSec, cap0.postSec, len(cap0.signals))
|
|
|
|
c.waitFor("triggerState: triggered", 5*time.Second, func() bool {
|
|
for _, s := range c.trigSt {
|
|
if s == "triggered" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
c.send(map[string]interface{}{"type": "disarm"})
|
|
|
|
fmt.Println("PASS streamhub-e2e: all checks passed")
|
|
}
|