Item Edit screen

This commit is contained in:
2025-08-25 10:28:26 +03:00
parent a608766e06
commit 11078e5313
22 changed files with 1197 additions and 248 deletions

View File

@@ -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))

View File

@@ -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) {

View File

@@ -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')]
}

View File

@@ -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]

View File

@@ -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]

View File

@@ -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>

View File

@@ -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]