Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ class BottomNavBlocImpl(
SettingsBloc.Output.OpenSignUp -> OpenSignUp
SettingsBloc.Output.OpenManageProfile -> OpenManageProfile
SettingsBloc.Output.OpenNotifications -> BottomNavBloc.Output.OpenNotifications
SettingsBloc.Output.OpenManageFamily -> BottomNavBloc.Output.OpenManageFamily
SettingsBloc.Output.OpenAppSettings -> OpenAppSettings
SettingsBloc.Output.OpenAiChat -> BottomNavBloc.Output.OpenAiChat
SettingsBloc.Output.OpenDeveloperSettings -> BottomNavBloc.Output.OpenDeveloperSettings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ interface BottomNavBloc : BackHandlerOwner, BackClickBloc, ComposeScreen {

data object OpenNotifications : Output()

/** Open the Manage Family flow (flagged); signed-out users go via sign-in first. */
data object OpenManageFamily : Output()

data object OpenAppSettings : Output()

data object OpenAiChat : Output()
Expand Down
4 changes: 4 additions & 0 deletions client/composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ kotlin {
api(projects.client.featureflag.impl)
api(projects.client.featureflag.public)
api(projects.client.family.data.impl)
api(projects.client.family.manage.impl)
api(projects.client.family.manage.public)
api(projects.client.grocery.autocomplete.impl)
api(projects.client.grocery.autocomplete.public)
api(projects.client.grocery.categoryRules.impl)
Expand Down Expand Up @@ -180,6 +182,8 @@ kotlin {
implementation(projects.client.profile.implRobots)
implementation(projects.client.notifications.implRobots)
implementation(projects.client.onboarding.implRobots)
implementation(projects.client.family.manage.implRobots)
implementation(projects.client.family.data.testing)
}
val jvmTest by getting {
dependencies {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.plusmobileapps.chefmate.fakes.FakeDatabase
import com.plusmobileapps.chefmate.fakes.FakeGeminiClient
import com.plusmobileapps.chefmate.fakes.FakeGeminiRecipeExtractor
import com.plusmobileapps.chefmate.fakes.TestAuthenticationRepository
import com.plusmobileapps.chefmate.fakes.TestFamilyRepository
import com.plusmobileapps.chefmate.fakes.TestFeatureFlags
import com.plusmobileapps.chefmate.fakes.TestSubscriptionRepository

Expand All @@ -14,6 +15,7 @@ interface TestApplicationComponent : ApplicationComponent {
val fakeGeminiClient: FakeGeminiClient
val fakeGeminiRecipeExtractor: FakeGeminiRecipeExtractor
val testFeatureFlags: TestFeatureFlags
val testFamilyRepository: TestFamilyRepository
val testSubscriptionRepository: TestSubscriptionRepository
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.plusmobileapps.chefmate.fakes

import com.plusmobileapps.chefmate.di.AppScope
import com.plusmobileapps.chefmate.family.data.FamilyRepository
import com.plusmobileapps.chefmate.family.data.impl.FamilyRepositoryImpl
import com.plusmobileapps.chefmate.family.data.testing.FakeFamilyRepository
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn

/**
* Replaces the production [FamilyRepositoryImpl] in tests. Families are online-only, so without
* this every Manage Family screen would render its offline error state. [FakeFamilyRepository]
* enforces the same permission rules the server does, so flows behave realistically.
*/
@SingleIn(AppScope::class)
@Inject
@ContributesBinding(scope = AppScope::class, replaces = [FamilyRepositoryImpl::class])
class TestFamilyRepository(private val fake: FakeFamilyRepository = FakeFamilyRepository()) :
FamilyRepository by fake {

/** Seeds a family the current user owns (or holds [seed]'s role in). Returns its id. */
fun seedFamily(
name: String,
myRole: com.plusmobileapps.chefmate.family.data.FamilyRole =
com.plusmobileapps.chefmate.family.data.FamilyRole.OWNER,
others: List<FakeFamilyRepository.SeedMember> = emptyList(),
): String = fake.seedFamily(name = name, myRole = myRole, others = others)

/** Seeds a pending invite addressed to the current user. Returns the member-row id. */
fun seedInvite(familyName: String): String = fake.seedInvite(familyName)

/** When non-null, every call throws it, so tests can exercise the offline paths. */
var failure: Throwable?
get() = fake.failure
set(value) {
fake.failure = value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package com.plusmobileapps.chefmate.tests

import androidx.compose.ui.test.ExperimentalTestApi
import com.plusmobileapps.chefmate.family.data.FamilyRole
import com.plusmobileapps.chefmate.family.data.testing.FakeFamilyRepository
import com.plusmobileapps.chefmate.family.manage.robots.familyDetail
import com.plusmobileapps.chefmate.family.manage.robots.familyList
import com.plusmobileapps.chefmate.featureflag.FeatureFlagRegistry
import com.plusmobileapps.chefmate.harness.TestUserState
import com.plusmobileapps.chefmate.harness.runRootBlocTest
import com.plusmobileapps.chefmate.recipe.bottomnav.robots.bottomNav
import com.plusmobileapps.chefmate.settings.robots.more
import kotlin.test.Test

@OptIn(ExperimentalTestApi::class)
class ManageFamilyNavigationUiTest {

@Test
fun the_more_tab_hides_manage_family_while_the_flag_is_off() = runRootBlocTest {
bottomNav().clickMoreTab()

more().awaitDisplayed().assertManageFamilyRowNotShown()
}

@Test
fun opening_manage_family_from_the_more_tab_lists_the_users_families() =
runRootBlocTest(
beforeContent = { app ->
app.testFeatureFlags.set(FeatureFlagRegistry.ManageFamily, true)
app.testFamilyRepository.seedFamily("The Smiths")
}
) {
bottomNav().clickMoreTab()
more().awaitDisplayed().clickManageFamilyRow()

familyList().awaitDisplayed().awaitFamily("The Smiths")
}

@Test
fun opening_a_family_shows_its_members_and_the_owners_actions() =
runRootBlocTest(
beforeContent = { app ->
app.testFeatureFlags.set(FeatureFlagRegistry.ManageFamily, true)
app.testFamilyRepository.seedFamily(
name = "The Smiths",
myRole = FamilyRole.OWNER,
others = listOf(FakeFamilyRepository.SeedMember(email = "riley@example.com")),
)
}
) {
bottomNav().clickMoreTab()
more().awaitDisplayed().clickManageFamilyRow()
familyList().awaitDisplayed().openFamily("The Smiths")

familyDetail().awaitDisplayed().awaitMember("riley@example.com")
}

@Test
fun a_member_sees_no_delete_button_on_a_family_they_do_not_own() =
runRootBlocTest(
beforeContent = { app ->
app.testFeatureFlags.set(FeatureFlagRegistry.ManageFamily, true)
app.testFamilyRepository.seedFamily(
name = "Lake House",
myRole = FamilyRole.MEMBER,
others =
listOf(
FakeFamilyRepository.SeedMember(
email = "owner@example.com",
role = FamilyRole.OWNER,
)
),
)
}
) {
bottomNav().clickMoreTab()
more().awaitDisplayed().clickManageFamilyRow()
familyList().awaitDisplayed().openFamily("Lake House")

familyDetail()
.awaitDisplayed()
.awaitMember("owner@example.com")
.assertDeleteNotShown()
.assertInviteNotShown()
}

@Test
fun creating_the_first_family_opens_it_straight_away() =
runRootBlocTest(
beforeContent = { app ->
app.testFeatureFlags.set(FeatureFlagRegistry.ManageFamily, true)
}
) {
bottomNav().clickMoreTab()
more().awaitDisplayed().clickManageFamilyRow()

familyList().awaitDisplayed().createFirstFamily().typeFamilyName("The Smiths")
familyList().confirmCreate()

// A brand-new family has nobody in it, so the detail screen is where the user wants to
// be.
// Matched on the creator's email — their display name ("Me") is a substring of the
// "Members" section title.
familyDetail().awaitDisplayed().awaitMember("me@example.com")
}

@Test
fun a_signed_out_user_is_still_offered_the_row() =
runRootBlocTest(
userState = TestUserState.UnauthenticatedWithRecipes(),
beforeContent = { app ->
app.testFeatureFlags.set(FeatureFlagRegistry.ManageFamily, true)
},
) {
bottomNav().clickMoreTab()

// Unlike Notifications, the row is offered in every auth state — tapping it routes
// through sign-in. This stops at asserting the row is there: the auth screen it opens
// renders a UIKitView (PlusAutofillTextField.ios) needing a LocalInteropContainer that
// runComposeUiTest doesn't provide, so composing it fails on iOS. The routing itself,
// including landing on Manage Family once sign-in succeeds, is covered in RootBlocTest.
more().awaitDisplayed().assertManageFamilyRowShown()
}
}
15 changes: 15 additions & 0 deletions client/family/manage/impl-robots/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
plugins {
alias(libs.plugins.kmpLibrary)
alias(libs.plugins.compose)
}

kotlin {
sourceSets {
commonMain.dependencies { implementation(projects.client.family.manage.public) }
}
}

plusLibrary {
namespace = "com.plusmobileapps.chefmate.family.manage.robots"
uiTest = true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
@file:OptIn(ExperimentalTestApi::class)

package com.plusmobileapps.chefmate.family.manage.robots

import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.waitUntilExactlyOneExists
import com.plusmobileapps.chefmate.family.manage.FamilyDetailTestTags

/** Robot for one family's member list, invite form and danger zone. */
class FamilyDetailRobot(private val test: ComposeUiTest) {

private val onScreen = hasAnyAncestor(hasTestTag(FamilyDetailTestTags.SCREEN))

fun awaitDisplayed(): FamilyDetailRobot = apply {
test.waitUntilExactlyOneExists(hasTestTag(FamilyDetailTestTags.SCREEN))
}

fun assertDisplayed(): FamilyDetailRobot = apply {
test.onNodeWithTag(FamilyDetailTestTags.SCREEN).assertIsDisplayed()
}

fun awaitMember(text: String): FamilyDetailRobot = apply {
test.waitUntilExactlyOneExists(hasText(text, substring = true) and onScreen)
}

fun typeInviteEmail(email: String): FamilyDetailRobot = apply {
test
.onNode(
hasSetTextAction() and
hasAnyAncestor(hasTestTag(FamilyDetailTestTags.INVITE_EMAIL_FIELD))
)
.performTextReplacement(email)
}

fun sendInvite(): FamilyDetailRobot = apply {
test.onNodeWithTag(FamilyDetailTestTags.INVITE_BUTTON).performClick()
}

fun leaveFamily(): FamilyDetailRobot = apply {
test.onNodeWithTag(FamilyDetailTestTags.LEAVE_BUTTON).performClick()
}

fun deleteFamily(): FamilyDetailRobot = apply {
test.onNodeWithTag(FamilyDetailTestTags.DELETE_BUTTON).performClick()
}

fun assertInviteNotShown(): FamilyDetailRobot = apply {
test.onNodeWithTag(FamilyDetailTestTags.INVITE_BUTTON).assertDoesNotExist()
}

fun assertDeleteNotShown(): FamilyDetailRobot = apply {
test.onNodeWithTag(FamilyDetailTestTags.DELETE_BUTTON).assertDoesNotExist()
}

/** Taps the confirm button of whichever dialog is showing. */
fun confirmDialog(label: String): FamilyDetailRobot = apply {
test.waitUntilExactlyOneExists(hasText(label))
test.onNode(hasText(label)).performClick()
}
}

fun ComposeUiTest.familyDetail(): FamilyDetailRobot = FamilyDetailRobot(this)
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
@file:OptIn(ExperimentalTestApi::class)

package com.plusmobileapps.chefmate.family.manage.robots

import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.waitUntilExactlyOneExists
import com.plusmobileapps.chefmate.family.manage.FamilyListTestTags

/**
* Robot for the Manage Family list screen. Every lookup is scoped under [FamilyListTestTags.SCREEN]
* so a family name never matches a like-named node elsewhere.
*/
class FamilyListRobot(private val test: ComposeUiTest) {

private val onScreen = hasAnyAncestor(hasTestTag(FamilyListTestTags.SCREEN))

fun awaitDisplayed(): FamilyListRobot = apply {
test.waitUntilExactlyOneExists(hasTestTag(FamilyListTestTags.SCREEN))
}

fun assertDisplayed(): FamilyListRobot = apply {
test.onNodeWithTag(FamilyListTestTags.SCREEN).assertIsDisplayed()
}

/** Waits for the empty state's "Create your first family" button and taps it. */
fun createFirstFamily(): FamilyListRobot = apply {
test.waitUntilExactlyOneExists(hasTestTag(FamilyListTestTags.CREATE_FIRST_BUTTON))
test.onNodeWithTag(FamilyListTestTags.CREATE_FIRST_BUTTON).performClick()
}

fun typeFamilyName(name: String): FamilyListRobot = apply {
// The test tag sits on the PlusTextField wrapper; the editable node is the inner field.
test
.onNode(
hasSetTextAction() and
hasAnyAncestor(hasTestTag(FamilyListTestTags.CREATE_NAME_FIELD))
)
.performTextReplacement(name)
}

/** Taps the confirm button of the "New family" dialog. */
fun confirmCreate(label: String = "Create"): FamilyListRobot = apply {
test.onNode(hasText(label)).performClick()
}

fun awaitFamily(name: String): FamilyListRobot = apply {
test.waitUntilExactlyOneExists(hasText(name, substring = true) and onScreen)
}

fun openFamily(name: String): FamilyListRobot = apply {
awaitFamily(name)
test.onNode(hasText(name, substring = true) and onScreen).performClick()
}
}

fun ComposeUiTest.familyList(): FamilyListRobot = FamilyListRobot(this)
Loading
Loading