diff --git a/README.md b/README.md index 2a49ab0..ea284d7 100644 --- a/README.md +++ b/README.md @@ -49,13 +49,13 @@ We are breaking down the various parts that we need to migrate to Swift in the s |---------------------|--------------------------------------------------|--------| | Files | `@featurevisor/types` ➡️ `Types.kt` | ✅ | | | SDK's `bucket.ts` ➡️ `Bucket.kt` | ✅ | -| | SDK's `conditions.ts` ➡️ `Conditions.kt` | 🟠 | -| | SDK's `datafileReader.ts` ➡️ `DatafileReader.kt` | | -| | SDK's `emitter.ts` ➡️ `Emitter.kt` | | -| | SDK's `feature.ts` ➡️ `Emitter.kt` | | -| | SDK's `instance.ts` ➡️ `Instance.kt` | | +| | SDK's `conditions.ts` ➡️ `Conditions.kt` | ✅ | +| | SDK's `datafileReader.ts` ➡️ `DatafileReader.kt` | ✅ | +| | SDK's `emitter.ts` ➡️ `Emitter.kt` | ✅ | +| | SDK's `feature.ts` ➡️ `Feature.kt` | | +| | SDK's `instance.ts` ➡️ `Instance.kt` | 🟠 | | | SDK's `logger.ts` ➡️ `Logger.kt` | | -| | SDK's `segments.ts` ➡️ `segments.kt` | | +| | SDK's `segments.ts` ➡️ `Segments.kt` | | | | | | | Constructor options | `bucketKeySeparator` | | | | `configureBucketKey` | | diff --git a/build.gradle.kts b/build.gradle.kts index 0337747..ddae965 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,7 +49,7 @@ dependencies { // Use the JUnit 5 integration. testImplementation("org.junit.jupiter:junit-jupiter-engine:5.9.2") // Uncomment when needed -// testImplementation("io.mockk:mockk:1.13.8") + testImplementation("io.mockk:mockk:1.13.8") testImplementation("io.kotest:kotest-assertions-core:5.7.2") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/src/main/kotlin/com/featurevisor/sdk/DataFileReader.kt b/src/main/kotlin/com/featurevisor/sdk/DataFileReader.kt new file mode 100644 index 0000000..0cfc697 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/DataFileReader.kt @@ -0,0 +1,34 @@ +package com.featurevisor.sdk + +import com.featurevisor.types.Attribute +import com.featurevisor.types.AttributeKey +import com.featurevisor.types.DatafileContent +import com.featurevisor.types.Feature +import com.featurevisor.types.FeatureKey +import com.featurevisor.types.Segment +import com.featurevisor.types.SegmentKey + +class DataFileReader constructor( + datafileJson: DatafileContent, +) { + private val schemaVersion: String = datafileJson.schemaVersion + private val revision: String = datafileJson.revision + private val attributes: List = datafileJson.attributes + private val segments: List = datafileJson.segments + private val features: List = datafileJson.features + + fun getRevision(): String = revision + + fun getSchemaVersion(): String = schemaVersion + + fun getAllAttributes(): List = attributes + + fun getAttribute(attributeKey: AttributeKey): Attribute? = + attributes.find { attribute -> attribute.key == attributeKey } + + fun getSegment(segmentKey: SegmentKey): Segment? = + segments.find { segment -> segment.key == segmentKey } + + fun getFeature(featureKey: FeatureKey): Feature? = + features.find { feature -> feature.key == featureKey } +} diff --git a/src/main/kotlin/com/featurevisor/sdk/Emitter.kt b/src/main/kotlin/com/featurevisor/sdk/Emitter.kt new file mode 100644 index 0000000..8affa92 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/Emitter.kt @@ -0,0 +1,23 @@ +package com.featurevisor.sdk + +import com.featurevisor.types.EventName + +class Emitter { + private val listeners = mutableMapOf Unit>() + + fun addListener(event: EventName, listener: () -> Unit) { + listeners.putIfAbsent(event, listener) + } + + fun removeListener(event: EventName) { + listeners.remove(event) + } + + fun removeAllListeners() { + listeners.clear() + } + + fun emit(event: EventName) { + listeners.getOrDefault(event, null)?.invoke() + } +} diff --git a/src/main/kotlin/com/featurevisor/types/DataFile.kt b/src/main/kotlin/com/featurevisor/types/DataFile.kt new file mode 100644 index 0000000..67367a6 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/types/DataFile.kt @@ -0,0 +1,81 @@ +package com.featurevisor.types + +/** + * Datafile-only types + */ +// 0 to 100,000 +typealias Percentage = Int + +data class Range( + val start: Percentage, + val end: Percentage, +) + +data class Allocation( + val variation: VariationValue, + val range: Range, +) + +data class Traffic( + val key: RuleKey, + val segments: GroupSegment, + val percentage: Percentage, + + val enabled: Boolean?, + val variation: VariationValue?, + val variables: VariableValues?, + + val allocation: List, +) + +typealias PlainBucketBy = String + +typealias AndBucketBy = List + +data class OrBucketBy( + val or: List, +) + +sealed class BucketBy { + data class Single(val bucketBy: PlainBucketBy) : BucketBy() + data class And(val bucketBy: AndBucketBy) : BucketBy() + data class Or(val bucketBy: OrBucketBy) : BucketBy() +} + +data class RequiredWithVariation( + val key: FeatureKey, + val variation: VariationValue, +) + +sealed class Required { + data class FeatureKey(val required: FeatureKey) : Required() + data class WithVariation(val required: RequiredWithVariation) : Required() +} + +data class Feature( + val key: FeatureKey, + val deprecated: Boolean?, + val variablesSchema: List?, + val variations: List?, + val bucketBy: BucketBy, + val required: List?, + val traffic: List, + val force: List?, + + // if in a Group (mutex), these are available slot ranges + val ranges: List?, +) + +data class DatafileContent( + val schemaVersion: String, + val revision: String, + val attributes: List, + val segments: List, + val features: List, +) + +data class OverrideFeature( + val enabled: Boolean, + val variation: VariationValue?, + val variables: VariableValues?, +) diff --git a/src/main/kotlin/com/featurevisor/types/EventName.kt b/src/main/kotlin/com/featurevisor/types/EventName.kt new file mode 100644 index 0000000..a868e05 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/types/EventName.kt @@ -0,0 +1,8 @@ +package com.featurevisor.types + +enum class EventName { + READY, + REFRESH, + UPDATE, + ACTIVATION, +} diff --git a/src/main/kotlin/com/featurevisor/types/Segment.kt b/src/main/kotlin/com/featurevisor/types/Segment.kt new file mode 100644 index 0000000..695f39b --- /dev/null +++ b/src/main/kotlin/com/featurevisor/types/Segment.kt @@ -0,0 +1,32 @@ +package com.featurevisor.types + +typealias SegmentKey = String + +data class Segment( + val archived: Boolean?, + val key: SegmentKey, + val conditions: Condition, +) + +typealias PlainGroupSegment = SegmentKey + +data class AndGroupSegment( + val and: List, +) + +data class OrGroupSegment( + val or: List, +) + +data class NotGroupSegment( + val not: List, +) + +sealed class GroupSegment { + data class Plain(val segment: PlainGroupSegment) : GroupSegment() + data class Multiple(val segments: List) : GroupSegment() + + data class And(val segment: AndGroupSegment) : GroupSegment() + data class Or(val segment: OrGroupSegment) : GroupSegment() + data class Not(val segment: NotGroupSegment) : GroupSegment() +} diff --git a/src/main/kotlin/com/featurevisor/types/Types.kt b/src/main/kotlin/com/featurevisor/types/Types.kt index 895a104..86ca3f4 100644 --- a/src/main/kotlin/com/featurevisor/types/Types.kt +++ b/src/main/kotlin/com/featurevisor/types/Types.kt @@ -2,37 +2,6 @@ package com.featurevisor.types typealias Context = Map -typealias SegmentKey = String - -data class Segment( - val archived: Boolean?, - val key: SegmentKey, - val conditions: Condition, -) - -typealias PlainGroupSegment = SegmentKey - -data class AndGroupSegment( - val and: List, -) - -data class OrGroupSegment( - val or: List, -) - -data class NotGroupSegment( - val not: List, -) - -sealed class GroupSegment { - data class Plain(val segment: PlainGroupSegment) : GroupSegment() - data class Multiple(val segments: List) : GroupSegment() - - data class And(val segment: AndGroupSegment) : GroupSegment() - data class Or(val segment: OrGroupSegment) : GroupSegment() - data class Not(val segment: NotGroupSegment) : GroupSegment() -} - typealias VariationValue = String typealias VariableKey = String @@ -124,86 +93,6 @@ typealias BucketKey = String // 0 to 100,000 typealias BucketValue = Int -/** - * Datafile-only types - */ -// 0 to 100,000 -typealias Percentage = Int - -data class Range( - val start: Percentage, - val end: Percentage, -) - -data class Allocation( - val variation: VariationValue, - val range: Range, -) - -data class Traffic( - val key: RuleKey, - val segments: GroupSegment, - val percentage: Percentage, - - val enabled: Boolean?, - val variation: VariationValue?, - val variables: VariableValues?, - - val allocation: List, -) - -typealias PlainBucketBy = String - -typealias AndBucketBy = List - -data class OrBucketBy( - val or: List, -) - -sealed class BucketBy { - data class Single(val bucketBy: PlainBucketBy) : BucketBy() - data class And(val bucketBy: AndBucketBy) : BucketBy() - data class Or(val bucketBy: OrBucketBy) : BucketBy() -} - -data class RequiredWithVariation( - val key: FeatureKey, - val variation: VariationValue, -) - -sealed class Required { - data class FeatureKey(val required: FeatureKey) : Required() - data class WithVariation(val required: RequiredWithVariation) : Required() -} - -data class Feature( - val key: FeatureKey, - val deprecated: Boolean?, - val variablesSchema: List?, - val variations: List?, - val bucketBy: BucketBy, - val required: List?, - val traffic: List, - val force: List?, - - // if in a Group (mutex), these are available slot ranges - val ranges: List?, -) - -data class DatafileContent( - val schemaVersion: String, - val revision: String, - val attributes: List, - val segments: List, - val features: List, -) - -data class OverrideFeature( - val enabled: Boolean, - val variation: VariationValue?, - val variables: VariableValues?, -) - typealias StickyFeatures = Map typealias InitialFeatures = Map diff --git a/src/test/kotlin/com/featurevisor/sdk/DataFileReaderTest.kt b/src/test/kotlin/com/featurevisor/sdk/DataFileReaderTest.kt new file mode 100644 index 0000000..dd3383e --- /dev/null +++ b/src/test/kotlin/com/featurevisor/sdk/DataFileReaderTest.kt @@ -0,0 +1,49 @@ +package com.featurevisor.sdk + +import com.featurevisor.sdk.factory.DatafileContentFactory +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test + +class DataFileReaderTest { + + private val systemUnderTest = DataFileReader( + datafileJson = DatafileContentFactory.get() + ) + + @Test + fun `getRevision() returns correct value`() { + systemUnderTest.getRevision() shouldBe "revision" + } + + @Test + fun `getSchemaVersion returns correct value`() { + systemUnderTest.getSchemaVersion() shouldBe "schemaVersion" + } + + @Test + fun `getAllAttributes() returns correct list`() { + systemUnderTest.getAllAttributes() shouldBe DatafileContentFactory.getAttributes() + } + + @Test + fun `getAttribute() returns correct value`() { + systemUnderTest.getAttribute("browser_type") shouldBe DatafileContentFactory.getAttributes().first() + } + + @Test + fun `getSegment() returns correct value`() { + systemUnderTest.getSegment("netherlands") shouldBe DatafileContentFactory.getSegments().first() + } + + @Test + fun `getFeature() returns correct value`() { + systemUnderTest.getFeature("landing_page") shouldBe DatafileContentFactory.getFeatures().first() + } + + @Test + fun `return null if key not present in collection`() { + systemUnderTest.getAttribute("country") shouldBe null + systemUnderTest.getSegment("germany") shouldBe null + systemUnderTest.getFeature("key_moments") shouldBe null + } +} diff --git a/src/test/kotlin/com/featurevisor/sdk/EmitterTest.kt b/src/test/kotlin/com/featurevisor/sdk/EmitterTest.kt new file mode 100644 index 0000000..93c05f7 --- /dev/null +++ b/src/test/kotlin/com/featurevisor/sdk/EmitterTest.kt @@ -0,0 +1,88 @@ +package com.featurevisor.sdk + +import com.featurevisor.types.EventName.ACTIVATION +import com.featurevisor.types.EventName.READY +import com.featurevisor.types.EventName.REFRESH +import com.featurevisor.types.EventName.UPDATE +import com.featurevisor.types.EventName.values +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test + +class EmitterTest { + + private val readyCallback: () -> Unit = mockk { + every { this@mockk() } answers { nothing } + } + private val refreshCallback: () -> Unit = mockk { + every { this@mockk() } answers { nothing } + } + private val updateCallback: () -> Unit = mockk { + every { this@mockk() } answers { nothing } + } + private val activationCallback: () -> Unit = mockk { + every { this@mockk() } answers { nothing } + } + + private val systemUnderTest = Emitter() + + @Test + fun `add listeners and confirm they are invoked`() { + systemUnderTest.addListener(READY, readyCallback) + systemUnderTest.addListener(REFRESH, refreshCallback) + systemUnderTest.addListener(UPDATE, updateCallback) + systemUnderTest.addListener(ACTIVATION, activationCallback) + + values().forEach { + systemUnderTest.emit(it) + } + + verify(exactly = 1) { + readyCallback() + refreshCallback() + updateCallback() + activationCallback() + } + } + + @Test + fun `removed listener is no longer invoked`() { + systemUnderTest.addListener(READY, readyCallback) + systemUnderTest.addListener(REFRESH, refreshCallback) + systemUnderTest.addListener(UPDATE, updateCallback) + + systemUnderTest.removeListener(REFRESH) + values().forEach { + systemUnderTest.emit(it) + } + + verify(exactly = 1) { + readyCallback() + updateCallback() + } + verify(exactly = 0) { + refreshCallback() + } + } + + @Test + fun `removeAllListeners() works correctly`() { + systemUnderTest.addListener(READY, readyCallback) + systemUnderTest.addListener(REFRESH, refreshCallback) + systemUnderTest.addListener(UPDATE, updateCallback) + systemUnderTest.addListener(ACTIVATION, activationCallback) + + systemUnderTest.removeAllListeners() + values().forEach { + systemUnderTest.emit(it) + } + + verify(exactly = 0) { + readyCallback() + refreshCallback() + updateCallback() + activationCallback() + } + } +} diff --git a/src/test/kotlin/com/featurevisor/sdk/factory/DatafileContentFactory.kt b/src/test/kotlin/com/featurevisor/sdk/factory/DatafileContentFactory.kt new file mode 100644 index 0000000..7dc13a7 --- /dev/null +++ b/src/test/kotlin/com/featurevisor/sdk/factory/DatafileContentFactory.kt @@ -0,0 +1,75 @@ +package com.featurevisor.sdk.factory + +import com.featurevisor.types.Attribute +import com.featurevisor.types.BucketBy +import com.featurevisor.types.Condition +import com.featurevisor.types.ConditionValue +import com.featurevisor.types.DatafileContent +import com.featurevisor.types.Feature +import com.featurevisor.types.Operator.EQUALS +import com.featurevisor.types.Operator.NOT_EQUALS +import com.featurevisor.types.Segment + +object DatafileContentFactory { + + fun get() = DatafileContent( + schemaVersion = "schemaVersion", + revision = "revision", + // Attributes are the building blocks of creating segments. They are the properties that you can use to target users. + attributes = getAttributes(), + // Segments are made up of conditions against various attributes. They are the groups of users that you can target. + segments = getSegments(), + // Features are the building blocks of creating traditional boolean feature flags and more advanced multivariate experiments. + features = getFeatures(), + ) + + fun getAttributes() = listOf( + Attribute( + key = "browser_type", + type = "string", + archived = false, + capture = true, + ), + Attribute( + key = "device", + type = "string", + archived = false, + capture = true, + ), + ) + + fun getSegments() = listOf( + Segment( + archived = false, + key = "netherlands", + conditions = Condition.And( + listOf( + Condition.Plain( + attributeKey = "browser_type", + operator = EQUALS, + value = ConditionValue.StringValue("chrome"), + ), + Condition.Plain( + attributeKey = "device", + operator = NOT_EQUALS, + value = ConditionValue.StringValue("tablet"), + ) + ), + ), + ), + ) + + fun getFeatures() = listOf( + Feature( + key = "landing_page", + deprecated = false, + variablesSchema = null, + variations = null, + bucketBy = BucketBy.Single("userId"), + required = null, + traffic = emptyList(), + force = null, + ranges = null, + ) + ) +}