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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user