mirror of
https://github.com/anyproto/anytype-kotlin.git
synced 2025-06-11 10:18:05 +09:00
Merge branch 'develop' into feature/483_Custom_menu_scroll
This commit is contained in:
commit
54b0c75f1c
13 changed files with 1462 additions and 24 deletions
28
CHANGELOG.md
28
CHANGELOG.md
|
@ -1,6 +1,6 @@
|
|||
# Change log for Android @Anytype app.
|
||||
|
||||
## Version 0.0.34 (WIP)
|
||||
## Version 0.0.35 (WIP)
|
||||
|
||||
### New features 🚀
|
||||
|
||||
|
@ -12,15 +12,33 @@
|
|||
|
||||
### Fixes & tech 🚒
|
||||
|
||||
* Inconsistent behavior when merging two highlight blocks (#478)
|
||||
* Should preserve text style while splitting (#479)
|
||||
* Should focus and open keyboard when creating headers or highlight block (#485)
|
||||
* Enabled markup links (#200)
|
||||
* Should focus document's title when first paragraph (as the first block in the document) is deleted (#498)
|
||||
|
||||
### Middleware ⚙
|
||||
|
||||
*
|
||||
|
||||
## Version 0.0.34
|
||||
|
||||
### New features 🚀
|
||||
|
||||
* Enabled markup for headers and highlight blocks (#480)
|
||||
|
||||
### Design & UX 🔳
|
||||
|
||||
* New screen for debug settings (#492)
|
||||
* Custom context menu. First iteration available only in debug mode (#430)
|
||||
|
||||
### Fixes & tech 🚒
|
||||
|
||||
* Enabled markup links (#200)
|
||||
* Added UI and integrations tests for basic CRUD, split and merge operations in editor (#497)
|
||||
* Better control over cursor position while CRUD, split and merge operations in editor (#491)
|
||||
* Fix incorrect cursor positioning while deleting an empty block (#493)
|
||||
* Fix Inconsistent behavior when merging two highlight blocks (#478)
|
||||
* Should preserve text style while splitting (#479)
|
||||
* Should focus and open keyboard when creating headers or highlight block (#485)
|
||||
|
||||
## Version 0.0.33
|
||||
|
||||
### New features 🚀
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
version.versionMajor=0
|
||||
version.versionMinor=0
|
||||
version.versionPatch=33
|
||||
version.versionPatch=34
|
||||
|
|
|
@ -0,0 +1,264 @@
|
|||
package com.agileburo.anytype.features.editor
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.testing.FragmentScenario
|
||||
import androidx.fragment.app.testing.launchFragmentInContainer
|
||||
import androidx.test.espresso.Espresso
|
||||
import androidx.test.espresso.action.ViewActions
|
||||
import androidx.test.espresso.assertion.ViewAssertions
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.LargeTest
|
||||
import com.agileburo.anytype.R
|
||||
import com.agileburo.anytype.core_ui.widgets.text.TextInputWidget
|
||||
import com.agileburo.anytype.domain.base.Either
|
||||
import com.agileburo.anytype.domain.block.interactor.CreateBlock
|
||||
import com.agileburo.anytype.domain.block.model.Block
|
||||
import com.agileburo.anytype.domain.block.model.Position
|
||||
import com.agileburo.anytype.domain.event.model.Event
|
||||
import com.agileburo.anytype.domain.event.model.Payload
|
||||
import com.agileburo.anytype.features.editor.base.EditorTestSetup
|
||||
import com.agileburo.anytype.features.editor.base.TestPageFragment
|
||||
import com.agileburo.anytype.mocking.MockDataFactory
|
||||
import com.agileburo.anytype.presentation.page.PageViewModel
|
||||
import com.agileburo.anytype.ui.page.PageFragment
|
||||
import com.agileburo.anytype.utils.CoroutinesTestRule
|
||||
import com.agileburo.anytype.utils.TestUtils
|
||||
import com.bartoszlipinski.disableanimationsrule.DisableAnimationsRule
|
||||
import com.nhaarman.mockitokotlin2.doReturn
|
||||
import com.nhaarman.mockitokotlin2.stub
|
||||
import com.nhaarman.mockitokotlin2.times
|
||||
import com.nhaarman.mockitokotlin2.verifyBlocking
|
||||
import kotlinx.android.synthetic.main.fragment_page.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@LargeTest
|
||||
class CreateBlockTesting : EditorTestSetup() {
|
||||
|
||||
@get:Rule
|
||||
val animationsRule = DisableAnimationsRule()
|
||||
|
||||
@get:Rule
|
||||
val coroutineTestRule = CoroutinesTestRule()
|
||||
|
||||
val args = bundleOf(PageFragment.ID_KEY to root)
|
||||
|
||||
@Before
|
||||
override fun setup() {
|
||||
super.setup()
|
||||
}
|
||||
|
||||
//region SCENARIO 1
|
||||
|
||||
/**
|
||||
* SCENARIO I
|
||||
* SHOULD CREATE A NEW PARAGRAPH BY PRESSING ENTER INSIDE ANY EMPTY TEXT BLOCK EXCEPT LISTS
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun createNewParagraphByPressingEnterInsideEmptyParagraph() {
|
||||
createNewParagraphByPressingEnterInsideAnyEmptyTextBlockExceptLists(
|
||||
targetStyle = Block.Content.Text.Style.P,
|
||||
targetViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createNewParagraphByPressingEnterInsideEmptyH1() {
|
||||
createNewParagraphByPressingEnterInsideAnyEmptyTextBlockExceptLists(
|
||||
targetStyle = Block.Content.Text.Style.H1,
|
||||
targetViewId = R.id.headerOne
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createNewParagraphByPressingEnterInsideEmptyH2() {
|
||||
createNewParagraphByPressingEnterInsideAnyEmptyTextBlockExceptLists(
|
||||
targetStyle = Block.Content.Text.Style.H2,
|
||||
targetViewId = R.id.headerTwo
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createNewParagraphByPressingEnterInsideEmptyH3() {
|
||||
createNewParagraphByPressingEnterInsideAnyEmptyTextBlockExceptLists(
|
||||
targetStyle = Block.Content.Text.Style.H3,
|
||||
targetViewId = R.id.headerThree
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createNewParagraphByPressingEnterInsideEmptyHighlight() {
|
||||
createNewParagraphByPressingEnterInsideAnyEmptyTextBlockExceptLists(
|
||||
targetStyle = Block.Content.Text.Style.QUOTE,
|
||||
targetViewId = R.id.highlightContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createNewParagraphByPressingEnterInsideEmptyToggle() {
|
||||
createNewParagraphByPressingEnterInsideAnyEmptyTextBlockExceptLists(
|
||||
targetStyle = Block.Content.Text.Style.TOGGLE,
|
||||
targetViewId = R.id.toggleContent
|
||||
)
|
||||
}
|
||||
|
||||
private fun createNewParagraphByPressingEnterInsideAnyEmptyTextBlockExceptLists(
|
||||
targetStyle: Block.Content.Text.Style,
|
||||
targetViewId: Int
|
||||
) {
|
||||
|
||||
// SETUP
|
||||
|
||||
val a = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields.empty(),
|
||||
children = emptyList(),
|
||||
content = Block.Content.Text(
|
||||
text = "",
|
||||
marks = emptyList(),
|
||||
style = targetStyle
|
||||
)
|
||||
)
|
||||
|
||||
val new = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields.empty(),
|
||||
children = emptyList(),
|
||||
content = Block.Content.Text(
|
||||
text = "",
|
||||
marks = emptyList(),
|
||||
style = Block.Content.Text.Style.P
|
||||
)
|
||||
)
|
||||
|
||||
val page = Block(
|
||||
id = root,
|
||||
fields = Block.Fields(emptyMap()),
|
||||
content = Block.Content.Smart(
|
||||
type = Block.Content.Smart.Type.PAGE
|
||||
),
|
||||
children = listOf(a.id)
|
||||
)
|
||||
|
||||
val document = listOf(page, a)
|
||||
|
||||
val events = listOf(
|
||||
Event.Command.UpdateStructure(
|
||||
context = root,
|
||||
id = root,
|
||||
children = listOf(a.id, new.id)
|
||||
),
|
||||
Event.Command.AddBlock(
|
||||
context = root,
|
||||
blocks = listOf(new)
|
||||
)
|
||||
)
|
||||
|
||||
val params = CreateBlock.Params(
|
||||
context = root,
|
||||
position = Position.BOTTOM,
|
||||
target = a.id,
|
||||
prototype = Block.Prototype.Text(
|
||||
style = Block.Content.Text.Style.P
|
||||
)
|
||||
)
|
||||
|
||||
stubInterceptEvents()
|
||||
stubOpenDocument(document)
|
||||
stubUpdateText()
|
||||
stubCreateBlocks(params, new, events)
|
||||
|
||||
val scenario = launchFragment(args)
|
||||
|
||||
// TESTING
|
||||
|
||||
val target = Espresso.onView(
|
||||
TestUtils.withRecyclerView(R.id.recycler).atPositionOnView(1, targetViewId)
|
||||
)
|
||||
|
||||
target.apply {
|
||||
perform(ViewActions.click())
|
||||
}
|
||||
|
||||
// Press ENTER on empty text block A
|
||||
|
||||
target.perform(ViewActions.pressImeActionButton())
|
||||
|
||||
// Check results
|
||||
|
||||
verifyBlocking(createBlock, times(1)) { invoke(params) }
|
||||
|
||||
Espresso.onView(
|
||||
TestUtils.withRecyclerView(R.id.recycler).atPositionOnView(1, targetViewId)
|
||||
).apply {
|
||||
check(ViewAssertions.matches(ViewMatchers.withText("")))
|
||||
}
|
||||
|
||||
Espresso.onView(
|
||||
TestUtils.withRecyclerView(R.id.recycler).atPositionOnView(2, R.id.textContent)
|
||||
).apply {
|
||||
check(ViewAssertions.matches(ViewMatchers.withText("")))
|
||||
check(ViewAssertions.matches(ViewMatchers.hasFocus()))
|
||||
}
|
||||
|
||||
// Check cursor position at block B
|
||||
|
||||
scenario.onFragment { fragment ->
|
||||
val item = fragment.recycler.getChildAt(2)
|
||||
item.findViewById<TextInputWidget>(R.id.textContent).apply {
|
||||
assertEquals(
|
||||
expected = 0,
|
||||
actual = selectionStart
|
||||
)
|
||||
assertEquals(
|
||||
expected = 0,
|
||||
actual = selectionEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Release pending coroutines
|
||||
|
||||
advance(PageViewModel.TEXT_CHANGES_DEBOUNCE_DURATION)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
// STUBBING & SETUP
|
||||
|
||||
private fun stubCreateBlocks(
|
||||
params: CreateBlock.Params,
|
||||
new: Block,
|
||||
events: List<Event.Command>
|
||||
) {
|
||||
createBlock.stub {
|
||||
onBlocking {
|
||||
invoke(params)
|
||||
} doReturn Either.Right(
|
||||
Pair(new.id, Payload(context = root, events = events))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun launchFragment(args: Bundle): FragmentScenario<TestPageFragment> {
|
||||
return launchFragmentInContainer<TestPageFragment>(
|
||||
fragmentArgs = args,
|
||||
themeResId = R.style.AppTheme
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves coroutines clock time.
|
||||
*/
|
||||
private fun advance(millis: Long) {
|
||||
coroutineTestRule.advanceTime(millis)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,652 @@
|
|||
package com.agileburo.anytype.features.editor
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.testing.FragmentScenario
|
||||
import androidx.fragment.app.testing.launchFragmentInContainer
|
||||
import androidx.test.espresso.Espresso
|
||||
import androidx.test.espresso.action.ViewActions
|
||||
import androidx.test.espresso.assertion.ViewAssertions
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.LargeTest
|
||||
import com.agileburo.anytype.R
|
||||
import com.agileburo.anytype.core_ui.widgets.text.TextInputWidget
|
||||
import com.agileburo.anytype.domain.base.Either
|
||||
import com.agileburo.anytype.domain.block.interactor.UnlinkBlocks
|
||||
import com.agileburo.anytype.domain.block.model.Block
|
||||
import com.agileburo.anytype.domain.event.model.Event
|
||||
import com.agileburo.anytype.domain.event.model.Payload
|
||||
import com.agileburo.anytype.features.editor.base.EditorTestSetup
|
||||
import com.agileburo.anytype.features.editor.base.TestPageFragment
|
||||
import com.agileburo.anytype.mocking.MockDataFactory
|
||||
import com.agileburo.anytype.presentation.page.PageViewModel
|
||||
import com.agileburo.anytype.ui.page.PageFragment
|
||||
import com.agileburo.anytype.utils.CoroutinesTestRule
|
||||
import com.agileburo.anytype.utils.TestUtils.withRecyclerView
|
||||
import com.bartoszlipinski.disableanimationsrule.DisableAnimationsRule
|
||||
import com.nhaarman.mockitokotlin2.doReturn
|
||||
import com.nhaarman.mockitokotlin2.stub
|
||||
import com.nhaarman.mockitokotlin2.times
|
||||
import com.nhaarman.mockitokotlin2.verifyBlocking
|
||||
import kotlinx.android.synthetic.main.fragment_page.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@LargeTest
|
||||
class DeleteBlockTesting : EditorTestSetup() {
|
||||
|
||||
@get:Rule
|
||||
val animationsRule = DisableAnimationsRule()
|
||||
|
||||
@get:Rule
|
||||
val coroutineTestRule = CoroutinesTestRule()
|
||||
|
||||
private val args = bundleOf(PageFragment.ID_KEY to root)
|
||||
|
||||
@Before
|
||||
override fun setup() {
|
||||
super.setup()
|
||||
}
|
||||
|
||||
//region SCENARIO I
|
||||
|
||||
/**
|
||||
* SCENARIO I
|
||||
* SHOULD DELETE SECOND BLOCK, THEN FOCUS FIRST ONE AND PLACE CURSOR AT ITS END
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondParagraphByDeletingItsTextThenFocusFirstParagraphWithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.P,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.textContent,
|
||||
targetViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondParagraphByDeletingItsTextThenFocusFirstHeader1WithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.H1,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.headerOne,
|
||||
targetViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondParagraphByDeletingItsTextThenFocusFirstHeader2WithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.H2,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.headerTwo,
|
||||
targetViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondParagraphByDeletingItsTextThenFocusFirstHeader3WithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.H3,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.headerThree,
|
||||
targetViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondParagraphByDeletingItsTextThenFocusFirstHighlightWithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.QUOTE,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.highlightContent,
|
||||
targetViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondParagraphByDeletingItsTextThenFocusFirstCheckboxWithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.CHECKBOX,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.checkboxContent,
|
||||
targetViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondParagraphByDeletingItsTextThenFocusFirstBulletedWithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.BULLET,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.bulletedListContent,
|
||||
targetViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondParagraphByDeletingItsTextThenFocusFirstNumberedWithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.NUMBERED,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.numberedListContent,
|
||||
targetViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondParagraphByDeletingItsTextThenFocusFirstToggleWithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.TOGGLE,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.toggleContent,
|
||||
targetViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondHeader1ByDeletingItsTextThenFocusFirstParagraphWithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.P,
|
||||
secondStyle = Block.Content.Text.Style.H1,
|
||||
firstViewId = R.id.textContent,
|
||||
targetViewId = R.id.headerOne
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondHeader2ByDeletingItsTextThenFocusFirstParagraphWithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.P,
|
||||
secondStyle = Block.Content.Text.Style.H2,
|
||||
firstViewId = R.id.textContent,
|
||||
targetViewId = R.id.headerTwo
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondHeader3ByDeletingItsTextThenFocusFirstParagraphWithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.P,
|
||||
secondStyle = Block.Content.Text.Style.H3,
|
||||
firstViewId = R.id.textContent,
|
||||
targetViewId = R.id.headerThree
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondHighlightByDeletingItsTextThenFocusFirstParagraphWithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.P,
|
||||
secondStyle = Block.Content.Text.Style.QUOTE,
|
||||
firstViewId = R.id.textContent,
|
||||
targetViewId = R.id.highlightContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondCheckboxByDeletingItsTextThenFocusFirstParagraphWithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.P,
|
||||
secondStyle = Block.Content.Text.Style.CHECKBOX,
|
||||
firstViewId = R.id.textContent,
|
||||
targetViewId = R.id.checkboxContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondBulletedByDeletingItsTextThenFocusFirstParagraphWithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.P,
|
||||
secondStyle = Block.Content.Text.Style.BULLET,
|
||||
firstViewId = R.id.textContent,
|
||||
targetViewId = R.id.bulletedListContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondNumberedByDeletingItsTextThenFocusFirstParagraphWithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.P,
|
||||
secondStyle = Block.Content.Text.Style.NUMBERED,
|
||||
firstViewId = R.id.textContent,
|
||||
targetViewId = R.id.numberedListContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondToggleByDeletingItsTextThenFocusFirstParagraphWithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.P,
|
||||
secondStyle = Block.Content.Text.Style.TOGGLE,
|
||||
firstViewId = R.id.textContent,
|
||||
targetViewId = R.id.toggleContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondH1ByDeletingItsTextThenFocusFirstHeader1WithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.H1,
|
||||
secondStyle = Block.Content.Text.Style.H1,
|
||||
firstViewId = R.id.headerOne,
|
||||
targetViewId = R.id.headerOne
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondH2ByDeletingItsTextThenFocusFirstHeader2WithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.H2,
|
||||
secondStyle = Block.Content.Text.Style.H2,
|
||||
firstViewId = R.id.headerTwo,
|
||||
targetViewId = R.id.headerTwo
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteSecondH3ByDeletingItsTextThenFocusFirstHeader3WithCursorAtItsEnd() {
|
||||
shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle = Block.Content.Text.Style.H3,
|
||||
secondStyle = Block.Content.Text.Style.H3,
|
||||
firstViewId = R.id.headerThree,
|
||||
targetViewId = R.id.headerThree
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldDeleteSecondBlockByDeletingItsTextThenFocusFirstOneWithCursorAtItsEnd(
|
||||
firstStyle: Block.Content.Text.Style,
|
||||
secondStyle: Block.Content.Text.Style,
|
||||
firstViewId: Int,
|
||||
targetViewId: Int
|
||||
) {
|
||||
|
||||
// SETUP
|
||||
|
||||
val a = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields.empty(),
|
||||
children = emptyList(),
|
||||
content = Block.Content.Text(
|
||||
text = "Foo",
|
||||
marks = emptyList(),
|
||||
style = firstStyle
|
||||
)
|
||||
)
|
||||
|
||||
val b = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields.empty(),
|
||||
children = emptyList(),
|
||||
content = Block.Content.Text(
|
||||
text = "Bar",
|
||||
marks = emptyList(),
|
||||
style = secondStyle
|
||||
)
|
||||
)
|
||||
|
||||
val page = Block(
|
||||
id = root,
|
||||
fields = Block.Fields(emptyMap()),
|
||||
content = Block.Content.Smart(
|
||||
type = Block.Content.Smart.Type.PAGE
|
||||
),
|
||||
children = listOf(a.id, b.id)
|
||||
)
|
||||
|
||||
val document = listOf(page, a, b)
|
||||
|
||||
val events = listOf(
|
||||
Event.Command.UpdateStructure(
|
||||
context = root,
|
||||
id = root,
|
||||
children = listOf(a.id)
|
||||
),
|
||||
Event.Command.DeleteBlock(
|
||||
context = root,
|
||||
targets = listOf(b.id)
|
||||
)
|
||||
)
|
||||
|
||||
val params = UnlinkBlocks.Params(
|
||||
context = root,
|
||||
targets = listOf(b.id)
|
||||
)
|
||||
|
||||
stubInterceptEvents()
|
||||
stubOpenDocument(document)
|
||||
stubUpdateText()
|
||||
|
||||
stubUnlinkBlocks(params, events)
|
||||
|
||||
val scenario = launchFragment(args)
|
||||
|
||||
// TESTING
|
||||
|
||||
val target = Espresso.onView(
|
||||
withRecyclerView(R.id.recycler).atPositionOnView(2, targetViewId)
|
||||
)
|
||||
|
||||
target.apply {
|
||||
perform(ViewActions.click())
|
||||
}
|
||||
|
||||
// Delete text from B
|
||||
|
||||
repeat(4) { target.perform(ViewActions.pressKey(KeyEvent.KEYCODE_DEL)) }
|
||||
|
||||
// Check results
|
||||
|
||||
verifyBlocking(unlinkBlocks, times(1)) { invoke(params) }
|
||||
|
||||
Espresso.onView(
|
||||
withRecyclerView(R.id.recycler).atPositionOnView(1, firstViewId)
|
||||
).apply {
|
||||
check(ViewAssertions.matches(ViewMatchers.withText("Foo")))
|
||||
check(ViewAssertions.matches(ViewMatchers.hasFocus()))
|
||||
}
|
||||
|
||||
// Check cursor position
|
||||
|
||||
scenario.onFragment { fragment ->
|
||||
val item = fragment.recycler.getChildAt(1)
|
||||
val view = item.findViewById<TextInputWidget>(firstViewId)
|
||||
assertEquals(
|
||||
expected = 3,
|
||||
actual = view.selectionStart
|
||||
)
|
||||
assertEquals(
|
||||
expected = 3,
|
||||
actual = view.selectionEnd
|
||||
)
|
||||
}
|
||||
|
||||
// Release pending coroutines
|
||||
|
||||
advance(PageViewModel.TEXT_CHANGES_DEBOUNCE_DURATION)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region SCENARIO II
|
||||
|
||||
/**
|
||||
* SCENARIO II
|
||||
* SHOULD DELETE THE FIRST BLOCK AFTER TITLE, THEN FOCUS THE DOCUMENT'S TITLE
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun shouldDeleteFirstParagraphAfterTitleByDeletingItsTextThenFocusTitle() {
|
||||
shouldDeleteFirstBlockAfterTitleByDeletingItsTextThenFocusTitle(
|
||||
firstStyle = Block.Content.Text.Style.P,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.textContent,
|
||||
secondViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteFirstH1AfterTitleByDeletingItsTextThenFocusTitle() {
|
||||
shouldDeleteFirstBlockAfterTitleByDeletingItsTextThenFocusTitle(
|
||||
firstStyle = Block.Content.Text.Style.H1,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.headerOne,
|
||||
secondViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteFirstH2AfterTitleByDeletingItsTextThenFocusTitle() {
|
||||
shouldDeleteFirstBlockAfterTitleByDeletingItsTextThenFocusTitle(
|
||||
firstStyle = Block.Content.Text.Style.H2,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.headerTwo,
|
||||
secondViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteFirstH3AfterTitleByDeletingItsTextThenFocusTitle() {
|
||||
shouldDeleteFirstBlockAfterTitleByDeletingItsTextThenFocusTitle(
|
||||
firstStyle = Block.Content.Text.Style.H3,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.headerThree,
|
||||
secondViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteFirstHighlightAfterTitleByDeletingItsTextThenFocusTitle() {
|
||||
shouldDeleteFirstBlockAfterTitleByDeletingItsTextThenFocusTitle(
|
||||
firstStyle = Block.Content.Text.Style.QUOTE,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.highlightContent,
|
||||
secondViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteFirstCheckboxAfterTitleByDeletingItsTextThenFocusTitle() {
|
||||
shouldDeleteFirstBlockAfterTitleByDeletingItsTextThenFocusTitle(
|
||||
firstStyle = Block.Content.Text.Style.CHECKBOX,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.checkboxContent,
|
||||
secondViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteFirstBulletAfterTitleByDeletingItsTextThenFocusTitle() {
|
||||
shouldDeleteFirstBlockAfterTitleByDeletingItsTextThenFocusTitle(
|
||||
firstStyle = Block.Content.Text.Style.BULLET,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.bulletedListContent,
|
||||
secondViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteFirstNumberedAfterTitleByDeletingItsTextThenFocusTitle() {
|
||||
shouldDeleteFirstBlockAfterTitleByDeletingItsTextThenFocusTitle(
|
||||
firstStyle = Block.Content.Text.Style.NUMBERED,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.numberedListContent,
|
||||
secondViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldDeleteFirstToggleAfterTitleByDeletingItsTextThenFocusTitle() {
|
||||
shouldDeleteFirstBlockAfterTitleByDeletingItsTextThenFocusTitle(
|
||||
firstStyle = Block.Content.Text.Style.TOGGLE,
|
||||
secondStyle = Block.Content.Text.Style.P,
|
||||
firstViewId = R.id.toggleContent,
|
||||
secondViewId = R.id.textContent
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldDeleteFirstBlockAfterTitleByDeletingItsTextThenFocusTitle(
|
||||
title: String = "Title",
|
||||
firstStyle: Block.Content.Text.Style,
|
||||
secondStyle: Block.Content.Text.Style,
|
||||
firstViewId: Int,
|
||||
secondViewId: Int
|
||||
) {
|
||||
|
||||
// SETUP
|
||||
|
||||
val a = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields.empty(),
|
||||
children = emptyList(),
|
||||
content = Block.Content.Text(
|
||||
text = "Foo",
|
||||
marks = emptyList(),
|
||||
style = firstStyle
|
||||
)
|
||||
)
|
||||
|
||||
val b = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields.empty(),
|
||||
children = emptyList(),
|
||||
content = Block.Content.Text(
|
||||
text = "Bar",
|
||||
marks = emptyList(),
|
||||
style = secondStyle
|
||||
)
|
||||
)
|
||||
|
||||
val page = Block(
|
||||
id = root,
|
||||
fields = Block.Fields(emptyMap()),
|
||||
content = Block.Content.Smart(
|
||||
type = Block.Content.Smart.Type.PAGE
|
||||
),
|
||||
children = listOf(a.id, b.id)
|
||||
)
|
||||
|
||||
val document = listOf(page, a, b)
|
||||
|
||||
val events = listOf(
|
||||
Event.Command.UpdateStructure(
|
||||
context = root,
|
||||
id = root,
|
||||
children = listOf(b.id)
|
||||
),
|
||||
Event.Command.DeleteBlock(
|
||||
context = root,
|
||||
targets = listOf(a.id)
|
||||
)
|
||||
)
|
||||
|
||||
val params = UnlinkBlocks.Params(
|
||||
context = root,
|
||||
targets = listOf(a.id)
|
||||
)
|
||||
|
||||
stubInterceptEvents()
|
||||
|
||||
stubOpenDocument(
|
||||
document = document,
|
||||
details = Block.Details(
|
||||
mapOf(
|
||||
root to Block.Fields(
|
||||
mapOf("name" to title)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
stubUpdateText()
|
||||
|
||||
stubUnlinkBlocks(params, events)
|
||||
|
||||
val scenario = launchFragment(args)
|
||||
|
||||
// TESTING
|
||||
|
||||
Espresso.onView(
|
||||
withRecyclerView(R.id.recycler).atPositionOnView(0, R.id.title)
|
||||
).apply {
|
||||
perform(ViewActions.click())
|
||||
}
|
||||
|
||||
// Set cursor programmatically
|
||||
|
||||
scenario.onFragment { fragment ->
|
||||
fragment.recycler.findViewById<TextInputWidget>(R.id.title).setSelection(0)
|
||||
}
|
||||
|
||||
Thread.sleep(100)
|
||||
|
||||
|
||||
|
||||
val target = Espresso.onView(
|
||||
withRecyclerView(R.id.recycler).atPositionOnView(1, firstViewId)
|
||||
)
|
||||
|
||||
target.apply {
|
||||
perform(ViewActions.click())
|
||||
}
|
||||
|
||||
// Delete text from B
|
||||
|
||||
repeat(4) { target.perform(ViewActions.pressKey(KeyEvent.KEYCODE_DEL)) }
|
||||
|
||||
// Check results
|
||||
|
||||
verifyBlocking(unlinkBlocks, times(1)) { invoke(params) }
|
||||
|
||||
Espresso.onView(
|
||||
withRecyclerView(R.id.recycler).atPositionOnView(0, R.id.title)
|
||||
).apply {
|
||||
check(ViewAssertions.matches(ViewMatchers.withText("Title")))
|
||||
check(ViewAssertions.matches(ViewMatchers.hasFocus()))
|
||||
}
|
||||
|
||||
Espresso.onView(
|
||||
withRecyclerView(R.id.recycler).atPositionOnView(1, secondViewId)
|
||||
).apply {
|
||||
check(ViewAssertions.matches(ViewMatchers.withText("Bar")))
|
||||
}
|
||||
|
||||
// Check cursor position
|
||||
|
||||
scenario.onFragment { fragment ->
|
||||
val item = fragment.recycler.getChildAt(0)
|
||||
val view = item.findViewById<TextInputWidget>(R.id.title)
|
||||
assertEquals(
|
||||
expected = title.length,
|
||||
actual = view.selectionStart
|
||||
)
|
||||
assertEquals(
|
||||
expected = title.length,
|
||||
actual = view.selectionEnd
|
||||
)
|
||||
}
|
||||
|
||||
// Release pending coroutines
|
||||
|
||||
advance(PageViewModel.TEXT_CHANGES_DEBOUNCE_DURATION)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
// STUBBING & SETUP
|
||||
|
||||
private fun stubUnlinkBlocks(
|
||||
params: UnlinkBlocks.Params,
|
||||
events: List<Event.Command>
|
||||
) {
|
||||
unlinkBlocks.stub {
|
||||
onBlocking { invoke(params) } doReturn Either.Right(
|
||||
Payload(
|
||||
context = root,
|
||||
events = events
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchFragment(args: Bundle): FragmentScenario<TestPageFragment> {
|
||||
return launchFragmentInContainer<TestPageFragment>(
|
||||
fragmentArgs = args,
|
||||
themeResId = R.style.AppTheme
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves coroutines clock time.
|
||||
*/
|
||||
private fun advance(millis: Long) {
|
||||
coroutineTestRule.advanceTime(millis)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,404 @@
|
|||
package com.agileburo.anytype.features.editor
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.testing.FragmentScenario
|
||||
import androidx.fragment.app.testing.launchFragmentInContainer
|
||||
import androidx.test.espresso.Espresso
|
||||
import androidx.test.espresso.action.ViewActions
|
||||
import androidx.test.espresso.assertion.ViewAssertions
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import com.agileburo.anytype.R
|
||||
import com.agileburo.anytype.core_ui.widgets.text.TextInputWidget
|
||||
import com.agileburo.anytype.domain.base.Either
|
||||
import com.agileburo.anytype.domain.block.interactor.CreateBlock
|
||||
import com.agileburo.anytype.domain.block.interactor.UpdateTextStyle
|
||||
import com.agileburo.anytype.domain.block.model.Block
|
||||
import com.agileburo.anytype.domain.block.model.Position
|
||||
import com.agileburo.anytype.domain.event.model.Event
|
||||
import com.agileburo.anytype.domain.event.model.Payload
|
||||
import com.agileburo.anytype.domain.ext.content
|
||||
import com.agileburo.anytype.features.editor.base.EditorTestSetup
|
||||
import com.agileburo.anytype.features.editor.base.TestPageFragment
|
||||
import com.agileburo.anytype.mocking.MockDataFactory
|
||||
import com.agileburo.anytype.presentation.page.PageViewModel
|
||||
import com.agileburo.anytype.ui.page.PageFragment
|
||||
import com.agileburo.anytype.utils.CoroutinesTestRule
|
||||
import com.agileburo.anytype.utils.TestUtils
|
||||
import com.bartoszlipinski.disableanimationsrule.DisableAnimationsRule
|
||||
import com.nhaarman.mockitokotlin2.doReturn
|
||||
import com.nhaarman.mockitokotlin2.stub
|
||||
import com.nhaarman.mockitokotlin2.times
|
||||
import com.nhaarman.mockitokotlin2.verifyBlocking
|
||||
import kotlinx.android.synthetic.main.fragment_page.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ListBlockTesting : EditorTestSetup() {
|
||||
|
||||
@get:Rule
|
||||
val animationsRule = DisableAnimationsRule()
|
||||
|
||||
@get:Rule
|
||||
val coroutineTestRule = CoroutinesTestRule()
|
||||
|
||||
val args = bundleOf(PageFragment.ID_KEY to root)
|
||||
|
||||
@Before
|
||||
override fun setup() {
|
||||
super.setup()
|
||||
}
|
||||
|
||||
//region SCENARIO 1
|
||||
|
||||
/**
|
||||
* SCENARIO I
|
||||
* SHOULD CREATE A LIST ITEM OF THE SAME STYLE BY PRESSING ENTER ON A LIST ITEM
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun shouldCreateNewBulletByPressingEnterAtEndOfBullet() {
|
||||
shouldCreateListItemWithSameStyleByPressingEnterAtEndOfListItem(
|
||||
style = Block.Content.Text.Style.BULLET,
|
||||
view = R.id.bulletedListContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldCreateNewNumberedByPressingEnterAtEndOfNumbered() {
|
||||
shouldCreateListItemWithSameStyleByPressingEnterAtEndOfListItem(
|
||||
style = Block.Content.Text.Style.NUMBERED,
|
||||
view = R.id.numberedListContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldCreateCheckboxByPressingEnterAtEndOfCheckbox() {
|
||||
shouldCreateListItemWithSameStyleByPressingEnterAtEndOfListItem(
|
||||
style = Block.Content.Text.Style.CHECKBOX,
|
||||
view = R.id.checkboxContent
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldCreateListItemWithSameStyleByPressingEnterAtEndOfListItem(
|
||||
style: Block.Content.Text.Style,
|
||||
view: Int
|
||||
) {
|
||||
|
||||
// SETUP
|
||||
|
||||
val text ="Should create a new list item with the same style by pressing ENTER at the end of the target list item"
|
||||
|
||||
val a = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields.empty(),
|
||||
children = emptyList(),
|
||||
content = Block.Content.Text(
|
||||
text = text,
|
||||
marks = emptyList(),
|
||||
style = style
|
||||
)
|
||||
)
|
||||
|
||||
val new = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields.empty(),
|
||||
children = emptyList(),
|
||||
content = Block.Content.Text(
|
||||
text = "",
|
||||
marks = emptyList(),
|
||||
style = style
|
||||
)
|
||||
)
|
||||
|
||||
val page = Block(
|
||||
id = root,
|
||||
fields = Block.Fields(emptyMap()),
|
||||
content = Block.Content.Smart(
|
||||
type = Block.Content.Smart.Type.PAGE
|
||||
),
|
||||
children = listOf(a.id)
|
||||
)
|
||||
|
||||
val document = listOf(page, a)
|
||||
|
||||
val events = listOf(
|
||||
Event.Command.UpdateStructure(
|
||||
context = root,
|
||||
id = root,
|
||||
children = listOf(a.id, new.id)
|
||||
),
|
||||
Event.Command.AddBlock(
|
||||
context = root,
|
||||
blocks = listOf(new)
|
||||
)
|
||||
)
|
||||
|
||||
val params = CreateBlock.Params(
|
||||
context = root,
|
||||
position = Position.BOTTOM,
|
||||
target = a.id,
|
||||
prototype = Block.Prototype.Text(
|
||||
style = style
|
||||
)
|
||||
)
|
||||
|
||||
stubInterceptEvents()
|
||||
stubOpenDocument(document)
|
||||
stubUpdateText()
|
||||
stubCreateBlocks(params, new, events)
|
||||
|
||||
val scenario = launchFragment(args)
|
||||
|
||||
// TESTING
|
||||
|
||||
val target = Espresso.onView(
|
||||
TestUtils.withRecyclerView(R.id.recycler).atPositionOnView(1, view)
|
||||
)
|
||||
|
||||
target.apply {
|
||||
perform(ViewActions.click())
|
||||
}
|
||||
|
||||
// Set cursor programmatically
|
||||
|
||||
scenario.onFragment { fragment ->
|
||||
fragment.recycler.findViewById<TextInputWidget>(view).setSelection(text.length)
|
||||
}
|
||||
|
||||
// Press ENTER on empty text block A
|
||||
|
||||
target.perform(ViewActions.pressImeActionButton())
|
||||
|
||||
// Check results
|
||||
|
||||
verifyBlocking(createBlock, times(1)) { invoke(params) }
|
||||
|
||||
Espresso.onView(
|
||||
TestUtils.withRecyclerView(R.id.recycler).atPositionOnView(1, view)
|
||||
).apply {
|
||||
check(ViewAssertions.matches(ViewMatchers.withText(a.content<Block.Content.Text>().text)))
|
||||
}
|
||||
|
||||
Espresso.onView(
|
||||
TestUtils.withRecyclerView(R.id.recycler).atPositionOnView(2, view)
|
||||
).apply {
|
||||
check(ViewAssertions.matches(ViewMatchers.withText("")))
|
||||
check(ViewAssertions.matches(ViewMatchers.hasFocus()))
|
||||
}
|
||||
|
||||
// Check cursor position at block B
|
||||
|
||||
scenario.onFragment { fragment ->
|
||||
val item = fragment.recycler.getChildAt(2)
|
||||
item.findViewById<TextInputWidget>(view).apply {
|
||||
assertEquals(
|
||||
expected = 0,
|
||||
actual = selectionStart
|
||||
)
|
||||
assertEquals(
|
||||
expected = 0,
|
||||
actual = selectionEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Release pending coroutines
|
||||
|
||||
advance(PageViewModel.TEXT_CHANGES_DEBOUNCE_DURATION)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region SCENARIO 2
|
||||
|
||||
/**
|
||||
* SCENARIO II
|
||||
* SHOULD REPLACE A LIST ITEM BY A PARAGRAPH IF ITS TEXT IS EMPTY ON ENTER PRESS.
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun shouldReplaceBulletByParagraphIfItsTextIsEmptyOnEnterPress() {
|
||||
shouldCreateListItemWithSameStyleByPressingEnterAtEndOfListItem(
|
||||
style = Block.Content.Text.Style.BULLET,
|
||||
view = R.id.bulletedListContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldReplaceNumberedByParagraphIfItsTextIsEmptyOnEnterPress() {
|
||||
shouldCreateListItemWithSameStyleByPressingEnterAtEndOfListItem(
|
||||
style = Block.Content.Text.Style.NUMBERED,
|
||||
view = R.id.numberedListContent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldReplaceCheckboxByParagraphIfItsTextIsEmptyOnEnterPress() {
|
||||
shouldCreateListItemWithSameStyleByPressingEnterAtEndOfListItem(
|
||||
style = Block.Content.Text.Style.CHECKBOX,
|
||||
view = R.id.checkboxContent
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldReplaceListItemByParagraphIfItsTextIsEmptyOnEnterPress(
|
||||
style: Block.Content.Text.Style,
|
||||
view: Int
|
||||
) {
|
||||
|
||||
// SETUP
|
||||
|
||||
val description ="Should replace the target list item by a paragraph on enter press if its text is empty"
|
||||
|
||||
val a = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields.empty(),
|
||||
children = emptyList(),
|
||||
content = Block.Content.Text(
|
||||
text = description,
|
||||
marks = emptyList(),
|
||||
style = style
|
||||
)
|
||||
)
|
||||
|
||||
val b = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields.empty(),
|
||||
children = emptyList(),
|
||||
content = Block.Content.Text(
|
||||
text = "",
|
||||
marks = emptyList(),
|
||||
style = style
|
||||
)
|
||||
)
|
||||
|
||||
val page = Block(
|
||||
id = root,
|
||||
fields = Block.Fields(emptyMap()),
|
||||
content = Block.Content.Smart(
|
||||
type = Block.Content.Smart.Type.PAGE
|
||||
),
|
||||
children = listOf(a.id, b.id)
|
||||
)
|
||||
|
||||
val document = listOf(page, a)
|
||||
|
||||
val events = listOf(
|
||||
Event.Command.GranularChange(
|
||||
context = root,
|
||||
id = b.id,
|
||||
style = Block.Content.Text.Style.P
|
||||
)
|
||||
)
|
||||
|
||||
val params = UpdateTextStyle.Params(
|
||||
context = root,
|
||||
targets = listOf(b.id),
|
||||
style = Block.Content.Text.Style.P
|
||||
)
|
||||
|
||||
stubInterceptEvents()
|
||||
stubOpenDocument(document)
|
||||
stubUpdateText()
|
||||
stubUpdateTextStyle(params, events)
|
||||
|
||||
val scenario = launchFragment(args)
|
||||
|
||||
// TESTING
|
||||
|
||||
val target = Espresso.onView(
|
||||
TestUtils.withRecyclerView(R.id.recycler).atPositionOnView(2, view)
|
||||
)
|
||||
|
||||
target.apply {
|
||||
perform(ViewActions.click())
|
||||
}
|
||||
|
||||
// Press ENTER on empty text block A
|
||||
|
||||
target.perform(ViewActions.pressImeActionButton())
|
||||
|
||||
// Check results
|
||||
|
||||
verifyBlocking(updateTextStyle, times(1)) { invoke(params) }
|
||||
|
||||
Espresso.onView(
|
||||
TestUtils.withRecyclerView(R.id.recycler).atPositionOnView(1, view)
|
||||
).apply {
|
||||
check(ViewAssertions.matches(ViewMatchers.withText(a.content<Block.Content.Text>().text)))
|
||||
}
|
||||
|
||||
Espresso.onView(
|
||||
TestUtils.withRecyclerView(R.id.recycler).atPositionOnView(2, view)
|
||||
).apply {
|
||||
check(ViewAssertions.matches(ViewMatchers.withText("")))
|
||||
check(ViewAssertions.matches(ViewMatchers.hasFocus()))
|
||||
}
|
||||
|
||||
// Check cursor position at block B
|
||||
|
||||
scenario.onFragment { fragment ->
|
||||
val item = fragment.recycler.getChildAt(2)
|
||||
item.findViewById<TextInputWidget>(R.id.textContent).apply {
|
||||
assertEquals(
|
||||
expected = 0,
|
||||
actual = selectionStart
|
||||
)
|
||||
assertEquals(
|
||||
expected = 0,
|
||||
actual = selectionEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Release pending coroutines
|
||||
|
||||
advance(PageViewModel.TEXT_CHANGES_DEBOUNCE_DURATION)
|
||||
}
|
||||
|
||||
// STUBBING & SETUP
|
||||
|
||||
private fun stubCreateBlocks(
|
||||
params: CreateBlock.Params,
|
||||
new: Block,
|
||||
events: List<Event.Command>
|
||||
) {
|
||||
createBlock.stub {
|
||||
onBlocking {
|
||||
invoke(params)
|
||||
} doReturn Either.Right(
|
||||
Pair(new.id, Payload(context = root, events = events))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stubUpdateTextStyle(
|
||||
params: UpdateTextStyle.Params,
|
||||
events: List<Event.Command.GranularChange>
|
||||
) {
|
||||
updateTextStyle.stub {
|
||||
onBlocking { invoke(params) } doReturn Either.Right(
|
||||
Payload(
|
||||
context = root,
|
||||
events = events
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchFragment(args: Bundle): FragmentScenario<TestPageFragment> {
|
||||
return launchFragmentInContainer<TestPageFragment>(
|
||||
fragmentArgs = args,
|
||||
themeResId = R.style.AppTheme
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves coroutines clock time.
|
||||
*/
|
||||
private fun advance(millis: Long) {
|
||||
coroutineTestRule.advanceTime(millis)
|
||||
}
|
||||
}
|
|
@ -202,7 +202,10 @@ open class EditorTestSetup {
|
|||
}
|
||||
}
|
||||
|
||||
fun stubOpenDocument(document: List<Block>) {
|
||||
fun stubOpenDocument(
|
||||
document: List<Block>,
|
||||
details: Block.Details = Block.Details()
|
||||
) {
|
||||
openPage.stub {
|
||||
onBlocking { invoke(any()) } doReturn Either.Right(
|
||||
Payload(
|
||||
|
@ -211,7 +214,7 @@ open class EditorTestSetup {
|
|||
Event.Command.ShowBlock(
|
||||
context = root,
|
||||
root = root,
|
||||
details = Block.Details(),
|
||||
details = details,
|
||||
blocks = document
|
||||
)
|
||||
)
|
||||
|
|
|
@ -19,7 +19,6 @@ import androidx.core.view.updatePadding
|
|||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.agileburo.anytype.BuildConfig
|
||||
import com.agileburo.anytype.R
|
||||
|
@ -300,7 +299,7 @@ open class PageFragment :
|
|||
recycler.apply {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
setHasFixedSize(true)
|
||||
itemAnimator = null
|
||||
//itemAnimator = null
|
||||
adapter = pageAdapter
|
||||
addOnScrollListener(titleVisibilityDetector)
|
||||
}
|
||||
|
@ -602,9 +601,7 @@ open class PageFragment :
|
|||
|
||||
state.multiSelect.apply {
|
||||
if (isVisible) {
|
||||
recycler.apply {
|
||||
itemAnimator = DefaultItemAnimator()
|
||||
}
|
||||
//recycler.apply { itemAnimator = DefaultItemAnimator() }
|
||||
hideSoftInput()
|
||||
Timber.d("Hiding top menu")
|
||||
topToolbar.invisible()
|
||||
|
@ -614,9 +611,7 @@ open class PageFragment :
|
|||
showSelectButton()
|
||||
}
|
||||
} else {
|
||||
recycler.apply {
|
||||
itemAnimator = null
|
||||
}
|
||||
//recycler.apply { itemAnimator = null }
|
||||
bottomMenu.hideWithAnimation()
|
||||
hideSelectButton()
|
||||
}
|
||||
|
|
|
@ -154,8 +154,9 @@ sealed class BlockView : ViewType, Parcelable {
|
|||
override val isFocused: Boolean,
|
||||
val text: String?,
|
||||
val emoji: String? = null,
|
||||
override val mode: Mode = Mode.EDIT
|
||||
) : BlockView(), Focusable, Permission {
|
||||
override val mode: Mode = Mode.EDIT,
|
||||
override val cursor: Int? = null
|
||||
) : BlockView(), Focusable, Cursor, Permission {
|
||||
override fun getViewType() = HOLDER_TITLE
|
||||
}
|
||||
|
||||
|
|
|
@ -209,6 +209,7 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
|||
icon.text = item.emoji ?: EMPTY_EMOJI
|
||||
} else {
|
||||
enableEditMode()
|
||||
if (item.isFocused) setCursor(item)
|
||||
focus(item.isFocused)
|
||||
content.setText(item.text, BufferType.EDITABLE)
|
||||
if (!item.text.isNullOrEmpty()) content.setSelection(item.text.length)
|
||||
|
@ -242,6 +243,9 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (payload.isCursorChanged) {
|
||||
if (item.isFocused) setCursor(item)
|
||||
}
|
||||
if (payload.focusChanged()) {
|
||||
focus(item.isFocused)
|
||||
}
|
||||
|
|
|
@ -380,6 +380,64 @@ class BlockAdapterTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should update title cursor`() {
|
||||
|
||||
// Setup
|
||||
|
||||
val title = BlockView.Title(
|
||||
text = MockDataFactory.randomString(),
|
||||
id = MockDataFactory.randomUuid(),
|
||||
isFocused = false,
|
||||
cursor = null
|
||||
)
|
||||
|
||||
val updated = title.copy(
|
||||
cursor = 2,
|
||||
isFocused = true
|
||||
)
|
||||
|
||||
val views = listOf(title)
|
||||
|
||||
val adapter = buildAdapter(views)
|
||||
|
||||
val recycler = RecyclerView(context).apply {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
}
|
||||
|
||||
val holder = adapter.onCreateViewHolder(recycler, BlockViewHolder.HOLDER_TITLE)
|
||||
|
||||
adapter.onBindViewHolder(holder, 0)
|
||||
|
||||
check(holder is BlockViewHolder.Title)
|
||||
|
||||
// Testing
|
||||
|
||||
assertEquals(
|
||||
expected = title.text,
|
||||
actual = holder.content.text.toString()
|
||||
)
|
||||
|
||||
holder.processPayloads(
|
||||
item = updated,
|
||||
payloads = listOf(
|
||||
BlockViewDiffUtil.Payload(
|
||||
changes = listOf(FOCUS_CHANGED, CURSOR_CHANGED)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
expected = 2,
|
||||
actual = holder.content.selectionStart
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
expected = 2,
|
||||
actual = holder.content.selectionEnd
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should call back when paragraph view gets focused`() {
|
||||
|
||||
|
|
|
@ -707,4 +707,40 @@ class BlockViewDiffUtilTest {
|
|||
actual = payload
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should detect cursor change in title view`() {
|
||||
|
||||
val index = 0
|
||||
|
||||
val id = MockDataFactory.randomUuid()
|
||||
|
||||
val oldBlock = BlockView.Title(
|
||||
id = id,
|
||||
text = MockDataFactory.randomString(),
|
||||
cursor = null,
|
||||
isFocused = true
|
||||
)
|
||||
|
||||
val newBlock: BlockView = oldBlock.copy(
|
||||
cursor = 2
|
||||
)
|
||||
|
||||
val old = listOf(oldBlock)
|
||||
|
||||
val new = listOf(newBlock)
|
||||
|
||||
val diff = BlockViewDiffUtil(old = old, new = new)
|
||||
|
||||
val payload = diff.getChangePayload(index, index)
|
||||
|
||||
val expected = Payload(
|
||||
changes = listOf(BlockViewDiffUtil.CURSOR_CHANGED)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
expected = expected,
|
||||
actual = payload
|
||||
)
|
||||
}
|
||||
}
|
|
@ -946,7 +946,7 @@ class PageViewModel(
|
|||
val index = parent.children.indexOf(target)
|
||||
|
||||
val previous = index.dec().let { prev ->
|
||||
if (prev != -1) parent.children[prev] else null
|
||||
if (prev != -1) parent.children[prev] else context
|
||||
}
|
||||
|
||||
val cursor = blocks.find { it.id == previous }?.let { block ->
|
||||
|
@ -957,17 +957,13 @@ class PageViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
val next = index.inc().let { nxt ->
|
||||
if (nxt <= parent.children.lastIndex) parent.children[nxt] else null
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
orchestrator.proxies.intents.send(
|
||||
Intent.CRUD.Unlink(
|
||||
context = context,
|
||||
targets = listOf(target),
|
||||
previous = previous,
|
||||
next = next,
|
||||
next = null,
|
||||
cursor = cursor
|
||||
)
|
||||
)
|
||||
|
|
|
@ -177,6 +177,13 @@ class DefaultBlockViewRenderer(
|
|||
name
|
||||
else
|
||||
null
|
||||
},
|
||||
cursor = focus.cursor?.let { cursor ->
|
||||
when (cursor) {
|
||||
is Cursor.Start -> 0
|
||||
is Cursor.End -> details.details[root.id]?.name?.length ?: 0
|
||||
is Cursor.Range -> cursor.range.first
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue