P2 Stream E: FeatureComparisonScreen (replaces FeatureComparisonDialog)

Full-screen feature comparison matching iOS FeatureComparisonView.
Two-column table, iOS-equivalent row set, CTA to upgrade flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:12:21 -05:00
parent 224f6643bf
commit 944161f0d1
10 changed files with 566 additions and 217 deletions
@@ -0,0 +1,132 @@
package com.tt.honeyDue.ui.screens.subscription
import com.tt.honeyDue.analytics.AnalyticsEvents
import com.tt.honeyDue.models.FeatureBenefit
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* P2 Stream E — FeatureComparisonScreen tests.
*
* These tests exercise the state-logic backing the full-screen feature
* comparison (replaces the old FeatureComparisonDialog). They use plain
* kotlin.test rather than Compose UI testing for the same reasons cited
* in ThemeSelectionScreenTest — the commonTest recomposer/Dispatchers
* interplay is flaky on iosSimulator.
*
* Mirrors iOS `iosApp/iosApp/Subscription/FeatureComparisonView.swift`.
*/
class FeatureComparisonScreenTest {
// 1. The default feature-row set matches iOS FeatureComparisonView.swift
// lines 99-105 (shown when DataManager.featureBenefits is empty).
@Test
fun defaultFeatureRowsMatchIOSOrderAndText() {
val rows = FeatureComparisonScreenState.defaultFeatureRows()
assertEquals(4, rows.size, "iOS default list has 4 rows")
assertEquals("Properties", rows[0].featureName)
assertEquals("1 property", rows[0].freeTierText)
assertEquals("Unlimited", rows[0].proTierText)
assertEquals("Tasks", rows[1].featureName)
assertEquals("10 tasks", rows[1].freeTierText)
assertEquals("Unlimited", rows[1].proTierText)
assertEquals("Contractors", rows[2].featureName)
assertEquals("Not available", rows[2].freeTierText)
assertEquals("Unlimited", rows[2].proTierText)
assertEquals("Documents", rows[3].featureName)
assertEquals("Not available", rows[3].freeTierText)
assertEquals("Unlimited", rows[3].proTierText)
}
// 2. freeHasFeature returns false for "Not available" rows (the iOS
// comparison renders the free column grey for those), true for
// rows with an actual limit text like "1 property".
@Test
fun freeHasFeatureFollowsIosPerRowBooleans() {
val rows = FeatureComparisonScreenState.defaultFeatureRows()
assertTrue(FeatureComparisonScreenState.freeHasFeature(rows[0]), "Properties: free has 1")
assertTrue(FeatureComparisonScreenState.freeHasFeature(rows[1]), "Tasks: free has 10")
assertFalse(FeatureComparisonScreenState.freeHasFeature(rows[2]), "Contractors: free has none")
assertFalse(FeatureComparisonScreenState.freeHasFeature(rows[3]), "Documents: free has none")
}
// 3. premiumHasFeature is true for every row on iOS — Pro is unlimited
// across the default set and every server-driven benefit.
@Test
fun premiumHasFeatureAlwaysTrueForProTier() {
val rows = FeatureComparisonScreenState.defaultFeatureRows()
rows.forEach { row ->
assertTrue(
FeatureComparisonScreenState.premiumHasFeature(row),
"Pro always has ${row.featureName}"
)
}
// Server-driven benefits: Pro tier is true unless the text is
// explicitly "Not available" (treated the same as the Free column).
val benefit = FeatureBenefit(
featureName = "Reports",
freeTierText = "Not available",
proTierText = "Not available"
)
assertFalse(FeatureComparisonScreenState.premiumHasFeature(benefit))
}
// 4. CTA invocation calls the expected navigation callback.
@Test
fun ctaInvokesUpgradeNavigationCallback() {
var navigated = false
FeatureComparisonScreenState.onUpgradeTap(
onNavigateToUpgrade = { navigated = true },
captureEvent = { _, _ -> /* ignore */ },
)
assertTrue(navigated, "Upgrade CTA must navigate to upgrade flow")
}
// 5. Upgrade CTA fires analytics event paywall_compare_cta.
@Test
fun ctaFiresPaywallCompareAnalyticsEvent() {
val captured = mutableListOf<Pair<String, Map<String, Any>?>>()
FeatureComparisonScreenState.onUpgradeTap(
onNavigateToUpgrade = { },
captureEvent = { event, props -> captured.add(event to props) },
)
assertEquals(1, captured.size, "Exactly one analytics event")
assertEquals(AnalyticsEvents.PAYWALL_COMPARE_CTA, captured[0].first)
}
// 6. Close / back callback is invoked when the user dismisses the
// screen (matches iOS "Close" toolbar button).
@Test
fun onCloseInvokesBackCallback() {
var closed = false
FeatureComparisonScreenState.onClose(onBack = { closed = true })
assertTrue(closed, "Close/Back must call the onBack callback")
}
// 7. When DataManager has server-driven benefits, the screen uses
// those in preference to the default list.
@Test
fun serverDrivenBenefitsOverrideDefaults() {
val serverBenefits = listOf(
FeatureBenefit("Custom Feature A", "Not available", "Unlimited"),
FeatureBenefit("Custom Feature B", "5 items", "Unlimited"),
)
val effective = FeatureComparisonScreenState.resolveFeatureRows(serverBenefits)
assertEquals(2, effective.size)
assertEquals("Custom Feature A", effective[0].featureName)
assertFalse(FeatureComparisonScreenState.freeHasFeature(effective[0]))
assertTrue(FeatureComparisonScreenState.freeHasFeature(effective[1]))
val empty = FeatureComparisonScreenState.resolveFeatureRows(emptyList())
assertEquals(4, empty.size, "Empty benefits falls back to default iOS list")
}
}