P1 Stream C: organic design primitives (BlobShape, RadialGlow, HoneycombOverlay)

Ports iOS OrganicDesign.swift primitives to Compose Multiplatform:
- BlobShape: seeded deterministic irregular shape
- RadialGlow: radial-gradient backdrop composable
- HoneycombOverlay: tiled hex pattern modifier
- OrganicRadius constants matching iOS

Determinism guaranteed by parametrized tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 12:34:47 -05:00
parent dcab30f862
commit db181c0d6a
2 changed files with 601 additions and 0 deletions
@@ -0,0 +1,162 @@
package com.tt.honeyDue.ui.design
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* Tests for organic design primitives ported from iOS OrganicDesign.swift.
*
* These tests are deterministic — they verify that the same input produces
* the same geometry so UI snapshot regressions and cross-platform parity
* can be asserted without rendering.
*/
class OrganicDesignTest {
// ---------------------------------------------------------------------
// Determinism: same seed + size → identical path
// ---------------------------------------------------------------------
@Test
fun blobShapeIsDeterministicForSameSeedAndSize() {
val size = Size(100f, 100f)
val a = BlobShape.serializePath(size = size, variation = 0, seed = 42L)
val b = BlobShape.serializePath(size = size, variation = 0, seed = 42L)
assertEquals(a, b, "BlobShape must produce identical paths for identical inputs")
assertTrue(a.isNotEmpty(), "Serialized path must not be empty")
}
@Test
fun blobShapeIsDeterministicAcrossMultipleInvocations() {
val size = Size(256f, 128f)
val runs = List(5) {
BlobShape.serializePath(size = size, variation = 1, seed = 7L)
}
val first = runs.first()
runs.forEach { assertEquals(first, it) }
}
// ---------------------------------------------------------------------
// Variations: N distinct shapes, matching iOS OrganicBlobShape count
// ---------------------------------------------------------------------
@Test
fun blobShapeHasExpectedVariationCountMatchingIos() {
// iOS OrganicBlobShape switches on variation % 3 (cloud / pebble / leaf).
assertEquals(3, BlobShape.VARIATION_COUNT)
}
@Test
fun blobShapeVariationsProduceDistinctPaths() {
val size = Size(200f, 200f)
val paths = (0 until BlobShape.VARIATION_COUNT).map { v ->
BlobShape.serializePath(size = size, variation = v, seed = 0L)
}
// Every pair must differ.
for (i in paths.indices) {
for (j in i + 1 until paths.size) {
assertNotEquals(
paths[i],
paths[j],
"variation $i and $j must produce distinct paths"
)
}
}
}
@Test
fun blobShapeVariationIndexWrapsModulo() {
val size = Size(100f, 100f)
val base = BlobShape.serializePath(size = size, variation = 0, seed = 0L)
val wrapped = BlobShape.serializePath(
size = size,
variation = BlobShape.VARIATION_COUNT,
seed = 0L
)
assertEquals(base, wrapped, "variation index must wrap modulo VARIATION_COUNT")
}
@Test
fun blobShapeDifferentSeedsProduceDifferentPaths() {
val size = Size(100f, 100f)
val a = BlobShape.serializePath(size = size, variation = 0, seed = 1L)
val b = BlobShape.serializePath(size = size, variation = 0, seed = 2L)
assertNotEquals(a, b, "different seeds must perturb the path")
}
// ---------------------------------------------------------------------
// RadialGlow: parameter API surface (not a pixel test)
// ---------------------------------------------------------------------
@Test
fun radialGlowParamsExposesColorCenterAndRadius() {
val params = RadialGlowParams(
color = Color.Red,
center = Offset(10f, 20f),
radius = 50f
)
assertEquals(Color.Red, params.color)
assertEquals(Offset(10f, 20f), params.center)
assertEquals(50f, params.radius)
}
@Test
fun radialGlowParamsEqualityIsValueBased() {
val a = RadialGlowParams(Color.Blue, Offset(1f, 2f), 3f)
val b = RadialGlowParams(Color.Blue, Offset(1f, 2f), 3f)
assertEquals(a, b)
}
// ---------------------------------------------------------------------
// OrganicRadius: constants match iOS point-values
// ---------------------------------------------------------------------
@Test
fun organicRadiusConstantsMatchIosValues() {
// iOS AppRadius (points == dp):
// xs 4, sm 8, md 12, lg 16, xl 20, xxl 24, full 9999
// iOS OrganicRoundedRectangle default cornerRadius == 28 → blob.
assertEquals(4.dp, OrganicRadius.xs)
assertEquals(8.dp, OrganicRadius.sm)
assertEquals(12.dp, OrganicRadius.md)
assertEquals(16.dp, OrganicRadius.lg)
assertEquals(20.dp, OrganicRadius.xl)
assertEquals(28.dp, OrganicRadius.blob)
}
// ---------------------------------------------------------------------
// OrganicSpacing: matches iOS OrganicSpacing (compact/cozy/…)
// ---------------------------------------------------------------------
@Test
fun organicSpacingConstantsMatchIosValues() {
// iOS OrganicSpacing:
// compact 8, cozy 20, comfortable 24, spacious 32, airy 40
assertEquals(8.dp, OrganicSpacing.compact)
assertEquals(20.dp, OrganicSpacing.cozy)
assertEquals(24.dp, OrganicSpacing.comfortable)
assertEquals(32.dp, OrganicSpacing.spacious)
assertEquals(40.dp, OrganicSpacing.airy)
}
// ---------------------------------------------------------------------
// Honeycomb overlay: configuration exposes tile size + color + opacity
// ---------------------------------------------------------------------
@Test
fun honeycombOverlayConfigDefaultsMatchIos() {
// iOS HoneycombTextureCache: tile 60 x 103.92, stroke #C4856A, 0.8pt.
val cfg = HoneycombOverlayConfig()
assertEquals(60f, cfg.tileWidth)
assertEquals(103.92f, cfg.tileHeight)
// #C4856A opaque — iOS honeycomb stroke color.
assertEquals(Color(0xFFC4856A), cfg.strokeColor)
assertEquals(0.8f, cfg.strokeWidth)
assertEquals(0.10f, cfg.opacity)
}
}