fix(editor): IMPL_15 - Add Item respects separator position in checklist
Changes: - NoteEditorViewModel.kt: Add calculateInsertIndexForNewItem() helper method - NoteEditorViewModel.kt: Rewrite addChecklistItemAtEnd() to insert before first checked item (MANUAL/UNCHECKED_FIRST) instead of appending at list end - NoteEditorViewModel.kt: Add cross-boundary guard to addChecklistItemAfter() preventing new unchecked items from being inserted inside checked section - ChecklistSortingTest.kt: Add ChecklistSortOption import - ChecklistSortingTest.kt: Add 10 IMPL_15 unit tests covering all sort modes, edge cases (empty list, all checked, no checked), and position stability Root cause: addChecklistItemAtEnd() appended new unchecked items at the end of the flat list, after checked items. The UI splits items by count (subList(0, uncheckedCount)), not by isChecked state — causing checked items to appear above the separator and new items below it. Fix: Insert new items at the semantically correct position per sort mode. MANUAL/UNCHECKED_FIRST: before first checked item (above separator). All other modes: at list end (no separator visible, no visual issue). All 19 unit tests pass (9 existing + 10 new). No UI changes required.
This commit is contained in:
@@ -244,13 +244,34 @@ class NoteEditorViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item nach dem angegebenen Item ein.
|
||||
*
|
||||
* Guard: Bei MANUAL/UNCHECKED_FIRST wird sichergestellt, dass das neue (unchecked)
|
||||
* Item nicht innerhalb der checked-Sektion eingefügt wird. Falls das Trigger-Item
|
||||
* checked ist, wird stattdessen vor dem ersten checked Item eingefügt.
|
||||
*/
|
||||
fun addChecklistItemAfter(afterItemId: String): String {
|
||||
val newItem = ChecklistItemState.createEmpty(0)
|
||||
_checklistItems.update { items ->
|
||||
val index = items.indexOfFirst { it.id == afterItemId }
|
||||
if (index >= 0) {
|
||||
val currentSort = _lastChecklistSortOption.value
|
||||
val hasSeparator = currentSort == ChecklistSortOption.MANUAL ||
|
||||
currentSort == ChecklistSortOption.UNCHECKED_FIRST
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_15): Wenn das Trigger-Item checked ist und ein Separator
|
||||
// existiert, darf das neue unchecked Item nicht in die checked-Sektion.
|
||||
// → Stattdessen vor dem ersten checked Item einfügen.
|
||||
val effectiveIndex = if (hasSeparator && items[index].isChecked) {
|
||||
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
|
||||
if (firstCheckedIndex >= 0) firstCheckedIndex else index + 1
|
||||
} else {
|
||||
index + 1
|
||||
}
|
||||
|
||||
val newList = items.toMutableList()
|
||||
newList.add(index + 1, newItem)
|
||||
newList.add(effectiveIndex, newItem)
|
||||
// Update order values
|
||||
newList.mapIndexed { i, item -> item.copy(order = i) }
|
||||
} else {
|
||||
@@ -259,12 +280,46 @@ class NoteEditorViewModel(
|
||||
}
|
||||
return newItem.id
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item an der semantisch korrekten Position ein.
|
||||
*
|
||||
* Bei MANUAL/UNCHECKED_FIRST: Vor dem ersten checked Item (= direkt über dem Separator).
|
||||
* Bei allen anderen Modi: Am Ende der Liste (kein Separator sichtbar).
|
||||
*
|
||||
* Verhindert, dass checked Items über den Separator springen oder das neue Item
|
||||
* unter dem Separator erscheint.
|
||||
*/
|
||||
fun addChecklistItemAtEnd(): String {
|
||||
val newItem = ChecklistItemState.createEmpty(_checklistItems.value.size)
|
||||
_checklistItems.update { items -> items + newItem }
|
||||
val newItem = ChecklistItemState.createEmpty(0)
|
||||
_checklistItems.update { items ->
|
||||
val insertIndex = calculateInsertIndexForNewItem(items)
|
||||
val newList = items.toMutableList()
|
||||
newList.add(insertIndex, newItem)
|
||||
newList.mapIndexed { i, item -> item.copy(order = i) }
|
||||
}
|
||||
return newItem.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 (IMPL_15): Berechnet die korrekte Insert-Position für ein neues unchecked Item.
|
||||
*
|
||||
* - MANUAL / UNCHECKED_FIRST: Vor dem ersten checked Item (direkt über dem Separator)
|
||||
* - Alle anderen Modi: Am Ende der Liste (kein Separator, kein visuelles Problem)
|
||||
*
|
||||
* Falls keine checked Items existieren, wird am Ende eingefügt.
|
||||
*/
|
||||
private fun calculateInsertIndexForNewItem(items: List<ChecklistItemState>): Int {
|
||||
val currentSort = _lastChecklistSortOption.value
|
||||
return when (currentSort) {
|
||||
ChecklistSortOption.MANUAL,
|
||||
ChecklistSortOption.UNCHECKED_FIRST -> {
|
||||
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
|
||||
if (firstCheckedIndex >= 0) firstCheckedIndex else items.size
|
||||
}
|
||||
else -> items.size
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteChecklistItem(itemId: String) {
|
||||
_checklistItems.update { items ->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.dettmer.simplenotes.ui.editor
|
||||
|
||||
import dev.dettmer.simplenotes.models.ChecklistSortOption
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
@@ -174,4 +175,204 @@ class ChecklistSortingTest {
|
||||
assertEquals(1, sorted[1].order)
|
||||
assertEquals(2, sorted[2].order)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🆕 v1.8.1 (IMPL_15): Tests für Add-Item Insert-Position
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Simulates calculateInsertIndexForNewItem() from NoteEditorViewModel.
|
||||
* Tests the insert position logic for new unchecked items.
|
||||
*/
|
||||
private fun calculateInsertIndexForNewItem(
|
||||
items: List<ChecklistItemState>,
|
||||
sortOption: ChecklistSortOption
|
||||
): Int {
|
||||
return when (sortOption) {
|
||||
ChecklistSortOption.MANUAL,
|
||||
ChecklistSortOption.UNCHECKED_FIRST -> {
|
||||
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
|
||||
if (firstCheckedIndex >= 0) firstCheckedIndex else items.size
|
||||
}
|
||||
else -> items.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates the full addChecklistItemAtEnd() logic:
|
||||
* 1. Calculate insert index
|
||||
* 2. Insert new item
|
||||
* 3. Reassign order values
|
||||
*/
|
||||
private fun simulateAddItemAtEnd(
|
||||
items: List<ChecklistItemState>,
|
||||
sortOption: ChecklistSortOption
|
||||
): List<ChecklistItemState> {
|
||||
val newItem = ChecklistItemState(id = "new", text = "", isChecked = false, order = 0)
|
||||
val insertIndex = calculateInsertIndexForNewItem(items, sortOption)
|
||||
val newList = items.toMutableList()
|
||||
newList.add(insertIndex, newItem)
|
||||
return newList.mapIndexed { i, item -> item.copy(order = i) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item at end inserts before separator in MANUAL mode`() {
|
||||
// Ausgangslage: 2 unchecked, 1 checked (sortiert)
|
||||
val items = listOf(
|
||||
item("a", checked = false, order = 0),
|
||||
item("b", checked = false, order = 1),
|
||||
item("c", checked = true, order = 2)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
|
||||
|
||||
// Neues Item muss an Index 2 stehen (vor dem checked Item)
|
||||
assertEquals(4, result.size)
|
||||
assertEquals("a", result[0].id)
|
||||
assertEquals("b", result[1].id)
|
||||
assertEquals("new", result[2].id) // ← Neues Item VOR Separator
|
||||
assertFalse(result[2].isChecked)
|
||||
assertEquals("c", result[3].id) // ← Checked Item bleibt UNTER Separator
|
||||
assertTrue(result[3].isChecked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item at end inserts before separator in UNCHECKED_FIRST mode`() {
|
||||
val items = listOf(
|
||||
item("a", checked = false, order = 0),
|
||||
item("b", checked = true, order = 1),
|
||||
item("c", checked = true, order = 2)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.UNCHECKED_FIRST)
|
||||
|
||||
assertEquals(4, result.size)
|
||||
assertEquals("a", result[0].id)
|
||||
assertEquals("new", result[1].id) // ← Neues Item direkt nach letztem unchecked
|
||||
assertFalse(result[1].isChecked)
|
||||
assertEquals("b", result[2].id)
|
||||
assertEquals("c", result[3].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item at end appends at end in CHECKED_FIRST mode`() {
|
||||
val items = listOf(
|
||||
item("a", checked = true, order = 0),
|
||||
item("b", checked = false, order = 1)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.CHECKED_FIRST)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("a", result[0].id)
|
||||
assertEquals("b", result[1].id)
|
||||
assertEquals("new", result[2].id) // ← Am Ende (kein Separator)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item at end appends at end in ALPHABETICAL_ASC mode`() {
|
||||
val items = listOf(
|
||||
item("a", checked = false, order = 0),
|
||||
item("b", checked = true, order = 1)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.ALPHABETICAL_ASC)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("new", result[2].id) // ← Am Ende
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item at end appends at end in ALPHABETICAL_DESC mode`() {
|
||||
val items = listOf(
|
||||
item("a", checked = true, order = 0),
|
||||
item("b", checked = false, order = 1)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.ALPHABETICAL_DESC)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("new", result[2].id) // ← Am Ende
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item with no checked items appends at end`() {
|
||||
val items = listOf(
|
||||
item("a", checked = false, order = 0),
|
||||
item("b", checked = false, order = 1)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("new", result[2].id) // Kein checked Item → ans Ende
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item with all checked items inserts at position 0`() {
|
||||
val items = listOf(
|
||||
item("a", checked = true, order = 0),
|
||||
item("b", checked = true, order = 1)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("new", result[0].id) // ← Ganz oben (vor allen checked Items)
|
||||
assertFalse(result[0].isChecked)
|
||||
assertEquals("a", result[1].id)
|
||||
assertEquals("b", result[2].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item to empty list in MANUAL mode`() {
|
||||
val items = emptyList<ChecklistItemState>()
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("new", result[0].id)
|
||||
assertEquals(0, result[0].order)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - order values are sequential after add item`() {
|
||||
val items = listOf(
|
||||
item("a", checked = false, order = 0),
|
||||
item("b", checked = false, order = 1),
|
||||
item("c", checked = true, order = 2)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
|
||||
|
||||
result.forEachIndexed { index, item ->
|
||||
assertEquals("Order at index $index should be $index", index, item.order)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - existing items do not change position after add item`() {
|
||||
// Kernforderung: Kein Item darf sich verschieben
|
||||
val items = listOf(
|
||||
item("cashews", checked = false, order = 0),
|
||||
item("noodles", checked = false, order = 1),
|
||||
item("coffee", checked = true, order = 2)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
|
||||
|
||||
// Relative Reihenfolge der bestehenden Items prüfen
|
||||
val existingIds = result.filter { it.id != "new" }.map { it.id }
|
||||
assertEquals(listOf("cashews", "noodles", "coffee"), existingIds)
|
||||
|
||||
// Cashews und Noodles müssen VOR dem neuen Item sein
|
||||
val cashewsIdx = result.indexOfFirst { it.id == "cashews" }
|
||||
val noodlesIdx = result.indexOfFirst { it.id == "noodles" }
|
||||
val newIdx = result.indexOfFirst { it.id == "new" }
|
||||
val coffeeIdx = result.indexOfFirst { it.id == "coffee" }
|
||||
|
||||
assertTrue("Cashews before new", cashewsIdx < newIdx)
|
||||
assertTrue("Noodles before new", noodlesIdx < newIdx)
|
||||
assertTrue("New before Coffee", newIdx < coffeeIdx)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user