refactor(e2e): rename Test/E2E/chain -> Test/E2E/suite, run_chain_e2e.sh -> run_e2e.sh

This commit is contained in:
Martino Ferrari
2026-07-01 18:42:33 +02:00
parent 1fcc4e4e6d
commit 69e52af20b
30 changed files with 14 additions and 14 deletions
+871
View File
@@ -0,0 +1,871 @@
// Command chain-client is the authoritative mock StreamHub client for the
// streaming-chain E2E suite. It connects to a running StreamHub, records the
// live binary stream to disk (received_<id>.bin), and runs behavioural checks
// (live / zoom / window / trigger), writing the results to checks_<id>.json.
//
// Unlike the streamhub smoke test, individual check failures are *recorded*
// (not fatal): only connection/protocol corruption exits non-zero, so one
// scenario's quirk never aborts the matrix. The waveform validator and report
// decide overall pass/fail from checks_<id>.json + the recorded stream.
//
// received_<id>.bin format ("RCV1"):
//
// magic[4]="RCV1"; [u32 nSig]; per signal:
// [u16 keyLen][key][u32 N][N×f64 t][N×f64 v]
//
// where each signal's samples are merged across all v1 pushes, sorted by time
// and de-duplicated (same timestamp kept once).
package main
import (
"encoding/binary"
"encoding/json"
"flag"
"fmt"
"log"
"math"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
var (
hubFlag = flag.String("hub", "127.0.0.1:8090", "StreamHub host:port")
scenario = flag.String("scenario", "", "scenario id (artifact basename)")
trigsig = flag.String("trigsig", "", "trigger signal key src:sig (empty = skip)")
trigthr = flag.Float64("trigthr", math.NaN(), "trigger threshold (NaN = use mean)")
checksCSV = flag.String("checks", "live,zoom,window,trigger", "checks to run")
outDir = flag.String("out", "/tmp/chain_e2e", "artifact output dir")
durSec = flag.Float64("dur", 4.0, "live recording duration (s)")
timeout = flag.Duration("timeout", 90*time.Second, "overall timeout")
verbose = flag.Bool("v", false, "log every event")
mode = flag.String("mode", "checks", "checks | stress")
reqrate = flag.Float64("reqrate", 0, "stress: sustained zoom requests/sec (0 = liveness only)")
clientID = flag.Int("clientid", 0, "stress: parallel client index (output suffix)")
)
// ── wire types ────────────────────────────────────────────────────────────
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 points 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"`
}
type pushFrame struct {
sourceID string
signals map[string]points
}
type captureFrame struct {
trigTime, preSec, postSec float64
signals map[string]points
}
// ── binary parsers (from Test/E2E/streamhub/main.go) ────────────────────────
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]points{}}
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 := points{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")
}
rd := func(off int) float64 { return math.Float64frombits(binary.LittleEndian.Uint64(b[off:])) }
f := &captureFrame{trigTime: rd(1), preSec: rd(9), postSec: rd(17), signals: map[string]points{}}
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 := points{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
}
// ── client ──────────────────────────────────────────────────────────────────
type client struct {
ws *websocket.Conn
deadline time.Time
mu sync.Mutex
sources []sourceInfo
configs map[string][]signalInfo
pushes []*pushFrame
zooms map[uint32]map[string]points
zoomArrival map[uint32]time.Time
trigSt []string
captures []*captureFrame
readErr error
done chan struct{}
}
func (c *client) send(v interface{}) {
b, _ := json.Marshal(v)
if err := c.ws.WriteMessage(websocket.TextMessage, b); err != nil {
fatal("ws write: %v", err)
}
}
// reader runs in its own goroutine for the lifetime of the connection. gorilla's
// websocket connection cannot survive a read deadline (the next ReadMessage
// panics), so we never set one here: we block on ReadMessage and let the overall
// timeout/cond logic in waitFor decide when enough has arrived.
func (c *client) reader() {
defer close(c.done)
for {
mt, data, err := c.ws.ReadMessage()
if err != nil {
c.mu.Lock()
c.readErr = err
c.mu.Unlock()
return
}
c.handle(mt, data)
}
}
func (c *client) handle(mt int, data []byte) {
switch mt {
case websocket.BinaryMessage:
if len(data) == 0 {
return
}
switch data[0] {
case 1:
if f, err := parsePush(data); err == nil {
c.mu.Lock()
c.pushes = append(c.pushes, f)
c.mu.Unlock()
} else {
fatal("bad v1 frame: %v", err)
}
case 2:
if f, err := parseCapture(data); err == nil {
c.mu.Lock()
c.captures = append(c.captures, f)
c.mu.Unlock()
} else {
fatal("bad v2 frame: %v", err)
}
default:
fatal("unknown binary frame version %d", data[0])
}
case websocket.TextMessage:
var ev event
if err := json.Unmarshal(data, &ev); err != nil {
return
}
if *verbose {
log.Printf("event %-12s %.140s", ev.Type, data)
}
switch ev.Type {
case "sources":
var s []sourceInfo
if json.Unmarshal(ev.Sources, &s) == nil {
c.mu.Lock()
c.sources = s
c.mu.Unlock()
}
case "config":
var s []signalInfo
if json.Unmarshal(ev.Signals, &s) == nil {
c.mu.Lock()
c.configs[ev.SourceID] = s
c.mu.Unlock()
}
case "zoom":
var body struct {
Signals map[string]points `json:"signals"`
}
if json.Unmarshal(data, &body) == nil {
c.mu.Lock()
c.zooms[ev.ReqID] = body.Signals
c.zoomArrival[ev.ReqID] = time.Now()
c.mu.Unlock()
}
case "triggerState":
c.mu.Lock()
c.trigSt = append(c.trigSt, ev.State)
c.mu.Unlock()
}
}
}
// waitFor polls cond (evaluated under the state lock) until it holds or the
// deadline passes. It returns early if the reader goroutine died.
func (c *client) waitFor(d time.Duration, cond func() bool) bool {
end := time.Now().Add(d)
if end.After(c.deadline) {
end = c.deadline
}
for time.Now().Before(end) {
c.mu.Lock()
ok := cond()
err := c.readErr
c.mu.Unlock()
if ok {
return true
}
if err != nil {
return false
}
select {
case <-c.done:
c.mu.Lock()
ok := cond()
c.mu.Unlock()
return ok
case <-time.After(10 * time.Millisecond):
}
}
return false
}
// zoom returns the recorded zoom reply for reqID, if present.
func (c *client) zoom(reqID uint32) (map[string]points, bool) {
c.mu.Lock()
defer c.mu.Unlock()
z, ok := c.zooms[reqID]
return z, ok
}
// lastCapture returns the most recently recorded trigger capture, if any.
func (c *client) lastCapture() *captureFrame {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.captures) == 0 {
return nil
}
return c.captures[len(c.captures)-1]
}
// nCaptures returns the number of trigger captures recorded so far.
func (c *client) nCaptures() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.captures)
}
// nPushes returns the number of live push frames recorded so far.
func (c *client) nPushes() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.pushes)
}
func fatal(format string, a ...interface{}) {
fmt.Printf("FATAL "+format+"\n", a...)
os.Exit(1)
}
// merged returns time-sorted, de-duplicated samples per "src:sig" key.
func (c *client) merged() map[string]points {
c.mu.Lock()
pushes := append([]*pushFrame(nil), c.pushes...)
c.mu.Unlock()
tmp := map[string]points{}
for _, f := range pushes {
for k, p := range f.signals {
full := f.sourceID + ":" + k
cur := tmp[full]
cur.T = append(cur.T, p.T...)
cur.V = append(cur.V, p.V...)
tmp[full] = cur
}
}
out := map[string]points{}
for k, p := range tmp {
idx := make([]int, len(p.T))
for i := range idx {
idx[i] = i
}
sort.Slice(idx, func(a, b int) bool { return p.T[idx[a]] < p.T[idx[b]] })
var op points
last := math.NaN()
for _, i := range idx {
if !math.IsNaN(last) && p.T[i] == last {
continue
}
op.T = append(op.T, p.T[i])
op.V = append(op.V, p.V[i])
last = p.T[i]
}
out[k] = op
}
return out
}
// ── received_<id>.bin writer ─────────────────────────────────────────────────
func writeReceived(path string, m map[string]points) error {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
var buf []byte
put32 := func(v uint32) { var t [4]byte; binary.LittleEndian.PutUint32(t[:], v); buf = append(buf, t[:]...) }
put16 := func(v uint16) { var t [2]byte; binary.LittleEndian.PutUint16(t[:], v); buf = append(buf, t[:]...) }
putf := func(v float64) { var t [8]byte; binary.LittleEndian.PutUint64(t[:], math.Float64bits(v)); buf = append(buf, t[:]...) }
buf = append(buf, []byte("RCV1")...)
put32(uint32(len(keys)))
for _, k := range keys {
put16(uint16(len(k)))
buf = append(buf, []byte(k)...)
p := m[k]
put32(uint32(len(p.T)))
for _, t := range p.T {
putf(t)
}
for _, v := range p.V {
putf(v)
}
}
_, err = f.Write(buf)
return err
}
// ── checks structures ────────────────────────────────────────────────────────
type zoomCheck struct {
Range [2]float64 `json:"range"`
N int `json:"n"`
Returned int `json:"returned"`
InRange bool `json:"inrange"`
Key string `json:"key"`
}
type windowCheck struct {
WindowSec float64 `json:"windowSec"`
Span float64 `json:"span"`
Returned int `json:"returned"`
OK bool `json:"ok"`
Key string `json:"key"`
}
type trigCheck struct {
Edge string `json:"edge"`
Mode string `json:"mode"`
Fired bool `json:"fired"`
TrigTime float64 `json:"trigTime"`
PreSec float64 `json:"preSec"`
PostSec float64 `json:"postSec"`
CapturePts int `json:"capturePts"`
EdgeOK bool `json:"edgeOk"`
WindowOK bool `json:"windowOk"`
Rearmed bool `json:"rearmed"`
Key string `json:"key"`
}
type liveCheck struct {
OK bool `json:"ok"`
Frames int `json:"frames"`
Signals int `json:"signals"`
Monotonic bool `json:"monotonic"`
WallClock bool `json:"wallclock"`
DurationS float64 `json:"duration"`
}
type checksOut struct {
Scenario string `json:"scenario"`
Live liveCheck `json:"live"`
Zoom []zoomCheck `json:"zoom"`
Window windowCheck `json:"window"`
Trigger []trigCheck `json:"trigger"`
}
func has(set, name string) bool {
for _, s := range strings.Split(set, ",") {
if strings.TrimSpace(s) == name {
return true
}
}
return false
}
func main() {
flag.Parse()
if *scenario == "" {
fatal("missing -scenario")
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
fatal("mkdir %s: %v", *outDir, err)
}
url := "ws://" + *hubFlag + "/ws"
log.Printf("connecting to %s (scenario %s)", url, *scenario)
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
fatal("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]points{},
zoomArrival: map[uint32]time.Time{},
done: make(chan struct{}),
}
go c.reader()
out := checksOut{Scenario: *scenario}
// 1. sources connected
c.send(map[string]interface{}{"type": "getSources"})
if !c.waitFor(20*time.Second, func() bool {
for _, s := range c.sources {
if s.State == "connected" {
return true
}
}
return false
}) {
fatal("no connected source within timeout")
}
for _, s := range c.sources {
c.send(map[string]interface{}{"type": "getConfig", "sourceId": s.ID})
}
c.waitFor(5*time.Second, func() bool {
for _, s := range c.sources {
if s.State == "connected" && len(c.configs[s.ID]) == 0 {
return false
}
}
return true
})
// stress mode: record liveness + sustained zoom latency, then exit. The
// correctness checks below are skipped (a separate gate framework).
if *mode == "stress" {
c.runStress(*scenario, *clientID, *durSec, *reqrate, *outDir)
return
}
// 2. live recording — the reader goroutine accumulates pushes in the
// background, so we simply wait out the recording window (or an early
// reader death).
c.waitFor(time.Duration(*durSec*float64(time.Second)), func() bool { return false })
m := c.merged()
recvPath := filepath.Join(*outDir, "received_"+*scenario+".bin")
if err := writeReceived(recvPath, m); err != nil {
fatal("write received: %v", err)
}
now := float64(time.Now().UnixNano()) / 1e9
mono, wall := true, true
for _, p := range m {
for i, t := range p.T {
if math.Abs(t-now) > 60.0 {
wall = false
}
if i > 0 && t < p.T[i-1]-1e-9 {
mono = false
}
}
}
nPush := c.nPushes()
out.Live = liveCheck{
OK: nPush >= 5 && len(m) > 0 && mono && wall,
Frames: nPush, Signals: len(m), Monotonic: mono,
WallClock: wall, DurationS: *durSec,
}
log.Printf("live: %d frames, %d signals, mono=%v wall=%v", nPush, len(m), mono, wall)
// busiest signal for zoom/window
var busy string
bn := 0
for k, p := range m {
if len(p.T) > bn {
bn, busy = len(p.T), k
}
}
// 3. zoom: narrow + wide
if has(*checksCSV, "zoom") && busy != "" {
ts := m[busy].T
t1 := ts[len(ts)-1]
t0full := ts[0]
ranges := [][2]float64{{t1 - 0.05, t1}, {t0full, t1}} // narrow, wide
for ri, rg := range ranges {
reqID := uint32(1000 + ri)
c.send(map[string]interface{}{
"type": "zoom", "reqId": reqID, "t0": rg[0], "t1": rg[1],
"n": 300, "signals": busy,
})
ok := c.waitFor(8*time.Second, func() bool { _, ok := c.zooms[reqID]; return ok })
zc := zoomCheck{Range: rg, N: 300, Key: busy, InRange: true}
if ok {
z, _ := c.zoom(reqID)
pts := z[busy]
zc.Returned = len(pts.T)
for _, t := range pts.T {
if t < rg[0]-1e-6 || t > rg[1]+1e-6 {
zc.InRange = false
}
}
} else {
zc.InRange = false
}
out.Zoom = append(out.Zoom, zc)
log.Printf("zoom [%.4f,%.4f] returned=%d inrange=%v", rg[0], rg[1], zc.Returned, zc.InRange)
}
}
// 4. window: zoom over [now-windowSec, now] and check span ≤ window
if has(*checksCSV, "window") && busy != "" {
ts := m[busy].T
t1 := ts[len(ts)-1]
const winSec = 1.0
reqID := uint32(2000)
c.send(map[string]interface{}{
"type": "zoom", "reqId": reqID, "t0": t1 - winSec, "t1": t1,
"n": 600, "signals": busy,
})
ok := c.waitFor(8*time.Second, func() bool { _, ok := c.zooms[reqID]; return ok })
wc := windowCheck{WindowSec: winSec, Key: busy}
if ok {
z, _ := c.zoom(reqID)
pts := z[busy]
wc.Returned = len(pts.T)
if len(pts.T) >= 2 {
wc.Span = pts.T[len(pts.T)-1] - pts.T[0]
wc.OK = wc.Span <= winSec+1e-3
}
}
out.Window = wc
log.Printf("window %.2fs: span=%.4f returned=%d ok=%v", winSec, wc.Span, wc.Returned, wc.OK)
}
// 5. trigger matrix
if has(*checksCSV, "trigger") && *trigsig != "" {
thr := *trigthr
if math.IsNaN(thr) {
if p, ok := m[*trigsig]; ok && len(p.V) > 0 {
s := 0.0
for _, v := range p.V {
s += v
}
thr = s / float64(len(p.V))
} else {
thr = 0.0
}
}
log.Printf("trigger signal %s threshold %.6g", *trigsig, thr)
for _, edge := range []string{"rising", "falling", "both"} {
for _, mode := range []string{"normal", "single"} {
out.Trigger = append(out.Trigger, c.runTrigger(*trigsig, edge, mode, thr))
}
}
}
// write checks json
cj := filepath.Join(*outDir, "checks_"+*scenario+".json")
b, _ := json.MarshalIndent(out, "", " ")
if err := os.WriteFile(cj, b, 0o644); err != nil {
fatal("write checks: %v", err)
}
fmt.Printf("OK chain-client %s: %s + %s\n", *scenario, filepath.Base(recvPath), filepath.Base(cj))
}
// ── stress mode ──────────────────────────────────────────────────────────────
type stressOut struct {
Scenario string `json:"scenario"`
ClientID int `json:"clientId"`
Frames int `json:"frames"`
Signals int `json:"signals"`
Monotonic bool `json:"monotonic"`
WallClock bool `json:"wallclock"`
DurationS float64 `json:"duration"`
ReqRate float64 `json:"reqRate"`
ZoomCount int `json:"zoomCount"`
ZoomFail int `json:"zoomFail"`
ZoomP50ms float64 `json:"zoomP50ms"`
ZoomP95ms float64 `json:"zoomP95ms"`
ZoomMaxms float64 `json:"zoomMaxms"`
Key string `json:"key"`
}
// busiestKey returns the full "src:sig" key carrying the most samples so far.
func (c *client) busiestKey() string {
m := c.merged()
var busy string
bn := 0
for k, p := range m {
if len(p.T) > bn {
bn, busy = len(p.T), k
}
}
return busy
}
// maxTimeFull returns the latest timestamp seen for a full "src:sig" key.
func (c *client) maxTimeFull(full string) (float64, bool) {
c.mu.Lock()
defer c.mu.Unlock()
var mx float64
found := false
for _, f := range c.pushes {
if !strings.HasPrefix(full, f.sourceID+":") {
continue
}
name := full[len(f.sourceID)+1:]
p, ok := f.signals[name]
if !ok {
continue
}
for _, t := range p.T {
if !found || t > mx {
mx, found = t, true
}
}
}
return mx, found
}
// runStress records liveness for the duration while (optionally) issuing zoom
// queries at a sustained rate, measuring round-trip latency. One request is in
// flight at a time, so latency reflects the hub's serialised zoom service time
// under whatever concurrent live/zoom load the matrix imposes.
func (c *client) runStress(scenario string, clientID int, dur, reqrate float64, outDir string) {
if !c.waitFor(20*time.Second, func() bool { return len(c.pushes) > 0 }) {
fatal("stress: no live push within timeout")
}
// brief warmup so the busiest signal and a usable time window exist.
c.waitFor(500*time.Millisecond, func() bool { return false })
key := c.busiestKey()
start := time.Now()
end := start.Add(time.Duration(dur * float64(time.Second)))
var lat []float64
zoomFail := 0
var reqID uint32 = 5000
var interval time.Duration
if reqrate > 0 {
interval = time.Duration(float64(time.Second) / reqrate)
}
for reqrate > 0 && key != "" && time.Now().Before(end) {
tick := time.Now()
t1, ok := c.maxTimeFull(key)
if !ok {
c.waitFor(50*time.Millisecond, func() bool { return false })
continue
}
reqID++
c.send(map[string]interface{}{
"type": "zoom", "reqId": reqID, "t0": t1 - 0.5, "t1": t1,
"n": 300, "signals": key,
})
sendT := time.Now()
got := c.waitFor(3*time.Second, func() bool {
_, ok := c.zoomArrival[reqID]
return ok
})
if got {
c.mu.Lock()
at := c.zoomArrival[reqID]
c.mu.Unlock()
lat = append(lat, at.Sub(sendT).Seconds()*1000.0)
} else {
zoomFail++
}
if rem := interval - time.Since(tick); rem > 0 {
c.waitFor(rem, func() bool { return false })
}
}
// if no reqrate, simply wait out the remaining liveness window.
if reqrate <= 0 {
c.waitFor(time.Until(end), func() bool { return false })
}
m := c.merged()
now := float64(time.Now().UnixNano()) / 1e9
mono, wall := true, true
for _, p := range m {
for i, t := range p.T {
if math.Abs(t-now) > 60.0 {
wall = false
}
if i > 0 && t < p.T[i-1]-1e-9 {
mono = false
}
}
}
sort.Float64s(lat)
pct := func(q float64) float64 {
if len(lat) == 0 {
return 0
}
idx := int(q * float64(len(lat)-1))
return lat[idx]
}
so := stressOut{
Scenario: scenario, ClientID: clientID,
Frames: c.nPushes(), Signals: len(m), Monotonic: mono,
WallClock: wall, DurationS: dur, ReqRate: reqrate, Key: key,
ZoomCount: len(lat), ZoomFail: zoomFail,
ZoomP50ms: pct(0.50), ZoomP95ms: pct(0.95),
}
if len(lat) > 0 {
so.ZoomMaxms = lat[len(lat)-1]
}
path := filepath.Join(outDir,
fmt.Sprintf("stress_%s_c%d.json", scenario, clientID))
b, _ := json.MarshalIndent(so, "", " ")
if err := os.WriteFile(path, b, 0o644); err != nil {
fatal("write stress: %v", err)
}
log.Printf("stress: frames=%d signals=%d zoom n=%d fail=%d p50=%.1fms p95=%.1fms max=%.1fms",
so.Frames, so.Signals, so.ZoomCount, so.ZoomFail, so.ZoomP50ms, so.ZoomP95ms, so.ZoomMaxms)
fmt.Printf("OK stress %s c%d: %s\n", scenario, clientID, filepath.Base(path))
}
// runTrigger configures one edge/mode trigger, arms it, and records the result.
func (c *client) runTrigger(key, edge, mode string, thr float64) trigCheck {
tc := trigCheck{Edge: edge, Mode: mode, Key: key}
beforeCaps := c.nCaptures()
c.send(map[string]interface{}{
"type": "setTrigger", "signal": key, "edge": edge,
"threshold": thr, "windowSec": 0.1, "prePercent": 20.0, "mode": mode,
})
c.send(map[string]interface{}{"type": "arm"})
fired := c.waitFor(8*time.Second, func() bool { return len(c.captures) > beforeCaps })
tc.Fired = fired
if !fired {
c.send(map[string]interface{}{"type": "disarm"})
return tc
}
cap0 := c.lastCapture()
tc.TrigTime, tc.PreSec, tc.PostSec = cap0.trigTime, cap0.preSec, cap0.postSec
tc.WindowOK = math.Abs(cap0.preSec-0.02) < 1e-6 && math.Abs(cap0.postSec-0.08) < 1e-6
if pts, ok := cap0.signals[key]; ok {
tc.CapturePts = len(pts.T)
tc.EdgeOK = edgeCrosses(pts, cap0.trigTime, edge, thr)
}
// re-arm behaviour
if mode == "normal" {
tc.Rearmed = c.waitFor(4*time.Second, func() bool { return len(c.captures) > beforeCaps+1 })
} else { // single: must NOT fire again until rearm
again := c.waitFor(1500*time.Millisecond, func() bool { return len(c.captures) > beforeCaps+1 })
c.send(map[string]interface{}{"type": "rearm"})
tc.Rearmed = !again && c.waitFor(4*time.Second, func() bool { return len(c.captures) > beforeCaps+1 })
}
c.send(map[string]interface{}{"type": "disarm"})
log.Printf("trigger %s/%s fired=%v edgeOk=%v winOk=%v rearm=%v",
edge, mode, tc.Fired, tc.EdgeOK, tc.WindowOK, tc.Rearmed)
return tc
}
// edgeCrosses verifies the captured waveform crosses thr in the edge direction
// near trigTime.
func edgeCrosses(p points, trigTime float64, edge string, thr float64) bool {
// find the sample pair straddling trigTime
for i := 1; i < len(p.T); i++ {
if p.T[i-1] <= trigTime && p.T[i] >= trigTime {
a, b := p.V[i-1], p.V[i]
switch edge {
case "rising":
return a <= thr && b >= thr
case "falling":
return a >= thr && b <= thr
case "both":
return (a <= thr && b >= thr) || (a >= thr && b <= thr)
}
}
}
return false
}