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?, linkUrl: String?,
linkTitle: String?, linkTitle: String?,
linkDescription: 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 { ): String {
val escapedText = escapeForJson(text).replace("\n", "\\n") 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 escapedUrl = escapeForJson(linkUrl)
val escapedTitle = linkTitle?.let { escapeForJson(it) } ?: "" val escapedTitle = linkTitle?.let { escapeForJson(it) } ?: ""
val escapedDesc = linkDescription?.let { escapeForJson(it) } ?: "" val escapedDesc = linkDescription?.let { escapeForJson(it) } ?: ""
"""["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]""" cardSections.add("[10,${cardsList.size}]")
} else "" cardsList.add("""["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]""")
}
val cardSection = if (linkUrl != null) ",[10,0]" else "" val cards = cardsList.joinToString(",")
return """{"version":"0.3.1","atoms":[],"cards":[$cards],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]$cardSection]}""" 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 { internal fun escapeForJson(value: String): String {

View file

@ -5,9 +5,11 @@ import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.swoosh.microblog.data.model.LocalPost 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) @TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@ -17,13 +19,21 @@ abstract class AppDatabase : RoomDatabase() {
@Volatile @Volatile
private var INSTANCE: AppDatabase? = null 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 { fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) { return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder( val instance = Room.databaseBuilder(
context.applicationContext, context.applicationContext,
AppDatabase::class.java, AppDatabase::class.java,
"swoosh_database" "swoosh_database"
).build() )
.addMigrations(MIGRATION_1_2)
.build()
INSTANCE = instance INSTANCE = instance
instance instance
} }

View file

@ -36,6 +36,7 @@ data class GhostPost(
val mobiledoc: String? = null, val mobiledoc: String? = null,
val status: String? = null, val status: String? = null,
val feature_image: String? = null, val feature_image: String? = null,
val feature_image_alt: String? = null,
val created_at: String? = null, val created_at: String? = null,
val updated_at: String? = null, val updated_at: String? = null,
val published_at: String? = null, val published_at: String? = null,
@ -66,6 +67,7 @@ data class LocalPost(
val linkTitle: String? = null, val linkTitle: String? = null,
val linkDescription: String? = null, val linkDescription: String? = null,
val linkImageUrl: String? = null, val linkImageUrl: String? = null,
val imageAlt: String? = null,
val scheduledAt: String? = null, val scheduledAt: String? = null,
val createdAt: Long = System.currentTimeMillis(), val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(), val updatedAt: Long = System.currentTimeMillis(),
@ -95,6 +97,7 @@ data class FeedPost(
val textContent: String, val textContent: String,
val htmlContent: String?, val htmlContent: String?,
val imageUrl: String?, val imageUrl: String?,
val imageAlt: String? = null,
val linkUrl: String?, val linkUrl: String?,
val linkTitle: String?, val linkTitle: String?,
val linkDescription: String?, val linkDescription: String?,

View file

@ -3,8 +3,10 @@ package com.swoosh.microblog.ui.composer
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale 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.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
@ -37,6 +43,7 @@ fun ComposerScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
var showLinkDialog by remember { mutableStateOf(false) } var showLinkDialog by remember { mutableStateOf(false) }
var showDatePicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) }
var showAltTextDialog by remember { mutableStateOf(false) }
var linkInput by remember { mutableStateOf("") } var linkInput by remember { mutableStateOf("") }
// Load post for editing // Load post for editing
@ -120,11 +127,14 @@ fun ComposerScreen(
Box { Box {
AsyncImage( AsyncImage(
model = state.imageUri, model = state.imageUri,
contentDescription = "Selected image", contentDescription = state.imageAlt.ifBlank { "Selected image" },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .height(200.dp)
.clip(MaterialTheme.shapes.medium), .clip(MaterialTheme.shapes.medium)
.semantics {
contentDescription = state.imageAlt.ifBlank { "Selected image" }
},
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
IconButton( IconButton(
@ -136,6 +146,36 @@ fun ComposerScreen(
tint = MaterialTheme.colorScheme.onSurface 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 } 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) @OptIn(ExperimentalMaterial3Api::class)

View file

@ -35,6 +35,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
it.copy( it.copy(
text = post.textContent, text = post.textContent,
imageUri = post.imageUrl?.let { url -> Uri.parse(url) }, imageUri = post.imageUrl?.let { url -> Uri.parse(url) },
imageAlt = post.imageAlt ?: "",
linkPreview = if (post.linkUrl != null) LinkPreview( linkPreview = if (post.linkUrl != null) LinkPreview(
url = post.linkUrl, url = post.linkUrl,
title = post.linkTitle, title = post.linkTitle,
@ -51,7 +52,17 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
} }
fun setImage(uri: Uri?) { 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) { fun fetchLinkPreview(url: String) {
@ -89,6 +100,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
val title = state.text.take(60) val title = state.text.take(60)
val altText = state.imageAlt.ifBlank { null }
if (status == PostStatus.DRAFT || !repository.isNetworkAvailable()) { if (status == PostStatus.DRAFT || !repository.isNetworkAvailable()) {
// Save locally // Save locally
val localPost = LocalPost( val localPost = LocalPost(
@ -98,6 +111,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
content = state.text, content = state.text,
status = status, status = status,
imageUri = state.imageUri?.toString(), imageUri = state.imageUri?.toString(),
imageAlt = altText,
linkUrl = state.linkPreview?.url, linkUrl = state.linkPreview?.url,
linkTitle = state.linkPreview?.title, linkTitle = state.linkPreview?.title,
linkDescription = state.linkPreview?.description, 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( val ghostPost = GhostPost(
title = title, title = title,
mobiledoc = mobiledoc, mobiledoc = mobiledoc,
status = status.name.lowercase(), status = status.name.lowercase(),
feature_image = featureImage, feature_image = featureImage,
feature_image_alt = altText,
published_at = state.scheduledAt, published_at = state.scheduledAt,
visibility = "public" visibility = "public"
) )
@ -159,6 +181,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
status = status, status = status,
imageUri = state.imageUri?.toString(), imageUri = state.imageUri?.toString(),
uploadedImageUrl = featureImage, uploadedImageUrl = featureImage,
imageAlt = altText,
linkUrl = state.linkPreview?.url, linkUrl = state.linkPreview?.url,
linkTitle = state.linkPreview?.title, linkTitle = state.linkPreview?.title,
linkDescription = state.linkPreview?.description, linkDescription = state.linkPreview?.description,
@ -185,6 +208,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
data class ComposerUiState( data class ComposerUiState(
val text: String = "", val text: String = "",
val imageUri: Uri? = null, val imageUri: Uri? = null,
val imageAlt: String = "",
val linkPreview: LinkPreview? = null, val linkPreview: LinkPreview? = null,
val isLoadingLink: Boolean = false, val isLoadingLink: Boolean = false,
val scheduledAt: String? = null, val scheduledAt: String? = null,

View file

@ -12,6 +12,9 @@ import androidx.compose.runtime.*
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.layout.ContentScale 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 androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
@ -81,12 +84,26 @@ fun DetailScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
AsyncImage( AsyncImage(
model = post.imageUrl, model = post.imageUrl,
contentDescription = "Post image", contentDescription = post.imageAlt ?: "Post image",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(MaterialTheme.shapes.medium), .clip(MaterialTheme.shapes.medium)
.semantics {
contentDescription = post.imageAlt ?: "Post image"
},
contentScale = ContentScale.FillWidth 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 // Link preview

View file

@ -1,10 +1,12 @@
package com.swoosh.microblog.ui.feed package com.swoosh.microblog.ui.feed
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
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.Add
@ -20,9 +22,13 @@ 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.layout.ContentScale 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
@ -251,15 +257,56 @@ fun PostCard(
// Image thumbnail // Image thumbnail
if (post.imageUrl != null) { if (post.imageUrl != null) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
var showAltPopup by remember { mutableStateOf(false) }
Box {
AsyncImage( AsyncImage(
model = post.imageUrl, model = post.imageUrl,
contentDescription = "Post image", contentDescription = post.imageAlt ?: "Post image",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(180.dp) .height(180.dp)
.clip(MaterialTheme.shapes.medium), .clip(MaterialTheme.shapes.medium)
.semantics {
contentDescription = post.imageAlt ?: "Post image"
},
contentScale = ContentScale.Crop 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 // Link preview

View file

@ -144,6 +144,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "", textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "",
htmlContent = html, htmlContent = html,
imageUrl = feature_image, imageUrl = feature_image,
imageAlt = feature_image_alt,
linkUrl = null, linkUrl = null,
linkTitle = null, linkTitle = null,
linkDescription = null, linkDescription = null,
@ -162,6 +163,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
textContent = content, textContent = content,
htmlContent = htmlContent, htmlContent = htmlContent,
imageUrl = uploadedImageUrl ?: imageUri, imageUrl = uploadedImageUrl ?: imageUri,
imageAlt = imageAlt,
linkUrl = linkUrl, linkUrl = linkUrl,
linkTitle = linkTitle, linkTitle = linkTitle,
linkDescription = linkDescription, linkDescription = linkDescription,

View file

@ -37,7 +37,10 @@ class PostUploadWorker(
featureImage = imageResult.getOrNull() 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( val ghostPost = GhostPost(
title = post.title, title = post.title,
@ -48,6 +51,7 @@ class PostUploadWorker(
else -> "draft" else -> "draft"
}, },
feature_image = featureImage, feature_image = featureImage,
feature_image_alt = post.imageAlt,
published_at = post.scheduledAt, published_at = post.scheduledAt,
visibility = "public" visibility = "public"
) )

View file

@ -251,4 +251,161 @@ class MobiledocBuilderTest {
fun `escapeForJson handles empty string`() { fun `escapeForJson handles empty string`() {
assertEquals("", MobiledocBuilder.escapeForJson("")) 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.mobiledoc)
assertNull(post.status) assertNull(post.status)
assertNull(post.feature_image) assertNull(post.feature_image)
assertNull(post.feature_image_alt)
assertNull(post.created_at) assertNull(post.created_at)
assertNull(post.updated_at) assertNull(post.updated_at)
assertNull(post.published_at) assertNull(post.published_at)
@ -252,4 +253,108 @@ class GhostModelsTest {
assertNull(preview.description) assertNull(preview.description)
assertNull(preview.imageUrl) 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\""))
}
} }