feat(webui): sporadic-trigger capture, CSV export and UI rework
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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
cf815e1d3f
commit
f334995865
@@ -0,0 +1,87 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user