mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add alt text support for images with accessibility
This commit is contained in:
parent
74f42fd2f1
commit
202e25b572
11 changed files with 500 additions and 23 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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\""))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue