Implement confirmed Android tree mutation workflows (#108)
This commit is contained in:
1 parent
6a864a8d7f
commit
316a875d63
22 files changed
+2170
-220
No files matched your search
@@ -331,6 +331,11 @@ class EntryViewerTest {
|
||||
saved = value
|
||||
dismissEdit()
|
||||
}
|
||||
override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) {
|
||||
inspect(id, revision)
|
||||
prepareMutation(action)
|
||||
}
|
||||
override fun unlockMutation(passphrase: String) = unlock(passphrase)
|
||||
override fun prepareMutation(action: PasswordMutation) {
|
||||
this.action = action
|
||||
state.value =
|
||||
@@ -354,7 +359,7 @@ class EntryViewerTest {
|
||||
override fun dismissMutation() {
|
||||
state.value = state.value.copy(mutation = null)
|
||||
}
|
||||
override fun mutate(destination: String?, overwrite: Boolean) {
|
||||
override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) {
|
||||
this.destination =
|
||||
destination
|
||||
dismissMutation()
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package de.rfc1437.ironstorage
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeLeft
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.core.AuthenticationState
|
||||
import de.rfc1437.ironstorage.core.PasswordDestination
|
||||
import de.rfc1437.ironstorage.core.PasswordKind
|
||||
import de.rfc1437.ironstorage.core.PasswordMutation
|
||||
import de.rfc1437.ironstorage.core.PasswordMutationPlan
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordRow
|
||||
import de.rfc1437.ironstorage.core.ShellIcon
|
||||
import de.rfc1437.ironstorage.features.PasswordScreen
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class MutationWorkflowTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
private fun display(fixture: Fixture) {
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setProtectedTestContent {
|
||||
MaterialTheme { PasswordScreen(fixture, fixture.base.authentication) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun fullSwipeOnlySelectsDeleteAndCancelNeverMutates() {
|
||||
val fixture = Fixture()
|
||||
display(fixture)
|
||||
compose.onNodeWithText("Source").performTouchInput { swipeLeft() }
|
||||
compose.waitUntil(5000) { fixture.state.value.mutation != null }
|
||||
assertEquals(PasswordMutation.Delete, fixture.state.value.mutation!!.action)
|
||||
assertEquals(0, fixture.committed)
|
||||
compose.onNodeWithText("Cancel").performClick()
|
||||
assertEquals(0, fixture.committed)
|
||||
compose.onNodeWithContentDescription("Actions for Source").performClick()
|
||||
compose.onNodeWithText("Delete entry").performClick()
|
||||
compose.onNodeWithText("Confirm").assertIsNotEnabled()
|
||||
compose.onNode(
|
||||
hasText("Unlock") and hasAnyAncestor(hasTestTag("mutation-destinations"))
|
||||
).performClick()
|
||||
compose.onNodeWithText("Confirm").performClick()
|
||||
assertEquals(1, fixture.committed)
|
||||
assertEquals("entry:source", fixture.lastId)
|
||||
}
|
||||
|
||||
@Test fun overwriteAndDirtyEditorDiscardRequireSeparateConsent() {
|
||||
val fixture = Fixture(dirty = true, collision = true)
|
||||
fixture.base.lease.value = AuthenticationState(unlocked = true)
|
||||
display(fixture)
|
||||
compose.onNodeWithContentDescription("Actions for Source").performClick()
|
||||
compose.onNodeWithText("Copy entry").performClick()
|
||||
compose.onNodeWithTag(
|
||||
"mutation-destinations"
|
||||
).performScrollToNode(hasText("Target\nFolder"))
|
||||
compose.onNodeWithText("Target\nFolder").performClick()
|
||||
compose.onNodeWithText("Confirm").assertIsNotEnabled()
|
||||
compose.onNodeWithTag(
|
||||
"mutation-destinations"
|
||||
).performScrollToNode(hasText("Discard its open editors and unsaved edits"))
|
||||
compose.onNodeWithText("Discard its open editors and unsaved edits").performClick()
|
||||
compose.onNodeWithText("Confirm").assertIsNotEnabled()
|
||||
compose.onNodeWithTag(
|
||||
"mutation-destinations"
|
||||
).performScrollToNode(hasText("Allow replacing the existing entry"))
|
||||
compose.onNodeWithText("Allow replacing the existing entry").performClick()
|
||||
compose.onNodeWithText("Confirm").performClick()
|
||||
assertEquals(1, fixture.committed)
|
||||
assertTrue(fixture.overwrite)
|
||||
assertTrue(fixture.discard)
|
||||
assertEquals("target", fixture.destination)
|
||||
}
|
||||
|
||||
@Test fun foldersHaveOverflowAndTalkBackActionsWithoutGestures() {
|
||||
val fixture = Fixture()
|
||||
fixture.base.lease.value = AuthenticationState(unlocked = true)
|
||||
display(fixture)
|
||||
val node = compose.onNodeWithText("Group").fetchSemanticsNode()
|
||||
val actions = node.config[SemanticsActions.CustomActions]
|
||||
assertTrue(actions.any { it.label == "Move folder Group" })
|
||||
assertTrue(actions.any { it.label == "Copy folder Group" })
|
||||
compose.runOnIdle {
|
||||
assertTrue(actions.single { it.label == "Delete folder Group" }.action())
|
||||
}
|
||||
assertEquals("group/", fixture.state.value.mutation!!.source)
|
||||
assertEquals(0, fixture.committed)
|
||||
compose.onNodeWithText("Confirm").performClick()
|
||||
assertEquals(1, fixture.committed)
|
||||
assertEquals("directory:group", fixture.lastId)
|
||||
}
|
||||
|
||||
@Test fun inspectDisposableMutationWithComputerUse() {
|
||||
assumeTrue(InstrumentationRegistry.getArguments().getString("mutationInspect") == "true")
|
||||
val fixture = Fixture(dirty = true, collision = true)
|
||||
val done = CountDownLatch(1)
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setProtectedTestContent {
|
||||
MaterialTheme {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TextButton(onClick = {
|
||||
done.countDown()
|
||||
}) { Text("Finish mutation inspection") }
|
||||
Text("Confirmed actions: ${fixture.committed}")
|
||||
PasswordScreen(fixture, fixture.base.authentication, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.waitForIdle()
|
||||
while (!done.await(100, TimeUnit.MILLISECONDS)) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
compose.waitForIdle()
|
||||
}
|
||||
}
|
||||
private class Fixture(
|
||||
private val dirty: Boolean = false,
|
||||
private val collision: Boolean = false,
|
||||
val base: EntryViewerTest.Fixture = EntryViewerTest.Fixture()
|
||||
) : PasswordRepository by base {
|
||||
override val state = base.state
|
||||
var committed by mutableStateOf(0)
|
||||
var lastId: String? = null
|
||||
var destination: String? = null
|
||||
var overwrite = false
|
||||
var discard = false
|
||||
init {
|
||||
state.value = state.value.copy(
|
||||
selected = null,
|
||||
rows = listOf(
|
||||
PasswordRow(
|
||||
"entry:source",
|
||||
"source",
|
||||
"Source",
|
||||
"Locked entry",
|
||||
PasswordKind.Entry,
|
||||
"entry-r",
|
||||
ShellIcon.Key
|
||||
),
|
||||
PasswordRow(
|
||||
"directory:group",
|
||||
"group",
|
||||
"Group",
|
||||
"Folder",
|
||||
PasswordKind.Folder,
|
||||
"folder-r",
|
||||
ShellIcon.Folder
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) {
|
||||
lastId = id
|
||||
val folder = id == "directory:group"
|
||||
state.value = state.value.copy(
|
||||
mutation = PasswordMutationPlan(
|
||||
action, if (folder) "group/" else "source", "plan-r",
|
||||
listOf(
|
||||
PasswordDestination(
|
||||
"target",
|
||||
"Target",
|
||||
"Folder",
|
||||
collision,
|
||||
collisionTargets = if (collision) {
|
||||
listOf(
|
||||
"target/source"
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
)
|
||||
),
|
||||
dirty, dirty, if (folder) PasswordKind.Folder else PasswordKind.Entry,
|
||||
if (folder) "Group" else "Source", if (folder) 2u else 1u
|
||||
)
|
||||
)
|
||||
}
|
||||
override fun prepareMutation(action: PasswordMutation) {
|
||||
state.value.selected?.let { prepareRowMutation(it.id, it.revision, action) }
|
||||
}
|
||||
override fun unlockMutation(passphrase: String) {
|
||||
base.lease.value =
|
||||
AuthenticationState(unlocked = true)
|
||||
}
|
||||
override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) {
|
||||
this.destination = destination
|
||||
this.overwrite = overwrite
|
||||
discard = discardEditor
|
||||
committed += 1
|
||||
dismissMutation()
|
||||
}
|
||||
override fun dismissMutation() {
|
||||
state.value = state.value.copy(mutation = null)
|
||||
}
|
||||
override fun inspect(id: String, revision: String) {
|
||||
state.value =
|
||||
state.value.copy(selected = state.value.rows.single { it.id == id })
|
||||
}
|
||||
override fun activate(id: String, revision: String) = inspect(id, revision)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.AuthenticationState
|
||||
import de.rfc1437.ironstorage.core.PasswordField
|
||||
import de.rfc1437.ironstorage.core.PasswordKind
|
||||
import de.rfc1437.ironstorage.core.PasswordMutation
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordRow
|
||||
import de.rfc1437.ironstorage.core.PasswordSection
|
||||
@@ -215,9 +216,14 @@ class PasswordScreenTest {
|
||||
override fun edit(id: ULong) = Unit
|
||||
override fun dismissEdit() = Unit
|
||||
override fun saveEdit(value: String) = Unit
|
||||
override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) {
|
||||
inspect(id, revision)
|
||||
prepareMutation(action)
|
||||
}
|
||||
override fun unlockMutation(passphrase: String) = unlock(passphrase)
|
||||
override fun prepareMutation(action: de.rfc1437.ironstorage.core.PasswordMutation) = Unit
|
||||
override fun dismissMutation() = Unit
|
||||
override fun mutate(destination: String?, overwrite: Boolean) = Unit
|
||||
override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) = Unit
|
||||
override fun openSearch(id: String, revision: String) {
|
||||
error("Search is not used by this fixture")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user