This commit is contained in:
7 files changed
+257
-93
No files matched your search
+31
@@ -1,8 +1,10 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
@@ -30,4 +32,33 @@ class MarkdownHeadingRenderingTest {
|
||||
composeTestRule.onNodeWithText("Set foo now").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("**Set `foo` now**").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun quoteAndListContinuationRenderNestedCodeBlocks() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
MarkdownText(
|
||||
text =
|
||||
"""
|
||||
> # Quote heading
|
||||
> ```kotlin
|
||||
> val quoted = 1
|
||||
> ```
|
||||
|
||||
- Item
|
||||
```kotlin
|
||||
val listed = 2
|
||||
```
|
||||
""".trimIndent(),
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Quote heading").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Item").assertIsDisplayed()
|
||||
composeTestRule.onAllNodesWithTag("code_block").assertCountEquals(2)
|
||||
composeTestRule.onNodeWithText("val quoted = 1", substring = true).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("val listed = 2", substring = true).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -32,4 +40,26 @@ class MarkdownTableRenderingTest {
|
||||
composeTestRule.onNodeWithText("**Name**").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("`code`").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun columnsSizeToContentAndStayAlignedAcrossRows() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
Box(Modifier.width(220.dp)) {
|
||||
MarkdownText(
|
||||
text = "| ID | Description |\n| --- | --- |\n| A | A considerably longer table value |",
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val idHeader = composeTestRule.onNodeWithTag("markdown_table_header_0").getUnclippedBoundsInRoot()
|
||||
val descHeader = composeTestRule.onNodeWithTag("markdown_table_header_1").getUnclippedBoundsInRoot()
|
||||
val idCell = composeTestRule.onNodeWithTag("markdown_table_cell_0_0").getUnclippedBoundsInRoot()
|
||||
val descCell = composeTestRule.onNodeWithTag("markdown_table_cell_0_1").getUnclippedBoundsInRoot()
|
||||
assertTrue(descHeader.right - descHeader.left > idHeader.right - idHeader.left)
|
||||
assertEquals(idHeader.right - idHeader.left, idCell.right - idCell.left)
|
||||
assertEquals(descHeader.right - descHeader.left, descCell.right - descCell.left)
|
||||
}
|
||||
}
|
||||
@@ -152,14 +152,14 @@ fun MarkdownText(
|
||||
}
|
||||
|
||||
is MdBlock.Heading -> {
|
||||
val fontSize =
|
||||
val headingStyle =
|
||||
when (block.level) {
|
||||
1 -> 22.sp
|
||||
2 -> 20.sp
|
||||
3 -> 18.sp
|
||||
4 -> 16.sp
|
||||
5 -> 15.sp
|
||||
else -> 14.sp
|
||||
1 -> MaterialTheme.typography.headlineMedium
|
||||
2 -> MaterialTheme.typography.headlineSmall
|
||||
3 -> MaterialTheme.typography.titleMedium
|
||||
4 -> MaterialTheme.typography.titleSmall
|
||||
5 -> MaterialTheme.typography.labelLarge
|
||||
else -> MaterialTheme.typography.labelMedium
|
||||
}
|
||||
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
|
||||
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
@@ -169,12 +169,10 @@ fun MarkdownText(
|
||||
textColor = textColor,
|
||||
latexMeasurer = latexMeasurer,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium
|
||||
.copy(
|
||||
fontSize = fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textDirection = bidiTextDirection(isRtl),
|
||||
),
|
||||
headingStyle.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
textDirection = bidiTextDirection(isRtl),
|
||||
),
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
@@ -209,20 +207,30 @@ fun MarkdownText(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
)
|
||||
MarkdownInlineText(
|
||||
text = block.text,
|
||||
textColor = textColor,
|
||||
latexMeasurer = latexMeasurer,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
textDirection = bidiTextDirection(isRtl),
|
||||
),
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
MarkdownInlineText(
|
||||
text = block.text,
|
||||
textColor = textColor,
|
||||
latexMeasurer = latexMeasurer,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
textDirection = bidiTextDirection(isRtl),
|
||||
),
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
)
|
||||
if (block.nestedSource.isNotEmpty()) {
|
||||
MarkdownText(
|
||||
text = block.nestedSource,
|
||||
textColor = textColor,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,20 +266,30 @@ fun MarkdownText(
|
||||
},
|
||||
modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp),
|
||||
)
|
||||
MarkdownInlineText(
|
||||
text = block.text,
|
||||
textColor = textColor,
|
||||
latexMeasurer = latexMeasurer,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
textDirection = bidiTextDirection(isRtl),
|
||||
),
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
MarkdownInlineText(
|
||||
text = block.text,
|
||||
textColor = textColor,
|
||||
latexMeasurer = latexMeasurer,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
textDirection = bidiTextDirection(isRtl),
|
||||
),
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
)
|
||||
if (block.nestedSource.isNotEmpty()) {
|
||||
MarkdownText(
|
||||
text = block.nestedSource,
|
||||
textColor = textColor,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,20 +313,30 @@ fun MarkdownText(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
)
|
||||
MarkdownInlineText(
|
||||
text = block.text,
|
||||
textColor = textColor,
|
||||
latexMeasurer = latexMeasurer,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
textDirection = bidiTextDirection(isRtl),
|
||||
),
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
MarkdownInlineText(
|
||||
text = block.text,
|
||||
textColor = textColor,
|
||||
latexMeasurer = latexMeasurer,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
textDirection = bidiTextDirection(isRtl),
|
||||
),
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
)
|
||||
if (block.nestedSource.isNotEmpty()) {
|
||||
MarkdownText(
|
||||
text = block.nestedSource,
|
||||
textColor = textColor,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,21 +358,33 @@ fun MarkdownText(
|
||||
.background(textColor.copy(alpha = 0.35f)),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
MarkdownInlineText(
|
||||
text = block.text,
|
||||
textColor = textColor,
|
||||
latexMeasurer = latexMeasurer,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
textDirection = bidiTextDirection(isRtl),
|
||||
),
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
val nestedBlocks = remember(block.text) { parseBlocks(block.text) }
|
||||
if (nestedBlocks.size == 1 && nestedBlocks.single() is MdBlock.Paragraph) {
|
||||
MarkdownInlineText(
|
||||
text = block.text,
|
||||
textColor = textColor,
|
||||
latexMeasurer = latexMeasurer,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
textDirection = bidiTextDirection(isRtl),
|
||||
),
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
MarkdownText(
|
||||
text = block.text,
|
||||
textColor = textColor,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
modifier = Modifier.weight(1f),
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,18 +18,21 @@ sealed interface MdBlock {
|
||||
data class Bullet(
|
||||
val text: String,
|
||||
val level: Int = 0,
|
||||
val nestedSource: String = "",
|
||||
) : MdBlock
|
||||
|
||||
data class Task(
|
||||
val checked: Boolean,
|
||||
val text: String,
|
||||
val level: Int = 0,
|
||||
val nestedSource: String = "",
|
||||
) : MdBlock
|
||||
|
||||
data class Ordered(
|
||||
val index: Int,
|
||||
val text: String,
|
||||
val level: Int = 0,
|
||||
val nestedSource: String = "",
|
||||
) : MdBlock
|
||||
|
||||
data class Image(
|
||||
|
||||
@@ -164,7 +164,7 @@ fun parseBlocks(src: String): List<MdBlock> {
|
||||
line.startsWith(">") -> {
|
||||
val quote = mutableListOf<String>()
|
||||
while (i < lines.size && lines[i].startsWith(">")) {
|
||||
quote.add(lines[i].removePrefix(">").trim())
|
||||
quote.add(lines[i].removePrefix(">").removePrefix(" "))
|
||||
i++
|
||||
}
|
||||
blocks.add(MdBlock.Quote(quote.joinToString("\n")))
|
||||
@@ -287,6 +287,11 @@ private fun parseList(
|
||||
}
|
||||
if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) {
|
||||
j = lookahead
|
||||
} else if (items.isNotEmpty() && lookahead < lines.size &&
|
||||
computeIndent(lines[lookahead].takeWhile { it == ' ' || it == '\t' }) >= 2
|
||||
) {
|
||||
items.last().continuationLines.add("")
|
||||
j = lookahead
|
||||
} else {
|
||||
break
|
||||
}
|
||||
@@ -334,32 +339,34 @@ private fun parseList(
|
||||
level = indentStack.size - 1
|
||||
}
|
||||
|
||||
val fullText =
|
||||
if (item.continuationLines.isEmpty()) {
|
||||
when (item) {
|
||||
is ParsedItem.Bullet -> item.text
|
||||
is ParsedItem.Task -> item.text
|
||||
is ParsedItem.Ordered -> item.text
|
||||
}
|
||||
val base =
|
||||
when (item) {
|
||||
is ParsedItem.Bullet -> item.text
|
||||
is ParsedItem.Task -> item.text
|
||||
is ParsedItem.Ordered -> item.text
|
||||
}
|
||||
val nestedSource =
|
||||
if (item.continuationLines.any(::startsNestedBlock)) {
|
||||
item.continuationLines.joinToString("\n")
|
||||
} else {
|
||||
val base =
|
||||
when (item) {
|
||||
is ParsedItem.Bullet -> item.text
|
||||
is ParsedItem.Task -> item.text
|
||||
is ParsedItem.Ordered -> item.text
|
||||
}
|
||||
base + " " + item.continuationLines.joinToString(" ")
|
||||
""
|
||||
}
|
||||
val fullText =
|
||||
if (nestedSource.isNotEmpty()) {
|
||||
base
|
||||
} else {
|
||||
(listOf(base) + item.continuationLines).joinToString(" ").trim()
|
||||
}
|
||||
|
||||
when (item) {
|
||||
is ParsedItem.Bullet -> {
|
||||
blocks.add(MdBlock.Bullet(fullText, level = level))
|
||||
blocks.add(MdBlock.Bullet(fullText, level = level, nestedSource = nestedSource))
|
||||
lastItemWasOrdered = false
|
||||
lastLevel = level
|
||||
}
|
||||
|
||||
is ParsedItem.Task -> {
|
||||
blocks.add(MdBlock.Task(item.checked, fullText, level = level))
|
||||
blocks.add(MdBlock.Task(item.checked, fullText, level = level, nestedSource = nestedSource))
|
||||
lastItemWasOrdered = false
|
||||
lastLevel = level
|
||||
}
|
||||
@@ -377,7 +384,7 @@ private fun parseList(
|
||||
currentSequenceNumber = item.rawNumber
|
||||
currentSequenceNumber
|
||||
}
|
||||
blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level))
|
||||
blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level, nestedSource = nestedSource))
|
||||
lastItemWasOrdered = true
|
||||
lastLevel = level
|
||||
}
|
||||
@@ -387,6 +394,15 @@ private fun parseList(
|
||||
return blocks to j
|
||||
}
|
||||
|
||||
/** Preserve block syntax in indented list continuations instead of flattening it into prose. */
|
||||
private fun startsNestedBlock(line: String): Boolean =
|
||||
parseCodeFenceStart(line) != null ||
|
||||
isValidHeading(line) ||
|
||||
line.startsWith(">") ||
|
||||
isHorizontalRule(line) ||
|
||||
line.startsWith("$$") ||
|
||||
line.startsWith("\\[")
|
||||
|
||||
private fun isHorizontalRule(line: String): Boolean {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.length < 3) return false
|
||||
|
||||
@@ -16,8 +16,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -25,8 +28,6 @@ import com.hrm.latex.renderer.measure.LatexMeasurerState
|
||||
import com.m57.hermescontrol.theme.SearchHighlightColors
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
|
||||
private val TABLE_COL_WIDTH = 140.dp
|
||||
|
||||
@Composable
|
||||
fun MarkdownTable(
|
||||
block: MdBlock.Table,
|
||||
@@ -46,6 +47,25 @@ fun MarkdownTable(
|
||||
val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
val headerBg = textColor.copy(alpha = 0.08f)
|
||||
val alignments = block.alignments
|
||||
val cellStyle = MaterialTheme.typography.bodySmall
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val columnWidths =
|
||||
remember(block, cellStyle, density, textMeasurer) {
|
||||
val padding = with(density) { 12.dp.roundToPx() }
|
||||
val minimum = with(density) { 48.dp.roundToPx() }
|
||||
val maximum = with(density) { 280.dp.roundToPx() }
|
||||
val columnCount = maxOf(block.header.size, block.rows.maxOfOrNull { it.size } ?: 0)
|
||||
List(columnCount) { column ->
|
||||
val cells =
|
||||
listOfNotNull(block.header.getOrNull(column)) + block.rows.mapNotNull { it.getOrNull(column) }
|
||||
val contentWidth =
|
||||
cells.maxOfOrNull { cell ->
|
||||
textMeasurer.measure(text = cell.take(128), style = cellStyle, maxLines = 1).size.width
|
||||
} ?: 0
|
||||
with(density) { (contentWidth + padding).coerceIn(minimum, maximum).toDp() }
|
||||
}
|
||||
}
|
||||
CompositionLocalProvider(LocalLayoutDirection provides tableDirection) {
|
||||
Column(
|
||||
modifier =
|
||||
@@ -72,14 +92,15 @@ fun MarkdownTable(
|
||||
highlights = highlights,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(TABLE_COL_WIDTH)
|
||||
.width(columnWidths[idx])
|
||||
.testTag("markdown_table_header_$idx")
|
||||
.padding(6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = textColor.copy(alpha = 0.25f))
|
||||
// Body rows
|
||||
block.rows.forEach { row ->
|
||||
block.rows.forEachIndexed { rowIndex, row ->
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
row.forEachIndexed { idx, cell ->
|
||||
MarkdownInlineText(
|
||||
@@ -96,7 +117,8 @@ fun MarkdownTable(
|
||||
highlights = highlights,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(TABLE_COL_WIDTH)
|
||||
.width(columnWidths[idx])
|
||||
.testTag("markdown_table_cell_${rowIndex}_$idx")
|
||||
.padding(6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -459,6 +459,28 @@ class MarkdownTextFeatureTest {
|
||||
assertEquals(0, o16.level)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun quotedBlocksRetainNestedHeadingsListsAndFences() {
|
||||
val source = "> # Heading\n> - item\n> ```kotlin\n> val x = 1\n> ```"
|
||||
val quote = parseBlocks(source).single() as MdBlock.Quote
|
||||
val nested = parseBlocks(quote.text)
|
||||
assertTrue(nested.any { it is MdBlock.Heading && it.text == "Heading" })
|
||||
val bullet = nested.filterIsInstance<MdBlock.Bullet>().single()
|
||||
assertEquals("item", bullet.text)
|
||||
assertEquals("val x = 1", (parseBlocks(bullet.nestedSource).single() as MdBlock.Code).code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listItemsRetainFencedCodeAndHeadingsAsNestedBlocks() {
|
||||
val source = "- intro\n ```kotlin\n val x = 1\n ```\n # Inside\n- next"
|
||||
val bullets = parseBlocks(source).filterIsInstance<MdBlock.Bullet>()
|
||||
assertEquals(listOf("intro", "next"), bullets.map { it.text })
|
||||
val nested = parseBlocks(bullets.first().nestedSource)
|
||||
assertTrue(nested.any { it is MdBlock.Code && it.code == "val x = 1" })
|
||||
assertTrue(nested.any { it is MdBlock.Heading && it.text == "Inside" })
|
||||
assertEquals("", bullets.last().nestedSource)
|
||||
}
|
||||
|
||||
// 19. NESTED CODEBLOCKS & EXTENDED CODE FENCES
|
||||
@Test
|
||||
fun testNestedCodeBlock_fourBackticksWrappingThreeBackticks() {
|
||||
|
||||
Reference in new issue
Block a user