Pad/internal/perf/perf_test.go
Greg Pomerantz be48ad8157 perf: default-off in-app profiler + debug scroll jumps; verify scroll perf & clamping
internal/perf: single-goroutine profiler (no locks). When enabled by the
/storage/emulated/0/PadPerf/enable marker it records one CSV row per logic
frame (seq, ms, frame delta, page, scroll Dp, max-scroll Dp, total lines,
visible byte range), logs a rolling ~1/s summary, and on Stop reports
nearest-rank p50/p90/p99/max. Flushes per row-batch but never fsyncs per
flush (avoids periodic hitches in the logic path).

editor: PerfRecord package-level hook (nil when off) + ProbeRecord;
Logic.emitFrame() now centralizes every frame emission so the profiler sees
each logic frame exactly once, on the owner goroutine. State gains
VisibleStart/VisibleEnd so the probe can confirm shaping stays
viewport-bounded.

logic: optional debug cmd poller (off by default) watches <dir>/cmd as a
one-shot file (top/bottom/frac <0..1>/dp <int>) and the owner applies a
clamped [0,MaxScroll] jump + frame. Enables deterministic large-offset scroll
tests without pixel taps.

main: wires the profiler when the marker file exists; logs present-fps
every 2s; stops the profiler on Destroy.

docs: README package inventory (add internal/perf); architecture §11
profiler facility; spec §5 invariant 7 (scroll always clamped to
[0,maxScroll]) + §6 measured rows; development_plan v5 + Phase 6 results.

Verified: go build/vet + full -race green. On-device 10 MB file:
logic-frame cadence flat across offsets 0.02->1.0 (no large-offset
degradation), visible byte range <=4.3 KB at every offset, clamping exact
across 2->130,955-line files, PSS plateaus ~250 MB (bounded, no leak);
profiler overhead negligible.
2026-08-16 16:53:28 -04:00

75 lines
1.9 KiB
Go

package perf
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPercentiles(t *testing.T) {
ds := []float64{10, 20, 30, 40, 50, 60, 70, 80, 90, 100}
p50, p90, p99, mx := percentiles(ds)
if p50 != 50 {
t.Errorf("p50 = %v, want 50", p50)
}
if p90 != 90 {
t.Errorf("p90 = %v, want 90", p90)
}
if p99 != 100 {
t.Errorf("p99 = %v, want 100", p99)
}
if mx != 100 {
t.Errorf("max = %v, want 100", mx)
}
// Empty input is a no-op.
if _, _, _, m := percentiles(nil); m != 0 {
t.Errorf("empty max = %v, want 0", m)
}
}
func TestDisabledIsNoOp(t *testing.T) {
p := New(false, t.TempDir(), "x.csv")
p.Record(Ctx{Page: "editor"})
if p.enabled {
t.Fatalf("disabled profiler reported enabled")
}
if p.seq != 0 || len(p.buf) != 0 {
t.Errorf("disabled profiler recorded: seq=%d buf=%d", p.seq, len(p.buf))
}
}
func TestRecordFlushAndStop(t *testing.T) {
dir := t.TempDir()
p := New(true, dir, "logic_frames.csv")
if p == nil || p.file == nil {
t.Fatalf("expected an open output file, got %+v", p)
}
for i := 0; i < 5; i++ {
p.Record(Ctx{Page: "editor", ScrollDP: float32(i * 10), MaxScrollDP: 1000, TotalLines: 42, VisStart: i, VisEnd: i + 100})
}
// Force a flush regardless of the 2s timer.
p.lastFlush = time.Time{}
p.Record(Ctx{Page: "editor", ScrollDP: 50})
p.Stop()
out := filepath.Join(dir, "logic_frames.csv")
b, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read csv: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
if len(lines) != 6 {
t.Fatalf("want 6 CSV rows, got %d:\n%s", len(lines), b)
}
// First row is seq 1, editor page.
if !strings.HasPrefix(lines[0], "1,") || !strings.Contains(lines[0], "editor") {
t.Errorf("unexpected first row: %s", lines[0])
}
// Last row should have TotalLines 42 (from the 5th record) and the 6th (Stop flush).
if !strings.Contains(lines[4], "42") {
t.Errorf("row 5 missing TotalLines=42: %s", lines[4])
}
}