Using Jetpack Compose on Android.
I have a test, that simulates a selection of several Text composable in a Column.
The selection starts by a long-press on the first item and then moves down over more Text composables and stop well inside the Column.
Usually the test should run unattended and fast.
But I want to be able to show the selection process in real time (for demonstration purposes and also to see, if it works like it's designed, e.g. at the beginning I forgot that I have to wait some time after the down()).
The first Text composable in the column is also used to find the element (->anchor), and the parent is the Column which is used to perform the move.
This is the function that performs the selection:
val duration = 3000L
val durationLongPress = 1000L
fun selectVertical(anchor: SemanticsNodeInteraction, parent: SemanticsNodeInteraction) {
anchor.performTouchInput {
down(center)
}
clock.advanceTimeBy(durationLongPress)
// the time jumps here, but the selection of the first word is visible
val nSteps = 100
val timeStep = (duration-durationLongPress)/nSteps
parent.performTouchInput {
moveTo(topCenter)
val step = (bottomCenter-topCenter)*0.8f/ nSteps.toFloat()
repeat(nSteps) {
moveBy(step, timeStep)
}
up()
}
}
this is the composable:
#Composable
fun SelectableText() {
val text = """
|Line
|Line start selecting here and swipe over the empty lines
|Line or select a word and extend it over the empty lines
|Line
|
|
|
|Line
|Line
|Line
|Line
""".trimMargin()
Column {
SelectionContainer {
Column {
Text("simple")
Text(text = text) // works
}
}
SelectionContainer {
Column {
Text("crash")
text.lines().forEach {
Text(text = it)
}
}
}
SelectionContainer {
Column {
Text("space")
text.lines().forEach {
// empty lines replaced by a space works
Text(text = if (it == "") " " else it)
}
}
}
}
}
a test goes like this:
#Test
fun works_simple() {
val anchor = test.onNodeWithText("simple")
val textNode = anchor.onParent()
textNode.printToLog("simple")
controlTime(duration) {
selectVertical(anchor, textNode)
}
}
controlTime is the part that does not work. I don't add it's code here to keep the solution open.
I tried to disable the autoAdvance on the virtual test clock and stepping the time in a loop in a coroutine.
When I step the time in 1ms steps and add a delay(1) each, the wait is correct, but I don't see the selection expanding (I want at least see the steps). Instead I see the selection of the first word, then nothing until the end of the move and then the end result.
I also divided the move into smaller steps e.g. 10 or 100, but it's still not showing the result.
ok, I found the solution myself when sleeping... the "sleeping" brain is obviously working on unsolved problems (well, I know this already).
The key is, to do each move that should be visible in it's own performXXX. I think, the result is only propagated to the UI, when the code block is finished, which makes sense for a test.
parent.performTouchInput {
inRealTime("moveBy($step, $timeStep)", timeStep) {
moveBy(step)
}
}
I couldn't find a way to determine the duration of a so called "frame", so I advance either the virtual or the real clock, depending on which is lagging until both reach the target time. This can probably be optimized to jump both clocks in one step. I'll investigate that later.
It's interesting, that even 100 steps don't show a smooth selection move.
Instead, there are still only a few steps, even when the step time is increased.
Btw. this purpose of this code is to show a crash in SelectionContainer, when it encounters an empty Text("") composable for a bug report I created. I will provide it on the issue tracker, but I also want to have the test in our app development, to see, when it's solved and to avoid a library that doesn't work. Sometimes we encounter regressions in libs, e.g. if the fix has a bug.
This is the complete test code:
package com.example.myapplication
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.*
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Before
import org.junit.Rule
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
#RunWith(AndroidJUnit4::class)
class CrashTest {
val duration = 3000L
val durationLongPress = 1000L
#Composable
fun SelectableText() {
val text = """
|Line
|Line start selecting here and swipe over the empty lines
|Line or select a word and extend it over the empty lines
|Line
|
|
|
|Line
|Line
|Line
|Line
""".trimMargin()
Column {
SelectionContainer {
Column {
Text("simple")
Text(text = text) // works
}
}
SelectionContainer {
Column {
Text("crash")
text.lines().forEach {
Text(text = it)
}
}
}
SelectionContainer {
Column {
Text("space")
text.lines().forEach {
// empty lines replaced by a space works
Text(text = if (it == "") " " else it)
}
}
}
}
}
#Rule
#JvmField
var test: ComposeContentTestRule = createComposeRule()
#Before
fun setUp() {
test.setContent { SelectableText() }
test.onRoot().printToLog("root")
}
val clock get() = test.mainClock
fun inRealTime(what: String? = null, duration: Long = 0, todo: () -> Unit) {
clock.autoAdvance = false
what?.let { Log.d("%%%%%%%%%%", it) }
val startVirt = clock.currentTime
val startReal = System.currentTimeMillis()
todo()
while (true) {
val virt = clock.currentTime - startVirt
val real = System.currentTimeMillis() - startReal
Log.d("..........", "virt: $virt real: $real")
if (virt > real)
Thread.sleep(1)
else
clock.advanceTimeByFrame()
if ((virt > duration) and (real > duration))
break
}
clock.autoAdvance = true
}
fun selectVertical(anchor: SemanticsNodeInteraction, parent: SemanticsNodeInteraction) {
inRealTime("down(center)", durationLongPress) {
anchor.performTouchInput {
down(center)
}
}
val nSteps = 100
val timeStep = (duration-durationLongPress)/nSteps
Log.d("----------", "timeStep = $timeStep")
var step = Offset(1f,1f)
parent.performTouchInput {
step = (bottomCenter-topCenter)*0.8f/ nSteps.toFloat()
}
repeat(nSteps) {
parent.performTouchInput {
inRealTime("moveBy($step, $timeStep)", timeStep) {
moveBy(step)
}
}
}
parent.performTouchInput {
inRealTime("up()") {
up()
}
}
}
#Test
fun works_simple() {
val anchor = test.onNodeWithText("simple")
val textNode = anchor.onParent()
textNode.printToLog("simple")
selectVertical(anchor, textNode)
}
#Test
fun crash() {
val anchor = test.onNodeWithText("crash")
val textNode = anchor.onParent()
textNode.printToLog("crash")
selectVertical(anchor, textNode)
}
#Test
fun works_space() {
val anchor = test.onNodeWithText("space")
val textNode = anchor.onParent()
textNode.printToLog("space")
selectVertical(anchor, textNode)
}
}
Related
Sorry if that title is not clear enough but I didn't know how to sum it up in one sentence.
I have a webservice that returns an ArrayList of objects named Father.
The Father object is structured like this:
class Father {
ArrayList<Child> children;
}
I have another webservice that returns me the detail of the object Child.
How can I concat the first call that returns me the arraylist of Father and the multiple calls for the multiple objects Child ?
So far I can make the calls separately, like this:
Call for ArrayList of Father
myRepository.getFathers().subscribeOn(Schedulers.io())
.observeOn(Schedulers.io()).subscribeWith(new DisposableSingleObserver<List<Father>>() {
})
multiple call for ArrayList of Child
childListObservable
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.flatMap((Function<List<Child>, ObservableSource<Child>>) Observable::fromIterable)
.flatMap((Function<Child, ObservableSource<Child>>) this::getChildDetailObservable)
.subscribeWith(new DisposableObserver<Child>() {
// do whatever action after the result of each Child
}))
Prerequisite
Gradle
implementation("io.reactivex.rxjava2:rxjava:2.2.10")
testImplementation("io.mockk:mockk:1.10.0")
testImplementation("org.assertj:assertj-core:3.11.1")
testImplementation("org.junit.jupiter:junit-jupiter-api:5.3.1")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.3.1")
Classes / Interfaces
interface Api {
fun getFather(): Single<List<Father>>
fun childDetailInfo(child: Child): Single<ChildDetailInfo>
}
interface Store {
fun store(father: Father): Completable
fun store(child: ChildDetailInfo): Completable
}
class ApiImpl : Api {
override fun getFather(): Single<List<Father>> {
val child = Child("1")
val child1 = Child("2")
return Single.just(listOf(Father(listOf(child, child1)), Father(listOf(child))))
}
override fun childDetailInfo(child: Child): Single<ChildDetailInfo> {
return Single.just(ChildDetailInfo(child.name))
}
}
data class Father(val childes: List<Child>)
data class Child(val name: String)
data class ChildDetailInfo(val name: String)
Solution
val fathersStore = api.getFather()
.flatMapObservable {
Observable.fromIterable(it)
}.flatMapCompletable {
val detailInfos = it.childes.map { child ->
api.childDetailInfo(child)
.flatMapCompletable { detail -> store.store(detail) }
}
store.store(it)
.andThen(Completable.concat(detailInfos))
}
On each emit of a List of fathers, the list is flatten. The next opreator (flatMapCompletable) will take an Father. The completable will get the details of each Child with Api#childDetailInfo. The result is build by calling the API one by one. There is no concurrency happening wegen "concat". When the father is stored sucessfully, the childs will be stored as-well, when retrieved successfully. If one of the API-calls fails (e.g. network) everything fails, because the onError will be propgated to the subscriber.
Test
#Test
fun so62299778() {
val api = ApiImpl()
val store = mockk<Store>()
every { store.store(any<Father>()) } returns Completable.complete()
every { store.store(any<ChildDetailInfo>()) } returns Completable.complete()
val fathersStore = api.getFather()
.flatMapObservable {
Observable.fromIterable(it)
}.flatMapCompletable {
val detailInfos = it.childes.map { child ->
api.childDetailInfo(child)
.flatMapCompletable { detail -> store.store(detail) }
}
store.store(it)
.andThen(Completable.concat(detailInfos))
}
fathersStore.test()
.assertComplete()
verify { store.store(eq(Father(listOf(Child("1"), Child("2"))))) }
verify { store.store(eq(Father(listOf(Child("1"))))) }
verify(atLeast = 2) { store.store(eq(ChildDetailInfo("1"))) }
verify(atLeast = 1) { store.store(eq(ChildDetailInfo("2"))) }
}
Please provide next time some classes/ interfaces. When your question contains all vital information, you will get an answer quicker.
I did some tests which compare speed of using async as a method of deferring results and CompletableDeferred with combination of Job or startCoroutine to do the same job.
In summary there are 3 use cases:
async with default type of start (right away) [async]
CompletableDeferred + launch (basically Job) [cdl]
CompletableDeferred + startCoroutine [ccdl]
results are presented here:
In short every iteration of each use case test generates 10000 of async / cdl / ccdl requests and waits for them to complete. This is repeated 225 times with 25 times as a warmUp (not included in results) and data points are collected over 100 iteration of process above (as min, max, avg).
here is a code:
import com.source.log.log
import kotlinx.coroutines.*
import kotlin.coroutines.Continuation
import kotlin.coroutines.startCoroutine
import kotlin.system.measureNanoTime
import kotlin.system.measureTimeMillis
/**
* #project Bricks
* #author SourceOne on 28.11.2019
*/
/*I know that there are better ways to benchmark speed
* but given the produced results this method is fine enough
* */
fun benchmark(warmUp: Int, repeat: Int, action: suspend () -> Unit): Pair<List<Long>, List<Long>> {
val warmUpResults = List(warmUp) {
measureNanoTime {
runBlocking {
action()
}
}
}
val benchmarkResults = List(repeat) {
measureNanoTime {
runBlocking {
action()
}
}
}
return warmUpResults to benchmarkResults
}
/* find way to cancel startedCoroutine when deferred is
* canceled (currently you have to cancel whole context)
* */
fun <T> CoroutineScope.completable(provider: suspend () -> T): Deferred<T> {
return CompletableDeferred<T>().also { completable ->
provider.startCoroutine(
Continuation(coroutineContext) { result ->
completable.completeWith(result)
}
)
}
}
suspend fun calculateAsyncStep() = coroutineScope {
val list = List(10000) {
async { "i'm a robot" }
}
awaitAll(*list.toTypedArray())
}
suspend fun calculateCDLStep() = coroutineScope {
val list = List(10000) {
CompletableDeferred<String>().also {
launch {
it.complete("i'm a robot")
}
}
}
awaitAll(*list.toTypedArray())
}
suspend fun calculateCCDLStep() = coroutineScope {
val list = List(10000) {
completable { "i'm a robot" }
}
awaitAll(*list.toTypedArray())
}
fun main() {
val labels = listOf("async", "cdl", "ccdl")
val collectedResults = listOf(
mutableListOf<Pair<List<Long>, List<Long>>>(),
mutableListOf(),
mutableListOf()
)
"stabilizing runs".log()
repeat(2) {
println("async $it")
benchmark(warmUp = 25, repeat = 200) {
calculateAsyncStep()
}
println("CDL $it")
benchmark(warmUp = 25, repeat = 200) {
calculateCDLStep()
}
println("CCDL $it")
benchmark(warmUp = 25, repeat = 200) {
calculateCCDLStep()
}
}
"\n#Benchmark start".log()
val benchmarkTime = measureTimeMillis {
repeat(100) {
println("async $it")
collectedResults[0] += benchmark(warmUp = 25, repeat = 200) {
calculateAsyncStep()
}
println("CDL $it")
collectedResults[1] += benchmark(warmUp = 25, repeat = 200) {
calculateCDLStep()
}
println("CCDL $it")
collectedResults[2] += benchmark(warmUp = 25, repeat = 200) {
calculateCCDLStep()
}
}
}
"\n#Benchmark completed in ${benchmarkTime}ms".log()
"#Benchmark results:".log()
val minMaxAvg = collectedResults.map { stageResults ->
stageResults.map { (_, benchmark) ->
arrayOf(
benchmark.minBy { it }!!, benchmark.maxBy { it }!!, benchmark.average().toLong()
)
}
}
minMaxAvg.forEachIndexed { index, list ->
"results for: ${labels[index]} [min, max, avg]".log()
list.forEach { results ->
"${results[0]}\t${results[1]}\t${results[2]}".log()
}
}
}
There is no surprise that the first two use cases (async and cdl) are very close to each other and async is always better (because you don't have the overhead of creating job to complete deferred object) but comparing async vs CompletableDeferred + startCoroutine there is a huge gap between them (almost 2 times) in favor of the last one. Why there is such a big difference and if anyone knows, why shouldn't we just be using CompletableDeferred + startCoroutine wrapper (like completable() here) instead of async?
Addition1:
Here is a sample for 1000 points:
There are constant spikes in async and cdl results and some in ccdl (maybe gc?) but still there is far less with ccdl. I will rerun these tests with changed order of tests interleaving but it seems that it's related to something under the coroutines machinery.
Edit1:
I've accepted Marko Topolnik answer, but in addition to it, you still can use this 'as he called' bare launch method if you await for the result within the scope you have launched it.
In example if you will launch few deffered coroutines (async) and at the end of that scope you will await them all then the ccdl method will work as expected (at least from what i've seen in my tests).
Since launch and async are built as a layer on top of the low-level primitive createCoroutineUnintercepted(), whereas startCoroutine is practically a direct call into it, there aren't any surprises in your benchmark results.
why shouldn't we just be using CompletableDeferred + startCoroutine wrapper (like completable() here) instead of async?
A comment in your code already hints to the answer:
/*
* find way to cancel startedCoroutine when deferred is
* canceled (currently you have to cancel whole context)
*/
The layer you short-circuited with startCoroutine is precisely the layer that handles things as cancellation, coroutine hierarchy, exception handling and propagation, and so on.
Here's a simple example that shows you one of the things that break when you replace launch with a bare coroutine:
fun main() = runBlocking {
bareLaunch {
try {
delay(1000)
println("Coroutine done")
} catch (e: CancellationException) {
println("Coroutine cancelled, the exception is: $e")
}
}
delay(10)
}
fun CoroutineScope.bareLaunch(block: suspend () -> Unit) =
block.startCoroutine(Continuation(coroutineContext) { Unit })
fun <T> CoroutineScope.bareAsync(block: suspend () -> T) =
CompletableDeferred<T>().also { deferred ->
block.startCoroutine(Continuation(coroutineContext) { result ->
result.exceptionOrNull()?.also {
deferred.completeExceptionally(it)
} ?: run {
deferred.complete(result.getOrThrow())
}
})
}
When you run this, you'll see the bare coroutine got cancelled after 10 milliseconds. The runBlocking builder didn't realize it had to wait for it to complete. If you replace bareLaunch { with launch {, you'll restore the designed behavior where the child coroutine completes normally. The same thing happens with bareAsync.
I am trying to get a class to have a property bound to another class's list property, where the 1st property is derived from a summarizing calculation over the objects in the list. The code below is a simplified version of my production code. (The production code is doing a summary over DateTime objects -- the essential part of the code below is the binding between a list and an object property (here, it is a String for simplicity).)
I have tried various things. One approach was using addListener on the list in the Summary class below but I was running into weird bugs with the listener callback making updates on the Summary object. After doing a bunch of reading I think that a binding between the summary string and the list is more appropriate but I don't know exactly how to hook up the binding to the property?
package com.example.demo.view
import javafx.beans.Observable
import javafx.beans.binding.StringBinding
import javafx.beans.property.SimpleIntegerProperty
import javafx.beans.property.SimpleListProperty
import javafx.beans.property.SimpleStringProperty
import javafx.collections.FXCollections
import tornadofx.View
import tornadofx.button
import tornadofx.label
import tornadofx.vbox
class Thing(x: Int) {
val xProperty = SimpleIntegerProperty(x)
val yProperty = SimpleStringProperty("xyz")
}
class Collection {
private var things = FXCollections.observableList(mutableListOf<Thing>()) {
arrayOf<Observable>(it.xProperty)
}
val thingsProperty = SimpleListProperty<Thing>(things)
fun addThing(thing: Thing) {
things.add(thing)
}
}
class Summary(var collection: Collection) {
val summaryBinding = object : StringBinding() {
// The real code is more practical but
// this is just a minimal example.
override fun computeValue(): String {
val sum = collection.thingsProperty.value
.map { it.xProperty.value }
.fold(0, { total, next -> total + next })
return "There are $sum things."
}
}
// How to make this property update when collection changes?
val summaryProperty = SimpleStringProperty("There are ? things.")
}
class MainView : View() {
val summary = Summary(Collection())
override val root = vbox {
label(summary.summaryProperty)
button("Add Thing") {
summary.collection.addThing(Thing(5))
}
}
}
Keep in mind that I made this answer based on your minimal example:
class Thing(x: Int) {
val xProperty = SimpleIntegerProperty(x)
var x by xProperty
val yProperty = SimpleStringProperty("xyz")
var y by yProperty
}
class MainView : View() {
val things = FXCollections.observableList(mutableListOf<Thing>()) {
arrayOf<Observable>(it.xProperty)
}
val thingsProperty = SimpleListProperty<Thing>(things)
val totalBinding = integerBinding(listProperty) {
value.map { it.x }.fold(0, { total, next -> total + next })
}
val phraseBinding = stringBinding(totalBinding) { "There are $value things." }
override val root = vbox {
label(phraseBinding)
button("Add Thing") {
action {
list.add(Thing(5))
}
}
}
}
I removed your other classes because I didn't see a reason for them based on the example. If the collection class has more functionality than holding a list property in your real project, then add just add it back in. If not, then there's no reason to give a list its own class. The summary class is really just two bindings (or one if you have no need to separate the total from the phrase). I don't see the need to give them their own class either unless you plan on using them in multiple views.
I think your biggest problem is that you didn't wrap your button's action in action {}. So your code just added a Thing(5) on init and had no action set.
P.S. The var x by xProperty stuff will only work if you import tornadofx.* for that file.
I have a text field that I want to limit to integers only. See the code below.
When the view containing the field starts, and if the model is constructed with an initial default value for someInteger, the view displays the number correctly, without extra formatting. It also filters new typed input as expected.
A problem arises when refactoring the model not to have a default value. Being an integer property, it defaults to 0. When I later assign a new value to the property, the controlNewText passed contains punctuation, such as 1,234. That causes the check to fail and the newly assigned value to be filtered out of the view.
Why is the controlNewText getting formatted in the first place? Is there a way to prevent that?
textfield(model.someInteger) {
required()
textFormatter = TextFormatter(IntegerStringConverter(), model.item.someInteger)
stripNonInteger()
filterInput { it.controlNewText.isInt() }
}
class SomeData {
val someIntegerProperty = SimpleIntegerProperty(this, "someInteger")
var someInteger by someIntegerProperty
}
class SomeDataModel : ItemViewModel<SomeData>(SomeData()) {
val someInteger = bind(SomeData::someIntegerProperty)
}
The formatting is performed by the TextFormatter you specified. Make sure to specify one that doesn't add thousand separators. Here is a complete runnable application that configures a NumberStringConverter inside the formatter. Notice that I've removed the filterInput statement, as that's already covered by stripNonInteger.
class MainView : View("Main view") {
val model = SomeDataModel()
override val root = borderpane {
center {
form {
fieldset {
field("Some integer") {
textfield(model.someInteger) {
required()
textFormatter = TextFormatter(NumberStringConverter("########"), model.someInteger.value)
stripNonInteger()
}
}
button("Set some value").action {
model.someInteger.value = 1234
}
}
}
}
}
}
class SomeData {
val someIntegerProperty = SimpleIntegerProperty(this, "someInteger")
var someInteger by someIntegerProperty
}
class SomeDataModel : ItemViewModel<SomeData>(SomeData()) {
val someInteger = bind(SomeData::someIntegerProperty)
}
I would like in my groovy script to dump all variables and display all values.
I would like to do it dynamically because I would like to surround all my huge groovies by a try/catch. In catch part I want to dump all variables state with the stacktrace. The code should be generic to all groovies.
The problem is that this.getBinding().getVariables() doesn't return the correct variable state.
I've made a small script to illustrate the situation:
def test1 = 1;
test1 = 2;
int test2 = 1;
test2 = 2;
test3 = 1;
test3 = 2;
def errLog=new File("c:/temp/groovy_debug.txt");
errLog.append("--------------------------------------------------------" + "\n");
errLog.append(" Context ["+getBinding().getVariables()+" ] \n");
errLog.append("--------" + "\n") ;
after the execution I get a very strange result
--------------------------------------------------------
Context [[[creationStackTrace= <not available>], test1:null, errLog:null, test2:null, test3:2] ]
--------
it means that the declared variables are always reported as null or as first assignment, but for not typed variables it get the last value.
I would like to get the last situation for all variables (value=2).
Is it possible to get them?
Tim Yates' answer illustrates why you're having difficulty accessing the non-global variables. For simple cases like yours, where you just have assignments and declarations, you can use a visitor to collect the results, like this
import org.codehaus.groovy.ast.expr.*
import org.codehaus.groovy.ast.stmt.*
import org.codehaus.groovy.ast.*
import org.codehaus.groovy.control.*
import org.codehaus.groovy.classgen.*
import java.security.CodeSource
def scriptText = '''
def test1 = 1;
test1 = 2;
int test2 = 1;
test2 = 2;
test3 = 1;
test3 = 2;
'''
class VariableVisitor extends ClassCodeVisitorSupport {
def vars = [:]
void visitExpressionStatement(ExpressionStatement statement) {
if (statement.expression instanceof BinaryExpression)
vars.put(statement.expression.leftExpression.name, statement.expression.rightExpression.value)
super.visitExpressionStatement(statement)
}
void visitReturnStatement(ReturnStatement statement) {
if (statement.expression instanceof BinaryExpression)
vars.put(statement.expression.leftExpression.name, statement.expression.rightExpression.value)
super.visitReturnStatement(statement)
}
protected SourceUnit getSourceUnit() {
return source;
}
}
class CustomSourceOperation extends CompilationUnit.PrimaryClassNodeOperation {
CodeVisitorSupport visitor
void call(SourceUnit source, GeneratorContext context, ClassNode classNode) throws CompilationFailedException {
classNode.visitContents(visitor)
}
}
class MyClassLoader extends GroovyClassLoader {
CodeVisitorSupport visitor
protected CompilationUnit createCompilationUnit(CompilerConfiguration config, CodeSource source) {
CompilationUnit cu = super.createCompilationUnit(config, source)
cu.addPhaseOperation(new CustomSourceOperation(visitor: visitor), Phases.CLASS_GENERATION)
return cu
}
}
def visitor = new VariableVisitor()
def myCL = new MyClassLoader(visitor: visitor)
def script = myCL.parseClass(scriptText)
assert visitor.vars == ["test1":2, "test2":2, "test3":2]
However, if your script has more complicated stuff going on (like conditional assignments), you'll need something more sophisticated. You'd need to actually run the script and collect the results.