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>
433 lines
16 KiB
Kotlin
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")
|
|
}
|
|
}
|
|
}
|