feat(agent): Implement item edit feature

Автоматизированная реализация на основе `Work Order`.

Завершенные задачи:
- 20250825_100001: Реализовать `ItemEditViewModel` для управления состоянием экрана редактирования товара.
- 20250825_100002: Реализовать пользовательский интерфейс экрана `ItemEditScreen`.
- 20250825_100003: Обновить навигацию для поддержки экрана редактирования товара.
This commit is contained in:
2025-08-28 16:10:00 +03:00
parent 11078e5313
commit 8ebdc3a7b3
8 changed files with 166 additions and 270 deletions

View File

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

View File

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