KMPCharts/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/TieredBufferTest.kt
Trentin Davide 0e3b79e2da feat(chart): v0.4.0 portfolio hardening
Ship 15-task plan via kmp-manager group-by-group flow. Library moves
from "prototype" to "senior-engineer-built KMP library" per 3-reviewer
audit (android-reviewer + mobile-performance-reviewer + mobile-architect).

Critical fixes:
- LICENSE MIT (T1) — legal blocker resolved
- explicitApi() Strict + BCV 0.16.3 + klib enabled (T2) — ABI locked
  baseline: chart-realtime.api 161 LOC + chart-realtime.klib.api 211 LOC
- Internal lockdown: CircularBuffer, TieredBuffer, LodDecimator,
  dataVersion, rememberFrameTick (T3)
- kotlinx-datetime 0.6.2 replaces expect/actual currentTimeMs (T4)
  Platform.kt + .android.kt + .ios.kt deleted
- CircularBuffer.writeIndex Int -> Long (T5) — fix 124d overflow @ 200Hz
- Remove !! and @Suppress("UNUSED_EXPRESSION") from commonMain (T6)
- Remove dead config: maxBufferSeconds, xLabelDecimals,
  SignalConfig.label (T7)

Correctness:
- POSITIVE_INFINITY/Float.MAX_VALUE sentinels -> hasData flag (T8)
- Snapshot.withMutableSnapshot + SnapshotApplyConflictException retry
  in push/addSignal/removeSignal (T9)
- dataVersion mutableLongStateOf — Compose-observable, idle = 0
  recompositions (T10) — RenderLoop.kt deleted, targetFps @Deprecated
- Single buffer.snapshot() per signal per frame via SignalEntry
  per-signal scratch arrays (T11) — render-time snapshot cost halved
- M4 binning Tier1/Tier2: firstTs/V + minTs/V + maxTs/V + lastTs/V
  with chronological dedup (T12) — no C7 vertical spike artifacts.
  TIER1/TIER2 CAPACITY x2 -> x4 (TOTAL 77.4k -> 94.8k)
- NaN/Inf/backward-timestamp guards on push (T13) — silent drop,
  per-signal lastPushedTs tracking, KDoc contract

Test gap fill:
- NumberFormat extracted from AxisRenderer to internal object (T14)
  +38 tests pinning rounding/scientific/time-format behavior
- TieredBufferTest +15 tests: tier roll, window-crossing, clear,
  edge cases (T15)
- LodDecimatorTest +10 LTTB-specific branch coverage tests (T15)

Test count: 33 -> 107 (+74).
ABI: 211/272 -> 161/211 LOC (locked, baseline committed).

Deferred to v0.5.0:
- Bump kotlinx-datetime 0.6.2 -> 0.7.x (needs Kotlin 2.1.20+)
- Remove ChartConfig.targetFps (currently @Deprecated, ignored)
- NumberFormat Long overflow @ 1e19 (safe via current routing)
- LTTB upper-bound inclusive vs snapshot half-open semantic mismatch
- Macrobench setup for -30% frame-time verification

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:27:15 +02:00

433 lines
16 KiB
Kotlin

package dev.dtrentin.chart.buffer
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class TieredBufferTest {
private fun outTs() = LongArray(TieredBuffer.TOTAL_CAPACITY)
private fun outVs() = FloatArray(TieredBuffer.TOTAL_CAPACITY)
/**
* Regression test for T8: legitimate sample equal to former sentinel value
* (Float.MAX_VALUE) must not be dropped or misclassified by tier1 bin
* accumulator. After T8 swaps sentinel-based "empty" detection for explicit
* hasData flag, MAX_VALUE samples are retained.
*/
@Test fun tier1BinAcceptsFloatMaxValueSample() {
val buf = TieredBuffer()
// All 4 pushes in tier0 (5min retention); tier1 bin = 100ms.
// ts=0,50,99 share bin [0,100); ts=100 rolls bin and flushes prior.
buf.push(0L, Float.MAX_VALUE)
buf.push(50L, Float.MAX_VALUE)
buf.push(99L, Float.MAX_VALUE)
buf.push(100L, 1f)
val outTs = LongArray(TieredBuffer.TOTAL_CAPACITY)
val outV = FloatArray(TieredBuffer.TOTAL_CAPACITY)
// Wide window covering tier0 region — should include raw samples.
val n = buf.snapshot(0L, 10_000L, outTs, outV)
assertTrue(n >= 4, "expected at least 4 samples in tier0 snapshot, got $n")
var found = 0
for (i in 0 until n) {
if (outV[i] == Float.MAX_VALUE) found++
}
assertTrue(found >= 3, "expected ≥3 MAX_VALUE samples retained, got $found")
}
/**
* T12: tier1 M4 bin emits first/min/max/last with their original timestamps,
* preserving visual shape. Snapshot via direct tier1 access (no tier0 fallback)
* by forcing time advancement well past tier0 retention.
*/
@Test fun tier1BinPreservesM4() {
val buf = TieredBuffer()
// Tier1 bin = 100ms. Push 4 distinct samples into bin [0, 100):
// ts=0 v=5 (first)
// ts=30 v=1 (min)
// ts=60 v=10 (max)
// ts=90 v=3 (last)
buf.push(0L, 5f)
buf.push(30L, 1f)
buf.push(60L, 10f)
buf.push(90L, 3f)
// Roll bin: push a sample past 100ms to trigger flush of prior bin.
buf.push(100L, 7f)
// Force snapshot to read from tier1 (not tier0) by advancing time past
// tier0 retention. Push sample at tier0-window-far-future.
val farFuture = TieredBuffer.TIER0_DURATION_MS + 200L
buf.push(farFuture, 0f)
val outTs = LongArray(TieredBuffer.TOTAL_CAPACITY)
val outV = FloatArray(TieredBuffer.TOTAL_CAPACITY)
// Window covers ts=[0, 100) — should hit tier1 (not tier0, since now - TIER0_DUR > 100).
val n = buf.snapshot(0L, 100L, outTs, outV)
assertTrue(n in 1..4, "expected 1..4 M4 records, got $n")
// Strict chronological order.
for (i in 1 until n) {
assertTrue(outTs[i] > outTs[i - 1], "ts must be strictly increasing across M4 records at i=$i: ${outTs[i - 1]} -> ${outTs[i]}")
}
// First record = (ts=0, v=5f)
assertEquals(0L, outTs[0])
assertEquals(5f, outV[0])
// Last record = (ts=90, v=3f)
assertEquals(90L, outTs[n - 1])
assertEquals(3f, outV[n - 1])
// Min (1f) and max (10f) must appear in the snapshot.
var hasMin = false; var hasMax = false
for (i in 0 until n) {
if (outV[i] == 1f) hasMin = true
if (outV[i] == 10f) hasMax = true
}
assertTrue(hasMin, "min value 1f missing from M4 records")
assertTrue(hasMax, "max value 10f missing from M4 records")
}
/**
* T12: tier2 M4 bin (1Hz, 1000ms) analogous to tier1 test.
* Forces snapshot to read from tier2 by pushing past tier0+tier1 retention.
*/
@Test fun tier2BinPreservesM4() {
val buf = TieredBuffer()
// Tier2 bin = 1000ms. Push 4 distinct samples into bin [0, 1000):
// ts=0 v=2 (first)
// ts=300 v=8 (max)
// ts=600 v=1 (min)
// ts=900 v=5 (last)
buf.push(0L, 2f)
buf.push(300L, 8f)
buf.push(600L, 1f)
buf.push(900L, 5f)
// Roll bin: push sample past 1000ms to flush prior tier2 bin.
buf.push(1000L, 4f)
// Force snapshot to read from tier2 by advancing past tier0 + tier1 retention.
val farFuture = TieredBuffer.TIER0_DURATION_MS + TieredBuffer.TIER1_DURATION_MS + 2000L
buf.push(farFuture, 0f)
val outTs = LongArray(TieredBuffer.TOTAL_CAPACITY)
val outV = FloatArray(TieredBuffer.TOTAL_CAPACITY)
// Window covers ts=[0, 1000) — should hit tier2 only.
val n = buf.snapshot(0L, 1000L, outTs, outV)
assertTrue(n in 1..4, "expected 1..4 M4 records in tier2, got $n")
// Strict chronological order.
for (i in 1 until n) {
assertTrue(outTs[i] > outTs[i - 1], "tier2 ts must be strictly increasing at i=$i")
}
// First & last anchors.
assertEquals(0L, outTs[0])
assertEquals(2f, outV[0])
assertEquals(900L, outTs[n - 1])
assertEquals(5f, outV[n - 1])
var hasMin = false; var hasMax = false
for (i in 0 until n) {
if (outV[i] == 1f) hasMin = true
if (outV[i] == 8f) hasMax = true
}
assertTrue(hasMin, "tier2 min 1f missing")
assertTrue(hasMax, "tier2 max 8f missing")
}
/**
* T12 (C7 fix): adjacent bins must not collide at the same timestamp.
* Previous (midTs, min)/(midTs, max) pair produced a vertical spike at flush time;
* with M4 each record carries its original ts → strict monotonicity preserved
* across bin boundaries.
*/
@Test fun noVerticalSpikesAtBinBoundaries() {
val buf = TieredBuffer()
// Bin A [0, 100): push samples that span the bin's value range.
buf.push(10L, 4f)
buf.push(40L, 1f)
buf.push(70L, 9f)
buf.push(99L, 3f)
// Bin B [100, 200): distinct values.
buf.push(110L, 6f)
buf.push(140L, 2f)
buf.push(170L, 8f)
buf.push(199L, 5f)
// Flush bin B by triggering a rollover.
buf.push(200L, 7f)
// Force snapshot from tier1.
val farFuture = TieredBuffer.TIER0_DURATION_MS + 500L
buf.push(farFuture, 0f)
val outTs = LongArray(TieredBuffer.TOTAL_CAPACITY)
val outV = FloatArray(TieredBuffer.TOTAL_CAPACITY)
val n = buf.snapshot(0L, 200L, outTs, outV)
assertTrue(n >= 4, "expected ≥4 tier1 records across two bins, got $n")
// Strict monotonicity across all records, including the bin A→B boundary.
for (i in 1 until n) {
assertTrue(
outTs[i] > outTs[i - 1],
"C7 regression: ts not strictly increasing at i=$i (${outTs[i - 1]} -> ${outTs[i]})",
)
}
}
// ---------- T15: empty state ----------
@Test fun snapshot_emptyBuffer_returnsZero() {
val buf = TieredBuffer()
val n = buf.snapshot(0L, 10_000L, outTs(), outVs())
assertEquals(0, n)
}
@Test fun latestTimestampMs_emptyBuffer_returnsNegative() {
val buf = TieredBuffer()
assertEquals(-1L, buf.latestTimestampMs())
}
// ---------- T15: tier0 only ----------
@Test fun tier0_singleSample_snapshotReturnsIt() {
val buf = TieredBuffer()
buf.push(100L, 1.5f)
val ts = outTs(); val vs = outVs()
val n = buf.snapshot(0L, 10_000L, ts, vs)
assertEquals(1, n)
assertEquals(100L, ts[0])
assertEquals(1.5f, vs[0])
}
@Test fun tier0_multipleSamples_preservesOrder() {
val buf = TieredBuffer()
val srcTs = longArrayOf(10L, 20L, 30L, 40L, 50L)
val srcVs = floatArrayOf(1f, 2f, 3f, 4f, 5f)
for (i in srcTs.indices) buf.push(srcTs[i], srcVs[i])
val ts = outTs(); val vs = outVs()
val n = buf.snapshot(0L, 10_000L, ts, vs)
assertEquals(5, n)
for (i in 1 until n) {
assertTrue(ts[i] >= ts[i - 1], "expected non-decreasing ts at i=$i")
}
// Values match source in order.
for (i in 0 until n) {
assertEquals(srcTs[i], ts[i])
assertEquals(srcVs[i], vs[i])
}
}
// ---------- T15: tier roll boundaries ----------
@Test fun tier1Roll_singleBinFlush() {
val buf = TieredBuffer()
// 3 samples in tier1 bin [0, 100), then roll bin with ts=100.
buf.push(10L, 1f)
buf.push(40L, 2f)
buf.push(80L, 3f)
buf.push(100L, 4f)
// Force tier1 snapshot path: push far-future.
val farFuture = TieredBuffer.TIER0_DURATION_MS + 300L
buf.push(farFuture, 9f)
val ts = outTs(); val vs = outVs()
val n = buf.snapshot(0L, 100L, ts, vs)
assertTrue(n >= 1, "expected ≥1 tier1 flushed record, got $n")
// All records inside [0, 100).
for (i in 0 until n) {
assertTrue(ts[i] in 0L..99L, "tier1 record out of window at i=$i: ${ts[i]}")
}
}
@Test fun tier1Roll_emptyBinSkipped() {
val buf = TieredBuffer()
// Single sample in bin [0, 100).
buf.push(50L, 7f)
// Jump several empty bins ahead → roll directly to bin [500, 600).
buf.push(550L, 8f)
// Push another to flush bin [500, 600).
buf.push(600L, 9f)
// Force tier1 path.
val farFuture = TieredBuffer.TIER0_DURATION_MS + 300L
buf.push(farFuture, 0f)
val ts = outTs(); val vs = outVs()
// Window over the empty span [100, 500): expect zero records (no spurious flush).
val n = buf.snapshot(100L, 400L, ts, vs)
assertEquals(0, n, "empty bins between sparse samples must not produce records")
}
@Test fun tier2Roll_singleBinFlush() {
val buf = TieredBuffer()
// tier2 bin = 1000ms.
buf.push(100L, 1f)
buf.push(400L, 2f)
buf.push(800L, 3f)
// Roll tier2 bin.
buf.push(1000L, 4f)
// Force tier2 snapshot: push past tier0 + tier1 retention.
val farFuture = TieredBuffer.TIER0_DURATION_MS + TieredBuffer.TIER1_DURATION_MS + 2000L
buf.push(farFuture, 9f)
val ts = outTs(); val vs = outVs()
val n = buf.snapshot(0L, 1000L, ts, vs)
assertTrue(n >= 1, "expected ≥1 tier2 record, got $n")
for (i in 0 until n) {
assertTrue(ts[i] in 0L..999L, "tier2 record out of window at i=$i: ${ts[i]}")
}
}
// ---------- T15: window crossing tier boundaries ----------
@Test fun snapshot_windowSpansT0T1Boundary() {
val buf = TieredBuffer()
// Old data destined for tier1 flush.
buf.push(0L, 1f)
buf.push(50L, 2f)
buf.push(99L, 3f)
// Flush bin [0,100).
buf.push(100L, 4f)
// Advance now past tier0 retention so old data exits tier0.
val nowTs = TieredBuffer.TIER0_DURATION_MS + 1000L
buf.push(nowTs, 5f)
// Recent sample fresh in tier0.
buf.push(nowTs + 100L, 6f)
val ts = outTs(); val vs = outVs()
// Window spans from before tier0Boundary to now → should pick up both tier1 and tier0 ranges.
val n = buf.snapshot(0L, nowTs + 200L, ts, vs)
assertTrue(n >= 2, "expected ≥2 samples across t0/t1, got $n")
// Chronological non-decreasing.
for (i in 1 until n) {
assertTrue(ts[i] >= ts[i - 1], "non-monotonic at i=$i: ${ts[i - 1]} -> ${ts[i]}")
}
}
@Test fun snapshot_windowSpansT1T2Boundary() {
val buf = TieredBuffer()
// Very old samples for tier2.
buf.push(0L, 1f)
buf.push(500L, 2f)
// Roll tier2 bin.
buf.push(1000L, 3f)
// Mid-age samples for tier1.
val tier1Start = TieredBuffer.TIER1_DURATION_MS
buf.push(tier1Start, 10f)
buf.push(tier1Start + 50L, 11f)
buf.push(tier1Start + 100L, 12f)
// Force snapshot past both tier0 + tier1 retention.
val nowTs = TieredBuffer.TIER0_DURATION_MS + TieredBuffer.TIER1_DURATION_MS + 2000L
buf.push(nowTs, 99f)
val ts = outTs(); val vs = outVs()
// Wide window covering both tier1 and tier2 regions.
val n = buf.snapshot(0L, nowTs + 100L, ts, vs)
assertTrue(n >= 2, "expected ≥2 records across t1/t2, got $n")
for (i in 1 until n) {
assertTrue(ts[i] >= ts[i - 1], "non-monotonic at i=$i")
}
}
@Test fun snapshot_windowSpansAll3Tiers() {
val buf = TieredBuffer()
// Oldest tier2 sample.
buf.push(0L, 1f)
buf.push(1000L, 2f) // flush bin [0,1000)
// Mid tier1 region.
val tier1Start = TieredBuffer.TIER1_DURATION_MS
buf.push(tier1Start, 5f)
buf.push(tier1Start + 100L, 6f) // flush bin
// Recent tier0.
val nowTs = TieredBuffer.TIER0_DURATION_MS + TieredBuffer.TIER1_DURATION_MS + 1000L
buf.push(nowTs, 10f)
buf.push(nowTs + 50L, 11f)
val ts = outTs(); val vs = outVs()
val n = buf.snapshot(0L, nowTs + 200L, ts, vs)
assertTrue(n >= 3, "expected ≥3 records across all tiers, got $n")
for (i in 1 until n) {
assertTrue(ts[i] >= ts[i - 1], "non-monotonic at i=$i: ${ts[i - 1]} -> ${ts[i]}")
}
}
// ---------- T15: clear() ----------
@Test fun clear_resetsBuffer() {
val buf = TieredBuffer()
for (i in 0 until 10) buf.push(i * 10L, i.toFloat())
buf.clear()
val n = buf.snapshot(0L, 10_000L, outTs(), outVs())
assertEquals(0, n)
assertEquals(-1L, buf.latestTimestampMs())
}
@Test fun clear_resetsBinAccumulators() {
val buf = TieredBuffer()
// Seed tier1 bin [0, 100) with 3 samples — pre-flush, accumulator holds state.
buf.push(10L, 100f)
buf.push(40L, 200f)
buf.push(80L, 300f)
buf.clear()
// Push 1 fresh sample into bin [0, 100) and roll.
buf.push(20L, 1f)
buf.push(100L, 2f) // roll flushes the post-clear single-sample bin
// Force tier1 snapshot.
val farFuture = TieredBuffer.TIER0_DURATION_MS + 300L
buf.push(farFuture, 0f)
val ts = outTs(); val vs = outVs()
val n = buf.snapshot(0L, 100L, ts, vs)
assertTrue(n >= 1, "expected ≥1 post-clear tier1 record, got $n")
// Records must only reflect post-clear data (single sample at ts=20, v=1f).
for (i in 0 until n) {
assertEquals(20L, ts[i], "stale ts leaked through clear at i=$i")
assertEquals(1f, vs[i], "stale value leaked through clear at i=$i")
}
}
// ---------- T15: edge cases ----------
@Test fun snapshot_windowEntirelyBeforeData_returnsZero() {
val buf = TieredBuffer()
buf.push(10_000L, 1f)
buf.push(10_500L, 2f)
val n = buf.snapshot(0L, 5_000L, outTs(), outVs())
assertEquals(0, n)
}
@Test fun snapshot_windowEntirelyAfterData_returnsZero() {
val buf = TieredBuffer()
buf.push(500L, 1f)
buf.push(1_000L, 2f)
val n = buf.snapshot(10_000L, 10_000L, outTs(), outVs())
assertEquals(0, n)
}
@Test fun tier1BinHandlesSamePostFlushTimestamp() {
val buf = TieredBuffer()
// Bin [0, 100): single sample.
buf.push(50L, 1f)
// Roll to bin [100, 200) at exactly ts=100.
buf.push(100L, 2f)
// Another sample at the same ts=100 within new bin.
buf.push(100L, 3f)
// Continue with monotonic-non-decreasing ts.
buf.push(150L, 4f)
buf.push(200L, 5f) // roll again
val ts = outTs(); val vs = outVs()
// Wide window via tier0 fallback — no crash expected, sane ordering.
val n = buf.snapshot(0L, 1_000L, ts, vs)
assertTrue(n >= 5, "expected raw samples retained in tier0, got $n")
for (i in 1 until n) {
assertTrue(ts[i] >= ts[i - 1], "monotonic-non-decreasing ts violated at i=$i")
}
}
}