Skip to content

Instantly share code, notes, and snippets.

@johnelm
Last active December 14, 2017 20:07
Show Gist options
  • Select an option

  • Save johnelm/c8c207dfe7ed42b0f5ecccf0990eb2d3 to your computer and use it in GitHub Desktop.

Select an option

Save johnelm/c8c207dfe7ed42b0f5ecccf0990eb2d3 to your computer and use it in GitHub Desktop.
Sequence Tweaking Spock Extension

Sequence Tweaking Spock Annotation Extension

Over the last few weeks I've been practicing algorithms on Codility and Leetcode.

The algorithm puzzles on Codility and Leetcode almost use int and/or int[] arguments and return types, and sometimes have BigO performance requirements. To whip up test cases in my IDE, I started using the data tables feature in the awesome Spock testing framework, and right away I started creating some helper methods to help with the generation of int[] sequences for input to the algorithms I was practicing.

Here's a simple example (below) of what I was doing.

  • I had a range() helper method (lines 6-9) in my Specification class, which I called from within my Spock data column to generate a range of ints..
  • I specified values for arbitrary sequence positions in a separate indexReplacements column of index:value maps. The replacements were performed by the code at lines 15-19, below.

Simple beginnings

import spock.lang.*
class MySpecification extends Specification {

    def solution = new AlgorithmSolution()

    // helper for generation of a range of ints
    ArrayList range( int from, int to ) {
        return IntStream.rangeClosed( from, to ).boxed().collect( Collectors.toList() )
    }

    @Unroll
    def "given input sequence #sequence with #indexReplacements, the result is #result"() {
        given:
            // replace values at specified positions if indexReplacements is not empty
            if ( !!indexReplacements ) {
                indexReplacements.each { position, value ->
                    sequence[ position ] = value
                }
            }            

        expect:
            solution.solution( sequence ) == result

        where:
            result | sequence        | indexReplacements
            5      | [ 1, 2, 3 ] * 2 | [ : ]              // 1, 2, 3, 1, 2, 3
            5      | range(1, 5) * 2 | [ 1: 99, 8: 99 ]   // 1, 99, 3, 4, 5, 1, 2, 3, 99, 5
            42     | [ 9 ] * 10_000  | [ 0: 0, 9_999: 0 ] // 0, 9, 9, 9 ...  9, 9, 9, 0 
            42     | range(1, 100)   | [ 0: 100, 99: 1 ]  // 100, 2, 3, 4 ...  97, 98, 99, 1

This worked pretty well, but I didn't like cluttering the feature method (or even the Specification) so much. The helper code distracted from the semantics of the solution and the functionality being tested.

Also, while Spock's data tables are very expressive, it can be a little tedious to add new facets to the generation of test data. For example, say I wanted to add a bit more utility to the Specification:

// (previous code omitted)
        given:
            if ( !!indexReplacements ) {
                indexReplacements.each { position, value ->
                    sequence[ position ] = value
                }
            }
            if ( !!valueExclusions ) // NEW CODE
                sequence.removeAll( valueExclusions ) 

        expect:
            solution.solution( sequence ) == result

        where:                                          // NEW COLUMN: values (i.e. empty lists) required 
            result | sequence         | indexReplacements  | valueExclusions
            5      | [ 1, 2, 3 ] * 2  | [ : ]              | [ ]
            5      | range(1, 5) * 2) | [ 1: 99, 8: 99 ]   | [ ]  
            42     | [ 9 ] * 10_000   | [ 0: 0, 9_999: 0 ] | [ ]
            5      | range(1, 100)    | [ 0: 100, 99: 1 ]  | [ ]
            42     | range(1, 10_000) | [ : ]              | [ 5_000, 5_001 ]  // <===  new test case

The new column requires a value for each row - requiring the addition of an empty list ([]) to every test case just to keep everything working. That's a lot of table fiddling just to something new for one test case. Plus, while this example only added two new lines of helper code, it further clutters the Specification and detracts from readability and clarity.

Improvement options

It's pretty easy to improve things by extracting helpers like the range() method above into a subclass of Spock's Specification base class, but most of the fiddling I was doing was adding and changing the columns in the data tables. And the other (non-method) utility code that uses column data for manipulation of the sequence, isn't easily abstracted into a superclass like range() is.

Aside from practicing algorithms, I've also been catching up on Java, which I loved and used for many years, but not since Java 5 came out. So, I saw this as a chance to practice custom Generics, Annotations, Lambdas, etc. I decided to write a Spock extension to replace the helper fixtures I had. Got to play with Groovy and practice custom Annotations.

Just a side note on Groovy - aside from a bit of build and deploy scripting about ten years ago, I hadn't done much with Groovy.. but I'm loving it.. a lot of Groovy's features (e.g. Closures) are pretty familiar from my recent years in ES6+ and Node.js.. it's like a perfect marriage of Java with the dynamic freedom I've grown accustomed to from using JS.

Here's what I wound up with. The extension is enabled by simply by applying the @TweakSequence Annotation to the Feature Method. It accepts one argument, for specifying which data parameter (column) contains the sequence you want to fiddle with ('sequence' is the default):

Enabling the extension

    @Unroll
    @TweakSequence("A") // ('sequence' is the default)
    def "#iterationCount: result is the expected #result"() {
    ...
    }

For the remainder of this article, I use 'sequence column' to refer to the column specified to the annotation, and since I'm solely discussing data tables for data-driven testing in Spock, I refer to Spock data variables as 'columns'. TODO - rename columns/parameters to data variables

Once this is done, the extension makes a few 'tweak' utilities available for the Spock feature: range, indexReplacements, valueReplacements, valueExclusions, sequence, and tweaks. It's easiest to show how they're used in Spock data tables:

    @Unroll
    @TweakSequence
        def "the sum of #sequence is #result"() {
        
        expect:
            IntStream.of( sequence ).sum == result
    
        where:
    
            result | sequence
            12     | [ 1, 2, 3, 1, 2, 3 ]
            12     | [ 1, 2, 3 ] * 2

Both of the rows in the table above are equivalent - Spock (actually Groovy) allow us to easiy repeat a List via 'multiplying' it.

Now let's apply the TweakSequence Spock extension for another way - all of these are also equivalent:

    @Unroll
    @TweakSequence
        def "the sum of #sequence is #result"() {
        
        expect:
            IntStream.of( sequence ).sum == result
        where:
            result | sequence
            12     | [ 1, 2, 3, 1, 2, 3 ]
            12     | [ 1, 2, 3 ] * 2
            12     | [ range: [ start: 1, end: 3 ]] * 2
            12     | [ range: [ start: 1, end: 3, repeat: 2 ]]

In the last two rows, a Map is specified instead of a List in the sequence column, which in turn, contains a range map with start and end attribute. When a map containing range is provided, the extension uses its start and end values to generate the sequence. It also recognizes repeat and step values, which do exactly what their names imply. If the start value is greater than end, the sequence is reversed. So, all these are equivalent:

        expect:
            IntStream.of( sequence ).sum == result
        where:
            result | sequence
            -6     | [ 2, -1, -4, 2, -1, -4 ]
            -6     | [ 2, -1, -4 ] * 2
            -6     | [ range: [ start: 2, end: -4, step: 3, repeat: 2 ]

Now let's bring back the indexReplacements utility I was using earlier, now available via the extension as a 'tweak'. There are a few ways to use it:

        expect:
            sequence == result
        where:
            result       | sequence                                                      | indexReplacements
            [ 1, 99, 3 ] | [ 1, 2, 3 ]                                                   | [ 1: 99 ]
            [ 1, 99, 3 ] | [ range: [ start: 1, end: 3 ], indexReplacements: [ 1: 99 ] ] | [ : ]
            [ 1, 99, 3 ] | [ sequence: [ 1, 2, 3 ], indexReplacements: [ 1: 99 ] ]       | [ : ]
            

In the first two rows, we see that if we provide an indexReplacements data column of position:value mappings, the replacements are performed like before.

In the third row, we see that if we're using range to generate the sequence, we can include the indexReplacements mappings within the same map as the range.

In the fourth and final row, we see something new: using the name of the sequence column again in the map allows us to specify a List literal as the source sequence, plus any other tweaks within the map, all within the one column. This way, we can specify all our sequences and tweaks without adding any columns. Essentially this is the same that we did earlier with range, but where we don't need to generate a range for the sequence.

There's one final way to specify a tweak. Let's bring back the valueExclusions tweak to demonstrate it:

            result       | sequence                        | tweaks
            [ 1, 3 ]     | [ 1, 2, 3 ]                     | [ valueExclusions: [ 2 ] ]
            [ 1, 3 ]     | [ range: [ start: 1, end: 3 ] ] | [ valueExclusions: [ 2 ] ]
            [ 99, 3 ]    | [ 1, 2, 3 ]                     | [ valueExclusions: [ 2 ], indexReplacements: 0, 99 ]

Here we see that we can provide a tweaks column containing a map of our tweaks. This way you can have a single column for all your tweaks, but separate from the sequence column.

Note: an alternate name for the tweaks column can be specified to the extension via the annotation: TweakSequence(tweaksParameterName="otherTweaks")

I find this is a decent separation and when setting up tests for a new challenge, I'll usually include a single tweaks column. But I still find all the methods of specifying tweaks useful. If there's one tweak that I'll apply to almost every row in a table, it's much less cluttered to include a dedicated column for it.

On the other hand, including a tweak in a map (within the sequence or tweaks column) is a quick way to experiment with one-off tweaks here and there, without adding a new column.

Now let's review everything that's available with the extension:

Available tweaks

Tweak required how named what it does How available Contents
sequence
(as a data column)
yes default is sequence. Can be specified via the sequenceParameterName annotation attribute: @TweakSequence( sequenceParameterName="alternateName" specifies the (pre-tweak) sequence * As a data column
* As a map, within the sequence column
List, e.g. [ 1, 2, 3 ]
or
[ sequence: [ 1, 2, 3 ] ]
sequence
range always 'range' * As a map, within the sequence column Map with attributes: start, end, step, repeat, e.g
[ start: 1, end: 3, step: 2, repeat: 2 ]
start and end are required
tweaks 'tweaks' can be overridden via the tweaksParameterName annotation attribute:@TweakSequence( sequenceParameterName="alternateName" As a data column each row specifies a Map with zero or more tweaks
indexReplacements * As a data column* As a map, within the sequence column or the tweaks column Map - of location / value mappings
valueExclusions * As a data column* As a map within the sequence column* As a map within the tweaks column List

range

Where available:

indexReplacements

expected: map

indexExclusions

valueExclusions

expected: List

valueReplacements

Adding new tweaks

use an annitation to make any method available as a tweaks

            6      | [ 1, 3, 4 ] * 2                                 | [ indexReplacements: [ 3: 100 ] ]
            3      | [ ].range( 1, 10 )                              | [ indexReplacements: [ 5: 1 ] ]
            3      | [ range: [ start: 1, end: 10 ] ]                | [ indexReplacements: [ 5: 1 ], valueExclusions: [ 5, 6 ] ]
            3      | [ range: [ start: 1, end: 10 ] ]                | [ indexReplacements: [ 5: 1 ], valueExclusions: [ 5, 6 ] ]
            20_002 | [ 10001 ] * 5                                   | [ : ]
            10     | [ 5, 6, 5, 6 ] * 8                              | [ : ]
            2      | [ 1 ] * 5                                       | [ : ]
            2      | [ 1 ] * 5                                       | [ : ]
            2      | [ 1 ] * 10                                      | [ : ]
            4      | [ 2 ] * 100_000                                 | [ indexReplacements: [ 0: 1, 100_000: 1 ] ]
            2      | [ 2 ] * 100_000                                 | [ indexReplacements: [ 1: 1, 99_998: 1 ] ]
            3      | [ range: [ start: 1, end: 100 ] ]               | [ indexReplacements: [ 98: 1 ] ]
            6      | [ range: [ start: 10, end: 1 ] ]                | [ : ]
            6      | [ range: [ start: 100, end: 1 ] ]               | [ : ]
            6      | [ range: [ start: 1, end: 100 ] ]               | [ indexReplacements: [ 2: 1 ] ]
            20     | [ range: [ start: 10, end: 100, repeat: 100 ] ] | [ indexReplacements: [ 2: 100 ] ]
            3      | [ range: [ start: 1, end: 100 ] ]               | [ indexReplacements: [ 3: 1 ] ]
            6      | [ range: [ start: 1, end: 100_000 ] ]           | [ valueExclusions: [ 5, 6 ] ]
            2      | [ range: [ start: 1, end: 100_000 ] ]           | [ indexReplacements: [ 1: 1, 99_998: 1 ] ]
            3      | [ range: [ start: 1, end: 100_000 ] ]           | [ indexReplacements: [ 99_998: 1 ] ]
            12     | [ range: [ start: 100_000, end: 10 ] ]          | [ indexReplacements: [ 5555: 1 ] ]







    }

    @Unroll
    @TweakSequence
        def "#iterationCount: result is the expected #result"() {
    
        where:
    
            result | sequence                                        | tweaks
            7      | [ 5 ] * 7                                       | [ indexReplacements: [ 1: 2, 2: 2 ] ]
            6      | [ 1, 3, 4 ] * 2                                 | [ indexReplacements: [ 3: 100 ] ]
            3      | [ ].range( 1, 10 )                              | [ indexReplacements: [ 5: 1 ] ]
            3      | [ range: [ start: 1, end: 10 ] ]                | [ indexReplacements: [ 5: 1 ], valueExclusions: [ 5, 6 ] ]
            3      | [ range: [ start: 1, end: 10 ] ]                | [ indexReplacements: [ 5: 1 ], valueExclusions: [ 5, 6 ] ]
            20_002 | [ 10001 ] * 5                                   | [ : ]
            10     | [ 5, 6, 5, 6 ] * 8                              | [ : ]
            2      | [ 1 ] * 5                                       | [ : ]
            2      | [ 1 ] * 5                                       | [ : ]
            2      | [ 1 ] * 10                                      | [ : ]
            4      | [ 2 ] * 100_000                                 | [ indexReplacements: [ 0: 1, 100_000: 1 ] ]
            2      | [ 2 ] * 100_000                                 | [ indexReplacements: [ 1: 1, 99_998: 1 ] ]
            3      | [ range: [ start: 1, end: 100 ] ]               | [ indexReplacements: [ 98: 1 ] ]
            6      | [ range: [ start: 10, end: 1 ] ]                | [ : ]
            6      | [ range: [ start: 100, end: 1 ] ]               | [ : ]
            6      | [ range: [ start: 1, end: 100 ] ]               | [ indexReplacements: [ 2: 1 ] ]
            20     | [ range: [ start: 10, end: 100, repeat: 100 ] ] | [ indexReplacements: [ 2: 100 ] ]
            3      | [ range: [ start: 1, end: 100 ] ]               | [ indexReplacements: [ 3: 1 ] ]
            6      | [ range: [ start: 1, end: 100_000 ] ]           | [ valueExclusions: [ 5, 6 ] ]
            2      | [ range: [ start: 1, end: 100_000 ] ]           | [ indexReplacements: [ 1: 1, 99_998: 1 ] ]
            3      | [ range: [ start: 1, end: 100_000 ] ]           | [ indexReplacements: [ 99_998: 1 ] ]
            12     | [ range: [ start: 100_000, end: 10 ] ]          | [ indexReplacements: [ 5555: 1 ] ]







    }






PS: For convenience, the sequence, for which ArrayList is its natural form in Groovy, is converted to an int[]




Lots of learning..  Java.. and algorithms.

all pretty easy by subclassing Spock's Specification class, but I thought it would be fun (and convenient) to get it done with a simple annotation.

New to annotations

New to groovy, but Javascript concepts helped a lot.

New to Spock

Needing to generate very large input arrays, of sizes up to 100,000 and values from +/- 1 billion.

As I worked through the problems, I wanted to be able to quickly create test cases..  edge cases and very large inputs so I could check out their correctness and how they perform

I'd done some basic Groovy build scripting about ten years ago


To use, simply use the annotation on your feature method:

```groovy


Usage

There are several ways.. So you shouldn't have to rewrite your data table to add columns etc. just to use a tweak once or twice.

            result | sequence                                        | tweaks
            7      | [ 5 ] * 7                                       | [ indexReplacements: [ 1: 2, 2: 2 ] ]
            6      | [ 1, 3, 4 ] * 2                                 | [ indexReplacements: [ 3: 100 ] ]
            3      | [ ].range( 1, 10 )                              | [ indexReplacements: [ 5: 1 ] ]
            3      | [ range: [ start: 1, end: 10 ] ]                | [ indexReplacements: [ 5: 1 ], valueExclusions: [ 5, 6 ] ]
            3      | [ range: [ start: 1, end: 10 ] ]                | [ indexReplacements: [ 5: 1 ], valueExclusions: [ 5, 6 ] ]
            20_002 | [ 10001 ] * 5                                   | [ : ]
            10     | [ 5, 6, 5, 6 ] * 8                              | [ : ]
            2      | [ 1 ] * 5                                       | [ : ]
            2      | [ 1 ] * 5                                       | [ : ]
            2      | [ 1 ] * 10                                      | [ : ]
            4      | [ 2 ] * 100_000                                 | [ indexReplacements: [ 0: 1, 100_000: 1 ] ]
            2      | [ 2 ] * 100_000                                 | [ indexReplacements: [ 1: 1, 99_998: 1 ] ]
            3      | [ range: [ start: 1, end: 100 ] ]               | [ indexReplacements: [ 98: 1 ] ]
            6      | [ range: [ start: 10, end: 1 ] ]                | [ : ]
            6      | [ range: [ start: 100, end: 1 ] ]               | [ : ]
            6      | [ range: [ start: 1, end: 100 ] ]               | [ indexReplacements: [ 2: 1 ] ]
            20     | [ range: [ start: 10, end: 100, repeat: 100 ] ] | [ indexReplacements: [ 2: 100 ] ]
            3      | [ range: [ start: 1, end: 100 ] ]               | [ indexReplacements: [ 3: 1 ] ]
            6      | [ range: [ start: 1, end: 100_000 ] ]           | [ valueExclusions: [ 5, 6 ] ]
            2      | [ range: [ start: 1, end: 100_000 ] ]           | [ indexReplacements: [ 1: 1, 99_998: 1 ] ]
            3      | [ range: [ start: 1, end: 100_000 ] ]           | [ indexReplacements: [ 99_998: 1 ] ]
            12     | [ range: [ start: 100_000, end: 10 ] ]          | [ indexReplacements: [ 5555: 1 ] ]




            result        | sequence                                           | indexReplacements
            10            | [ 1 ] * 5                                          | [ : ]
            9             | [ 1, 2, 3 ] * 3                                    | [ : ]
            18            | [ 1, 2, 3 ] * 4                                    | [ : ]

            result        | [ 1, 2, 3, 4, 5 ]                                  | [ 1: 0, 3: 0 ]



            result        | sequence                                           | indexReplacements
            10            | [ 1 ] * 5                                          | [ : ]
            9             | [ 1, 2, 3 ] * 3                                    | [ : ]
            18            | [ 1, 2, 3 ] * 4                                    | [ : ]
            15            | [ 1, 2, 3, 4, 5 ] * 3                              | [ : ]
            50            | [ 1, 2, 3, 4, 5 ] * 5                              | [ : ]
            49995000      | [ 1 ] * 10_000                                     | [ : ]
            1_000_000_000 | [ range: [ start: 0, end: 20, repeat: 10_000 ] ]   | [ : ]
            1_000_000_000 | [ range: [ start: -10, end: 10, repeat: 10_000 ] ] | [ : ]
            1_000_000_000 | [ range: [ start: -10, end: 10, repeat: 9_999 ] ]  | [ : ]
            0             | [ range: [ start: 50_000, end: -49_999 ] ]         | [ : ]
            1             | [ range: [ start: 50_000, end: -49_999 ] ]         | [ 0: 0 ]
            5             | [ range: [ start: 50_000, end: -49_999 ] ]         | [ 0: 0, 100: 1, 200: 2, 30_000: 3, 70_000: 4 ]
            21            | [ range: [ start: 50_000, end: -49_999 ] ]         | [ 0: 0, 100: 0, 200: 0, 30_000: 0, 70_000: 0, 90_000: 0 ]

            result        | sequence                                           | indexReplacements
            10            | [ ]                                                | [ indexReplacements: [ 1: 0, 3: 0 ], valueExclusions: [ 1, 2 ] ]


            result        | sequence                                           | indexReplacements

            10            | [ sequence: [ 1, 2, 3, 4, 5, ], indexReplacements: [ 1: 0, 3: 0 ], valueExclusions: [ 1, 2 ] ]
            [ range: [ start: 1, end: 5 ], indexReplacements: [ 1: 0, 3: 0 ], valueExclusions: [ 1, 2 ] ]

Notes: watch out for order of precedence.at the moment, they're applied in this order:

when you're generating very large sequences, you probably want to avoid including the entire sequence in the name of the feature method (i.e..if you're naming using Spock's @Unroll annotation).

any provided are added after these in order

and of course any operations added afterward, outside the sequence

import spock.lang.*
class ExampleSpecification extends Specification {
@Unroll
@TweakSequence
def "#iterationCount: result given #sequence is #result"() {
expect:
sequence == result
where:
result | sequence | indexReplacements
[ 1, 99, 3 ] | [ 1, 2, 3 ] | [ 1: 99 ]
[ 1, 99, 3 ] | [ range: [ start: 1, end: 3 ] ] | [ 1: 99 ]
[ 1, 99, 3 ] | [ range: [ start: 1, end: 3 ], indexReplacements: [ 1: 99 ] ] | [ : ]
[ 1, 99, 3 ] | [ sequence: [ 1, 2, 3 ], indexReplacements: [ 1: 99 ] ] | [ : ]
}
// @Unroll
// @TweakSequence
// def "#iterationCount: result is the expected #result"() {
// given:
//
// def string = new String()
//
// expect:
//
// solution.solution( sequence.toArray() as int[] ) == result
//
//
// where:
//
// result | sequence | indexReplacements
// 10 | [ 1 ] * 5 | [ : ]
// 9 | [ 1, 2, 3 ] * 3 | [ : ]
// 18 | [ 1, 2, 3 ] * 4 | [ : ]
//
// result | [ 1, 2, 3, 4, 5 ] | [ 1: 0, 3: 0 ]
//
//
//
// result | sequence | indexReplacements
// 10 | [ 1 ] * 5 | [ : ]
// 9 | [ 1, 2, 3 ] * 3 | [ : ]
// 18 | [ 1, 2, 3 ] * 4 | [ : ]
// 15 | [ 1, 2, 3, 4, 5 ] * 3 | [ : ]
// 50 | [ 1, 2, 3, 4, 5 ] * 5 | [ : ]
// 49995000 | [ 1 ] * 10_000 | [ : ]
// 1_000_000_000 | [ range: [ start: 0, end: 20, repeat: 10_000 ] ] | [ : ]
// 1_000_000_000 | [ range: [ start: -10, end: 10, repeat: 10_000 ] ] | [ : ]
// 1_000_000_000 | [ range: [ start: -10, end: 10, repeat: 9_999 ] ] | [ : ]
// 0 | [ range: [ start: 50_000, end: -49_999 ] ] | [ : ]
// 1 | [ range: [ start: 50_000, end: -49_999 ] ] | [ 0: 0 ]
// 5 | [ range: [ start: 50_000, end: -49_999 ] ] | [ 0: 0, 100: 1, 200: 2, 30_000: 3, 70_000: 4 ]
// 21 | [ range: [ start: 50_000, end: -49_999 ] ] | [ 0: 0, 100: 0, 200: 0, 30_000: 0, 70_000: 0, 90_000: 0 ]
//
// result | sequence | indexReplacements
// 10 | [ ] | [ indexReplacements: [ 1: 0, 3: 0 ], valueExclusions: [ 1, 2 ] ]
//
//
// result | sequence | indexReplacements
//
// 10 | [ sequence: [ 1, 2, 3, 4, 5, ], indexReplacements: [ 1: 0, 3: 0 ], valueExclusions: [ 1, 2 ] ]
// [ range: [ start: 1, end: 5 ], indexReplacements: [ 1: 0, 3: 0 ], valueExclusions: [ 1, 2 ] ]
// }
}
// Copyright (c) 2017 John Elm
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
import org.spockframework.runtime.extension.*
import org.spockframework.runtime.model.*
import java.lang.annotation.*
import java.util.stream.*
@Retention( RetentionPolicy.RUNTIME )
@Target( ElementType.METHOD )
@ExtensionAnnotation( SequenceTweakingIterationExtension )
@interface TweakSequence {
String sequenceParameterName() default "sequence"
String tweaksParameterName() default "tweaks"
}
@Retention( RetentionPolicy.RUNTIME )
@Target( ElementType.METHOD )
@interface TweaksSequence {} // methods with this annotation are added to the tweakOperations map
// TODO consider enforcing that the methods are static
// TODO consider using BiFunctions instead to enforce <List>, <List>, <Object> signature
class SequenceTweakingIterationExtension extends AbstractAnnotationDrivenExtension<TweakSequence> {
private final static PARAMETER_NAME_RANGE = "range"
@Override
void visitFeatureAnnotation( TweakSequence annotation, FeatureInfo feature ) {
def parameterNames = feature.getParameterNames()
def sequenceParameterName = annotation.sequenceParameterName()
def tweaksParameterName = annotation.tweaksParameterName()
int parameterIndexToFiddleWith = parameterNames.indexOf( sequenceParameterName )
def dataValues = null
feature.addIterationInterceptor(
new IMethodInterceptor() {
@Override
void intercept( IMethodInvocation invocation ) throws Throwable {
IterationInfo iteration = invocation.getIteration()
dataValues = iteration.getDataValues() // capture the data values via closure
invocation.proceed()
}
}
)
feature.getFeatureMethod().addInterceptor(
new IMethodInterceptor() {
@Override
void intercept( IMethodInvocation invocation ) throws Throwable {
// now manipulate the data
def inputSequence = dataValues[ parameterIndexToFiddleWith ]
def tweaks
// TODO support things like valueReplacements, indexExclusions (?)
def tweakOperations = [
//TODO allow addition of these tweaks from the specification
indexReplacements: { theMap, List inputList ->
if ( !( theMap instanceof Map ) ) {
throw new RuntimeException( "indexReplacements must be a map of indexes and replacements (encountered: $theMap)" )
}
theMap.each { position, value ->
inputList[ position ] = value
}
return inputList
},
valueExclusions : { valuesToExclude, List inputList ->
inputList.removeAll( valuesToExclude ) // or return inputList.filter{ i -> !valuesToExclude.contains( i ) }
return inputList
}
]
def specInstance = invocation.sharedInstance
def methods = specInstance.getClass().getMethods()
// http://mrhaki.blogspot.com/2009/08/groovy-goodness-turn-methods-into.html
// groovy has some utilities that might help locate and invoke the function easier
annotation.tweakFunctions().toList().forEach( { functionFromAnnotation ->
def functionName = functionFromAnnotation.value()
List nameMatchingMethods = methods.toList().stream().filter { method -> method.getName() == functionName }.toArray()
if ( nameMatchingMethods.isEmpty() )
throw new NoSuchMethodError( "Method $functionName not found" )
def firstMatchingMethod = nameMatchingMethods[ 0 ]
tweakOperations[ functionFromAnnotation.value() ] = { arg, List inputList ->
firstMatchingMethod.invoke( specInstance, arg, inputList )
}
} )
// play here with data values - extract the parameter names so I can do this in reverse -
// and seek methods in the spec class that match the parameter name
def getDataForParameterName = { parameterName ->
parameterName
if ( inputSequence instanceof Map && !!inputSequence[ parameterName ] ) {
return inputSequence[ parameterName ]
}
if ( parameterNames.contains( parameterName ) ) {
return dataValues[ parameterNames.indexOf( parameterName ) ]
}
if ( !!tweaks ) {
return tweaks[ parameterName ];
}
return null
}
tweaks = getDataForParameterName( tweaksParameterName )
// if the parameter specified by the annotation is a map (potentially containing tweaks also),
// get the sequence out of the map. henceforth the subject sequence is newSequence
def newSequence = inputSequence
if ( inputSequence instanceof Map ) {
if ( !!inputSequence[ sequenceParameterName ] ) {
if ( !!inputSequence[ PARAMETER_NAME_RANGE ] ) {
throw new RuntimeException( "either '$sequenceParameterName' or 'range' must be provided, not both" )
}
newSequence = inputSequence[ sequenceParameterName ] // List literal
} else if ( !!inputSequence[ PARAMETER_NAME_RANGE ] ) {
//TODO convert this into a function and genericize the tweakOperations map to handle data generation, i.e. random
def range = inputSequence[ PARAMETER_NAME_RANGE ]
def start = range.start ?: 0, end = range.end ?: 0, step = range.step ?: 1, repeat = range.repeat ?: 1
// disappointed that groovy doesn't support destructuring assignment from maps :-(
def stepFilterFunction = { i -> ( i - start ) % step == 0 }
def orderingFunction = { i -> i }
if ( start > end ) {
orderingFunction = { i -> end - i + start }
( start, end ) = [ end, start ]
}
newSequence = IntStream.rangeClosed( start, end )
.map( orderingFunction )
.filter( stepFilterFunction )
.boxed().collect( Collectors.toList() ) * repeat
} else {
throw new RuntimeException( "the specified parameter '$sequenceParameterName' is a Map, so one of '$sequenceParameterName' or 'range' attributes must be provided." )
}
}
if ( !( newSequence instanceof List ) ) {
throw new RuntimeException( "the derived '$sequenceParameterName' data parameter must be a List (found: $newSequence)" )
}
tweakOperations.each { operationName, operation ->
def operationData = getDataForParameterName( operationName )
if ( !!operationData ) {
// if manipulations have been specified but the specified parameter name (table column)
// doesn't exist, throw an exception.
if ( parameterIndexToFiddleWith < 0 ) {
throw new RuntimeException( "data parameter $sequenceParameterName does not exist" )
}
newSequence = operation( operationData, newSequence )
}
}
// TODO now add a new #sequenceTweakDescription parameter, per the bottom of
// http://spockframework.org/spock/docs/1.1-rc-4/extensions.html#_injecting_method_parameters
invocation.arguments[ parameterIndexToFiddleWith ] = newSequence.toArray() as int[]
invocation.proceed()
}
}
)
}
}
import spock.lang.*
class TweakSequenceUtilSpecification extends Specification {
@Unroll
@TweakSequence
def "#iterationCount): sequence using indexReplacements column correctly results as #result"() {
expect:
sequence == result
where:
result | sequence | indexReplacements
[ 1, 99, 3 ] | [ 1, 2, 3 ] | [ 1: 99 ]
[ 1, 99, 3 ] | [ range: [ start: 1, end: 3 ] ] | [ 1: 99 ]
}
@Unroll
@TweakSequence
def "#iterationCount): sequence or range specified with tweaks in a single map correctly results in #result"() {
expect:
sequence == result
where:
result | sequence
[ 1, 2, 3 ] | [ 1, 2, 3 ]
[ 1, 2, 3 ] | [ range: [ start: 1, end: 3 ] ]
[ 1, 99, 3 ] | [ range: [ start: 1, end: 3 ], indexReplacements: [ 1: 99 ] ]
[ 1, 99, 3 ] | [ sequence: [ 1, 2, 3 ], indexReplacements: [ 1: 99 ] ]
}
@Unroll
@TweakSequence
def "#iterationCount): sequence using tweak column correctly results as #result"() {
expect:
sequence == result
where:
result | sequence | tweaks
[ 1, 3 ] | [ 1, 2, 3 ] | [ valueExclusions: [ 2 ] ]
[ 1, 3 ] | [ range: [ start: 1, end: 3 ] ] | [ valueExclusions: [ 2 ] ]
}
@Unroll
@TweakSequence( tweaksParameterName = "otherTweaks" )
def "#iterationCount): alternate tweaks column: sequence using tweak column correctly results as #result"() {
expect:
sequence == result
where:
result | sequence | otherTweaks
[ 1, 3 ] | [ 1, 2, 3 ] | [ valueExclusions: [ 2 ] ]
[ 1, 3 ] | [ range: [ start: 1, end: 3 ] ] | [ valueExclusions: [ 2 ] ]
}
@Unroll
@TweakSequence( sequenceParameterName = "A" )
def "#iterationCount): alternate sequence name: sequence #A embedded in map correctly results as #result"() {
expect:
A == result
where:
result | A
[ 1, 2, 3 ] | [ 1, 2, 3 ]
[ 1, 2, 3 ] | [ A: [ 1, 2, 3 ] ]
[ 1, 99, 3 ] | [ A: [ 1, 2, 3 ], indexReplacements: [ 1: 99 ] ]
}
@TweaksSequence
static List addNumberToSequence( int numberToAdd, List sequence ) {
return sequence.stream().mapToInt { i -> i + numberToAdd }.toArray()
}
@TweaksSequence
static List subtractNumberFromSequence( int numberToSubtract, List sequence ) {
return sequence.stream().mapToInt { i -> i - numberToSubtract }.toArray()
}
@TweaksSequence
static List reverseSequence( boolean whetherToReverse, List sequence ) {
if ( !whetherToReverse ) return sequence
return new LinkedList( sequence ).descendingIterator().toList()
}
@Unroll
@TweakSequence
def "#iterationCount): added TweakFunctions are used correctly: #sequence"() {
expect:
sequence == result
where:
result | sequence
[ 1, 2, 3 ] | [ 1, 2, 3 ]
[ 1, 2, 3 ] | [ sequence: [ 1, 2, 3 ] ]
[ 2, 3, 4 ] | [ sequence: [ 1, 2, 3 ], addNumberToSequence: 1 ]
[ 0, 1, 2 ] | [ sequence: [ 1, 2, 3 ], subtractNumberFromSequence: 1 ]
[ 1, 2, 3 ] | [ sequence: [ 1, 2, 3 ], reverseSequence: false ]
[ 3, 2, 1 ] | [ sequence: [ 1, 2, 3 ], reverseSequence: true ]
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment