Brings the Go hub and web SPA work developed on feature/udpscope onto main, without the udpscope client itself. The trigger engine could not capture a sporadic event: it armed on the live tail only, so a burst shorter than one push window was already past by the time the FSM looked for it. It now searches the ring history for the crossing, which also makes a capture reproducible from the same data rather than dependent on push timing (wshub/trigger.go, ringbuf.go, history.go). Adds CSV/JSON export of the visible window (wshub/export.go) and reworks the SPA: per-signal axis controls, a readable trigger panel, and a fix for the flicker caused by repainting on every push instead of on a frame tick (static/app.js, index.html, style.css). BUFFER_AND_TRIGGER.md documents the ring/decimation/trigger interaction, which is otherwise only inferable from the three files that implement it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
88 lines
2.5 KiB
Go
88 lines
2.5 KiB
Go
package wshub
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/parquet-go/parquet-go"
|
|
)
|
|
|
|
func TestHandleExportParquetFullResolution(t *testing.T) {
|
|
h := NewHub()
|
|
// Two signals with different lengths and offset time bases: the export must
|
|
// keep every sample of each, on its own timestamps (no holes, no
|
|
// resampling, no decimation).
|
|
sig1 := newSigRing(10000)
|
|
sig2 := newSigRing(10000)
|
|
t1, v1 := make([]float64, 1000), make([]float64, 1000)
|
|
for i := range t1 {
|
|
t1[i] = float64(i) * 0.001
|
|
v1[i] = float64(i) * 2
|
|
}
|
|
sig1.write(t1, v1)
|
|
t2, v2 := make([]float64, 500), make([]float64, 500)
|
|
for i := range t2 {
|
|
t2[i] = 0.1 + float64(i)*0.002
|
|
v2[i] = -float64(i)
|
|
}
|
|
sig2.write(t2, v2)
|
|
h.rings["s1:Ch1"] = sig1
|
|
h.rings["s1:Ch2"] = sig2
|
|
|
|
req := httptest.NewRequest("GET", "/api/export?t0=0&t1=2&signals=s1:Ch1,s1:Ch2", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleExport(rec, req)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
reader := parquet.NewGenericReader[ExportSample](bytes.NewReader(rec.Body.Bytes()))
|
|
defer reader.Close()
|
|
|
|
var got []ExportSample
|
|
buf := make([]ExportSample, 1000)
|
|
for {
|
|
n, err := reader.Read(buf)
|
|
got = append(got, buf[:n]...)
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
if len(got) != 1500 {
|
|
t.Fatalf("rows = %d, want 1500 (every sample of both signals)", len(got))
|
|
}
|
|
ch1 := filterExportSamples(got, "s1", "Ch1")
|
|
ch2 := filterExportSamples(got, "s1", "Ch2")
|
|
if len(ch1) != 1000 || len(ch2) != 500 {
|
|
t.Fatalf("ch1=%d ch2=%d rows, want 1000/500 (no holes, no resampling)", len(ch1), len(ch2))
|
|
}
|
|
if ch1[0].Time != 0 || ch1[0].Value != 0 || ch1[999].Time != 0.999 || ch1[999].Value != 1998 {
|
|
t.Fatalf("ch1 endpoints wrong: first=%+v last=%+v", ch1[0], ch1[999])
|
|
}
|
|
if ch2[0].Time != 0.1 || ch2[499].Time != 0.1+499*0.002 || ch2[499].Value != -499 {
|
|
t.Fatalf("ch2 endpoints wrong: first=%+v last=%+v", ch2[0], ch2[499])
|
|
}
|
|
}
|
|
|
|
func filterExportSamples(rows []ExportSample, source, signal string) []ExportSample {
|
|
out := make([]ExportSample, 0, len(rows))
|
|
for _, r := range rows {
|
|
if r.Source == source && r.Signal == signal {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestHandleExportParquetBadRange(t *testing.T) {
|
|
h := NewHub()
|
|
h.rings["s1:Ch1"] = newSigRing(10)
|
|
req := httptest.NewRequest("GET", "/api/export?t0=2&t1=1", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleExport(rec, req)
|
|
if rec.Code != 400 {
|
|
t.Fatalf("status = %d, want 400 for inverted range", rec.Code)
|
|
}
|
|
}
|