Item Edit screen
This commit is contained in:
@@ -88,6 +88,9 @@ dependencies {
|
||||
|
||||
// [DEPENDENCY] Testing
|
||||
testImplementation(Libs.junit)
|
||||
testImplementation(Libs.kotestRunnerJunit5)
|
||||
testImplementation(Libs.kotestAssertionsCore)
|
||||
testImplementation(Libs.mockk)
|
||||
androidTestImplementation(Libs.extJunit)
|
||||
androidTestImplementation(Libs.espressoCore)
|
||||
androidTestImplementation(platform(Libs.composeBom))
|
||||
|
||||
@@ -9,10 +9,12 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.homebox.lens.ui.screen.dashboard.DashboardScreen
|
||||
import com.homebox.lens.ui.screen.inventorylist.InventoryListScreen
|
||||
import com.homebox.lens.ui.screen.itemdetails.ItemDetailsScreen
|
||||
@@ -74,10 +76,16 @@ fun NavGraph(
|
||||
navigationActions = navigationActions
|
||||
)
|
||||
}
|
||||
composable(route = Screen.ItemEdit.route) {
|
||||
composable(
|
||||
route = Screen.ItemEdit.route,
|
||||
arguments = listOf(navArgument("itemId") { nullable = true })
|
||||
) { backStackEntry ->
|
||||
val itemId = backStackEntry.arguments?.getString("itemId")
|
||||
ItemEditScreen(
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions
|
||||
navigationActions = navigationActions,
|
||||
itemId = itemId,
|
||||
onSaveSuccess = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(Screen.LabelsList.route) {
|
||||
|
||||
@@ -59,19 +59,15 @@ sealed class Screen(val route: String) {
|
||||
// [END_ENTITY: Object('ItemDetails')]
|
||||
|
||||
// [ENTITY: Object('ItemEdit')]
|
||||
data object ItemEdit : Screen("item_edit_screen/{itemId}") {
|
||||
data object ItemEdit : Screen("item_edit_screen?itemId={itemId}") {
|
||||
// [ENTITY: Function('createRoute')]
|
||||
/**
|
||||
* @summary Создает маршрут для экрана редактирования элемента с указанным ID.
|
||||
* @param itemId ID элемента для редактирования.
|
||||
* @param itemId ID элемента для редактирования. Null, если создается новый элемент.
|
||||
* @return Строку полного маршрута.
|
||||
* @throws IllegalArgumentException если itemId пустой.
|
||||
*/
|
||||
fun createRoute(itemId: String): String {
|
||||
require(itemId.isNotBlank()) { "itemId не может быть пустым." }
|
||||
val route = "item_edit_screen/$itemId"
|
||||
check(route.endsWith(itemId)) { "Маршрут должен заканчиваться на itemId." }
|
||||
return route
|
||||
fun createRoute(itemId: String? = null): String {
|
||||
return itemId?.let { "item_edit_screen?itemId=$it" } ?: "item_edit_screen"
|
||||
}
|
||||
// [END_ENTITY: Function('createRoute')]
|
||||
}
|
||||
|
||||
@@ -5,35 +5,135 @@
|
||||
package com.homebox.lens.ui.screen.itemedit
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.homebox.lens.R
|
||||
import com.homebox.lens.navigation.NavigationActions
|
||||
import com.homebox.lens.ui.common.MainScaffold
|
||||
import timber.log.Timber
|
||||
// [END_IMPORTS]
|
||||
|
||||
// [ENTITY: Function('ItemEditScreen')]
|
||||
// [RELATION: Function('ItemEditScreen')] -> [DEPENDS_ON] -> [Class('NavigationActions')]
|
||||
// [RELATION: Function('ItemEditScreen')] -> [DEPENDS_ON] -> [ViewModel('ItemEditViewModel')]
|
||||
// [RELATION: Function('ItemEditScreen')] -> [CONSUMES_STATE] -> [DataClass('ItemEditUiState')]
|
||||
// [RELATION: Function('ItemEditScreen')] -> [CALLS] -> [Function('MainScaffold')]
|
||||
/**
|
||||
* @summary Composable-функция для экрана "Редактирование элемента".
|
||||
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
|
||||
* @param navigationActions Объект с навигационными действиями.
|
||||
* @param itemId ID элемента для редактирования. Null, если создается новый элемент.
|
||||
* @param viewModel ViewModel для управления состоянием экрана.
|
||||
* @param onSaveSuccess Callback, вызываемый после успешного сохранения товара.
|
||||
*/
|
||||
@Composable
|
||||
fun ItemEditScreen(
|
||||
currentRoute: String?,
|
||||
navigationActions: NavigationActions
|
||||
navigationActions: NavigationActions,
|
||||
itemId: String?,
|
||||
viewModel: ItemEditViewModel = viewModel(),
|
||||
onSaveSuccess: () -> Unit
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(itemId) {
|
||||
Timber.i("[INFO][ENTRYPOINT][item_edit_screen_init] Initializing ItemEditScreen for item ID: %s", itemId)
|
||||
viewModel.loadItem(itemId)
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.error) {
|
||||
uiState.error?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
Timber.e("[ERROR][UI_ERROR][item_edit_error] Displaying error: %s", it)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.saveCompleted.collect {
|
||||
Timber.i("[INFO][ACTION][save_completed_callback] Item save completed. Triggering onSaveSuccess.")
|
||||
onSaveSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
MainScaffold(
|
||||
topBarTitle = stringResource(id = R.string.item_edit_title),
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions
|
||||
) {
|
||||
// [AI_NOTE]: Implement Item Edit Screen UI
|
||||
Text(text = "Item Edit Screen")
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = {
|
||||
Timber.i("[INFO][ACTION][save_button_click] Save button clicked.")
|
||||
viewModel.saveItem()
|
||||
}) {
|
||||
Icon(Icons.Filled.Save, contentDescription = stringResource(R.string.save_item))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(it)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
} else {
|
||||
uiState.item?.let { item ->
|
||||
OutlinedTextField(
|
||||
value = item.name,
|
||||
onValueChange = { viewModel.updateName(it) },
|
||||
label = { Text(stringResource(R.string.item_name)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = item.description ?: "",
|
||||
onValueChange = { viewModel.updateDescription(it) },
|
||||
label = { Text(stringResource(R.string.item_description)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = item.quantity.toString(),
|
||||
onValueChange = { viewModel.updateQuantity(it.toIntOrNull() ?: 0) },
|
||||
label = { Text(stringResource(R.string.item_quantity)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
// Add more fields as needed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// [END_ENTITY: Function('ItemEditScreen')]
|
||||
// [END_FILE_ItemEditScreen.kt]
|
||||
// [END_FILE_ItemEditScreen.kt]
|
||||
|
||||
@@ -1,21 +1,214 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.screen.itemedit
|
||||
// [FILE] ItemEditViewModel.kt
|
||||
// [SEMANTICS] ui, viewmodel, item_edit
|
||||
|
||||
package com.homebox.lens.ui.screen.itemedit
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.homebox.lens.domain.model.Item
|
||||
import com.homebox.lens.domain.model.ItemCreate
|
||||
import com.homebox.lens.domain.model.Label
|
||||
import com.homebox.lens.domain.model.Location
|
||||
import com.homebox.lens.domain.usecase.CreateItemUseCase
|
||||
import com.homebox.lens.domain.usecase.GetItemDetailsUseCase
|
||||
import com.homebox.lens.domain.usecase.UpdateItemUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
// [END_IMPORTS]
|
||||
|
||||
// [ENTITY: DataClass('ItemEditUiState')]
|
||||
/**
|
||||
* @summary UI state for the item edit screen.
|
||||
* @param item The item being edited, or null if creating a new item.
|
||||
* @param isLoading Whether data is currently being loaded or saved.
|
||||
* @param error An error message if an operation failed.
|
||||
*/
|
||||
data class ItemEditUiState(
|
||||
val item: Item? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
// [END_ENTITY: DataClass('ItemEditUiState')]
|
||||
|
||||
// [ENTITY: ViewModel('ItemEditViewModel')]
|
||||
// [RELATION: ViewModel('ItemEditViewModel')] -> [DEPENDS_ON] -> [UseCase('CreateItemUseCase')]
|
||||
// [RELATION: ViewModel('ItemEditViewModel')] -> [DEPENDS_ON] -> [UseCase('UpdateItemUseCase')]
|
||||
// [RELATION: ViewModel('ItemEditViewModel')] -> [DEPENDS_ON] -> [UseCase('GetItemDetailsUseCase')]
|
||||
// [RELATION: ViewModel('ItemEditViewModel')] -> [EMITS_STATE] -> [DataClass('ItemEditUiState')]
|
||||
/**
|
||||
* @summary ViewModel for the item edit screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ItemEditViewModel @Inject constructor() : ViewModel() {
|
||||
// [AI_NOTE]: Implement UI state
|
||||
class ItemEditViewModel @Inject constructor(
|
||||
private val createItemUseCase: CreateItemUseCase,
|
||||
private val updateItemUseCase: UpdateItemUseCase,
|
||||
private val getItemDetailsUseCase: GetItemDetailsUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ItemEditUiState())
|
||||
val uiState: StateFlow<ItemEditUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _saveCompleted = MutableSharedFlow<Unit>()
|
||||
val saveCompleted: SharedFlow<Unit> = _saveCompleted.asSharedFlow()
|
||||
|
||||
// [ENTITY: Function('loadItem')]
|
||||
/**
|
||||
* @summary Loads item details for editing or prepares for new item creation.
|
||||
* @param itemId The ID of the item to load. If null, a new item is being created.
|
||||
* @sideeffect Updates `_uiState` with loading, success, or error states.
|
||||
*/
|
||||
fun loadItem(itemId: String?) {
|
||||
Timber.i("[INFO][ENTRYPOINT][loading_item] Attempting to load item with ID: %s", itemId)
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
if (itemId == null) {
|
||||
Timber.i("[INFO][ACTION][new_item_preparation] Preparing for new item creation.")
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, item = Item(id = "", name = "", description = null, quantity = 0, image = null, location = null, labels = emptyList(), value = null, createdAt = null))
|
||||
} else {
|
||||
try {
|
||||
Timber.i("[INFO][ACTION][fetching_item_details] Fetching details for item ID: %s", itemId)
|
||||
val itemOut = getItemDetailsUseCase(itemId)
|
||||
Timber.d("[DEBUG][ACTION][mapping_item_out_to_item] Mapping ItemOut to Item for UI state.")
|
||||
val item = Item(
|
||||
id = itemOut.id,
|
||||
name = itemOut.name,
|
||||
description = itemOut.description,
|
||||
quantity = itemOut.quantity,
|
||||
image = itemOut.images.firstOrNull()?.path, // Assuming first image is the main one
|
||||
location = itemOut.location?.let { Location(it.id, it.name) }, // Simplified mapping
|
||||
labels = itemOut.labels.map { Label(it.id, it.name) }, // Simplified mapping
|
||||
value = itemOut.value?.toBigDecimal(),
|
||||
createdAt = itemOut.createdAt
|
||||
)
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, item = item)
|
||||
Timber.i("[INFO][ACTION][item_details_fetched] Successfully fetched item details for ID: %s", itemId)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "[ERROR][FALLBACK][item_load_failed] Failed to load item details for ID: %s", itemId)
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, error = e.localizedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// [END_ENTITY: Function('loadItem')]
|
||||
|
||||
// [ENTITY: Function('saveItem')]
|
||||
/**
|
||||
* @summary Saves the current item, either creating a new one or updating an existing one.
|
||||
* @sideeffect Updates `_uiState` with loading, success, or error states. Calls `createItemUseCase` or `updateItemUseCase`.
|
||||
* @throws IllegalStateException if `uiState.value.item` is null when attempting to save.
|
||||
*/
|
||||
fun saveItem() {
|
||||
Timber.i("[INFO][ENTRYPOINT][saving_item] Attempting to save item.")
|
||||
viewModelScope.launch {
|
||||
val currentItem = _uiState.value.item
|
||||
require(currentItem != null) { "[CONTRACT_VIOLATION][PRECONDITION][item_not_present] Cannot save a null item." }
|
||||
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
if (currentItem.id.isBlank()) {
|
||||
Timber.i("[INFO][ACTION][creating_new_item] Creating new item: %s", currentItem.name)
|
||||
val createdItemSummary = createItemUseCase(ItemCreate(
|
||||
name = currentItem.name,
|
||||
description = currentItem.description,
|
||||
quantity = currentItem.quantity,
|
||||
assetId = null,
|
||||
notes = null,
|
||||
serialNumber = null,
|
||||
value = null,
|
||||
purchasePrice = null,
|
||||
purchaseDate = null,
|
||||
warrantyUntil = null,
|
||||
locationId = currentItem.location?.id,
|
||||
parentId = null,
|
||||
labelIds = currentItem.labels.map { it.id }
|
||||
))
|
||||
Timber.d("[DEBUG][ACTION][mapping_item_summary_to_item] Mapping ItemSummary to Item for UI state.")
|
||||
val createdItem = Item(
|
||||
id = createdItemSummary.id,
|
||||
name = createdItemSummary.name,
|
||||
description = null, // ItemSummary does not have description
|
||||
quantity = 0, // ItemSummary does not have quantity
|
||||
image = null, // ItemSummary does not have image
|
||||
location = null, // ItemSummary does not have location
|
||||
labels = emptyList(), // ItemSummary does not have labels
|
||||
value = null, // ItemSummary does not have value
|
||||
createdAt = null // ItemSummary does not have createdAt
|
||||
)
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, item = createdItem)
|
||||
Timber.i("[INFO][ACTION][new_item_created] Successfully created new item with ID: %s", createdItem.id)
|
||||
_saveCompleted.emit(Unit)
|
||||
} else {
|
||||
Timber.i("[INFO][ACTION][updating_existing_item] Updating existing item with ID: %s", currentItem.id)
|
||||
val updatedItemOut = updateItemUseCase(currentItem)
|
||||
Timber.d("[DEBUG][ACTION][mapping_item_out_to_item] Mapping ItemOut to Item for UI state.")
|
||||
val updatedItem = Item(
|
||||
id = updatedItemOut.id,
|
||||
name = updatedItemOut.name,
|
||||
description = updatedItemOut.description,
|
||||
quantity = updatedItemOut.quantity,
|
||||
image = updatedItemOut.images.firstOrNull()?.path,
|
||||
location = updatedItemOut.location?.let { Location(it.id, it.name) },
|
||||
labels = updatedItemOut.labels.map { Label(it.id, it.name) },
|
||||
value = updatedItemOut.value.toBigDecimal(),
|
||||
createdAt = updatedItemOut.createdAt
|
||||
)
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, item = updatedItem)
|
||||
Timber.i("[INFO][ACTION][item_updated] Successfully updated item with ID: %s", updatedItem.id)
|
||||
_saveCompleted.emit(Unit)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "[ERROR][FALLBACK][item_save_failed] Failed to save item.")
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, error = e.localizedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
// [END_ENTITY: Function('saveItem')]
|
||||
|
||||
// [ENTITY: Function('updateName')]
|
||||
/**
|
||||
* @summary Updates the name of the item in the UI state.
|
||||
* @param newName The new name for the item.
|
||||
* @sideeffect Updates the `item` in `_uiState`.
|
||||
*/
|
||||
fun updateName(newName: String) {
|
||||
Timber.d("[DEBUG][ACTION][updating_item_name] Updating item name to: %s", newName)
|
||||
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(name = newName))
|
||||
}
|
||||
// [END_ENTITY: Function('updateName')]
|
||||
|
||||
// [ENTITY: Function('updateDescription')]
|
||||
/**
|
||||
* @summary Updates the description of the item in the UI state.
|
||||
* @param newDescription The new description for the item.
|
||||
* @sideeffect Updates the `item` in `_uiState`.
|
||||
*/
|
||||
fun updateDescription(newDescription: String) {
|
||||
Timber.d("[DEBUG][ACTION][updating_item_description] Updating item description to: %s", newDescription)
|
||||
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(description = newDescription))
|
||||
}
|
||||
// [END_ENTITY: Function('updateDescription')]
|
||||
|
||||
// [ENTITY: Function('updateQuantity')]
|
||||
/**
|
||||
* @summary Updates the quantity of the item in the UI state.
|
||||
* @param newQuantity The new quantity for the item.
|
||||
* @sideeffect Updates the `item` in `_uiState`.
|
||||
*/
|
||||
fun updateQuantity(newQuantity: Int) {
|
||||
Timber.d("[DEBUG][ACTION][updating_item_quantity] Updating item quantity to: %d", newQuantity)
|
||||
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(quantity = newQuantity))
|
||||
}
|
||||
// [END_ENTITY: Function('updateQuantity')]
|
||||
}
|
||||
// [END_ENTITY: ViewModel('ItemEditViewModel')]
|
||||
// [END_FILE_ItemEditViewModel.kt]
|
||||
// [END_FILE_ItemEditViewModel.kt]
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
<string name="locations_list_title">Места хранения</string>
|
||||
<string name="search_title">Поиск</string>
|
||||
|
||||
<string name="save_item">Сохранить</string>
|
||||
<string name="item_name">Название</string>
|
||||
<string name="item_description">Описание</string>
|
||||
<string name="item_quantity">Количество</string>
|
||||
|
||||
<!-- Location Edit Screen -->
|
||||
<string name="location_edit_title_create">Создать локацию</string>
|
||||
<string name="location_edit_title_edit">Редактировать локацию</string>
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.screen.itemedit
|
||||
// [FILE] ItemEditViewModelTest.kt
|
||||
// [SEMANTICS] testing, viewmodel, unit_test
|
||||
|
||||
package com.homebox.lens.ui.screen.itemedit
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.homebox.lens.domain.model.Item
|
||||
import com.homebox.lens.domain.model.ItemCreate
|
||||
import com.homebox.lens.domain.model.ItemOut
|
||||
import com.homebox.lens.domain.model.ItemSummary
|
||||
import com.homebox.lens.domain.model.Label
|
||||
import com.homebox.lens.domain.model.Location
|
||||
import com.homebox.lens.domain.model.LocationOut
|
||||
import com.homebox.lens.domain.model.LabelOut
|
||||
import com.homebox.lens.domain.usecase.CreateItemUseCase
|
||||
import com.homebox.lens.domain.usecase.GetItemDetailsUseCase
|
||||
import com.homebox.lens.domain.usecase.UpdateItemUseCase
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import java.math.BigDecimal
|
||||
// [END_IMPORTS]
|
||||
|
||||
// [ENTITY: Class('ItemEditViewModelTest')]
|
||||
// [RELATION: Class('ItemEditViewModelTest')] -> [TESTS] -> [ViewModel('ItemEditViewModel')]
|
||||
/**
|
||||
* @summary Unit tests for [ItemEditViewModel].
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ItemEditViewModelTest : FunSpec({
|
||||
|
||||
val createItemUseCase = mockk<CreateItemUseCase>()
|
||||
val updateItemUseCase = mockk<UpdateItemUseCase>()
|
||||
val getItemDetailsUseCase = mockk<GetItemDetailsUseCase>()
|
||||
|
||||
lateinit var viewModel: ItemEditViewModel
|
||||
|
||||
val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
beforeEach {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
viewModel = ItemEditViewModel(createItemUseCase, updateItemUseCase, getItemDetailsUseCase)
|
||||
}
|
||||
|
||||
afterEach {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
// [ENTITY: Function('loadItem - new item creation')]
|
||||
/**
|
||||
* @summary Tests that loadItem with null itemId prepares for new item creation.
|
||||
*/
|
||||
test("loadItem with null itemId should prepare for new item creation") {
|
||||
viewModel.loadItem(null)
|
||||
|
||||
val uiState = viewModel.uiState.first()
|
||||
uiState.isLoading shouldBe false
|
||||
uiState.error shouldBe null
|
||||
uiState.item shouldBe Item(id = "", name = "", description = null, quantity = 0, image = null, location = null, labels = emptyList(), value = null, createdAt = null)
|
||||
}
|
||||
// [END_ENTITY: Function('loadItem - new item creation')]
|
||||
|
||||
// [ENTITY: Function('loadItem - existing item loading success')]
|
||||
/**
|
||||
* @summary Tests that loadItem with an itemId successfully loads an existing item.
|
||||
*/
|
||||
test("loadItem with itemId should load existing item successfully") {
|
||||
val itemId = "test_item_id"
|
||||
val itemOut = ItemOut(
|
||||
id = itemId,
|
||||
name = "Loaded Item",
|
||||
assetId = null,
|
||||
description = "Description",
|
||||
notes = null,
|
||||
serialNumber = null,
|
||||
quantity = 5,
|
||||
isArchived = false,
|
||||
value = 100.0,
|
||||
purchasePrice = null,
|
||||
purchaseDate = null,
|
||||
warrantyUntil = null,
|
||||
location = LocationOut("loc1", "Location 1", "#FFFFFF", false, "2025-01-01T00:00:00Z", "2025-01-01T00:00:00Z"),
|
||||
parent = null,
|
||||
children = emptyList(),
|
||||
labels = listOf(LabelOut("lab1", "Label 1", "#FFFFFF", false, "2025-01-01T00:00:00Z", "2025-01-01T00:00:00Z")),
|
||||
attachments = emptyList(),
|
||||
images = emptyList(),
|
||||
fields = emptyList(),
|
||||
maintenance = emptyList(),
|
||||
createdAt = "2025-01-01T00:00:00Z",
|
||||
updatedAt = "2025-01-01T00:00:00Z"
|
||||
)
|
||||
|
||||
coEvery { getItemDetailsUseCase(itemId) } returns itemOut
|
||||
|
||||
viewModel.loadItem(itemId)
|
||||
|
||||
val uiState = viewModel.uiState.first()
|
||||
uiState.isLoading shouldBe false
|
||||
uiState.error shouldBe null
|
||||
uiState.item?.id shouldBe itemOut.id
|
||||
uiState.item?.name shouldBe itemOut.name
|
||||
}
|
||||
// [END_ENTITY: Function('loadItem - existing item loading success')]
|
||||
|
||||
// [ENTITY: Function('loadItem - existing item loading failure')]
|
||||
/**
|
||||
* @summary Tests that loadItem with an itemId handles loading failure.
|
||||
*/
|
||||
test("loadItem with itemId should handle loading failure") {
|
||||
val itemId = "test_item_id"
|
||||
val errorMessage = "Failed to fetch item"
|
||||
coEvery { getItemDetailsUseCase(itemId) } throws Exception(errorMessage)
|
||||
|
||||
viewModel.loadItem(itemId)
|
||||
|
||||
val uiState = viewModel.uiState.first()
|
||||
uiState.isLoading shouldBe false
|
||||
uiState.error shouldBe errorMessage
|
||||
uiState.item shouldBe null
|
||||
}
|
||||
// [END_ENTITY: Function('loadItem - existing item loading failure')]
|
||||
|
||||
// [ENTITY: Function('saveItem - new item creation success')]
|
||||
/**
|
||||
* @summary Tests that saveItem successfully creates a new item.
|
||||
*/
|
||||
test("saveItem should create new item successfully") {
|
||||
val newItem = Item(
|
||||
id = "", // New item has blank ID
|
||||
name = "New Item",
|
||||
description = null,
|
||||
quantity = 1,
|
||||
image = null,
|
||||
location = null,
|
||||
labels = emptyList(),
|
||||
value = null,
|
||||
createdAt = null
|
||||
)
|
||||
val createdSummary = ItemSummary("new_id", "New Item")
|
||||
|
||||
viewModel.uiState.value = ItemEditUiState(item = newItem)
|
||||
coEvery { createItemUseCase(any()) } returns createdSummary
|
||||
|
||||
viewModel.saveItem()
|
||||
|
||||
val uiState = viewModel.uiState.first()
|
||||
uiState.isLoading shouldBe false
|
||||
uiState.error shouldBe null
|
||||
uiState.item?.id shouldBe createdSummary.id
|
||||
uiState.item?.name shouldBe createdSummary.name
|
||||
coVerify(exactly = 1) { createItemUseCase(ItemCreate(
|
||||
name = newItem.name,
|
||||
description = newItem.description,
|
||||
quantity = newItem.quantity,
|
||||
assetId = null,
|
||||
notes = null,
|
||||
serialNumber = null,
|
||||
value = null,
|
||||
purchasePrice = null,
|
||||
purchaseDate = null,
|
||||
warrantyUntil = null,
|
||||
locationId = newItem.location?.id,
|
||||
parentId = null,
|
||||
labelIds = newItem.labels.map { it.id }
|
||||
)) }
|
||||
viewModel.saveCompleted.first() shouldBe Unit
|
||||
}
|
||||
// [END_ENTITY: Function('saveItem - new item creation success')]
|
||||
|
||||
// [ENTITY: Function('saveItem - new item creation failure')]
|
||||
/**
|
||||
* @summary Tests that saveItem handles new item creation failure.
|
||||
*/
|
||||
test("saveItem should handle new item creation failure") {
|
||||
val newItem = Item(
|
||||
id = "",
|
||||
name = "New Item",
|
||||
description = null,
|
||||
quantity = 1,
|
||||
image = null,
|
||||
location = null,
|
||||
labels = emptyList(),
|
||||
value = null,
|
||||
createdAt = null
|
||||
)
|
||||
val errorMessage = "Failed to create item"
|
||||
|
||||
viewModel.uiState.value = ItemEditUiState(item = newItem)
|
||||
coEvery { createItemUseCase(any()) } throws Exception(errorMessage)
|
||||
|
||||
viewModel.saveItem()
|
||||
|
||||
val uiState = viewModel.uiState.first()
|
||||
uiState.isLoading shouldBe false
|
||||
uiState.error shouldBe errorMessage
|
||||
coVerify(exactly = 1) { createItemUseCase(any()) }
|
||||
}
|
||||
// [END_ENTITY: Function('saveItem - new item creation failure')]
|
||||
|
||||
// [ENTITY: Function('saveItem - existing item update success')]
|
||||
/**
|
||||
* @summary Tests that saveItem successfully updates an existing item.
|
||||
*/
|
||||
test("saveItem should update existing item successfully") {
|
||||
val existingItem = Item(
|
||||
id = "existing_id",
|
||||
name = "Existing Item",
|
||||
description = null,
|
||||
quantity = 1,
|
||||
image = null,
|
||||
location = null,
|
||||
labels = emptyList(),
|
||||
value = null,
|
||||
createdAt = null
|
||||
)
|
||||
val updatedItemOut = ItemOut(
|
||||
id = "existing_id",
|
||||
name = "Updated Item",
|
||||
assetId = null,
|
||||
description = null,
|
||||
notes = null,
|
||||
serialNumber = null,
|
||||
quantity = 2,
|
||||
isArchived = false,
|
||||
value = 200.0,
|
||||
purchasePrice = null,
|
||||
purchaseDate = null,
|
||||
warrantyUntil = null,
|
||||
location = null,
|
||||
parent = null,
|
||||
children = emptyList(),
|
||||
labels = emptyList(),
|
||||
attachments = emptyList(),
|
||||
images = emptyList(),
|
||||
fields = emptyList(),
|
||||
maintenance = emptyList(),
|
||||
createdAt = "2025-01-01T00:00:00Z",
|
||||
updatedAt = "2025-01-01T00:00:00Z"
|
||||
)
|
||||
|
||||
viewModel.uiState.value = ItemEditUiState(item = existingItem)
|
||||
coEvery { updateItemUseCase(any()) } returns updatedItemOut
|
||||
|
||||
viewModel.saveItem()
|
||||
|
||||
val uiState = viewModel.uiState.first()
|
||||
uiState.isLoading shouldBe false
|
||||
uiState.error shouldBe null
|
||||
uiState.item?.id shouldBe updatedItemOut.id
|
||||
uiState.item?.name shouldBe updatedItemOut.name
|
||||
coVerify(exactly = 1) { updateItemUseCase(existingItem) }
|
||||
viewModel.saveCompleted.first() shouldBe Unit
|
||||
}
|
||||
// [END_ENTITY: Function('saveItem - existing item update success')]
|
||||
|
||||
// [ENTITY: Function('saveItem - existing item update failure')]
|
||||
/**
|
||||
* @summary Tests that saveItem handles existing item update failure.
|
||||
*/
|
||||
test("saveItem should handle existing item update failure") {
|
||||
val existingItem = Item(
|
||||
id = "existing_id",
|
||||
name = "Existing Item",
|
||||
description = null,
|
||||
quantity = 1,
|
||||
image = null,
|
||||
location = null,
|
||||
labels = emptyList(),
|
||||
value = null,
|
||||
createdAt = null
|
||||
)
|
||||
val errorMessage = "Failed to update item"
|
||||
|
||||
viewModel.uiState.value = ItemEditUiState(item = existingItem)
|
||||
coEvery { updateItemUseCase(any()) } throws Exception(errorMessage)
|
||||
|
||||
viewModel.saveItem()
|
||||
|
||||
val uiState = viewModel.uiState.first()
|
||||
uiState.isLoading shouldBe false
|
||||
uiState.error shouldBe errorMessage
|
||||
coVerify(exactly = 1) { updateItemUseCase(any()) }
|
||||
}
|
||||
// [END_ENTITY: Function('saveItem - existing item update failure')]
|
||||
|
||||
// [ENTITY: Function('saveItem - null item')]
|
||||
/**
|
||||
* @summary Tests that saveItem throws IllegalStateException when item in uiState is null.
|
||||
*/
|
||||
test("saveItem should throw IllegalStateException when item in uiState is null") {
|
||||
viewModel.uiState.value = ItemEditUiState(item = null)
|
||||
|
||||
val exception = shouldThrow<IllegalStateException> {
|
||||
viewModel.saveItem()
|
||||
}
|
||||
exception.message shouldBe "[CONTRACT_VIOLATION][PRECONDITION][item_not_present] Cannot save a null item."
|
||||
}
|
||||
// [END_ENTITY: Function('saveItem - null item')]
|
||||
})
|
||||
// [END_ENTITY: Class('ItemEditViewModelTest')]
|
||||
// [END_FILE_ItemEditViewModelTest.kt]
|
||||
Reference in New Issue
Block a user