Implemented new C++ logic
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
// 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 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
|
||||
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 "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{},
|
||||
}
|
||||
|
||||
// ── 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
|
||||
})
|
||||
|
||||
// ── 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")
|
||||
}
|
||||
Reference in New Issue
Block a user