Ships v0.5.0 via kmp-manager 6-phase flow. 13 plan tasks (D1, T1-T10, D3, D4), 14 agent calls, 6 P-groups. 107 → 207 tests on iosSimulatorArm64. Toolchain (D1): - Kotlin 2.1.0 → 2.3.21 - Compose-Multiplatform 1.8.0 → 1.11.0 - Android Gradle Plugin 8.7.3 → 9.2.0 - Gradle 8.11.1 → 9.5.1 - coroutines 1.9.0 → 1.11.0, kotlin-test → 2.3.21 - Drop kotlinx-datetime; use stdlib kotlin.time.Clock - Drop iosX64 target (Compose-MP 1.11.0 has no ios_x64 variant) Architecture (T1-T6, T10): - TieredBuffer.snapshotWindow: bisect-based windowed snapshot - New lod/ package: LodStrategy interface + MinMax/Lttb/MinMaxLttb impls (MinMaxLttb SOTA per arXiv 2305.00332, 1.80× faster than pure LTTB) - New render/SignalRenderer: public interface + LineSignalRenderer object - New render/AxisFormatter: 4 default impls (Time, Decimal, DateTime, Unit) - HARD BREAK: deleted LodMode, LodDecimator, ChartConfig.targetFps - ChartConfig split: DataConfig + AxisConfig + RenderConfig + FrameRate sealed - @Immutable/@Stable on all public types (0 unstable) - RealtimeChartState.clear() API Interaction layer (T7): - New interaction/ package - ChartInteractionState + rememberChartInteractionState() - ViewportMode sealed: Following / Frozen / History(anchorMs) - Pinch zoom + drag pan + tap crosshair gestures - Swipe-to-edge resumes Following - InverseProjection: pixel → ms + bisect nearest-sample Perf finishing (T8, T9, D3): - LineSignalRenderer Stroke cache, AxisRenderer TextStyle cache - resolveYRange Pair<Float,Float> → FloatArray out-param - RealtimeChartState.signalsArray cached (invalidated on add/remove only) - LTTB upper-bound aligned to half-open [start, start+windowMs) semantic Correctness (D4): - NumberFormat.formatFixed Long overflow guard @ |v|≥1e19 ABI baseline regenerated: - chart-realtime.api: 161 → 428 LOC - chart-realtime.klib.api: 211 → 501 LOC Modules touched: chart-realtime (lib), app (consumer), gradle (toolchain), .paul (state). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
158 lines
5.5 KiB
Kotlin
158 lines
5.5 KiB
Kotlin
package dev.dtrentin.chart.interaction
|
|
|
|
import androidx.compose.ui.graphics.Color
|
|
import dev.dtrentin.chart.SignalEntry
|
|
import dev.dtrentin.chart.buffer.TieredBuffer
|
|
import dev.dtrentin.chart.model.SignalConfig
|
|
import kotlin.test.Test
|
|
import kotlin.test.assertEquals
|
|
import kotlin.test.assertTrue
|
|
|
|
class InverseProjectionTest {
|
|
|
|
// ── pixelXToTimestampMs ───────────────────────────────────────────────────
|
|
|
|
@Test fun pixelXToTimestampMs_leftEdge() {
|
|
val ts = InverseProjection.pixelXToTimestampMs(
|
|
pixelX = 0f,
|
|
chartLeft = 0f,
|
|
chartRight = 1000f,
|
|
windowStartMs = 100_000L,
|
|
windowMs = 10_000L,
|
|
)
|
|
assertEquals(100_000L, ts)
|
|
}
|
|
|
|
@Test fun pixelXToTimestampMs_rightEdge() {
|
|
val ts = InverseProjection.pixelXToTimestampMs(
|
|
pixelX = 1000f,
|
|
chartLeft = 0f,
|
|
chartRight = 1000f,
|
|
windowStartMs = 100_000L,
|
|
windowMs = 10_000L,
|
|
)
|
|
assertEquals(110_000L, ts)
|
|
}
|
|
|
|
@Test fun pixelXToTimestampMs_middle() {
|
|
val ts = InverseProjection.pixelXToTimestampMs(
|
|
pixelX = 500f,
|
|
chartLeft = 0f,
|
|
chartRight = 1000f,
|
|
windowStartMs = 100_000L,
|
|
windowMs = 10_000L,
|
|
)
|
|
assertEquals(105_000L, ts)
|
|
}
|
|
|
|
@Test fun pixelXToTimestampMs_clampsBelowChartLeft() {
|
|
val ts = InverseProjection.pixelXToTimestampMs(
|
|
pixelX = -50f,
|
|
chartLeft = 0f,
|
|
chartRight = 1000f,
|
|
windowStartMs = 100_000L,
|
|
windowMs = 10_000L,
|
|
)
|
|
assertEquals(100_000L, ts) // clamped to left edge
|
|
}
|
|
|
|
@Test fun pixelXToTimestampMs_clampsAboveChartRight() {
|
|
val ts = InverseProjection.pixelXToTimestampMs(
|
|
pixelX = 5000f,
|
|
chartLeft = 0f,
|
|
chartRight = 1000f,
|
|
windowStartMs = 100_000L,
|
|
windowMs = 10_000L,
|
|
)
|
|
assertEquals(110_000L, ts) // clamped to right edge
|
|
}
|
|
|
|
@Test fun pixelXToTimestampMs_withChartLeftInset() {
|
|
// chartLeft=52 (Y-axis label inset), chartRight=552 → 500px chart area.
|
|
val ts = InverseProjection.pixelXToTimestampMs(
|
|
pixelX = 302f, // 250 px into chart area = 50%
|
|
chartLeft = 52f,
|
|
chartRight = 552f,
|
|
windowStartMs = 200_000L,
|
|
windowMs = 4_000L,
|
|
)
|
|
assertEquals(202_000L, ts)
|
|
}
|
|
|
|
@Test fun pixelXToTimestampMs_degenerateChartWidth_returnsWindowStart() {
|
|
// chartRight == chartLeft → coerced to width 1; frac clamped to [0,1].
|
|
val ts = InverseProjection.pixelXToTimestampMs(
|
|
pixelX = 100f,
|
|
chartLeft = 100f,
|
|
chartRight = 100f,
|
|
windowStartMs = 50_000L,
|
|
windowMs = 1000L,
|
|
)
|
|
// pixelX - chartLeft = 0 → frac = 0 → windowStartMs.
|
|
assertEquals(50_000L, ts)
|
|
}
|
|
|
|
// ── nearestSampleValue ────────────────────────────────────────────────────
|
|
|
|
private fun newEntry(): SignalEntry =
|
|
SignalEntry(SignalConfig(color = Color.Red), TieredBuffer())
|
|
|
|
/** Populate [entry] scratch arrays directly with [samples] (ts, v). */
|
|
private fun populate(entry: SignalEntry, samples: List<Pair<Long, Float>>) {
|
|
for ((i, s) in samples.withIndex()) {
|
|
entry.scratchTs[i] = s.first
|
|
entry.scratchV[i] = s.second
|
|
}
|
|
entry.scratchCount = samples.size
|
|
}
|
|
|
|
@Test fun nearestSampleValue_exactMatch() {
|
|
val e = newEntry()
|
|
populate(e, listOf(100L to 1f, 200L to 2f, 300L to 3f))
|
|
assertEquals(2f, InverseProjection.nearestSampleValue(e, 200L))
|
|
}
|
|
|
|
@Test fun nearestSampleValue_betweenSamples_picksNearer() {
|
|
val e = newEntry()
|
|
populate(e, listOf(100L to 1f, 200L to 2f, 300L to 3f))
|
|
assertEquals(2f, InverseProjection.nearestSampleValue(e, 230L)) // closer to 200
|
|
assertEquals(3f, InverseProjection.nearestSampleValue(e, 270L)) // closer to 300
|
|
}
|
|
|
|
@Test fun nearestSampleValue_tieBreaksLower() {
|
|
val e = newEntry()
|
|
populate(e, listOf(100L to 1f, 200L to 2f))
|
|
// Midpoint 150: |100-150| == |200-150| == 50; idxA <= idxB → idxA = lo-1 = 0 → value 1f.
|
|
assertEquals(1f, InverseProjection.nearestSampleValue(e, 150L))
|
|
}
|
|
|
|
@Test fun nearestSampleValue_targetBeforeAllSamples() {
|
|
val e = newEntry()
|
|
populate(e, listOf(500L to 5f, 600L to 6f))
|
|
assertEquals(5f, InverseProjection.nearestSampleValue(e, 100L))
|
|
}
|
|
|
|
@Test fun nearestSampleValue_targetAfterAllSamples() {
|
|
val e = newEntry()
|
|
populate(e, listOf(500L to 5f, 600L to 6f))
|
|
assertEquals(6f, InverseProjection.nearestSampleValue(e, 10_000L))
|
|
}
|
|
|
|
@Test fun nearestSampleValue_emptyEntry_returnsNaN() {
|
|
val e = newEntry()
|
|
// scratchCount stays 0
|
|
val v = InverseProjection.nearestSampleValue(e, 100L)
|
|
assertTrue(v.isNaN())
|
|
}
|
|
|
|
@Test fun nearestSampleIndex_emptyReturnsNegOne() {
|
|
val e = newEntry()
|
|
assertEquals(-1, InverseProjection.nearestSampleIndex(e, 100L))
|
|
}
|
|
|
|
@Test fun nearestSampleIndex_findsClosest() {
|
|
val e = newEntry()
|
|
populate(e, listOf(100L to 1f, 200L to 2f, 300L to 3f))
|
|
assertEquals(1, InverseProjection.nearestSampleIndex(e, 220L))
|
|
}
|
|
}
|