Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import com.interlinedlist.android.core.materialize.domain.RowDataStyle
* field list, so showing renamed list columns here would promise something the
* request does not ask for.
*
* A source that already **is** a document short-circuits all of that: when the
* entry point supplies [MaterializePreview.documentMarkdown] the destination
* copies it through, so it is shown verbatim rather than re-rendered from rows.
*
* The result is a preview, not the document: the server re-derives the real one
* from its own copy of the source.
*/
Expand All @@ -20,6 +24,16 @@ internal fun renderDocumentPreview(
preview: MaterializePreview,
listStyle: DocumentListStyle,
rowDataStyle: RowDataStyle,
): String {
preview.documentMarkdown?.takeIf { it.isNotBlank() }?.let { return it.trimEnd() }
return renderRowsAsDocument(title, preview, listStyle, rowDataStyle)
}

private fun renderRowsAsDocument(
title: String,
preview: MaterializePreview,
listStyle: DocumentListStyle,
rowDataStyle: RowDataStyle,
): String = buildString {
if (title.isNotBlank()) appendLine("# ${title.trim()}").appendLine()
preview.suggestedDescription?.takeIf { it.isNotBlank() }?.let {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ object MaterializeWindowTestTags {
const val ADD_COLUMN = "materializeAddColumn"
const val TABLE_PREVIEW = "materializeTablePreview"
const val DOCUMENT_PREVIEW = "materializeDocumentPreview"
const val DRAFT_PREVIEW = "materializeDraftPreview"
const val FILE_NAME = "materializeFileName"
const val CONFIRM = "materializeConfirm"
const val CANCEL = "materializeCancel"
Expand Down Expand Up @@ -286,7 +287,7 @@ private fun EditorPane(
Banners(state)

if (state.createsDraft) {
DraftNotice()
DraftNotice(state.preview.draftBody)
return@Column
}

Expand Down Expand Up @@ -373,7 +374,7 @@ private fun Banners(state: MaterializeWindowUiState) {

/** The one destination that creates nothing: it hands the composer a draft. */
@Composable
private fun DraftNotice() {
private fun DraftNotice(draftBody: String?) {
Card {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Opens in the composer", style = MaterialTheme.typography.titleMedium)
Expand All @@ -385,6 +386,18 @@ private fun DraftNotice() {
)
}
}
// What the source reads as, when the entry point could work it out. The
// server still builds and sizes the body it hands the composer, so this is
// shown as a preview and never sent.
draftBody?.takeIf { it.isNotBlank() }?.let { body ->
Card(Modifier.testTag(MaterializeWindowTestTags.DRAFT_PREVIEW)) {
Text(
text = body,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
)
}
}
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@ data class MaterializePreview(
val totalRowCount: Int = rows.size,
/** The account's `defaultPubliclyVisible`, so the toggle opens where the user expects. */
val defaultIsPublic: Boolean = false,
/**
* The markdown a `doc` destination copies through, when the entry point
* already has it verbatim. A document source is copied, not rendered from
* rows, so laying [rows] out as bullets would misdescribe what gets created.
*
* Display only, like the rest of this class: the server re-derives the real
* document from its own copy of the source.
*/
val documentMarkdown: String? = null,
/**
* The plain-text body a `message` destination would produce, when the entry
* point can derive it — so the draft is visible before it is asked for.
*
* Display only, and deliberately **not** sent: `MaterializeRequest.ToMessageDraft`
* carries no `content`, leaving the server to build the body and size it
* against the account's own limit.
*/
val draftBody: String? = null,
) {
/** How many rows exist beyond the ones being shown. */
val hiddenRowCount: Int get() = (totalRowCount - rows.size).coerceAtLeast(0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ class MaterializeWindowViewModel @Inject constructor(
_uiState.value = state
}

/**
* Ends the flow: the next [start] opens a clean window even if it is handed
* the identical launch.
*
* [start] deliberately keeps the edits when it is re-entered with the same
* launch, so a recomposition or a rotation does not throw the user's work
* away. That leaves the host to say when the flow is actually over — closing
* the window — because only the host can tell the two apart.
*/
fun reset() {
activeLaunch = null
nextUiId = 0L
_uiState.value = null
}

/**
* Switches destination from inside the window. Nothing is discarded here:
* the edits stay in state and [MaterializeWindowUiState.toRequest] decides
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,33 @@ class DocumentPreviewTest {

assertThat(rendered).isEqualTo("# Launch notes")
}

@Test
fun `a source that is already a document is previewed verbatim`() {
val markdown = "# Launch notes\n\n- Ship it\n- Tell everyone\n"

val rendered = renderDocumentPreview(
title = "Copy of Launch notes",
preview = preview.copy(documentMarkdown = markdown),
listStyle = DocumentListStyle.NUMBERED,
rowDataStyle = RowDataStyle.SUB_ITEMS,
)

// The rows and the edited title are ignored: this destination copies the
// source document through rather than rendering a table as bullets.
assertThat(rendered).isEqualTo("# Launch notes\n\n- Ship it\n- Tell everyone")
assertThat(rendered).doesNotContain("The Dream Machine")
}

@Test
fun `a blank verbatim markdown falls back to rendering the rows`() {
val rendered = renderDocumentPreview(
title = "Books to Read",
preview = preview.copy(documentMarkdown = " "),
listStyle = DocumentListStyle.BULLETED,
rowDataStyle = RowDataStyle.INLINE,
)

assertThat(rendered).contains("- The Dream Machine")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,57 @@ class MaterializeWindowViewModelTest {
assertThat(success.canOpenDocument).isFalse()
}

@Test
fun `reopening after a reset starts a clean window`() = runTest(dispatcher) {
repository.result = ApiResult.Success(
MaterializeOutcome.ListCreated(MaterializedList("lst_new", "Books to Read")),
)
val vm = viewModel()
vm.updateTitle("Edited title")
vm.confirm()
advanceUntilIdle()
assertThat(vm.state.success).isNotNull()

// The host closed the window: the flow is over.
vm.reset()
assertThat(vm.uiState.value).isNull()

// The identical launch must not resurrect the finished one.
vm.start(MaterializeLaunch(source, MaterializeTarget.LIST, preview()))

assertThat(vm.state.success).isNull()
assertThat(vm.state.title).isEqualTo("Books to Read")
}

@Test
fun `a previewed draft body is shown but never sent`() = runTest(dispatcher) {
repository.result = ApiResult.Success(
MaterializeOutcome.DraftReady(
MessageDraft("Server copy", listOf("Server copy"), isThread = false, charLimit = 300),
),
)
val vm = MaterializeWindowViewModel(repository).also {
it.start(
MaterializeLaunch(
source = source,
initialTarget = MaterializeTarget.MESSAGE,
preview = preview().copy(draftBody = "Books to Read\n\nMy reading backlog."),
),
)
}

assertThat(vm.state.preview.draftBody).isEqualTo("Books to Read\n\nMy reading backlog.")

vm.confirm()
advanceUntilIdle()

// The composer must receive the body the server sized, not the one the
// entry point guessed, so the guess stays on the device.
val request = sentRequest() as MaterializeRequest.ToMessageDraft
assertThat(request.messageConfig).isNull()
assertThat(vm.state.success?.draft?.content).isEqualTo("Server copy")
}

@Test
fun `a messages source does not offer the message destination`() = runTest(dispatcher) {
val vm = viewModel(source = MaterializeSource.Messages(listOf("msg_1")))
Expand Down
5 changes: 5 additions & 0 deletions feature/documents/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ dependencies {
// feature modules that use them, which is what the Powered Document entry
// point on this surface is. No other feature module is depended on.
implementation(project(":feature:ai"))
// "Create from…": the shared materialize domain, repository and the one
// preview/edit/confirm window every entry point opens. It is a :core:
// capability, not a feature dependency — documents only supplies the source
// and the preview it can derive locally.
implementation(project(":core:materialize"))

implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package com.interlinedlist.android.feature.documents.ui

import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInputSelection
import androidx.compose.ui.text.TextRange
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
import com.interlinedlist.android.core.materialize.domain.MaterializeTarget
import com.interlinedlist.android.feature.documents.domain.Document
import com.interlinedlist.android.feature.documents.domain.FolderContents
import com.interlinedlist.android.feature.documents.domain.FolderNode
import com.interlinedlist.android.feature.documents.domain.FolderSummary
import com.interlinedlist.android.feature.documents.ui.browser.DocumentsBrowserScreen
import com.interlinedlist.android.feature.documents.ui.browser.DocumentsBrowserUiState
import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorScreen
import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorTestTags
import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorUiState
import com.interlinedlist.android.feature.documents.ui.materialize.CreateFromTestTags
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* The + Create entry points on a document: the browser row, the editor, and the
* editor's highlighted selection. What each one *builds* is covered by the JVM
* tests; this covers that the controls exist and raise the destination picked.
*/
@RunWith(AndroidJUnit4::class)
class CreateFromEntryPointsTest {

@get:Rule
val composeRule = createComposeRule()

private val document = Document(
id = "d1",
title = "Launch plan",
content = "# Launch plan\n- Ship it",
snippet = "Launch plan",
folderId = null,
folderName = null,
isPublic = false,
updatedAt = null,
)

@Test
fun browserRow_createMenu_raisesThePickedDestination() {
var picked: Pair<Document, MaterializeTarget>? = null
composeRule.setContent {
InterlinedListTheme {
DocumentsBrowserScreen(
state = DocumentsBrowserUiState(
isLoading = false,
contents = FolderContents(
folderId = FolderNode.ROOT_ID,
folderName = FolderNode.ROOT_NAME,
parentId = null,
subfolders = emptyList(),
documents = listOf(document),
breadcrumb = listOf(FolderSummary(FolderNode.ROOT_ID, FolderNode.ROOT_NAME)),
),
),
onOpenFolder = {},
onOpenDocument = {},
onCreateDocument = {},
onCreateFolder = {},
onRenameFolder = { _, _ -> },
onDeleteFolder = {},
onMoveDocument = { _, _ -> },
onDeleteDocument = {},
onOpenSearch = {},
onCloseSearch = {},
onSearchQueryChange = {},
onBack = null,
onCreateFrom = { doc, target -> picked = doc to target },
)
}
}

composeRule.onNodeWithTag(CreateFromTestTags.row("d1")).performClick()
composeRule.onNodeWithTag(CreateFromTestTags.target(MaterializeTarget.LIST)).performClick()

assertThat(picked).isEqualTo(document to MaterializeTarget.LIST)
}

@Test
fun editor_createMenu_raisesThePickedDestination() {
var picked: MaterializeTarget? = null
setEditorContent(onCreateFrom = { picked = it })

composeRule.onNodeWithTag(CreateFromTestTags.EDITOR).performClick()
composeRule.onNodeWithTag(CreateFromTestTags.target(MaterializeTarget.DOC)).performClick()

assertThat(picked).isEqualTo(MaterializeTarget.DOC)
}

@Test
fun editor_selectionAction_isHidden_untilSomethingIsHighlighted() {
setEditorContent()

composeRule.onAllNodesWithTag(DocumentEditorTestTags.SELECTION_BAR).assertCountEquals(0)
}

@OptIn(ExperimentalTestApi::class)
@Test
fun editor_selectionAction_raisesTheHighlightedMarkdown() {
var picked: Pair<String, MaterializeTarget>? = null
setEditorContent(onCreateFromSelection = { markdown, target -> picked = markdown to target })

// Highlight the "## Week one\n- Ship it" passage.
val body = "# Launch plan\n\n## Week one\n- Ship it"
val start = body.indexOf("## Week one")
composeRule.onNodeWithTag(DocumentEditorTestTags.BODY)
.performTextInputSelection(TextRange(start, body.length))

composeRule.onNodeWithTag(CreateFromTestTags.SELECTION).performClick()
composeRule.onNodeWithTag(CreateFromTestTags.target(MaterializeTarget.LIST)).performClick()

assertThat(picked).isEqualTo("## Week one\n- Ship it" to MaterializeTarget.LIST)
}

private fun setEditorContent(
onCreateFrom: (MaterializeTarget) -> Unit = {},
onCreateFromSelection: (String, MaterializeTarget) -> Unit = { _, _ -> },
) {
composeRule.setContent {
InterlinedListTheme {
DocumentEditorScreen(
state = DocumentEditorUiState(
documentId = "d1",
title = "Launch plan",
content = "# Launch plan\n\n## Week one\n- Ship it",
isLoading = false,
),
onTitleChange = {},
onContentChange = {},
onTogglePreview = {},
onSave = {},
onDelete = {},
onBack = {},
onCreateFrom = onCreateFrom,
onCreateFromSelection = onCreateFromSelection,
)
}
}
}
}
Loading
Loading