feat: add alt text support for images with accessibility

This commit is contained in:
Paweł Orzech 2026-03-19 10:37:07 +01:00
parent 74f42fd2f1
commit 202e25b572
No known key found for this signature in database
11 changed files with 500 additions and 23 deletions

View file

@ -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<String>()
val cardSections = mutableListOf<String>()
// 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 {

View file

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

View file

@ -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?,

View file

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

View file

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

View file

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

View file

@ -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))
var showAltPopup by remember { mutableStateOf(false) }
Box {
AsyncImage(
model = post.imageUrl,
contentDescription = "Post image",
contentDescription = post.imageAlt ?: "Post image",
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(MaterialTheme.shapes.medium),
.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

View file

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

View file

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

View file

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

View file

@ -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\""))
}
}