KMPCharts/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/render/AxisRendererTest.kt
Trentin Davide 0e3360e4a0 perf(chart-realtime): cut per-frame render CPU on realtime chart
Profiling (Pixel 7 + SM-T819) showed the chart CPU-bound in Skia
analytic-AA path fill plus a full-buffer copy on every frame.

- LineSignalRenderer: draw the signal polyline with anti-aliasing off,
  via a cached common Paint + drawIntoCanvas (KMP-safe, no nativeCanvas).
  Flips Skia from CPU coverage-mask raster to GPU tessellation;
  configured strokeWidth still honored. Removes the old Stroke cache.
- TieredBuffer/CircularBuffer: replace the full ~60k-sample copy+scan in
  the draw hot path with an O(log n) bisect window read (copyWindow).
  Output byte-identical (equivalence tests incl. ring-wrap + edges).
- RealtimeChart: split the single Canvas into a cold layer (Y-axis line,
  grid, labels) and a hot layer (signal, X-axis, crosshair) so per-frame
  TextMeasurer layout stops running every frame. Y range stabilized to
  the tick grid and shared by both layers (signal never clips).
- minSdk 26 -> 24.

Renderer unit test moved commonTest -> iosTest (Compose Paint delegates
to a non-mockable android.graphics.Paint stub on the JVM host; Skiko
backs it for real). Verified on SM-T819 (release): frame time 150ms ->
48ms, ~10 -> ~31 fps at full 8x200 Hz load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:39:00 +02:00

81 lines
3.5 KiB
Kotlin

package dev.dtrentin.chart.render
import dev.dtrentin.chart.model.ChartConfig
import dev.dtrentin.chart.model.DataConfig
import dev.dtrentin.chart.model.YRange
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class AxisRendererTest {
// T9: resolveYRange writes into out param (Fixed range).
@Test fun resolveYRange_writesIntoOutArray_fixed() {
val cfg = ChartConfig(data = DataConfig(yRange = YRange.Fixed(min = -5f, max = 12f)))
val out = FloatArray(2)
AxisRenderer.resolveYRange(cfg, dataMin = -100f, dataMax = 100f, outYRange = out)
assertEquals(-5f, out[0])
assertEquals(12f, out[1])
}
// T9: resolveYRange writes into out param (Auto with padding).
@Test fun resolveYRange_writesIntoOutArray_autoWithPadding() {
val cfg = ChartConfig(data = DataConfig(yRange = YRange.Auto(paddingFraction = 0.1f)))
val out = FloatArray(2)
AxisRenderer.resolveYRange(cfg, dataMin = 0f, dataMax = 10f, outYRange = out)
// padding = (10 - 0) * 0.1 = 1 → expect [-1, 11].
assertEquals(-1f, out[0])
assertEquals(11f, out[1])
}
// T9: resolveYRange handles flat data — uses range floor 1e-6 to avoid div-by-zero.
// With paddingFraction 0.1 and floor 1e-6, the effective padding is 1e-7. Below Float
// resolution at value 5f, so out[0] == out[1] == 5f is permitted (downstream renderers
// re-apply their own (yMax-yMin).coerceAtLeast(1e-6f)). Just assert no NaN/Infinity.
@Test fun resolveYRange_flatData_writesFiniteValues() {
val cfg = ChartConfig(data = DataConfig(yRange = YRange.Auto(paddingFraction = 0.1f)))
val out = FloatArray(2)
AxisRenderer.resolveYRange(cfg, dataMin = 5f, dataMax = 5f, outYRange = out)
assertTrue(out[0].isFinite())
assertTrue(out[1].isFinite())
assertTrue(out[1] >= out[0])
}
// T-split: stabilizeYRange snaps outward to the tick grid and CONTAINS the exact range.
@Test fun stabilizeYRange_snapsToTicks_andContainsExactRange() {
val out = FloatArray(2)
// range = 9.1 → niceInterval(9.1, 5) = 2 → floor(0.3/2)*2 = 0, ceil(9.4/2)*2 = 10.
AxisRenderer.stabilizeYRange(0.3f, 9.4f, out)
assertEquals(0f, out[0])
assertEquals(10f, out[1])
assertTrue(out[0] <= 0.3f && out[1] >= 9.4f)
}
// T-split: small frame-to-frame wobble inside a tick band → IDENTICAL bounds (cold stays cold).
@Test fun stabilizeYRange_stableUnderSmallWobble() {
val a = FloatArray(2)
val b = FloatArray(2)
AxisRenderer.stabilizeYRange(0.31f, 9.38f, a)
AxisRenderer.stabilizeYRange(0.34f, 9.42f, b)
assertEquals(a[0], b[0])
assertEquals(a[1], b[1])
}
// T-split: empty / non-positive range passes through unchanged (no NaN/Inf).
@Test fun stabilizeYRange_passthroughForEmptyRange() {
val out = FloatArray(2)
AxisRenderer.stabilizeYRange(5f, 5f, out)
assertEquals(5f, out[0])
assertEquals(5f, out[1])
}
// T-split: negative range spanning zero snaps symmetrically outward.
@Test fun stabilizeYRange_negativeRange_snapsOutward() {
val out = FloatArray(2)
// range = 2.4 → niceInterval(2.4, 5) = 0.5 → floor(-1.2/0.5)*0.5 = -1.5, ceil(1.2/0.5)*0.5 = 1.5.
AxisRenderer.stabilizeYRange(-1.2f, 1.2f, out)
assertEquals(-1.5f, out[0])
assertEquals(1.5f, out[1])
assertTrue(out[0] <= -1.2f && out[1] >= 1.2f)
}
}