diff --git a/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt b/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt index 0714851..7632193 100644 --- a/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt +++ b/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt @@ -17,18 +17,43 @@ object MobiledocBuilder { linkUrl: String?, linkTitle: String?, linkDescription: String? + ): String { + return build(text, linkUrl, linkTitle, linkDescription, null, null) + } + + fun build( + text: String, + linkUrl: String?, + linkTitle: String?, + linkDescription: String?, + imageUrl: String?, + imageAlt: String? ): String { val escapedText = escapeForJson(text).replace("\n", "\\n") - val cards = if (linkUrl != null) { + val cardsList = mutableListOf() + val cardSections = mutableListOf() + + // Image card + if (imageUrl != null) { + val escapedImgUrl = escapeForJson(imageUrl) + val escapedAlt = imageAlt?.let { escapeForJson(it) } ?: "" + cardSections.add("[10,${cardsList.size}]") + cardsList.add("""["image",{"src":"$escapedImgUrl","alt":"$escapedAlt","caption":""}]""") + } + + // Bookmark card + if (linkUrl != null) { val escapedUrl = escapeForJson(linkUrl) val escapedTitle = linkTitle?.let { escapeForJson(it) } ?: "" val escapedDesc = linkDescription?.let { escapeForJson(it) } ?: "" - """["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]""" - } else "" + cardSections.add("[10,${cardsList.size}]") + cardsList.add("""["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]""") + } - val cardSection = if (linkUrl != null) ",[10,0]" else "" - return """{"version":"0.3.1","atoms":[],"cards":[$cards],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]$cardSection]}""" + val cards = cardsList.joinToString(",") + val extraSections = if (cardSections.isNotEmpty()) "," + cardSections.joinToString(",") else "" + return """{"version":"0.3.1","atoms":[],"cards":[$cards],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]$extraSections]}""" } internal fun escapeForJson(value: String): String { diff --git a/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt b/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt index 3b3f23f..2dec738 100644 --- a/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt +++ b/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt @@ -5,9 +5,11 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import com.swoosh.microblog.data.model.LocalPost -@Database(entities = [LocalPost::class], version = 1, exportSchema = false) +@Database(entities = [LocalPost::class], version = 2, exportSchema = false) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { @@ -17,13 +19,21 @@ abstract class AppDatabase : RoomDatabase() { @Volatile private var INSTANCE: AppDatabase? = null + val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE local_posts ADD COLUMN imageAlt TEXT DEFAULT NULL") + } + } + fun getInstance(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "swoosh_database" - ).build() + ) + .addMigrations(MIGRATION_1_2) + .build() INSTANCE = instance instance } diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt index 9adfc02..dddd40b 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt @@ -36,6 +36,7 @@ data class GhostPost( val mobiledoc: String? = null, val status: String? = null, val feature_image: String? = null, + val feature_image_alt: String? = null, val created_at: String? = null, val updated_at: String? = null, val published_at: String? = null, @@ -66,6 +67,7 @@ data class LocalPost( val linkTitle: String? = null, val linkDescription: String? = null, val linkImageUrl: String? = null, + val imageAlt: String? = null, val scheduledAt: String? = null, val createdAt: Long = System.currentTimeMillis(), val updatedAt: Long = System.currentTimeMillis(), @@ -95,6 +97,7 @@ data class FeedPost( val textContent: String, val htmlContent: String?, val imageUrl: String?, + val imageAlt: String? = null, val linkUrl: String?, val linkTitle: String?, val linkDescription: String?, diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index b26f002..c5c836a 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -3,8 +3,10 @@ package com.swoosh.microblog.ui.composer import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -18,7 +20,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage @@ -37,6 +43,7 @@ fun ComposerScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() var showLinkDialog by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) } + var showAltTextDialog by remember { mutableStateOf(false) } var linkInput by remember { mutableStateOf("") } // Load post for editing @@ -120,11 +127,14 @@ fun ComposerScreen( Box { AsyncImage( model = state.imageUri, - contentDescription = "Selected image", + contentDescription = state.imageAlt.ifBlank { "Selected image" }, modifier = Modifier .fillMaxWidth() .height(200.dp) - .clip(MaterialTheme.shapes.medium), + .clip(MaterialTheme.shapes.medium) + .semantics { + contentDescription = state.imageAlt.ifBlank { "Selected image" } + }, contentScale = ContentScale.Crop ) IconButton( @@ -136,6 +146,36 @@ fun ComposerScreen( tint = MaterialTheme.colorScheme.onSurface ) } + // ALT badge when alt text is set + if (state.imageAlt.isNotBlank()) { + Text( + text = "ALT", + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + .background( + MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.8f), + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp), + color = MaterialTheme.colorScheme.inverseOnSurface, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + } + } + + // Add alt text button + Spacer(modifier = Modifier.height(4.dp)) + TextButton( + onClick = { showAltTextDialog = true }, + contentPadding = PaddingValues(horizontal = 0.dp) + ) { + Text( + text = if (state.imageAlt.isBlank()) "Add alt text" else "Edit alt text", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) } } @@ -302,6 +342,49 @@ fun ComposerScreen( onDismiss = { showDatePicker = false } ) } + + // Alt text dialog + if (showAltTextDialog) { + var altInput by remember { mutableStateOf(state.imageAlt) } + AlertDialog( + onDismissRequest = { showAltTextDialog = false }, + title = { Text("Alt Text") }, + text = { + Column { + OutlinedTextField( + value = altInput, + onValueChange = { altInput = it.take(250) }, + placeholder = { Text("Describe this image for people who can't see it") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 4, + supportingText = { + Text( + "${altInput.length}/250", + style = MaterialTheme.typography.labelSmall, + color = if (altInput.length >= 250) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } + }, + confirmButton = { + TextButton(onClick = { + viewModel.updateImageAlt(altInput) + showAltTextDialog = false + }) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = { showAltTextDialog = false }) { + Text("Cancel") + } + } + ) + } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt index 86e9683..a7cf7d9 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt @@ -35,6 +35,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application it.copy( text = post.textContent, imageUri = post.imageUrl?.let { url -> Uri.parse(url) }, + imageAlt = post.imageAlt ?: "", linkPreview = if (post.linkUrl != null) LinkPreview( url = post.linkUrl, title = post.linkTitle, @@ -51,7 +52,17 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application } fun setImage(uri: Uri?) { - _uiState.update { it.copy(imageUri = uri) } + _uiState.update { + if (uri == null) { + it.copy(imageUri = null, imageAlt = "") + } else { + it.copy(imageUri = uri) + } + } + } + + fun updateImageAlt(alt: String) { + _uiState.update { it.copy(imageAlt = alt.take(250)) } } fun fetchLinkPreview(url: String) { @@ -89,6 +100,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application val title = state.text.take(60) + val altText = state.imageAlt.ifBlank { null } + if (status == PostStatus.DRAFT || !repository.isNetworkAvailable()) { // Save locally val localPost = LocalPost( @@ -98,6 +111,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application content = state.text, status = status, imageUri = state.imageUri?.toString(), + imageAlt = altText, linkUrl = state.linkPreview?.url, linkTitle = state.linkPreview?.title, linkDescription = state.linkPreview?.description, @@ -127,13 +141,21 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application ) } - val mobiledoc = MobiledocBuilder.build(state.text, state.linkPreview) + val mobiledoc = MobiledocBuilder.build( + state.text, + state.linkPreview?.url, + state.linkPreview?.title, + state.linkPreview?.description, + featureImage, + altText + ) val ghostPost = GhostPost( title = title, mobiledoc = mobiledoc, status = status.name.lowercase(), feature_image = featureImage, + feature_image_alt = altText, published_at = state.scheduledAt, visibility = "public" ) @@ -159,6 +181,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application status = status, imageUri = state.imageUri?.toString(), uploadedImageUrl = featureImage, + imageAlt = altText, linkUrl = state.linkPreview?.url, linkTitle = state.linkPreview?.title, linkDescription = state.linkPreview?.description, @@ -185,6 +208,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application data class ComposerUiState( val text: String = "", val imageUri: Uri? = null, + val imageAlt: String = "", val linkPreview: LinkPreview? = null, val isLoadingLink: Boolean = false, val scheduledAt: String? = null, diff --git a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt index bc6d465..b47f137 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt @@ -12,6 +12,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.swoosh.microblog.data.model.FeedPost @@ -81,12 +84,26 @@ fun DetailScreen( Spacer(modifier = Modifier.height(16.dp)) AsyncImage( model = post.imageUrl, - contentDescription = "Post image", + contentDescription = post.imageAlt ?: "Post image", modifier = Modifier .fillMaxWidth() - .clip(MaterialTheme.shapes.medium), + .clip(MaterialTheme.shapes.medium) + .semantics { + contentDescription = post.imageAlt ?: "Post image" + }, contentScale = ContentScale.FillWidth ) + // Alt text display + if (!post.imageAlt.isNullOrBlank()) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Alt: ${post.imageAlt}", + style = MaterialTheme.typography.bodySmall.copy( + fontStyle = FontStyle.Italic + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } // Link preview diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index 9952c7c..7437cf4 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -1,10 +1,12 @@ package com.swoosh.microblog.ui.feed +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -20,9 +22,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage @@ -251,15 +257,56 @@ fun PostCard( // Image thumbnail if (post.imageUrl != null) { Spacer(modifier = Modifier.height(8.dp)) - AsyncImage( - model = post.imageUrl, - contentDescription = "Post image", - modifier = Modifier - .fillMaxWidth() - .height(180.dp) - .clip(MaterialTheme.shapes.medium), - contentScale = ContentScale.Crop - ) + var showAltPopup by remember { mutableStateOf(false) } + Box { + AsyncImage( + model = post.imageUrl, + contentDescription = post.imageAlt ?: "Post image", + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .clip(MaterialTheme.shapes.medium) + .semantics { + contentDescription = post.imageAlt ?: "Post image" + }, + contentScale = ContentScale.Crop + ) + if (!post.imageAlt.isNullOrBlank()) { + Text( + text = "ALT", + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background( + MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.8f) + ) + .clickable { showAltPopup = !showAltPopup } + .padding(horizontal = 6.dp, vertical = 2.dp), + color = MaterialTheme.colorScheme.inverseOnSurface, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + } + } + // Alt text popup + if (showAltPopup && !post.imageAlt.isNullOrBlank()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Text( + text = post.imageAlt, + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } // Link preview diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 350de2c..c26d90e 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -144,6 +144,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "", htmlContent = html, imageUrl = feature_image, + imageAlt = feature_image_alt, linkUrl = null, linkTitle = null, linkDescription = null, @@ -162,6 +163,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { textContent = content, htmlContent = htmlContent, imageUrl = uploadedImageUrl ?: imageUri, + imageAlt = imageAlt, linkUrl = linkUrl, linkTitle = linkTitle, linkDescription = linkDescription, diff --git a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt index 17c9357..f13cc01 100644 --- a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt +++ b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt @@ -37,7 +37,10 @@ class PostUploadWorker( featureImage = imageResult.getOrNull() } - val mobiledoc = MobiledocBuilder.build(post.content, post.linkUrl, post.linkTitle, post.linkDescription) + val mobiledoc = MobiledocBuilder.build( + post.content, post.linkUrl, post.linkTitle, post.linkDescription, + featureImage, post.imageAlt + ) val ghostPost = GhostPost( title = post.title, @@ -48,6 +51,7 @@ class PostUploadWorker( else -> "draft" }, feature_image = featureImage, + feature_image_alt = post.imageAlt, published_at = post.scheduledAt, visibility = "public" ) diff --git a/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt b/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt index 4a36c39..6d884ea 100644 --- a/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt @@ -251,4 +251,161 @@ class MobiledocBuilderTest { fun `escapeForJson handles empty string`() { assertEquals("", MobiledocBuilder.escapeForJson("")) } + + // --- Image card with alt text --- + + @Test + fun `build with image card produces valid JSON`() { + val result = MobiledocBuilder.build( + "Post text", null, null, null, + "https://example.com/photo.jpg", "A sunset" + ) + val json = JsonParser.parseString(result).asJsonObject + assertNotNull(json) + } + + @Test + fun `build with image card includes image type`() { + val result = MobiledocBuilder.build( + "Text", null, null, null, + "https://example.com/photo.jpg", "Alt text" + ) + assertTrue("Should contain image card type", result.contains("\"image\"")) + } + + @Test + fun `build with image card includes src`() { + val result = MobiledocBuilder.build( + "Text", null, null, null, + "https://example.com/photo.jpg", "Alt text" + ) + assertTrue("Should contain image src", result.contains("https://example.com/photo.jpg")) + } + + @Test + fun `build with image card includes alt text`() { + val result = MobiledocBuilder.build( + "Text", null, null, null, + "https://example.com/photo.jpg", "A beautiful sunset" + ) + val json = JsonParser.parseString(result).asJsonObject + val cards = json.getAsJsonArray("cards") + assertEquals(1, cards.size()) + val card = cards.get(0).asJsonArray + assertEquals("image", card.get(0).asString) + val cardData = card.get(1).asJsonObject + assertEquals("A beautiful sunset", cardData.get("alt").asString) + } + + @Test + fun `build with image card and null alt uses empty string`() { + val result = MobiledocBuilder.build( + "Text", null, null, null, + "https://example.com/photo.jpg", null + ) + val json = JsonParser.parseString(result).asJsonObject + val cards = json.getAsJsonArray("cards") + val card = cards.get(0).asJsonArray + val cardData = card.get(1).asJsonObject + assertEquals("", cardData.get("alt").asString) + } + + @Test + fun `build with image card includes caption field`() { + val result = MobiledocBuilder.build( + "Text", null, null, null, + "https://example.com/photo.jpg", "Alt" + ) + val json = JsonParser.parseString(result).asJsonObject + val card = json.getAsJsonArray("cards").get(0).asJsonArray + val cardData = card.get(1).asJsonObject + assertEquals("", cardData.get("caption").asString) + } + + @Test + fun `build with image card has card section`() { + val result = MobiledocBuilder.build( + "Text", null, null, null, + "https://example.com/photo.jpg", "Alt" + ) + val json = JsonParser.parseString(result).asJsonObject + val sections = json.getAsJsonArray("sections") + assertEquals("Should have text section and image card section", 2, sections.size()) + } + + @Test + fun `build with image and link has both cards`() { + val result = MobiledocBuilder.build( + "Text", "https://link.com", "Link Title", "Link Desc", + "https://example.com/photo.jpg", "Image alt" + ) + val json = JsonParser.parseString(result).asJsonObject + val cards = json.getAsJsonArray("cards") + assertEquals("Should have image card and bookmark card", 2, cards.size()) + // Image card comes first + assertEquals("image", cards.get(0).asJsonArray.get(0).asString) + assertEquals("bookmark", cards.get(1).asJsonArray.get(0).asString) + } + + @Test + fun `build with image and link has three sections`() { + val result = MobiledocBuilder.build( + "Text", "https://link.com", "Title", "Desc", + "https://example.com/photo.jpg", "Alt" + ) + val json = JsonParser.parseString(result).asJsonObject + val sections = json.getAsJsonArray("sections") + assertEquals("Should have text, image card, and bookmark card sections", 3, sections.size()) + } + + @Test + fun `build with image card escapes alt text`() { + val result = MobiledocBuilder.build( + "Text", null, null, null, + "https://example.com/photo.jpg", "He said \"hello\"" + ) + val json = JsonParser.parseString(result).asJsonObject + assertNotNull("Should produce valid JSON with escaped alt text", json) + } + + @Test + fun `build without image produces no image card`() { + val result = MobiledocBuilder.build( + "Text", null, null, null, + null, null + ) + val json = JsonParser.parseString(result).asJsonObject + assertTrue("Should have no cards", json.getAsJsonArray("cards").isEmpty) + } + + @Test + fun `build with image card section references correct card index`() { + val result = MobiledocBuilder.build( + "Text", null, null, null, + "https://example.com/photo.jpg", "Alt" + ) + val json = JsonParser.parseString(result).asJsonObject + val sections = json.getAsJsonArray("sections") + val cardSection = sections.get(1).asJsonArray + assertEquals(10, cardSection.get(0).asInt) + assertEquals(0, cardSection.get(1).asInt) + } + + @Test + fun `build with image and link card sections reference correct indices`() { + val result = MobiledocBuilder.build( + "Text", "https://link.com", "Title", null, + "https://example.com/photo.jpg", "Alt" + ) + val json = JsonParser.parseString(result).asJsonObject + val sections = json.getAsJsonArray("sections") + // Image card section at index 0 + val imgSection = sections.get(1).asJsonArray + assertEquals(10, imgSection.get(0).asInt) + assertEquals(0, imgSection.get(1).asInt) + // Bookmark card section at index 1 + val bookmarkSection = sections.get(2).asJsonArray + assertEquals(10, bookmarkSection.get(0).asInt) + assertEquals(1, bookmarkSection.get(1).asInt) + } } diff --git a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt index 6f4f370..c0c42d2 100644 --- a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt @@ -72,6 +72,7 @@ class GhostModelsTest { assertNull(post.mobiledoc) assertNull(post.status) assertNull(post.feature_image) + assertNull(post.feature_image_alt) assertNull(post.created_at) assertNull(post.updated_at) assertNull(post.published_at) @@ -252,4 +253,108 @@ class GhostModelsTest { assertNull(preview.description) assertNull(preview.imageUrl) } + + // --- Alt text --- + + @Test + fun `LocalPost default imageAlt is null`() { + val post = LocalPost() + assertNull(post.imageAlt) + } + + @Test + fun `LocalPost stores imageAlt`() { + val post = LocalPost(imageAlt = "A sunset over the ocean") + assertEquals("A sunset over the ocean", post.imageAlt) + } + + @Test + fun `GhostPost default feature_image_alt is null`() { + val post = GhostPost() + assertNull(post.feature_image_alt) + } + + @Test + fun `GhostPost stores feature_image_alt`() { + val post = GhostPost( + feature_image = "https://example.com/img.jpg", + feature_image_alt = "A beautiful landscape" + ) + assertEquals("A beautiful landscape", post.feature_image_alt) + } + + @Test + fun `GhostPost serializes feature_image_alt to JSON`() { + val post = GhostPost( + title = "Test", + feature_image = "https://example.com/img.jpg", + feature_image_alt = "Alt description" + ) + val json = gson.toJson(post) + assertTrue(json.contains("\"feature_image_alt\":\"Alt description\"")) + } + + @Test + fun `GhostPost deserializes feature_image_alt from JSON`() { + val json = """{"id":"1","feature_image":"https://example.com/img.jpg","feature_image_alt":"Test alt"}""" + val post = gson.fromJson(json, GhostPost::class.java) + assertEquals("Test alt", post.feature_image_alt) + } + + @Test + fun `GhostPost deserializes with missing feature_image_alt`() { + val json = """{"id":"1","feature_image":"https://example.com/img.jpg"}""" + val post = gson.fromJson(json, GhostPost::class.java) + assertNull(post.feature_image_alt) + } + + @Test + fun `FeedPost default imageAlt is null`() { + val post = FeedPost( + title = "Test", + textContent = "Content", + htmlContent = null, + imageUrl = null, + linkUrl = null, + linkTitle = null, + linkDescription = null, + linkImageUrl = null, + status = "published", + publishedAt = null, + createdAt = null, + updatedAt = null + ) + assertNull(post.imageAlt) + } + + @Test + fun `FeedPost stores imageAlt`() { + val post = FeedPost( + title = "Test", + textContent = "Content", + htmlContent = null, + imageUrl = "https://example.com/img.jpg", + imageAlt = "Image description", + linkUrl = null, + linkTitle = null, + linkDescription = null, + linkImageUrl = null, + status = "published", + publishedAt = null, + createdAt = null, + updatedAt = null + ) + assertEquals("Image description", post.imageAlt) + } + + @Test + fun `PostWrapper includes feature_image_alt in serialized JSON`() { + val wrapper = PostWrapper(listOf(GhostPost( + title = "Post with alt", + feature_image = "https://example.com/photo.jpg", + feature_image_alt = "Photo description" + ))) + val json = gson.toJson(wrapper) + assertTrue(json.contains("\"feature_image_alt\":\"Photo description\"")) + } }