1
0
Fork 0
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:
Ivanov Konstantin 2020-06-08 18:23:27 +03:00
commit 54b0c75f1c
13 changed files with 1462 additions and 24 deletions

View file

@ -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 🚀

View file

@ -1,3 +1,3 @@
version.versionMajor=0
version.versionMinor=0
version.versionPatch=33
version.versionPatch=34

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
)
)

View file

@ -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()
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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`() {

View file

@ -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
)
}
}

View file

@ -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
)
)

View file

@ -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
}
}
)
)