merge: integrate swipe actions feature (resolve conflicts)

This commit is contained in:
Paweł Orzech 2026-03-19 10:46:21 +01:00
commit 881b2f016f
No known key found for this signature in database
4 changed files with 659 additions and 21 deletions

View file

@ -4,6 +4,7 @@ import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -16,11 +17,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BrightnessAuto import androidx.compose.material.icons.filled.BrightnessAuto
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@ -34,9 +37,12 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -61,6 +67,7 @@ fun FeedScreen(
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onPostClick: (FeedPost) -> Unit, onPostClick: (FeedPost) -> Unit,
onCompose: () -> Unit, onCompose: () -> Unit,
onEditPost: (FeedPost) -> Unit,
viewModel: FeedViewModel = viewModel(), viewModel: FeedViewModel = viewModel(),
themeViewModel: ThemeViewModel? = null themeViewModel: ThemeViewModel? = null
) { ) {
@ -71,6 +78,9 @@ fun FeedScreen(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val baseUrl = remember { CredentialsManager(context).ghostUrl } val baseUrl = remember { CredentialsManager(context).ghostUrl }
// Track which post is pending delete confirmation
var postPendingDelete by remember { mutableStateOf<FeedPost?>(null) }
// Pull-to-refresh // Pull-to-refresh
val pullRefreshState = rememberPullRefreshState( val pullRefreshState = rememberPullRefreshState(
refreshing = state.isRefreshing, refreshing = state.isRefreshing,
@ -91,6 +101,20 @@ fun FeedScreen(
} }
} }
// Listen for snackbar events from the ViewModel
LaunchedEffect(Unit) {
viewModel.snackbarEvent.collect { event ->
val result = snackbarHostState.showSnackbar(
message = event.message,
actionLabel = event.actionLabel,
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed && event.onAction != null) {
event.onAction.invoke()
}
}
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@ -191,7 +215,7 @@ fun FeedScreen(
contentPadding = PaddingValues(vertical = 8.dp) contentPadding = PaddingValues(vertical = 8.dp)
) { ) {
items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
PostCard( SwipeablePostCard(
post = post, post = post,
onClick = { onPostClick(post) }, onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) }, onCancelQueue = { viewModel.cancelQueuedPost(post) },
@ -213,6 +237,8 @@ fun FeedScreen(
clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl)) clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl))
} }
}, },
onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post },
snackbarHostState = snackbarHostState snackbarHostState = snackbarHostState
) )
} }
@ -251,16 +277,178 @@ fun FeedScreen(
} }
} }
} }
// Delete confirmation dialog
if (postPendingDelete != null) {
AlertDialog(
onDismissRequest = { postPendingDelete = null },
title = { Text("Delete this post?") },
text = { Text("This action cannot be undone.") },
confirmButton = {
TextButton(
onClick = {
val post = postPendingDelete!!
postPendingDelete = null
viewModel.deletePostWithUndo(post)
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { postPendingDelete = null }) { Text("Cancel") }
}
)
}
} }
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PostCard( fun SwipeablePostCard(
post: FeedPost, post: FeedPost,
onClick: () -> Unit, onClick: () -> Unit,
onCancelQueue: () -> Unit, onCancelQueue: () -> Unit,
onShare: () -> Unit = {}, onShare: () -> Unit = {},
onCopyLink: () -> Unit = {}, onCopyLink: () -> Unit = {},
onEdit: () -> Unit,
onDelete: () -> Unit,
snackbarHostState: SnackbarHostState? = null
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
when (value) {
SwipeToDismissBoxValue.StartToEnd -> {
// Swipe right -> Edit
onEdit()
false // Don't settle in dismissed state; snap back
}
SwipeToDismissBoxValue.EndToStart -> {
// Swipe left -> Delete confirmation
onDelete()
false // Don't settle; snap back and show dialog
}
SwipeToDismissBoxValue.Settled -> true
}
},
positionalThreshold = { totalDistance -> totalDistance * 0.4f }
)
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
SwipeBackground(dismissState)
},
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 4.dp)
.semantics {
customActions = listOf(
CustomAccessibilityAction("Edit post") {
onEdit()
true
},
CustomAccessibilityAction("Delete post") {
onDelete()
true
}
)
}
) {
PostCardContent(
post = post,
onClick = onClick,
onCancelQueue = onCancelQueue,
onShare = onShare,
onCopyLink = onCopyLink,
onEdit = onEdit,
onDelete = onDelete,
snackbarHostState = snackbarHostState
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeBackground(dismissState: SwipeToDismissBoxState) {
val direction = dismissState.dismissDirection
val color by animateColorAsState(
when (direction) {
SwipeToDismissBoxValue.StartToEnd -> MaterialTheme.colorScheme.primary
SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.error
SwipeToDismissBoxValue.Settled -> Color.Transparent
},
label = "swipe_bg_color"
)
val icon = when (direction) {
SwipeToDismissBoxValue.StartToEnd -> Icons.Default.Edit
SwipeToDismissBoxValue.EndToStart -> Icons.Default.Delete
SwipeToDismissBoxValue.Settled -> null
}
val label = when (direction) {
SwipeToDismissBoxValue.StartToEnd -> "Edit"
SwipeToDismissBoxValue.EndToStart -> "Delete"
SwipeToDismissBoxValue.Settled -> null
}
val alignment = when (direction) {
SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart
SwipeToDismissBoxValue.EndToStart -> Alignment.CenterEnd
SwipeToDismissBoxValue.Settled -> Alignment.Center
}
Box(
modifier = Modifier
.fillMaxSize()
.background(color, MaterialTheme.shapes.medium)
.padding(horizontal = 24.dp),
contentAlignment = alignment
) {
if (icon != null && label != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (direction == SwipeToDismissBoxValue.StartToEnd) {
Icon(
imageVector = icon,
contentDescription = label,
tint = Color.White
)
Text(
text = label,
color = Color.White,
style = MaterialTheme.typography.labelLarge
)
} else {
Text(
text = label,
color = Color.White,
style = MaterialTheme.typography.labelLarge
)
Icon(
imageVector = icon,
contentDescription = label,
tint = Color.White
)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PostCardContent(
post: FeedPost,
onClick: () -> Unit,
onCancelQueue: () -> Unit,
onShare: () -> Unit = {},
onCopyLink: () -> Unit = {},
onEdit: () -> Unit,
onDelete: () -> Unit,
snackbarHostState: SnackbarHostState? = null snackbarHostState: SnackbarHostState? = null
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
@ -281,11 +469,10 @@ fun PostCard(
.padding(horizontal = 16.dp, vertical = 4.dp) .padding(horizontal = 16.dp, vertical = 4.dp)
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onClickLabel = "View post details",
onLongClick = { onLongClick = {
if (isPublished && hasShareableUrl) {
showContextMenu = true showContextMenu = true
} }
}
), ),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
@ -463,12 +650,21 @@ fun PostCard(
} }
} }
// Long-press context menu for copy link // Long-press context menu
DropdownMenu( DropdownMenu(
expanded = showContextMenu, expanded = showContextMenu,
onDismissRequest = { showContextMenu = false }, onDismissRequest = { showContextMenu = false },
offset = DpOffset(16.dp, 0.dp) offset = DpOffset(16.dp, 0.dp)
) { ) {
DropdownMenuItem(
text = { Text("Edit") },
onClick = {
showContextMenu = false
onEdit()
},
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }
)
if (isPublished && hasShareableUrl) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Copy link") }, text = { Text("Copy link") },
onClick = { onClick = {
@ -495,6 +691,21 @@ fun PostCard(
} }
) )
} }
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
showContextMenu = false
onDelete()
},
leadingIcon = {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
}
)
}
// Post stats badges // Post stats badges
if (post.textContent.isNotBlank()) { if (post.textContent.isNotBlank()) {
@ -533,6 +744,22 @@ fun PostCard(
} }
} }
// Keep the old PostCard signature for backward compatibility (used in tests/other screens)
@Composable
fun PostCard(
post: FeedPost,
onClick: () -> Unit,
onCancelQueue: () -> Unit
) {
PostCardContent(
post = post,
onClick = onClick,
onCancelQueue = onCancelQueue,
onEdit = {},
onDelete = {}
)
}
@Composable @Composable
fun StatusBadge(post: FeedPost) { fun StatusBadge(post: FeedPost) {
val (label, color) = when { val (label, color) = when {

View file

@ -17,6 +17,12 @@ import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import javax.net.ssl.SSLException import javax.net.ssl.SSLException
data class SnackbarEvent(
val message: String,
val actionLabel: String? = null,
val onAction: (() -> Unit)? = null
)
class FeedViewModel(application: Application) : AndroidViewModel(application) { class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application) private val repository = PostRepository(application)
@ -24,6 +30,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(FeedUiState()) private val _uiState = MutableStateFlow(FeedUiState())
val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow() val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
private val _snackbarEvent = MutableSharedFlow<SnackbarEvent>(extraBufferCapacity = 1)
val snackbarEvent: SharedFlow<SnackbarEvent> = _snackbarEvent.asSharedFlow()
private var currentPage = 1 private var currentPage = 1
private var hasMorePages = true private var hasMorePages = true
private var remotePosts = listOf<FeedPost>() private var remotePosts = listOf<FeedPost>()
@ -125,6 +134,50 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
/**
* Delete a post with undo support.
* For local posts: deletes from Room and offers undo (re-insert).
* For remote posts: deletes via API (no undo since Ghost API has no undelete).
*/
fun deletePostWithUndo(post: FeedPost) {
viewModelScope.launch {
if (post.isLocal && post.localId != null) {
// For local posts, we can support undo by saving the data before deleting
val localPost = repository.getLocalPostById(post.localId)
repository.deleteLocalPost(post.localId)
if (localPost != null) {
_snackbarEvent.emit(
SnackbarEvent(
message = "Post deleted",
actionLabel = "Undo",
onAction = {
viewModelScope.launch {
repository.saveLocalPost(localPost)
}
}
)
)
} else {
_snackbarEvent.emit(SnackbarEvent(message = "Post deleted"))
}
} else if (post.ghostId != null) {
// For remote posts, delete via API (no undo available)
repository.deletePost(post.ghostId).fold(
onSuccess = {
// Remove from local remote posts list immediately
remotePosts = remotePosts.filter { it.ghostId != post.ghostId }
mergePosts()
_snackbarEvent.emit(SnackbarEvent(message = "Post deleted"))
},
onFailure = { e ->
_uiState.update { it.copy(error = "Delete failed: ${e.message}") }
}
)
}
}
}
fun cancelQueuedPost(post: FeedPost) { fun cancelQueuedPost(post: FeedPost) {
viewModelScope.launch { viewModelScope.launch {
post.localId?.let { repository.deleteLocalPost(it) } post.localId?.let { repository.deleteLocalPost(it) }

View file

@ -64,6 +64,10 @@ fun SwooshNavGraph(
onCompose = { onCompose = {
editPost = null editPost = null
navController.navigate(Routes.COMPOSER) navController.navigate(Routes.COMPOSER)
},
onEditPost = { post ->
editPost = post
navController.navigate(Routes.COMPOSER)
} }
) )
} }

View file

@ -0,0 +1,354 @@
package com.swoosh.microblog.ui.feed
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.QueueStatus
import org.junit.Assert.*
import org.junit.Test
/**
* Tests for swipe action logic, delete confirmation, and undo behavior.
* These test the pure data/logic aspects that don't require Android framework.
*/
class SwipeActionsTest {
private fun createLocalPost(
localId: Long = 1L,
ghostId: String? = null,
content: String = "Test post content",
status: String = "draft",
isLocal: Boolean = true,
queueStatus: QueueStatus = QueueStatus.NONE
) = FeedPost(
localId = localId,
ghostId = ghostId,
title = content.take(60),
textContent = content,
htmlContent = null,
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = status,
publishedAt = null,
createdAt = null,
updatedAt = null,
isLocal = isLocal,
queueStatus = queueStatus
)
private fun createRemotePost(
ghostId: String = "remote_1",
content: String = "Remote post content",
status: String = "published"
) = FeedPost(
localId = null,
ghostId = ghostId,
title = content.take(60),
textContent = content,
htmlContent = "<p>$content</p>",
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = status,
publishedAt = "2024-01-15T10:00:00.000Z",
createdAt = "2024-01-15T09:00:00.000Z",
updatedAt = "2024-01-15T10:00:00.000Z",
isLocal = false,
queueStatus = QueueStatus.NONE
)
// --- Post identification for swipe actions ---
@Test
fun `local post has localId for undo support`() {
val post = createLocalPost(localId = 42)
assertTrue(post.isLocal)
assertNotNull(post.localId)
assertEquals(42L, post.localId)
}
@Test
fun `remote post has ghostId for delete action`() {
val post = createRemotePost(ghostId = "abc123")
assertFalse(post.isLocal)
assertNotNull(post.ghostId)
assertEquals("abc123", post.ghostId)
}
@Test
fun `local post can have both localId and ghostId`() {
val post = createLocalPost(localId = 1, ghostId = "ghost_123")
assertTrue(post.isLocal)
assertNotNull(post.localId)
assertNotNull(post.ghostId)
}
// --- Swipe action applicability ---
@Test
fun `edit action applies to local draft posts`() {
val post = createLocalPost(status = "draft")
// Edit is always available - post has data to populate composer
assertTrue(post.textContent.isNotEmpty())
}
@Test
fun `edit action applies to remote published posts`() {
val post = createRemotePost(status = "published")
assertTrue(post.textContent.isNotEmpty())
assertNotNull(post.ghostId)
}
@Test
fun `edit action applies to remote draft posts`() {
val post = createRemotePost(ghostId = "draft_1", status = "draft")
assertTrue(post.textContent.isNotEmpty())
}
@Test
fun `edit action applies to scheduled posts`() {
val post = createRemotePost(ghostId = "sched_1", status = "scheduled")
assertEquals("scheduled", post.status)
}
@Test
fun `delete action applies to local posts with localId`() {
val post = createLocalPost(localId = 5)
assertNotNull(post.localId)
assertTrue(post.isLocal)
}
@Test
fun `delete action applies to remote posts with ghostId`() {
val post = createRemotePost(ghostId = "remote_5")
assertNotNull(post.ghostId)
assertFalse(post.isLocal)
}
// --- Undo eligibility ---
@Test
fun `local post is eligible for undo after delete`() {
val post = createLocalPost(localId = 10)
// Undo is supported for local posts (re-insert into Room)
assertTrue(post.isLocal)
assertNotNull(post.localId)
}
@Test
fun `remote post is not eligible for undo after delete`() {
val post = createRemotePost(ghostId = "remote_10")
// Remote posts cannot be undeleted via Ghost API
assertFalse(post.isLocal)
assertNull(post.localId)
}
@Test
fun `local post with ghostId - undo re-inserts local copy only`() {
val post = createLocalPost(localId = 3, ghostId = "ghost_3", isLocal = true)
// Even local posts with ghostId can be undone (re-insert local row)
assertTrue(post.isLocal)
assertNotNull(post.localId)
assertNotNull(post.ghostId)
}
// --- SnackbarEvent data class ---
@Test
fun `SnackbarEvent with undo action has actionLabel`() {
val event = SnackbarEvent(
message = "Post deleted",
actionLabel = "Undo",
onAction = { /* restore */ }
)
assertEquals("Post deleted", event.message)
assertEquals("Undo", event.actionLabel)
assertNotNull(event.onAction)
}
@Test
fun `SnackbarEvent without undo has null action`() {
val event = SnackbarEvent(message = "Post deleted")
assertEquals("Post deleted", event.message)
assertNull(event.actionLabel)
assertNull(event.onAction)
}
@Test
fun `SnackbarEvent onAction callback is invocable`() {
var undoCalled = false
val event = SnackbarEvent(
message = "Post deleted",
actionLabel = "Undo",
onAction = { undoCalled = true }
)
event.onAction?.invoke()
assertTrue(undoCalled)
}
// --- Post card key generation ---
@Test
fun `post with ghostId uses ghostId as key`() {
val post = createRemotePost(ghostId = "unique_ghost_id")
val key = post.ghostId ?: "local_${post.localId}"
assertEquals("unique_ghost_id", key)
}
@Test
fun `local post without ghostId uses localId as key`() {
val post = createLocalPost(localId = 42, ghostId = null)
val key = post.ghostId ?: "local_${post.localId}"
assertEquals("local_42", key)
}
// --- FeedUiState ---
@Test
fun `FeedUiState default values are correct`() {
val state = FeedUiState()
assertTrue(state.posts.isEmpty())
assertFalse(state.isRefreshing)
assertFalse(state.isLoadingMore)
assertNull(state.error)
assertFalse(state.isConnectionError)
}
@Test
fun `FeedUiState posts can be filtered by isLocal`() {
val posts = listOf(
createLocalPost(localId = 1),
createRemotePost(ghostId = "r1"),
createLocalPost(localId = 2),
createRemotePost(ghostId = "r2")
)
val state = FeedUiState(posts = posts)
val localPosts = state.posts.filter { it.isLocal }
val remotePosts = state.posts.filter { !it.isLocal }
assertEquals(2, localPosts.size)
assertEquals(2, remotePosts.size)
}
@Test
fun `removing a post from list simulates delete`() {
val posts = listOf(
createRemotePost(ghostId = "r1", content = "First"),
createRemotePost(ghostId = "r2", content = "Second"),
createRemotePost(ghostId = "r3", content = "Third")
)
val postToDelete = posts[1]
val afterDelete = posts.filter { it.ghostId != postToDelete.ghostId }
assertEquals(2, afterDelete.size)
assertEquals("r1", afterDelete[0].ghostId)
assertEquals("r3", afterDelete[1].ghostId)
}
@Test
fun `re-adding a post to list simulates undo`() {
val originalPosts = listOf(
createLocalPost(localId = 1, content = "First"),
createLocalPost(localId = 2, content = "Second")
)
val deleted = originalPosts[0]
val afterDelete = originalPosts.filter { it.localId != deleted.localId }
assertEquals(1, afterDelete.size)
// Simulate undo: re-add
val afterUndo = listOf(deleted) + afterDelete
assertEquals(2, afterUndo.size)
assertEquals(1L, afterUndo[0].localId)
}
// --- Queue status interactions ---
@Test
fun `queued post can still be swiped for edit`() {
val post = createLocalPost(
localId = 1,
queueStatus = QueueStatus.QUEUED_PUBLISH
)
assertTrue(post.queueStatus != QueueStatus.NONE)
// Edit should still work for queued posts
assertTrue(post.textContent.isNotEmpty())
}
@Test
fun `uploading post can be swiped for delete`() {
val post = createLocalPost(
localId = 1,
queueStatus = QueueStatus.UPLOADING
)
assertTrue(post.queueStatus == QueueStatus.UPLOADING)
assertNotNull(post.localId)
}
@Test
fun `failed upload post can be swiped for edit and delete`() {
val post = createLocalPost(
localId = 1,
queueStatus = QueueStatus.FAILED
)
assertTrue(post.queueStatus == QueueStatus.FAILED)
assertTrue(post.textContent.isNotEmpty())
assertNotNull(post.localId)
}
// --- Post data preservation for edit ---
@Test
fun `post preserves all fields needed for edit`() {
val post = FeedPost(
localId = 5L,
ghostId = "g5",
title = "My Title",
textContent = "Full content here",
htmlContent = "<p>Full content here</p>",
imageUrl = "https://example.com/image.jpg",
linkUrl = "https://example.com",
linkTitle = "Example",
linkDescription = "An example link",
linkImageUrl = "https://example.com/og.jpg",
status = "published",
publishedAt = "2024-01-15T10:00:00.000Z",
createdAt = "2024-01-15T09:00:00.000Z",
updatedAt = "2024-01-15T10:00:00.000Z",
isLocal = false
)
// All fields needed by ComposerViewModel.loadForEdit
assertEquals("Full content here", post.textContent)
assertEquals("https://example.com/image.jpg", post.imageUrl)
assertEquals("https://example.com", post.linkUrl)
assertEquals("Example", post.linkTitle)
assertEquals("An example link", post.linkDescription)
assertEquals("https://example.com/og.jpg", post.linkImageUrl)
assertEquals("2024-01-15T10:00:00.000Z", post.updatedAt)
}
@Test
fun `post with empty content has minimal fields for edit`() {
val post = createLocalPost(content = "")
assertEquals("", post.textContent)
}
// --- Multiple deletes ---
@Test
fun `multiple sequential deletes reduce post count correctly`() {
var posts = (1L..5L).map { createLocalPost(localId = it, content = "Post $it") }
assertEquals(5, posts.size)
posts = posts.filter { it.localId != 3L }
assertEquals(4, posts.size)
posts = posts.filter { it.localId != 1L }
assertEquals(3, posts.size)
val remaining = posts.map { it.localId }
assertTrue(2L in remaining)
assertTrue(4L in remaining)
assertTrue(5L in remaining)
}
}