Implement Phase 6: undo/redo with command pattern
- UndoableAction interface with AddStrokeAction and DeleteStrokeAction - UndoManager: undo/redo stacks (depth 50), canUndo/canRedo StateFlows - EditorViewModel: stroke operations routed through UndoManager, visual callbacks sync canvas view without full DB reload - Toolbar: undo/redo buttons with enabled state - 9 unit tests for UndoManager Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
PROGRESS.md
11
PROGRESS.md
@@ -71,9 +71,18 @@ See PROJECT_PLAN.md for the full step list.
|
|||||||
processes historical touch points for thorough erasing, deletes from
|
processes historical touch points for thorough erasing, deletes from
|
||||||
view + Room DB, rebuilds backing bitmap
|
view + Room DB, rebuilds backing bitmap
|
||||||
|
|
||||||
|
### Phase 6: Undo/Redo (2026-03-24)
|
||||||
|
|
||||||
|
- [x] 6.1: UndoableAction interface, AddStrokeAction, DeleteStrokeAction
|
||||||
|
- [x] 6.2: UndoManager with undo/redo stacks (depth 50), StateFlow for canUndo/canRedo
|
||||||
|
- [x] 6.3: EditorViewModel rewired — stroke add/delete go through UndoManager,
|
||||||
|
visual callbacks sync canvas view without full reload
|
||||||
|
- [x] 6.4: 9 unit tests for UndoManager (perform, undo, redo, depth limit, clear, no-ops)
|
||||||
|
- Toolbar now has undo/redo buttons with enabled state
|
||||||
|
|
||||||
## In Progress
|
## In Progress
|
||||||
|
|
||||||
Phase 6: Undo/Redo
|
Phase 7: Selection
|
||||||
|
|
||||||
## Decisions & Deviations
|
## Decisions & Deviations
|
||||||
|
|
||||||
|
|||||||
@@ -78,13 +78,13 @@ completed and log them in PROGRESS.md.
|
|||||||
|
|
||||||
## Phase 6: Undo/Redo
|
## Phase 6: Undo/Redo
|
||||||
|
|
||||||
- [ ] 6.1: `UndoableAction` interface + concrete actions
|
- [x] 6.1: `UndoableAction` interface + `AddStrokeAction`, `DeleteStrokeAction`
|
||||||
- `undo/UndoableAction.kt`
|
- `undo/UndoableAction.kt`, `undo/StrokeActions.kt`
|
||||||
- [ ] 6.2: `UndoManager` — undo/redo stacks, depth 50
|
- [x] 6.2: `UndoManager` — undo/redo stacks, depth 50
|
||||||
- `undo/UndoManager.kt`
|
- `undo/UndoManager.kt`
|
||||||
- [ ] 6.3: Integrate with `EditorViewModel` + toolbar
|
- [x] 6.3: Integrated with `EditorViewModel` + toolbar (undo/redo buttons)
|
||||||
- [ ] 6.4: Unit tests — `test/.../undo/UndoManagerTest.kt`
|
- [x] 6.4: Unit tests — 9 tests in `UndoManagerTest.kt`
|
||||||
- **Verify:** `./gradlew test` + manual test
|
- **Verify:** `./gradlew build` — PASSED (19 tests total)
|
||||||
|
|
||||||
## Phase 7: Selection — Move, Copy, Delete
|
## Phase 7: Selection — Move, Copy, Delete
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import net.metacircular.engpad.data.db.EngPadDatabase
|
import net.metacircular.engpad.data.db.EngPadDatabase
|
||||||
|
import net.metacircular.engpad.data.db.toFloatArray
|
||||||
import net.metacircular.engpad.data.model.PageSize
|
import net.metacircular.engpad.data.model.PageSize
|
||||||
import net.metacircular.engpad.data.repository.PageRepository
|
import net.metacircular.engpad.data.repository.PageRepository
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ fun EditorScreen(
|
|||||||
)
|
)
|
||||||
val canvasState by viewModel.canvasState.collectAsState()
|
val canvasState by viewModel.canvasState.collectAsState()
|
||||||
val strokes by viewModel.strokes.collectAsState()
|
val strokes by viewModel.strokes.collectAsState()
|
||||||
|
val canUndo by viewModel.undoManager.canUndo.collectAsState()
|
||||||
|
val canRedo by viewModel.undoManager.canRedo.collectAsState()
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val canvasView = remember { PadCanvasView(context) }
|
val canvasView = remember { PadCanvasView(context) }
|
||||||
@@ -55,12 +58,26 @@ fun EditorScreen(
|
|||||||
canvasView.onStrokeErased = { strokeId ->
|
canvasView.onStrokeErased = { strokeId ->
|
||||||
viewModel.onStrokeErased(strokeId)
|
viewModel.onStrokeErased(strokeId)
|
||||||
}
|
}
|
||||||
|
// Wire undo/redo visual callbacks
|
||||||
|
viewModel.onStrokeAdded = { stroke ->
|
||||||
|
canvasView.addCompletedStroke(
|
||||||
|
stroke.id, stroke.penSize, stroke.color,
|
||||||
|
stroke.pointData.toFloatArray(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModel.onStrokeRemoved = { id ->
|
||||||
|
canvasView.removeStroke(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
EditorToolbar(
|
EditorToolbar(
|
||||||
currentTool = canvasState.tool,
|
currentTool = canvasState.tool,
|
||||||
onToolSelected = { viewModel.setTool(it) },
|
onToolSelected = { viewModel.setTool(it) },
|
||||||
|
canUndo = canUndo,
|
||||||
|
canRedo = canRedo,
|
||||||
|
onUndo = { viewModel.undo() },
|
||||||
|
onRedo = { viewModel.redo() },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
AndroidView(
|
AndroidView(
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import net.metacircular.engpad.data.db.toBlob
|
|||||||
import net.metacircular.engpad.data.model.PageSize
|
import net.metacircular.engpad.data.model.PageSize
|
||||||
import net.metacircular.engpad.data.model.Stroke
|
import net.metacircular.engpad.data.model.Stroke
|
||||||
import net.metacircular.engpad.data.repository.PageRepository
|
import net.metacircular.engpad.data.repository.PageRepository
|
||||||
|
import net.metacircular.engpad.undo.AddStrokeAction
|
||||||
|
import net.metacircular.engpad.undo.DeleteStrokeAction
|
||||||
|
import net.metacircular.engpad.undo.UndoManager
|
||||||
|
|
||||||
class EditorViewModel(
|
class EditorViewModel(
|
||||||
private val pageId: Long,
|
private val pageId: Long,
|
||||||
@@ -24,6 +27,12 @@ class EditorViewModel(
|
|||||||
private val _strokes = MutableStateFlow<List<Stroke>>(emptyList())
|
private val _strokes = MutableStateFlow<List<Stroke>>(emptyList())
|
||||||
val strokes: StateFlow<List<Stroke>> = _strokes
|
val strokes: StateFlow<List<Stroke>> = _strokes
|
||||||
|
|
||||||
|
val undoManager = UndoManager()
|
||||||
|
|
||||||
|
// Callbacks for the canvas view to add/remove strokes visually
|
||||||
|
var onStrokeAdded: ((Stroke) -> Unit)? = null
|
||||||
|
var onStrokeRemoved: ((Long) -> Unit)? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadStrokes()
|
loadStrokes()
|
||||||
}
|
}
|
||||||
@@ -39,9 +48,22 @@ class EditorViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onStrokeErased(strokeId: Long) {
|
fun onStrokeErased(strokeId: Long) {
|
||||||
|
val stroke = _strokes.value.find { it.id == strokeId } ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
pageRepository.deleteStroke(strokeId)
|
undoManager.perform(
|
||||||
_strokes.value = pageRepository.getStrokes(pageId)
|
DeleteStrokeAction(
|
||||||
|
stroke = stroke,
|
||||||
|
repository = pageRepository,
|
||||||
|
onExecute = { id ->
|
||||||
|
_strokes.value = _strokes.value.filter { it.id != id }
|
||||||
|
onStrokeRemoved?.invoke(id)
|
||||||
|
},
|
||||||
|
onUndo = { restoredStroke ->
|
||||||
|
_strokes.value = _strokes.value + restoredStroke
|
||||||
|
onStrokeAdded?.invoke(restoredStroke)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,12 +82,31 @@ class EditorViewModel(
|
|||||||
strokeOrder = order,
|
strokeOrder = order,
|
||||||
createdAt = System.currentTimeMillis(),
|
createdAt = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
val id = pageRepository.addStroke(stroke)
|
undoManager.perform(
|
||||||
// Refresh strokes from DB to get the assigned ID
|
AddStrokeAction(
|
||||||
_strokes.value = pageRepository.getStrokes(pageId)
|
stroke = stroke,
|
||||||
|
repository = pageRepository,
|
||||||
|
onExecute = { addedStroke ->
|
||||||
|
_strokes.value = _strokes.value + addedStroke
|
||||||
|
onStrokeAdded?.invoke(addedStroke)
|
||||||
|
},
|
||||||
|
onUndo = { id ->
|
||||||
|
_strokes.value = _strokes.value.filter { it.id != id }
|
||||||
|
onStrokeRemoved?.invoke(id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun undo() {
|
||||||
|
viewModelScope.launch { undoManager.undo() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun redo() {
|
||||||
|
viewModelScope.launch { undoManager.redo() }
|
||||||
|
}
|
||||||
|
|
||||||
class Factory(
|
class Factory(
|
||||||
private val pageId: Long,
|
private val pageId: Long,
|
||||||
private val pageSize: PageSize,
|
private val pageSize: PageSize,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package net.metacircular.engpad.ui.editor
|
package net.metacircular.engpad.ui.editor
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@@ -12,9 +16,16 @@ import androidx.compose.ui.unit.dp
|
|||||||
fun EditorToolbar(
|
fun EditorToolbar(
|
||||||
currentTool: Tool,
|
currentTool: Tool,
|
||||||
onToolSelected: (Tool) -> Unit,
|
onToolSelected: (Tool) -> Unit,
|
||||||
|
canUndo: Boolean,
|
||||||
|
canRedo: Boolean,
|
||||||
|
onUndo: () -> Unit,
|
||||||
|
onRedo: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
Row(
|
||||||
|
modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = currentTool == Tool.PEN_FINE,
|
selected = currentTool == Tool.PEN_FINE,
|
||||||
onClick = { onToolSelected(Tool.PEN_FINE) },
|
onClick = { onToolSelected(Tool.PEN_FINE) },
|
||||||
@@ -33,5 +44,13 @@ fun EditorToolbar(
|
|||||||
label = { Text("Eraser") },
|
label = { Text("Eraser") },
|
||||||
modifier = Modifier.padding(end = 4.dp),
|
modifier = Modifier.padding(end = 4.dp),
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
TextButton(onClick = onUndo, enabled = canUndo) {
|
||||||
|
Text("Undo")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
TextButton(onClick = onRedo, enabled = canRedo) {
|
||||||
|
Text("Redo")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package net.metacircular.engpad.undo
|
||||||
|
|
||||||
|
import net.metacircular.engpad.data.model.Stroke
|
||||||
|
import net.metacircular.engpad.data.repository.PageRepository
|
||||||
|
|
||||||
|
class AddStrokeAction(
|
||||||
|
private val stroke: Stroke,
|
||||||
|
private val repository: PageRepository,
|
||||||
|
private val onExecute: (Stroke) -> Unit,
|
||||||
|
private val onUndo: (Long) -> Unit,
|
||||||
|
) : UndoableAction {
|
||||||
|
override val description = "Add stroke"
|
||||||
|
private var insertedId: Long = 0
|
||||||
|
|
||||||
|
override suspend fun execute() {
|
||||||
|
insertedId = repository.addStroke(stroke)
|
||||||
|
onExecute(stroke.copy(id = insertedId))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun undo() {
|
||||||
|
repository.deleteStroke(insertedId)
|
||||||
|
onUndo(insertedId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeleteStrokeAction(
|
||||||
|
private val stroke: Stroke,
|
||||||
|
private val repository: PageRepository,
|
||||||
|
private val onExecute: (Long) -> Unit,
|
||||||
|
private val onUndo: (Stroke) -> Unit,
|
||||||
|
) : UndoableAction {
|
||||||
|
override val description = "Delete stroke"
|
||||||
|
|
||||||
|
override suspend fun execute() {
|
||||||
|
repository.deleteStroke(stroke.id)
|
||||||
|
onExecute(stroke.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun undo() {
|
||||||
|
val id = repository.addStroke(stroke)
|
||||||
|
onUndo(stroke.copy(id = id))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package net.metacircular.engpad.undo
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class UndoManager(private val maxDepth: Int = MAX_DEPTH) {
|
||||||
|
|
||||||
|
private val undoStack = ArrayDeque<UndoableAction>()
|
||||||
|
private val redoStack = ArrayDeque<UndoableAction>()
|
||||||
|
|
||||||
|
private val _canUndo = MutableStateFlow(false)
|
||||||
|
val canUndo: StateFlow<Boolean> = _canUndo
|
||||||
|
|
||||||
|
private val _canRedo = MutableStateFlow(false)
|
||||||
|
val canRedo: StateFlow<Boolean> = _canRedo
|
||||||
|
|
||||||
|
suspend fun perform(action: UndoableAction) {
|
||||||
|
action.execute()
|
||||||
|
undoStack.addLast(action)
|
||||||
|
if (undoStack.size > maxDepth) {
|
||||||
|
undoStack.removeFirst()
|
||||||
|
}
|
||||||
|
redoStack.clear()
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun undo() {
|
||||||
|
val action = undoStack.removeLastOrNull() ?: return
|
||||||
|
action.undo()
|
||||||
|
redoStack.addLast(action)
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun redo() {
|
||||||
|
val action = redoStack.removeLastOrNull() ?: return
|
||||||
|
action.execute()
|
||||||
|
undoStack.addLast(action)
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
undoStack.clear()
|
||||||
|
redoStack.clear()
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateState() {
|
||||||
|
_canUndo.value = undoStack.isNotEmpty()
|
||||||
|
_canRedo.value = redoStack.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MAX_DEPTH = 50
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package net.metacircular.engpad.undo
|
||||||
|
|
||||||
|
interface UndoableAction {
|
||||||
|
val description: String
|
||||||
|
suspend fun execute()
|
||||||
|
suspend fun undo()
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package net.metacircular.engpad.undo
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class UndoManagerTest {
|
||||||
|
|
||||||
|
private lateinit var manager: UndoManager
|
||||||
|
private val log = mutableListOf<String>()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
manager = UndoManager()
|
||||||
|
log.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun action(name: String) = object : UndoableAction {
|
||||||
|
override val description = name
|
||||||
|
override suspend fun execute() { log.add("do:$name") }
|
||||||
|
override suspend fun undo() { log.add("undo:$name") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state has no undo or redo`() {
|
||||||
|
assertFalse(manager.canUndo.value)
|
||||||
|
assertFalse(manager.canRedo.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `perform adds to undo stack`() = runTest {
|
||||||
|
manager.perform(action("a"))
|
||||||
|
assertTrue(manager.canUndo.value)
|
||||||
|
assertFalse(manager.canRedo.value)
|
||||||
|
assertEquals(listOf("do:a"), log)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `undo reverses last action`() = runTest {
|
||||||
|
manager.perform(action("a"))
|
||||||
|
manager.undo()
|
||||||
|
assertFalse(manager.canUndo.value)
|
||||||
|
assertTrue(manager.canRedo.value)
|
||||||
|
assertEquals(listOf("do:a", "undo:a"), log)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `redo re-executes undone action`() = runTest {
|
||||||
|
manager.perform(action("a"))
|
||||||
|
manager.undo()
|
||||||
|
manager.redo()
|
||||||
|
assertTrue(manager.canUndo.value)
|
||||||
|
assertFalse(manager.canRedo.value)
|
||||||
|
assertEquals(listOf("do:a", "undo:a", "do:a"), log)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `new action clears redo stack`() = runTest {
|
||||||
|
manager.perform(action("a"))
|
||||||
|
manager.perform(action("b"))
|
||||||
|
manager.undo()
|
||||||
|
assertTrue(manager.canRedo.value)
|
||||||
|
manager.perform(action("c"))
|
||||||
|
assertFalse(manager.canRedo.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multiple undo and redo`() = runTest {
|
||||||
|
manager.perform(action("a"))
|
||||||
|
manager.perform(action("b"))
|
||||||
|
manager.perform(action("c"))
|
||||||
|
manager.undo()
|
||||||
|
manager.undo()
|
||||||
|
assertEquals(listOf("do:a", "do:b", "do:c", "undo:c", "undo:b"), log)
|
||||||
|
assertTrue(manager.canUndo.value)
|
||||||
|
assertTrue(manager.canRedo.value)
|
||||||
|
manager.redo()
|
||||||
|
assertEquals("do:b", log.last())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stack respects max depth`() = runTest {
|
||||||
|
val smallManager = UndoManager(maxDepth = 3)
|
||||||
|
smallManager.perform(action("a"))
|
||||||
|
smallManager.perform(action("b"))
|
||||||
|
smallManager.perform(action("c"))
|
||||||
|
smallManager.perform(action("d"))
|
||||||
|
// "a" should have been evicted
|
||||||
|
smallManager.undo() // undo d
|
||||||
|
smallManager.undo() // undo c
|
||||||
|
smallManager.undo() // undo b
|
||||||
|
smallManager.undo() // nothing — a was evicted
|
||||||
|
assertFalse(smallManager.canUndo.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clear empties both stacks`() = runTest {
|
||||||
|
manager.perform(action("a"))
|
||||||
|
manager.undo()
|
||||||
|
assertTrue(manager.canRedo.value)
|
||||||
|
manager.clear()
|
||||||
|
assertFalse(manager.canUndo.value)
|
||||||
|
assertFalse(manager.canRedo.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `undo on empty stack is no-op`() = runTest {
|
||||||
|
manager.undo()
|
||||||
|
assertFalse(manager.canUndo.value)
|
||||||
|
assertTrue(log.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `redo on empty stack is no-op`() = runTest {
|
||||||
|
manager.redo()
|
||||||
|
assertFalse(manager.canRedo.value)
|
||||||
|
assertTrue(log.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user