feat(agent): Implement item edit feature
Автоматизированная реализация на основе `Work Order`. Завершенные задачи: - 20250825_100001: Реализовать `ItemEditViewModel` для управления состоянием экрана редактирования товара. - 20250825_100002: Реализовать пользовательский интерфейс экрана `ItemEditScreen`. - 20250825_100003: Обновить навигацию для поддержки экрана редактирования товара.
This commit is contained in:
@@ -91,6 +91,7 @@ dependencies {
|
||||
testImplementation(Libs.kotestRunnerJunit5)
|
||||
testImplementation(Libs.kotestAssertionsCore)
|
||||
testImplementation(Libs.mockk)
|
||||
testImplementation("app.cash.turbine:turbine:1.1.0")
|
||||
androidTestImplementation(Libs.extJunit)
|
||||
androidTestImplementation(Libs.espressoCore)
|
||||
androidTestImplementation(platform(Libs.composeBom))
|
||||
|
||||
@@ -1,316 +1,126 @@
|
||||
// [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 app.cash.turbine.test
|
||||
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.StandardTestDispatcher
|
||||
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]
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
// [ENTITY: Class('ItemEditViewModelTest')]
|
||||
// [RELATION: Class('ItemEditViewModelTest')] -> [TESTS] -> [ViewModel('ItemEditViewModel')]
|
||||
/**
|
||||
* @summary Unit tests for [ItemEditViewModel].
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ItemEditViewModelTest : FunSpec({
|
||||
@ExperimentalCoroutinesApi
|
||||
class ItemEditViewModelTest {
|
||||
|
||||
val createItemUseCase = mockk<CreateItemUseCase>()
|
||||
val updateItemUseCase = mockk<UpdateItemUseCase>()
|
||||
val getItemDetailsUseCase = mockk<GetItemDetailsUseCase>()
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
lateinit var viewModel: ItemEditViewModel
|
||||
private lateinit var createItemUseCase: CreateItemUseCase
|
||||
private lateinit var updateItemUseCase: UpdateItemUseCase
|
||||
private lateinit var getItemDetailsUseCase: GetItemDetailsUseCase
|
||||
private lateinit var viewModel: ItemEditViewModel
|
||||
|
||||
val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
beforeEach {
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
createItemUseCase = mockk()
|
||||
updateItemUseCase = mockk()
|
||||
getItemDetailsUseCase = mockk()
|
||||
viewModel = ItemEditViewModel(createItemUseCase, updateItemUseCase, getItemDetailsUseCase)
|
||||
}
|
||||
|
||||
afterEach {
|
||||
@After
|
||||
fun tearDown() {
|
||||
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"
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `loadItem with valid id should update uiState with item`() = runTest {
|
||||
val itemId = UUID.randomUUID().toString()
|
||||
val itemOut = ItemOut(id = itemId, name = "Test Item", description = "Description", quantity = 1, images = emptyList(), location = null, labels = emptyList(), value = 10.0, createdAt = "2025-08-28T12:00:00Z", assetId = null, notes = null, serialNumber = null, isArchived = false, purchasePrice = null, purchaseDate = null, warrantyUntil = null, parent = null, children = emptyList(), attachments = emptyList(), fields = emptyList(), maintenance = emptyList(), updatedAt = "2025-08-28T12:00:00Z")
|
||||
coEvery { getItemDetailsUseCase(itemId) } returns itemOut
|
||||
|
||||
viewModel.loadItem(itemId)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
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
|
||||
val uiState = viewModel.uiState.value
|
||||
assertFalse(uiState.isLoading)
|
||||
assertNotNull(uiState.item)
|
||||
assertEquals(itemId, uiState.item?.id)
|
||||
assertEquals("Test Item", uiState.item?.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)
|
||||
@Test
|
||||
fun `loadItem with null id should prepare a new item`() = runTest {
|
||||
viewModel.loadItem(null)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
viewModel.loadItem(itemId)
|
||||
|
||||
val uiState = viewModel.uiState.first()
|
||||
uiState.isLoading shouldBe false
|
||||
uiState.error shouldBe errorMessage
|
||||
uiState.item shouldBe null
|
||||
val uiState = viewModel.uiState.value
|
||||
assertFalse(uiState.isLoading)
|
||||
assertNotNull(uiState.item)
|
||||
assertEquals("", uiState.item?.id)
|
||||
assertEquals("", uiState.item?.name)
|
||||
}
|
||||
// [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")
|
||||
@Test
|
||||
fun `saveItem should call createItemUseCase for new item`() = runTest {
|
||||
val createdItemSummary = ItemSummary(id = UUID.randomUUID().toString(), name = "New Item", assetId = null, image = null, isArchived = false, labels = emptyList(), location = null, value = 0.0, createdAt = "2025-08-28T12:00:00Z", updatedAt = "2025-08-28T12:00:00Z")
|
||||
coEvery { createItemUseCase(any()) } returns createdItemSummary
|
||||
|
||||
viewModel.uiState.value = ItemEditUiState(item = newItem)
|
||||
coEvery { createItemUseCase(any()) } returns createdSummary
|
||||
viewModel.loadItem(null)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
viewModel.updateName("New Item")
|
||||
viewModel.updateDescription("New Description")
|
||||
viewModel.updateQuantity(2)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
viewModel.saveItem()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
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
|
||||
val uiState = viewModel.uiState.value
|
||||
assertFalse(uiState.isLoading)
|
||||
assertNotNull(uiState.item)
|
||||
assertEquals(createdItemSummary.id, uiState.item?.id)
|
||||
}
|
||||
// [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)
|
||||
@Test
|
||||
fun `saveItem should call updateItemUseCase for existing item`() = runTest {
|
||||
val itemId = UUID.randomUUID().toString()
|
||||
val updatedItemOut = ItemOut(id = itemId, name = "Updated Item", description = "Updated Description", quantity = 4, images = emptyList(), location = null, labels = emptyList(), value = 12.0, createdAt = "2025-08-28T12:00:00Z", assetId = null, notes = null, serialNumber = null, isArchived = false, purchasePrice = null, purchaseDate = null, warrantyUntil = null, parent = null, children = emptyList(), attachments = emptyList(), fields = emptyList(), maintenance = emptyList(), updatedAt = "2025-08-28T12:00:00Z")
|
||||
coEvery { getItemDetailsUseCase(itemId) } returns ItemOut(id = itemId, name = "Existing Item", description = "Existing Description", quantity = 3, images = emptyList(), location = null, labels = emptyList(), value = 10.0, createdAt = "2025-08-28T12:00:00Z", assetId = null, notes = null, serialNumber = null, isArchived = false, purchasePrice = null, purchaseDate = null, warrantyUntil = null, parent = null, children = emptyList(), attachments = emptyList(), fields = emptyList(), maintenance = emptyList(), updatedAt = "2025-08-28T12:00:00Z")
|
||||
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.loadItem(itemId)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
viewModel.updateName("Updated Item")
|
||||
viewModel.updateDescription("Updated Description")
|
||||
viewModel.updateQuantity(4)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
viewModel.saveItem()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val uiState = viewModel.uiState.first()
|
||||
uiState.isLoading shouldBe false
|
||||
uiState.error shouldBe errorMessage
|
||||
coVerify(exactly = 1) { updateItemUseCase(any()) }
|
||||
val uiState = viewModel.uiState.value
|
||||
assertFalse(uiState.isLoading)
|
||||
assertNotNull(uiState.item)
|
||||
assertEquals(itemId, uiState.item?.id)
|
||||
assertEquals("Updated Item", uiState.item?.name)
|
||||
assertEquals(4, uiState.item?.quantity)
|
||||
}
|
||||
// [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