Created module for feedback
This provides a reusable way to generate streams of feedback events that can be directed to a file or analytics service. Address item 1 in issue #28
This commit is contained in:
parent
18c62287a4
commit
00813ba05f
|
@ -1,17 +0,0 @@
|
||||||
package cash.z.ecc.android
|
|
||||||
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example local unit test, which will execute on the development machine (host).
|
|
||||||
*
|
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
|
||||||
*/
|
|
||||||
class ExampleUnitTest {
|
|
||||||
@Test
|
|
||||||
fun addition_isCorrect() {
|
|
||||||
assertEquals(4, 2 + 2)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -36,8 +36,8 @@ object Deps {
|
||||||
val STDLIB = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$version"
|
val STDLIB = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$version"
|
||||||
object Coroutines : Version("1.3.2") {
|
object Coroutines : Version("1.3.2") {
|
||||||
val ANDROID = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version"
|
val ANDROID = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version"
|
||||||
|
|
||||||
val CORE = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version"
|
val CORE = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version"
|
||||||
|
val TEST = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
/build
|
|
@ -0,0 +1,37 @@
|
||||||
|
import cash.z.ecc.android.Deps
|
||||||
|
|
||||||
|
apply plugin: 'com.android.library'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
apply plugin: 'kotlin-android-extensions'
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdkVersion Deps.compileSdkVersion
|
||||||
|
buildToolsVersion Deps.buildToolsVersion
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdkVersion Deps.minSdkVersion
|
||||||
|
targetSdkVersion Deps.targetSdkVersion
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
consumerProguardFiles 'consumer-rules.pro'
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation Deps.Kotlin.STDLIB
|
||||||
|
implementation Deps.AndroidX.APPCOMPAT
|
||||||
|
implementation Deps.AndroidX.CORE_KTX
|
||||||
|
implementation Deps.Kotlin.Coroutines.CORE
|
||||||
|
implementation Deps.Kotlin.Coroutines.TEST
|
||||||
|
testImplementation Deps.Test.JUNIT
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
|
@ -0,0 +1,3 @@
|
||||||
|
<manifest
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="cash.z.ecc.android.feedback" />
|
|
@ -0,0 +1,5 @@
|
||||||
|
package cash.z.ecc.android.feedback
|
||||||
|
|
||||||
|
interface Action {
|
||||||
|
val name: String
|
||||||
|
}
|
|
@ -0,0 +1,143 @@
|
||||||
|
package cash.z.ecc.android.feedback
|
||||||
|
|
||||||
|
import cash.z.ecc.android.feedback.util.CompositeJob
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.channels.BroadcastChannel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.asFlow
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
|
class Feedback(capacity: Int = 256) {
|
||||||
|
lateinit var scope: CoroutineScope
|
||||||
|
private set
|
||||||
|
|
||||||
|
private val _metrics = BroadcastChannel<Metric>(capacity)
|
||||||
|
private val _actions = BroadcastChannel<Action>(capacity)
|
||||||
|
private var onStartListener: () -> Unit = {}
|
||||||
|
|
||||||
|
private val jobs = CompositeJob()
|
||||||
|
|
||||||
|
val metrics: Flow<Metric> = _metrics.asFlow()
|
||||||
|
val actions: Flow<Action> = _actions.asFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that this class is not leaking anything. Checks that all underlying channels are
|
||||||
|
* closed and all launched reporting jobs are inactive.
|
||||||
|
*/
|
||||||
|
val isStopped get() = ensureScope() && _metrics.isClosedForSend && _actions.isClosedForSend && !scope.isActive && !jobs.isActive()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts this feedback as a child of the calling coroutineContext, meaning when that context
|
||||||
|
* ends, this feedback's scope and anything it launced will cancel. Note that the [metrics] and
|
||||||
|
* [actions] channels will remain open unless [stop] is also called on this instance.
|
||||||
|
*/
|
||||||
|
suspend fun start(): Feedback {
|
||||||
|
check(!::scope.isInitialized) {
|
||||||
|
"Error: cannot initialize feedback because it has already been initialized."
|
||||||
|
}
|
||||||
|
scope = CoroutineScope(Dispatchers.IO + SupervisorJob(coroutineContext[Job]))
|
||||||
|
scope.coroutineContext[Job]!!.invokeOnCompletion {
|
||||||
|
_metrics.close()
|
||||||
|
_actions.close()
|
||||||
|
}
|
||||||
|
onStartListener()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes the given callback after the scope has been initialized or immediately, if the scope
|
||||||
|
* has already been initialized. This is used by [FeedbackProcessor] and things like it that
|
||||||
|
* want to immediately begin collecting the metrics/actions flows because any emissions that
|
||||||
|
* occur before subscription are dropped.
|
||||||
|
*/
|
||||||
|
fun onStart(onStartListener: () -> Unit) {
|
||||||
|
if (::scope.isInitialized) {
|
||||||
|
onStartListener()
|
||||||
|
} else {
|
||||||
|
this.onStartListener = onStartListener
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop this instance and close all reporting channels. This function will first wait for all
|
||||||
|
* in-flight reports to complete.
|
||||||
|
*/
|
||||||
|
suspend fun stop() {
|
||||||
|
// expose instances where stop is being called before start occurred.
|
||||||
|
ensureScope()
|
||||||
|
await()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspends until all in-flight reports have completed. This is automatically called before
|
||||||
|
* [stop].
|
||||||
|
*/
|
||||||
|
suspend fun await() {
|
||||||
|
jobs.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures the given block of code by surrounding it with time metrics and the reporting the
|
||||||
|
* result.
|
||||||
|
*
|
||||||
|
* Don't measure code that launches coroutines, instead measure the code within the coroutine
|
||||||
|
* that gets launched. Otherwise, the timing will be incorrect because the launched coroutine
|
||||||
|
* will run concurrently--meaning a "happens before" relationship between the measurer and the
|
||||||
|
* measured cannot be established and thereby the concurrent action cannot be timed.
|
||||||
|
*/
|
||||||
|
inline fun <T> measure(description: Any = "measurement", block: () -> T) {
|
||||||
|
ensureScope()
|
||||||
|
val metric = TimeMetric(description.toString()).markTime()
|
||||||
|
block()
|
||||||
|
metric.markTime()
|
||||||
|
report(metric)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the given metric to the stream of metric events.
|
||||||
|
*
|
||||||
|
* @param metric the metric to add.
|
||||||
|
*/
|
||||||
|
fun report(metric: Metric) {
|
||||||
|
jobs += scope.launch {
|
||||||
|
_metrics.send(metric)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the given action to the stream of action events.
|
||||||
|
*
|
||||||
|
* @param action the action to add.
|
||||||
|
*/
|
||||||
|
fun report(action: Action) {
|
||||||
|
jobs += scope.launch {
|
||||||
|
_actions.send(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that the scope for this instance has been initialized.
|
||||||
|
*/
|
||||||
|
fun ensureScope(): Boolean {
|
||||||
|
check(::scope.isInitialized) {
|
||||||
|
"Error: feedback has not been initialized. Before attempting to use this feedback" +
|
||||||
|
" object, ensure feedback.start() has been called."
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ensureStopped(): Boolean {
|
||||||
|
val errors = mutableListOf<String>()
|
||||||
|
|
||||||
|
if (!_metrics.isClosedForSend && !_actions.isClosedForSend) errors += "both channels are still open"
|
||||||
|
else if (!_actions.isClosedForSend) errors += "the actions channel is still open"
|
||||||
|
else if (!_metrics.isClosedForSend) errors += "the metrics channel is still open"
|
||||||
|
|
||||||
|
if (scope.isActive) errors += "the scope is still active"
|
||||||
|
if (jobs.isActive()) errors += "reporting jobs are still active"
|
||||||
|
if (errors.isEmpty()) return true
|
||||||
|
throw IllegalStateException("Feedback is still active because ${errors.joinToString(", ")}.")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
package cash.z.ecc.android.feedback
|
||||||
|
|
||||||
|
import cash.z.ecc.android.feedback.util.CompositeJob
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes care of the boilerplate involved in processing feedback emissions. Simply provide callbacks
|
||||||
|
* and emissions will occur in a mutually exclusive way, across all processors, so that things like
|
||||||
|
* writing to a file can occur without clobbering changes. This class also provides a mechanism for
|
||||||
|
* waiting for any in-flight emissions to complete. Lastly, all monitoring will cleanly complete
|
||||||
|
* whenever the feedback is stopped or its parent scope is cancelled.
|
||||||
|
*/
|
||||||
|
class FeedbackProcessor(
|
||||||
|
val feedback: Feedback,
|
||||||
|
val onMetricListener: (Metric) -> Unit = {},
|
||||||
|
val onActionListener: (Action) -> Unit = {}
|
||||||
|
) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
feedback.onStart {
|
||||||
|
initMetrics()
|
||||||
|
initActions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var contextMetrics = Dispatchers.IO
|
||||||
|
private var contextActions = Dispatchers.IO
|
||||||
|
private var jobs = CompositeJob()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for any in-flight listeners to complete.
|
||||||
|
*/
|
||||||
|
suspend fun await() {
|
||||||
|
jobs.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun metricsOn(dispatcher: CoroutineDispatcher): FeedbackProcessor {
|
||||||
|
contextMetrics = dispatcher
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun actionsOn(dispatcher: CoroutineDispatcher): FeedbackProcessor {
|
||||||
|
contextActions = dispatcher
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initMetrics() {
|
||||||
|
feedback.metrics.onEach {
|
||||||
|
jobs += feedback.scope.launch {
|
||||||
|
withContext(contextMetrics) {
|
||||||
|
mutex.withLock {
|
||||||
|
onMetricListener(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.launchIn(feedback.scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initActions() {
|
||||||
|
feedback.actions.onEach {
|
||||||
|
val id = coroutineContext.hashCode()
|
||||||
|
jobs += feedback.scope.launch {
|
||||||
|
withContext(contextActions) {
|
||||||
|
mutex.withLock {
|
||||||
|
onActionListener(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.launchIn(feedback.scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val mutex: Mutex = Mutex()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
package cash.z.ecc.android.feedback
|
||||||
|
|
||||||
|
interface Metric {
|
||||||
|
val name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TimeMetric(
|
||||||
|
override val name: String,
|
||||||
|
val times: MutableList<Long> = mutableListOf()
|
||||||
|
) : Metric {
|
||||||
|
val startTime: Long?
|
||||||
|
get() = times.firstOrNull()
|
||||||
|
|
||||||
|
val endTime: Long?
|
||||||
|
get() = times.lastOrNull()
|
||||||
|
|
||||||
|
val elapsedTime: Long? get() = endTime?.minus(startTime ?: 0)
|
||||||
|
|
||||||
|
fun markTime(): TimeMetric {
|
||||||
|
times.add(System.currentTimeMillis())
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
package cash.z.ecc.android.feedback.util
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
class CompositeJob {
|
||||||
|
|
||||||
|
private val jobs = mutableListOf<Job>()
|
||||||
|
val size: Int get() = jobs.size
|
||||||
|
|
||||||
|
fun add(job: Job) {
|
||||||
|
jobs.add(job)
|
||||||
|
job.invokeOnCompletion {
|
||||||
|
remove(job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(job: Job): Boolean {
|
||||||
|
return jobs.remove(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isActive(): Boolean {
|
||||||
|
return jobs.any { isActive() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun await() {
|
||||||
|
var activeJobs = jobs.filter { it.isActive }
|
||||||
|
while (activeJobs.isNotEmpty()) {
|
||||||
|
// allow for concurrent modification since the list isn't coroutine or thread safe
|
||||||
|
repeat(jobs.size) {
|
||||||
|
if (it < jobs.size) jobs[it].join()
|
||||||
|
}
|
||||||
|
delay(100)
|
||||||
|
activeJobs = jobs.filter { it.isActive }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun plusAssign(also: Job) {
|
||||||
|
add(also)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
package cash.z.ecc.android.feedback
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.test.TestCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.test.TestCoroutineScope
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.rules.TestWatcher
|
||||||
|
import org.junit.runner.Description
|
||||||
|
|
||||||
|
class CoroutinesTestRule(
|
||||||
|
val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
|
||||||
|
) : TestWatcher() {
|
||||||
|
|
||||||
|
lateinit var testScope: TestCoroutineScope
|
||||||
|
|
||||||
|
override fun starting(description: Description?) {
|
||||||
|
super.starting(description)
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
testScope = TestCoroutineScope()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun finished(description: Description?) {
|
||||||
|
super.finished(description)
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
testDispatcher.cleanupTestCoroutines()
|
||||||
|
if (testScope.coroutineContext[Job]?.isActive == true) testScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
package cash.z.ecc.android.feedback
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FeedbackProcessorTest {
|
||||||
|
|
||||||
|
private val processors = mutableListOf<FeedbackProcessor>()
|
||||||
|
private var counter: Int = 0
|
||||||
|
private val simpleAction = object : Action {
|
||||||
|
override val name = "ButtonClick"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testConcurrency() = runBlocking {
|
||||||
|
val actionCount = 40
|
||||||
|
val processorCount = 40
|
||||||
|
val expectedTotal = actionCount * processorCount
|
||||||
|
|
||||||
|
val feedback = Feedback().start()
|
||||||
|
repeat(processorCount) {
|
||||||
|
createProcessor(feedback)
|
||||||
|
}
|
||||||
|
repeat(actionCount) {
|
||||||
|
sendAction(feedback)
|
||||||
|
}
|
||||||
|
|
||||||
|
feedback.await()
|
||||||
|
processors.forEach { it.await() }
|
||||||
|
feedback.stop()
|
||||||
|
assertEquals(
|
||||||
|
"Concurrent modification happened ${expectedTotal - counter} times",
|
||||||
|
expectedTotal,
|
||||||
|
counter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createProcessor(feedback: Feedback) {
|
||||||
|
processors += FeedbackProcessor(feedback) {
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendAction(feedback: Feedback) {
|
||||||
|
feedback.report(simpleAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,111 @@
|
||||||
|
package cash.z.ecc.android.feedback
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FeedbackTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMeasure_blocking() = runBlocking {
|
||||||
|
val duration = 1_100L
|
||||||
|
val feedback = Feedback().start()
|
||||||
|
verifyDuration(feedback, duration)
|
||||||
|
|
||||||
|
feedback.measure {
|
||||||
|
workBlocking(duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMeasure_suspending() = runBlocking {
|
||||||
|
val duration = 1_100L
|
||||||
|
val feedback = Feedback().start()
|
||||||
|
verifyDuration(feedback, duration)
|
||||||
|
|
||||||
|
feedback.measure {
|
||||||
|
workSuspending(duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTrack() = runBlocking {
|
||||||
|
val simpleAction = object : Action {
|
||||||
|
override val name: String = "ButtonClick"
|
||||||
|
}
|
||||||
|
val feedback = Feedback().start()
|
||||||
|
verifyAction(feedback, simpleAction.name)
|
||||||
|
|
||||||
|
feedback.report(simpleAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCancellation_stop() = runBlocking {
|
||||||
|
verifyFeedbackCancellation { feedback, _ ->
|
||||||
|
feedback.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCancellation_cancel() = runBlocking {
|
||||||
|
verifyFeedbackCancellation { _, parentJob ->
|
||||||
|
parentJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = IllegalStateException::class)
|
||||||
|
fun testCancellation_noCancel() = runBlocking {
|
||||||
|
verifyFeedbackCancellation { _, _ -> }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifyFeedbackCancellation(testBlock: suspend (Feedback, Job) -> Unit) = runBlocking {
|
||||||
|
val feedback = Feedback()
|
||||||
|
var counter = 0
|
||||||
|
val parentJob = launch {
|
||||||
|
feedback.start()
|
||||||
|
feedback.scope.launch {
|
||||||
|
delay(50)
|
||||||
|
counter = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// give feedback.start a chance to happen before cancelling
|
||||||
|
delay(25)
|
||||||
|
// stop or cancel things here
|
||||||
|
testBlock(feedback, parentJob)
|
||||||
|
delay(75)
|
||||||
|
feedback.ensureStopped()
|
||||||
|
assertEquals(0, counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifyDuration(feedback: Feedback, duration: Long) {
|
||||||
|
feedback.metrics.onEach {
|
||||||
|
val metric = (it as? TimeMetric)?.elapsedTime
|
||||||
|
assertTrue(
|
||||||
|
"Measured time did not match duration. Expected $duration but was $metric",
|
||||||
|
metric ?: 0 >= duration
|
||||||
|
)
|
||||||
|
feedback.stop()
|
||||||
|
}.launchIn(feedback.scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifyAction(feedback: Feedback, name: String) {
|
||||||
|
feedback.actions.onEach {
|
||||||
|
assertTrue("Action did not match. Expected $name but was ${it.name}", name == it.name)
|
||||||
|
feedback.stop()
|
||||||
|
}.launchIn(feedback.scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun workBlocking(duration: Long) {
|
||||||
|
Thread.sleep(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun workSuspending(duration: Long) {
|
||||||
|
delay(duration)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,17 +0,0 @@
|
||||||
package cash.z.android.qrecycler;
|
|
||||||
|
|
||||||
import org.junit.Test;
|
|
||||||
|
|
||||||
import static org.junit.Assert.*;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example local unit test, which will execute on the development machine (host).
|
|
||||||
*
|
|
||||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
|
||||||
*/
|
|
||||||
public class ExampleUnitTest {
|
|
||||||
@Test
|
|
||||||
public void addition_isCorrect() {
|
|
||||||
assertEquals(4, 2 + 2);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,2 +1,2 @@
|
||||||
rootProject.name='Zcash Wallet'
|
rootProject.name='Zcash Wallet'
|
||||||
include ':app', ':qrecycler'
|
include ':app', ':qrecycler', ':feedback'
|
||||||
|
|
Loading…
Reference in New Issue