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>
81 lines
3.5 KiB
Kotlin
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)
|
|
}
|
|
}
|