package wshub import ( "encoding/binary" "math" "os" "path/filepath" "testing" "marte2/common/udpsprotocol" ) // newTestHistory opens a writer in a temp dir with one signal file of the given // declared rate, and returns the writer plus that signal's key. func newTestHistory(t *testing.T, cfg HistoryConfig, rate float64) (*historyWriter, string) { t.Helper() if cfg.Directory == "" { cfg.Directory = t.TempDir() } hw, err := newHistoryWriter(cfg) if err != nil { t.Fatalf("newHistoryWriter: %v", err) } hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{ {Name: "sig", TypeCode: 8, SamplingRate: rate}, }) t.Cleanup(hw.close) return hw, "src:sig" } func ramp(t0 float64, dt float64, n int) ([]float64, []float64) { ts := make([]float64, n) vs := make([]float64, n) for i := range ts { ts[i] = t0 + float64(i)*dt vs[i] = float64(i) } return ts, vs } // A budget that cannot hold the window at full rate must buy the window by // widening the min/max bucket, not by archiving a shorter stretch: a user // looking at 600 s wants 600 s of it archived, coarser if need be. func TestHistCapacityKeepsWindowByBucketing(t *testing.T) { const mega = 1 << 20 cases := []struct { name string window float64 rate float64 maxPts int wantBucket int }{ // 60 s of 1 kSps is 60 k samples — well inside 1 MPt, so stored verbatim. {"slow signal keeps full resolution", 60, 1000, mega, 1}, // 600 s of 1 MSps is 600 M samples against 16 Mi points: at 2 points per // bucket and the headroom, ceil(2 × 1.25 × 600e6 / 16Mi) = 90 per bucket. {"fast signal is enveloped", 600, 1e6, 16 * mega, 90}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { capacity, bucket := histCapacityFor(c.window, c.rate, 1, c.maxPts) if bucket != c.wantBucket { t.Errorf("bucket = %d, want %d", bucket, c.wantBucket) } if capacity > uint32(c.maxPts) { t.Errorf("capacity %d exceeds the %d-point budget", capacity, c.maxPts) } // The whole window has to fit, which is the entire point. if covered := histCoverageSec(capacity, bucket, 1, c.rate); covered < c.window { t.Errorf("archive covers %.1f s, want the %.1f s window", covered, c.window) } }) } } // The file exists to serve the window, so it must track it: a client that widens // what it displays must not be left reading an archive sized for the old span. func TestHistorySetWindowResizesFiles(t *testing.T) { hw, key := newTestHistory(t, HistoryConfig{WindowSec: 10}, 1000) before := hw.files[key] if before.bucket != 1 || histCoverageSec(before.capacity, 1, 1, 1000) < 10 { t.Fatalf("initial geometry = cap %d bucket %d, want 10 s verbatim", before.capacity, before.bucket) } if !hw.setWindow(600) { t.Fatal("setWindow reported no change for a 60× wider window") } after := hw.files[key] if after == before { t.Fatal("the file was not re-created") } if cov := histCoverageSec(after.capacity, after.bucket, 1, 1000); cov < 600 { t.Fatalf("archive covers %.1f s, want the new 600 s window", cov) } // Same window again: nothing to do, and re-creating the file would throw the // archive away for nothing. if hw.setWindow(600) { t.Fatal("setWindow re-sized for an unchanged window") } // A nudge inside the hysteresis band must not either. if hw.setWindow(610) { t.Fatal("setWindow re-sized for a 2 % window change") } if hw.files[key] != after { t.Fatal("the file was re-created despite the hysteresis") } } // The archive is what a zoom beyond the rings reads, so a spike that only the // archive still holds must survive being written to it. func TestHistoryBucketedWriteKeepsPeaks(t *testing.T) { // 1 kSps for 1 s = 1000 samples, plus headroom, into a 100-point budget → // buckets of ceil(2 × 1.25 × 1000 / 100) = 25. hw, key := newTestHistory(t, HistoryConfig{ WindowSec: 1, MinDiskFreeMB: -1, MaxPointsPerSignal: 100, }, 1000) hf := hw.files[key] if hf.bucket != 25 { t.Fatalf("bucket = %d, want 25", hf.bucket) } ts := make([]float64, 1000) vs := make([]float64, 1000) for i := range ts { ts[i] = float64(i) * 0.001 } vs[137] = 7.5 // a one-sample positive spike vs[500] = -3.5 // and a negative one hw.write(key, ts, vs) rt, rv := hw.readRange(key, 0, 1, 1000) if len(rt) == 0 { t.Fatal("nothing archived") } hi, lo := false, false for i := range rv { if rv[i] == 7.5 && rt[i] == ts[137] { hi = true } if rv[i] == -3.5 && rt[i] == ts[500] { lo = true } } if !hi || !lo { t.Errorf("archive lost a spike (positive kept=%v, negative kept=%v)", hi, lo) } // A partial bucket is not written until it completes, so the last few // samples may be missing; everything before them must be there. if hf.count == 0 || hf.count > hf.capacity { t.Errorf("archived %d points into a %d-point file", hf.count, hf.capacity) } } func TestHistoryDisabledWithoutDirectory(t *testing.T) { hw, err := newHistoryWriter(HistoryConfig{}) if err != nil { t.Fatalf("newHistoryWriter: %v", err) } if hw != nil { t.Fatal("empty Directory must disable history") } // Every method must stay usable on the nil writer, which is how the hub // avoids guarding each call site. if hw.enabled() { t.Fatal("nil writer reports enabled") } hw.write("src:sig", []float64{1}, []float64{1}) hw.flushHeaders() hw.close() if rt, _ := hw.readRange("src:sig", 0, 1, 10); rt != nil { t.Fatal("nil writer returned data") } if len(hw.info()) != 0 { t.Fatal("nil writer returned info entries") } } func TestHistoryWriteReadRoundTrip(t *testing.T) { hw, key := newTestHistory(t, HistoryConfig{}, 100) ts, vs := ramp(10, 0.01, 500) hw.write(key, ts, vs) rt, rv := hw.readRange(key, 10.5, 11.0, 10000) if len(rt) != 51 { // inclusive both ends, 0.01 s spacing t.Fatalf("read %d points, want 51", len(rt)) } if rt[0] < 10.5-1e-9 || rt[len(rt)-1] > 11.0+1e-9 { t.Fatalf("range [%v, %v] escapes the request", rt[0], rt[len(rt)-1]) } for i := range rt { wantV := math.Round((rt[i] - 10) / 0.01) if math.Abs(rv[i]-wantV) > 1e-6 { t.Fatalf("point %d: value %v, want %v", i, rv[i], wantV) } } } func TestHistoryReadRangeOutsideDataIsEmpty(t *testing.T) { hw, key := newTestHistory(t, HistoryConfig{}, 100) ts, vs := ramp(10, 0.01, 100) hw.write(key, ts, vs) if rt, _ := hw.readRange(key, 100, 200, 1000); len(rt) != 0 { t.Fatalf("read %d points past the newest sample", len(rt)) } if rt, _ := hw.readRange(key, 0, 5, 1000); len(rt) != 0 { t.Fatalf("read %d points before the oldest sample", len(rt)) } if rt, _ := hw.readRange("src:missing", 10, 11, 1000); rt != nil { t.Fatal("unknown key returned data") } if rt, _ := hw.readRange(key, 11, 10, 1000); rt != nil { t.Fatal("inverted range returned data") } } // Once the file has wrapped, the oldest samples must be gone and the retained // window must still read back contiguously across the wrap point. func TestHistoryWrapAround(t *testing.T) { // A sub-second window at 1 Sps sizes below the 1000-pair floor, which is a // cheap capacity to wrap. hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1) hf := hw.files[key] if hf.capacity != histMinCapacity { t.Fatalf("capacity = %d, want the %d floor", hf.capacity, histMinCapacity) } // 2.5 fills, in batches that do not align with the capacity so the wrap // lands mid-batch. total := 2500 ts, vs := ramp(0, 1, total) for i := 0; i < total; i += 333 { end := i + 333 if end > total { end = total } hw.write(key, ts[i:end], vs[i:end]) } if hf.count != histMinCapacity { t.Fatalf("count = %d, want a full %d", hf.count, histMinCapacity) } wantOldest := float64(total - histMinCapacity) if hf.tOldest != wantOldest { t.Fatalf("tOldest = %v, want %v", hf.tOldest, wantOldest) } if hf.tNewest != float64(total-1) { t.Fatalf("tNewest = %v, want %v", hf.tNewest, float64(total-1)) } rt, rv := hw.readRange(key, wantOldest, float64(total-1), 10000) if len(rt) != histMinCapacity { t.Fatalf("read %d points, want the full %d", len(rt), histMinCapacity) } for i := range rt { want := wantOldest + float64(i) if rt[i] != want || rv[i] != want { t.Fatalf("point %d = (%v, %v), want (%v, %v)", i, rt[i], rv[i], want, want) } } // The evicted samples must not come back. if et, _ := hw.readRange(key, 0, wantOldest-1, 10000); len(et) != 0 { t.Fatalf("read %d evicted points", len(et)) } } // A single batch larger than the file keeps its tail, not its head. func TestHistoryOversizedBatchKeepsTail(t *testing.T) { hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1) ts, vs := ramp(0, 1, 3000) hw.write(key, ts, vs) hf := hw.files[key] if hf.count != histMinCapacity { t.Fatalf("count = %d, want %d", hf.count, histMinCapacity) } if hf.tNewest != 2999 { t.Fatalf("tNewest = %v, want 2999", hf.tNewest) } rt, _ := hw.readRange(key, 2000, 2999, 10000) if len(rt) != histMinCapacity || rt[0] != 2000 { t.Fatalf("retained window starts at %v with %d points, want 2000 / %d", rt[0], len(rt), histMinCapacity) } } func TestHistoryDecimation(t *testing.T) { hw, key := newTestHistory(t, HistoryConfig{Decimation: 4}, 100) // Two batches, so the decimation phase must carry across the call boundary // rather than restarting. ts, vs := ramp(0, 0.01, 100) hw.write(key, ts[:37], vs[:37]) hw.write(key, ts[37:], vs[37:]) rt, _ := hw.readRange(key, -1, 1e9, 10000) if len(rt) != 25 { t.Fatalf("kept %d of 100 points at decimation 4, want 25", len(rt)) } for i := 1; i < len(rt); i++ { if d := rt[i] - rt[i-1]; math.Abs(d-0.04) > 1e-9 { t.Fatalf("spacing at %d = %v, want 0.04", i, d) } } } // The input slices are shared with the zoom ring and the trigger, so decimation // must not touch them. func TestHistoryWriteDoesNotMutateInput(t *testing.T) { hw, key := newTestHistory(t, HistoryConfig{Decimation: 3}, 100) ts, vs := ramp(0, 0.01, 30) tCopy := append([]float64(nil), ts...) vCopy := append([]float64(nil), vs...) hw.write(key, ts, vs) for i := range ts { if ts[i] != tCopy[i] || vs[i] != vCopy[i] { t.Fatalf("write mutated input at %d", i) } } } // Reopening the same directory must pick the file back up with its contents, // which is the whole point of persisting the header. func TestHistoryReopenPreservesData(t *testing.T) { dir := t.TempDir() cfg := HistoryConfig{Directory: dir, WindowSec: 0.36} sigs := []udpsprotocol.SignalInfo{{Name: "sig", TypeCode: 8, SamplingRate: 1}} hw, err := newHistoryWriter(cfg) if err != nil { t.Fatalf("newHistoryWriter: %v", err) } hw.onSourceConfigured("src", sigs) ts, vs := ramp(0, 1, 400) hw.write("src:sig", ts, vs) hw.close() hw2, err := newHistoryWriter(cfg) if err != nil { t.Fatalf("reopen: %v", err) } defer hw2.close() hw2.onSourceConfigured("src", sigs) hf := hw2.files["src:sig"] if hf.count != 400 || hf.head != 400 { t.Fatalf("reopened count=%d head=%d, want 400/400", hf.count, hf.head) } rt, rv := hw2.readRange("src:sig", 100, 199, 10000) if len(rt) != 100 || rt[0] != 100 || rv[0] != 100 { t.Fatalf("reopened read = %d points starting (%v, %v)", len(rt), rt[0], rv[0]) } // Appending after the reopen must continue where the file left off. ts2, vs2 := ramp(400, 1, 50) hw2.write("src:sig", ts2, vs2) if hf.tNewest != 449 { t.Fatalf("tNewest after append = %v, want 449", hf.tNewest) } } // A file sized for a different rate cannot be reused, so it must be recreated // rather than reopened with a mismatched capacity. func TestHistoryReopenWithDifferentCapacityRecreates(t *testing.T) { dir := t.TempDir() cfg := HistoryConfig{Directory: dir, WindowSec: 3600} hw, _ := newHistoryWriter(cfg) hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{ {Name: "sig", TypeCode: 8, SamplingRate: 10}, }) firstCap := hw.files["src:sig"].capacity hw.write("src:sig", []float64{1, 2}, []float64{1, 2}) hw.close() hw2, _ := newHistoryWriter(cfg) defer hw2.close() hw2.onSourceConfigured("src", []udpsprotocol.SignalInfo{ {Name: "sig", TypeCode: 8, SamplingRate: 100}, // 10× the rate }) hf := hw2.files["src:sig"] if hf.capacity == firstCap { t.Fatalf("capacity unchanged at %d despite a 10x rate change", firstCap) } if hf.count != 0 { t.Fatalf("recreated file kept %d samples", hf.count) } } // A corrupt header must not be trusted: the file gets rebuilt instead. func TestHistoryCorruptHeaderRecreates(t *testing.T) { dir := t.TempDir() cfg := HistoryConfig{Directory: dir, WindowSec: 0.36} sigs := []udpsprotocol.SignalInfo{{Name: "sig", TypeCode: 8, SamplingRate: 1}} hw, _ := newHistoryWriter(cfg) hw.onSourceConfigured("src", sigs) hw.write("src:sig", []float64{1, 2, 3}, []float64{1, 2, 3}) hw.close() path := filepath.Join(dir, "src", "sig.shist") f, err := os.OpenFile(path, os.O_RDWR, 0o644) if err != nil { t.Fatalf("open: %v", err) } if _, err := f.WriteAt([]byte("XXXX"), 0); err != nil { // clobber the magic t.Fatalf("clobber: %v", err) } f.Close() hw2, _ := newHistoryWriter(cfg) defer hw2.close() hw2.onSourceConfigured("src", sigs) if got := hw2.files["src:sig"].count; got != 0 { t.Fatalf("count = %d, want a recreated empty file", got) } } // Raising the budget from the UI has to buy resolution: same duration, a // narrower min/max bucket. Lowering it again must not overrun the new budget. func TestSetBudgetRebucketsAtTheSameDuration(t *testing.T) { // 100 s of 100 kSps is 10 M samples, well past either budget. hw, key := newTestHistory(t, HistoryConfig{ WindowSec: 100, MaxPointsPerSignal: 100_000, }, 1e5) before := hw.files[key] if before.bucket <= 1 { t.Fatalf("bucket = %d, want the signal enveloped to fit the budget", before.bucket) } if got := hw.setBudget(1_000_000); got != 1_000_000 { t.Fatalf("setBudget = %d, want 1000000", got) } after := hw.files[key] if after == before { t.Fatal("the file was not re-created") } if after.bucket >= before.bucket { t.Fatalf("bucket %d → %d, want a finer envelope for a 10× budget", before.bucket, after.bucket) } if after.capacity > 1_000_000 { t.Fatalf("capacity = %d, over the 1 MPts budget", after.capacity) } // The point of the envelope: the duration is covered whatever the budget. if cov := float64(after.capacity) * float64(after.bucket) / 2 / 1e5; cov < 99 { t.Fatalf("coverage = %.1f s, want ~100 s", cov) } if got := hw.setBudget(100_000); got != 100_000 { t.Fatalf("setBudget back = %d, want 100000", got) } if c := hw.files[key].capacity; c > 100_000 { t.Fatalf("capacity = %d, over the restored 100 kPts budget", c) } } // A budget that leaves a signal's geometry alone must leave its archive alone // too — re-creating files nobody asked to resize would throw away history. func TestSetBudgetKeepsUnaffectedFiles(t *testing.T) { hw, key := newTestHistory(t, HistoryConfig{ WindowSec: 1, MaxPointsPerSignal: 16 << 20, }, 1000) ts, vs := ramp(0, 0.001, 100) hw.write(key, ts, vs) hw.setBudget(8 << 20) // still far more than the 1000 points this signal needs hf := hw.files[key] if hf.bucket != 1 { t.Fatalf("bucket = %d, want the slow signal still archived verbatim", hf.bucket) } if hf.count != 100 { t.Fatalf("count = %d, want the 100 archived samples kept", hf.count) } } // Time-reference signals are the clock for the others, so archiving them would // just waste disk. func TestHistorySkipsTimeSignals(t *testing.T) { dir := t.TempDir() hw, _ := newHistoryWriter(HistoryConfig{Directory: dir}) defer hw.close() hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{ {Name: "TimeArray", TypeCode: histTypeCodeUint64, SamplingRate: 1000}, {Name: "data", TypeCode: 8, SamplingRate: 1000}, }) if _, ok := hw.files["src:TimeArray"]; ok { t.Fatal("uint64 time signal was archived") } if _, ok := hw.files["src:data"]; !ok { t.Fatal("data signal was not archived") } } // A second CONFIG for the same source must not throw away the history already // collected for signals it re-declares. func TestHistoryReconfigureKeepsExistingFile(t *testing.T) { hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1) hw.write(key, []float64{1, 2, 3}, []float64{1, 2, 3}) before := hw.files[key] hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{ {Name: "sig", TypeCode: 8, SamplingRate: 1}, {Name: "sig2", TypeCode: 8, SamplingRate: 1}, }) if hw.files[key] != before { t.Fatal("re-CONFIG replaced the existing signal file") } if before.count != 3 { t.Fatalf("count = %d, want the 3 already written", before.count) } if _, ok := hw.files["src:sig2"]; !ok { t.Fatal("newly declared signal was not opened") } } // The C++ UDPStreamer declares samplingRate=0, so sizing the file on the spot // would use a guess that is three orders of magnitude out at 1 MSps. func TestHistoryDefersSignalsWithoutDeclaredRate(t *testing.T) { dir := t.TempDir() hw, _ := newHistoryWriter(HistoryConfig{Directory: dir}) defer hw.close() hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{ {Name: "fast", TypeCode: 8, SamplingRate: 0}, {Name: "known", TypeCode: 8, SamplingRate: 100}, }) if _, ok := hw.files["src:fast"]; ok { t.Fatal("undeclared-rate signal was sized before its rate was measured") } if got := hw.pendingKeys(); len(got) != 1 || got[0] != "src:fast" { t.Fatalf("pendingKeys = %v, want [src:fast]", got) } if _, ok := hw.files["src:known"]; !ok { t.Fatal("declared-rate signal was deferred") } // Data for a deferred signal is dropped, not misfiled. hw.write("src:fast", []float64{1}, []float64{1}) // A repeated CONFIG must not queue it twice. hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{ {Name: "fast", TypeCode: 8, SamplingRate: 0}, }) if got := hw.pendingKeys(); len(got) != 1 { t.Fatalf("pendingKeys = %v after re-CONFIG, want one entry", got) } if !hw.openPending("src:fast", 100000) { t.Fatal("openPending refused a measured rate") } hf, ok := hw.files["src:fast"] if !ok { t.Fatal("file not opened after the rate was measured") } // The default window × 100 kSps, enveloped if it does not fit the budget. wantCap, wantBucket := histCapacityFor(defaultLiveWindowSec, 100000, 1, histDefaultMaxPoints) if hf.capacity != wantCap || hf.bucket != wantBucket { t.Fatalf("capacity/bucket = %d/%d, want %d/%d", hf.capacity, hf.bucket, wantCap, wantBucket) } if len(hw.pendingKeys()) != 0 { t.Fatal("signal still pending after being opened") } if hw.openPending("src:fast", 100000) { t.Fatal("openPending reopened an already-open signal") } } func TestOpenPendingHistoryFilesUsesMeasuredRate(t *testing.T) { h := NewHub() if err := h.EnableHistory(HistoryConfig{Directory: t.TempDir(), WindowSec: 3.6}); err != nil { t.Fatalf("EnableHistory: %v", err) } defer h.CloseHistory() h.hist.onSourceConfigured("s1", []udpsprotocol.SignalInfo{ {Name: "sig", TypeCode: 8, SamplingRate: 0}, }) rb := newSigRing(200000) h.rings["s1:sig"] = rb // Too little data to measure a rate from: the sweep must wait rather than // size the file from a burst. fillRing(rb, 0, 100000, 100) // 1 ms of data h.openPendingHistoryFiles(100) if len(h.hist.pendingKeys()) != 1 { t.Fatal("sweep sized the file from a sub-millisecond sample") } fillRing(rb, 0, 100000, 100000) // 1 s at 100 kSps h.openPendingHistoryFiles(200) hf, ok := h.hist.files["s1:sig"] if !ok { t.Fatal("file not opened once the rate was measurable") } // 3.6 s at ~100 kSps, plus headroom, ≈ 450 000 pairs; a fixed 1 kHz guess // would have produced the 1000-sample floor instead. if hf.capacity < 400_000 || hf.capacity > 500_000 { t.Fatalf("capacity = %d, want ~450000 from the measured 100 kSps", hf.capacity) } } func TestOpenPendingHistoryFilesIsThrottled(t *testing.T) { h := NewHub() if err := h.EnableHistory(HistoryConfig{Directory: t.TempDir()}); err != nil { t.Fatalf("EnableHistory: %v", err) } defer h.CloseHistory() h.hist.onSourceConfigured("s1", []udpsprotocol.SignalInfo{ {Name: "sig", TypeCode: 8, SamplingRate: 0}, }) h.openPendingHistoryFiles(100) // no ring yet: nothing to measure rb := newSigRing(20000) fillRing(rb, 0, 1000, 20000) h.rings["s1:sig"] = rb h.openPendingHistoryFiles(100.5) if len(h.hist.files) != 0 { t.Fatal("sweep ran inside the throttle window") } h.openPendingHistoryFiles(200) if len(h.hist.files) != 1 { t.Fatal("sweep did not run after the throttle window elapsed") } } // A hub without history must tolerate the sweep, since Run() calls it every tick. func TestOpenPendingHistoryFilesNoopWithoutHistory(t *testing.T) { h := NewHub() h.openPendingHistoryFiles(100) } func TestHistoryInfoShape(t *testing.T) { hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1) // Reported before any data arrives, so clients can enable their history UI. inf := hw.info() if e, ok := inf[key]; !ok || e.Count != 0 || e.Capacity != histMinCapacity { t.Fatalf("pre-data info = %+v (present=%v)", inf[key], ok) } ts, vs := ramp(5, 1, 10) hw.write(key, ts, vs) e := hw.info()[key] if e.Count != 10 || e.T0 != 5 || e.T1 != 14 { t.Fatalf("info = %+v, want count=10 t0=5 t1=14", e) } } func TestHistoryHeaderIsPersistedOnFlush(t *testing.T) { dir := t.TempDir() hw, key := newTestHistory(t, HistoryConfig{Directory: dir, WindowSec: 0.36, Decimation: 2}, 1) ts, vs := ramp(0, 1, 20) hw.write(key, ts, vs) hw.flushHeaders() hdr, err := os.ReadFile(filepath.Join(dir, "src", "sig.shist")) if err != nil { t.Fatalf("read: %v", err) } if string(hdr[0:4]) != "SHR1" { t.Fatalf("magic = %q", hdr[0:4]) } if v := binary.LittleEndian.Uint32(hdr[4:]); v != histVersion { t.Fatalf("version = %d, want %d", v, histVersion) } if c := binary.LittleEndian.Uint32(hdr[8:]); c != histMinCapacity { t.Fatalf("capacity = %d, want %d", c, histMinCapacity) } if h := binary.LittleEndian.Uint32(hdr[12:]); h != 10 { t.Fatalf("head = %d, want 10 (20 samples, decimation 2)", h) } if n := binary.LittleEndian.Uint32(hdr[16:]); n != 10 { t.Fatalf("count = %d, want 10", n) } if d := binary.LittleEndian.Uint32(hdr[20:]); d != 2 { t.Fatalf("decimation = %d, want 2", d) } if got := math.Float64frombits(binary.LittleEndian.Uint64(hdr[32:])); got != 19 { t.Fatalf("tNewest = %v, want 19", got) } // The data region must be pre-allocated in full, not grown as it fills. if want := int64(histHeaderSize) + histMinCapacity*histPairSize; int64(len(hdr)) != want { t.Fatalf("file size = %d, want the pre-allocated %d", len(hdr), want) } } func TestSanitizeHistName(t *testing.T) { cases := map[string]string{ "Signal_1": "Signal_1", "GAM.Out[0]": "GAM.Out[0]", "a/b": "a_b", "../../etc/pass": ".._.._etc_pass", "": "_", ".": "_", "..": "_", "with space": "with_space", "nul\x00byte": "nul_byte", } for in, want := range cases { if got := sanitizeHistName(in); got != want { t.Errorf("sanitizeHistName(%q) = %q, want %q", in, got, want) } } } // A producer-supplied name must never place a file outside the history dir. func TestHistoryNameCannotEscapeDirectory(t *testing.T) { dir := t.TempDir() hw, _ := newHistoryWriter(HistoryConfig{Directory: dir}) defer hw.close() hw.onSourceConfigured("../evil", []udpsprotocol.SignalInfo{ {Name: "../../pwned", TypeCode: 8, SamplingRate: 1}, }) found := false err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { if err == nil && !info.IsDir() { found = true } return err }) if err != nil { t.Fatalf("walk: %v", err) } if !found { t.Fatal("no file created inside the history directory") } if _, err := os.Stat(filepath.Join(dir, "..", "..", "pwned.shist")); err == nil { t.Fatal("a file escaped the history directory") } } func TestHistCapacityFor(t *testing.T) { cases := []struct { window float64 rate float64 decim int maxPts int want uint32 wantBucket int }{ // window × rate / decimation, plus the 1.25 headroom. {600, 1000, 1, 0, 750_000, 1}, {600, 1000, 10, 0, 75_000, 1}, {10, 100, 1, 0, 1250, 1}, {600, 0.001, 1, 0, histMinCapacity, 1}, // absurdly slow → the floor // Absurdly fast: bounded by histMaxCapacity, and the window is bought with // a correspondingly absurd bucket rather than by storing less of it. {600, 1e9, 1, 0, 1_073_729_421, 1397}, {math.NaN(), 1000, 1, 0, histMinCapacity, 1}, {600, math.NaN(), 1, 0, histMinCapacity, 1}, // A budget envelopes a fast signal without touching a slow one, and the // window is kept either way. {600, 1e6, 1, 16 << 20, 16_666_667, 90}, {600, 1000, 1, 16 << 20, 750_000, 1}, } for _, c := range cases { got, bucket := histCapacityFor(c.window, c.rate, c.decim, c.maxPts) if got != c.want || bucket != c.wantBucket { t.Errorf("histCapacityFor(%v, %v, %d, %d) = %d/%d, want %d/%d", c.window, c.rate, c.decim, c.maxPts, got, bucket, c.want, c.wantBucket) } } } func TestHistoryConfigDefaults(t *testing.T) { c := HistoryConfig{}.withDefaults() if c.WindowSec != defaultLiveWindowSec || c.Decimation != 1 || c.FlushIntervalSec != 5 || c.MinDiskFreeMB != 500 { t.Fatalf("defaults = %+v", c) } // A negative value is the explicit "no disk guard", so it must survive // defaulting rather than being turned back into 500. if got := (HistoryConfig{MinDiskFreeMB: -1}).withDefaults().MinDiskFreeMB; got != -1 { t.Fatalf("MinDiskFreeMB = %d, want the -1 that disables the guard", got) } } func TestHistoryWritePausedWhenDiskLow(t *testing.T) { hw, key := newTestHistory(t, HistoryConfig{}, 100) hw.diskLow = true hw.write(key, []float64{1, 2, 3}, []float64{1, 2, 3}) if hw.files[key].count != 0 { t.Fatalf("count = %d, want 0 while the disk guard is tripped", hw.files[key].count) } hw.diskLow = false hw.write(key, []float64{1, 2, 3}, []float64{1, 2, 3}) if hw.files[key].count != 3 { t.Fatalf("count = %d, want 3 once writing resumes", hw.files[key].count) } } func TestHistoryReadRangeRespectsMaxOut(t *testing.T) { // A window wide enough that the whole ramp is still on disk when it is read. hw, key := newTestHistory(t, HistoryConfig{WindowSec: 50}, 100) ts, vs := ramp(0, 0.01, 5000) hw.write(key, ts, vs) rt, rv := hw.readRange(key, -1, 1e9, 100) if len(rt) != 100 || len(rv) != 100 { t.Fatalf("read %d/%d points, want the 100 cap", len(rt), len(rv)) } } func TestHistoryReadRangeSpansWholeRange(t *testing.T) { // A capped read must thin the range out, not return its first maxOut // samples: a client asking for 100 points over 50 s and getting the first // second of it draws a flat line and falls back to its coarse copy. hw, key := newTestHistory(t, HistoryConfig{WindowSec: 50}, 100) ts, vs := ramp(0, 0.01, 5000) hw.write(key, ts, vs) rt, _ := hw.readRange(key, 0, 49.99, 100) if len(rt) == 0 { t.Fatal("no points read") } if got := rt[len(rt)-1] - rt[0]; got < 0.95*49.99 { t.Fatalf("read spans %.2f s of the 49.99 s asked; a capped read must "+ "cover the whole range", got) } } func TestHistoryReadRangeUncappedIsExact(t *testing.T) { // Below the cap every sample in the range comes back, so a zoom deep enough // to fit is served at full resolution. hw, key := newTestHistory(t, HistoryConfig{WindowSec: 50}, 100) ts, vs := ramp(0, 0.01, 5000) hw.write(key, ts, vs) rt, rv := hw.readRange(key, 1, 1.99, 1000) if len(rt) != 100 { t.Fatalf("read %d points, want the 100 samples in [1, 1.99]", len(rt)) } if rv[0] != 100 || rv[len(rv)-1] != 199 { t.Fatalf("values %.0f..%.0f, want 100..199", rv[0], rv[len(rv)-1]) } } // The capture copy is what makes a trigger window zoomable long after the // circular archive has wrapped over it. func TestCaptureRangeOutlivesTheArchive(t *testing.T) { hw, key := newTestHistory(t, HistoryConfig{}, 0.001) // floor capacity: 1000 hf := hw.files[key] if hf.capacity != histMinCapacity { t.Fatalf("capacity = %d, want the %d floor", hf.capacity, histMinCapacity) } ts, vs := ramp(0, 1, 1000) // t = 0..999, exactly full hw.write(key, ts, vs) hw.captureRange(500, 600) // Wrap the archive right over the captured window. ts2, vs2 := ramp(1000, 1, 1000) hw.write(key, ts2, vs2) if hf.tOldest != 1000 || hf.tNewest != 1999 { t.Fatalf("archive holds [%v, %v], want [1000, 1999]: capturing must not "+ "stop or divert the archive", hf.tOldest, hf.tNewest) } rt, rv := hw.readRange(key, 500, 600, 1000) if len(rt) != 101 { t.Fatalf("read %d captured samples in [500, 600], want 101", len(rt)) } if rv[0] != 500 || rv[len(rv)-1] != 600 { t.Fatalf("captured values %.0f..%.0f, want 500..600", rv[0], rv[len(rv)-1]) } // A range the capture does not hold is still answered by the archive. if at, _ := hw.readRange(key, 1500, 1600, 1000); len(at) != 101 { t.Fatalf("read %d archived samples in [1500, 1600], want 101", len(at)) } // The next capture replaces the last one, and only then. hw.captureRange(1500, 1600) if ct, _ := hw.readRange(key, 500, 600, 1000); len(ct) != 0 { t.Fatalf("read %d samples of a replaced capture, want 0", len(ct)) } } // Delivering a capture copies its window out of the archive, and the archive // keeps rolling so the next capture's pre-trigger window is there when it fires. func TestTriggerCaptureCopiesWindowToDisk(t *testing.T) { h := NewHub() if err := h.EnableHistory(HistoryConfig{ Directory: t.TempDir(), WindowSec: 36, MinDiskFreeMB: -1, }); err != nil { t.Fatalf("EnableHistory: %v", err) } t.Cleanup(h.CloseHistory) h.hist.onSourceConfigured("s1", []udpsprotocol.SignalInfo{ {Name: "sig", TypeCode: 8, SamplingRate: 1000}, }) h.rings["s1:sig"] = newSigRing(10000) h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", edge: "rising", threshold: 0, windowSec: 1, prePercent: 20, mode: "single"}) h.trigger.Arm() // Cross the threshold, then cover the post-trigger window so the capture // comes due on the next tick. h.ingest("s1:sig", 1, []float64{5.0, 5.001}, []float64{-1, 1}) h.ingest("s1:sig", 1, []float64{6.0}, []float64{1}) h.triggerTick() if h.trigger.State() != trigTriggered { t.Fatalf("state = %q, want triggered", h.trigger.State()) } cf := h.hist.captures["s1:sig"] if cf == nil { t.Fatal("capture delivered but its window was not copied to disk") } // The window is [trigTime-0.2, trigTime+0.8] around the 5.001 crossing, so // the sample at 6.0 falls outside it. if cf.count != 2 || cf.tOldest != 5.0 || cf.tNewest != 5.001 { t.Fatalf("capture holds %d samples in [%v, %v], want 2 in [5, 5.001]", cf.count, cf.tOldest, cf.tNewest) } // Copying the window leaves the archive rolling, so the next capture's // pre-trigger window — written before its trigger fires — is there for it. h.ingest("s1:sig", 1, []float64{7.0}, []float64{1}) if got := h.hist.files["s1:sig"].count; got != 4 { t.Fatalf("archived %d samples, want 4: capturing must not stop writing", got) } // Rearming does not discard the capture: it stays on screen until the next // trigger replaces it. h.trigger.Arm() h.triggerTick() if h.hist.captures["s1:sig"] != cf { t.Fatal("rearming discarded the capture the client is still showing") } } func TestHistSearch(t *testing.T) { vals := []float64{0, 1, 2, 3, 4, 5} at := func(i uint32) float64 { return vals[i] } if got := histSearch(0, 6, func(i uint32) bool { return at(i) < 3 }); got != 3 { t.Fatalf("lower bound = %d, want 3", got) } if got := histSearch(0, 6, func(i uint32) bool { return at(i) <= 3 }); got != 4 { t.Fatalf("upper bound = %d, want 4", got) } if got := histSearch(0, 6, func(i uint32) bool { return at(i) < -1 }); got != 0 { t.Fatalf("all-false = %d, want 0", got) } if got := histSearch(0, 6, func(i uint32) bool { return at(i) < 100 }); got != 6 { t.Fatalf("all-true = %d, want 6", got) } }