Part 2 of 5 in our series on TestBox 7.1's 47 new assertions. Read the release announcement.
Before exploring the 14 range matchers, let’s take a closer look at the BoxLang Range itself. Ranges go well beyond the familiar .. syntax, providing a powerful and flexible foundation for working with sequences and boundaries. Understanding how ranges work makes the matcher API much easier to use and reason about. If you are coming from a CFML background, then this will be brand new to you as ranges have never existed until Boxlang.
Learn more about ranges in the BoxLang documentation.
What a Range actually is
In most languages, 1..10 is sugar for "build me an array of 10 numbers." In BoxLang, 1..10 produces a Range object: a lazy, typed, iterable interval. It does not materialize anything until you ask it to.
myRange = 1..5
// a Range object, not an array
arrayLen( 1..10 ) // 10, coerces on demand
arrayToList( 1..5, "," ) // "1,2,3,4,5"
That laziness is not a curiosity, it is the point. This does not allocate a hundred billion integers, and yes, BoxLang supports _ digit separators for ease of readability.
for( i in 1..100_000_000_000 ) {
result = i
break // instant
}
And a Range plugs directly into the Java Stream API:
(1..100_000_000_000).stream().limit( 5 ).toList() // [1, 2, 3, 4, 5]
(1..).stream().limit( 5 ).toList() // [1, 2, 3, 4, 5]
Four boundary modes
The .. operator has exclusive variants, not just the inclusive default:
1..5 // inclusive both: 1, 2, 3, 4, 5
1>..5 // exclude start: 2, 3, 4, 5
1..<5 // exclude end: 1, 2, 3, 4
1>..<5 // exclude both: 2, 3, 4
Bounded, half-bounded, and unbounded
A Range can be open on either end, or both:
1.. // open end, no upper bound
..5 // open start, no lower bound
.. // fully open, contains everything non-null
Half-bounded ranges are still iterable, since they have a starting point:
result = []
for( i in 1.. ) {
result.append( i )
if( i == 5 ) break
}
// [1, 2, 3, 4, 5]
Open-start and fully-open ranges cannot be iterated, there's nowhere to start counting from, but they still answer contains():
(1..).contains( 999 ) // true
(..5).contains( 3 ) // true
(..).contains( "anything" ) // true
(..).contains( null ) // false, null is never in any range
You can even constrain a fully open range to a type, using BoxLang's own casting rules:
(..).type( "number" ).contains( "foo" ) // false, wrong type
(..).type( "number" ).contains( "5" ) // true, coercible
(..).type( "integer" ).contains( 5.5 ) // false, not a whole integer
More than integers
This is where BoxLang Ranges depart from what most languages call a range. Any type with a natural ordering can form one:
// Decimals, direction auto-detected
3.5..1.5 // 3.5, 2.5, 1.5
// Characters
for( c in "a".."e" ) { } // a, b, c, d, e
// DateTime, iterates by day by default
start = createDate( 2024, 1, 1 )
end = createDate( 2024, 1, 5 )
arrayLen( start..end ) // 5
// Step by calendar unit instead of a raw number
(start..end).step( 1, "month" )
(start..end).step( 1, "week" )
Multi-character strings can form a range too, even though there's no well-defined "next string." You lose iteration, but you keep bounds checking:
r = "aaa".."zzz"
r.contains( "foo" ) // true, lexicographically between the bounds
r.isIterable() // false, no natural next value from "aaa"
And if you implement BoxLang's IRangeable interface on your own class, ranges work over anything with a logical progression, Fibonacci numbers, Roman numerals, musical notes, semantic versions, whatever your domain needs. That's beyond the scope of this post, but worth knowing the door is open. Full details are in the BoxLang Ranges documentation, including a two-part deep dive: Ranges Supercharged and Custom Types with IRangeable.
Stepping and contains() are connected
The step() method returns a new Range with a custom increment (Ranges are copy-on-write, the original is never mutated):
arrayToList( (1..10).step(2), "," ) // "1,3,5,7,9"
arrayToList( (10..1).step(-3), "," ) // "10,7,4,1"
Once a Range has a step other than 1, contains() stops being a simple bounds check and starts verifying step-reachability:
r = (1..10).step(3) // produces: 1, 4, 7, 10
r.contains( 4 ) // true, reachable
r.contains( 5 ) // false, inside the bounds but not on the step
This is the detail that trips people up the first time they meet it, and exactly the detail the toContainValue() matcher (below) is built to verify.
Clamping and position checks
(1..10).clamp( 11 ) // 10, snapped down to the high boundary
(1..10).clamp( 0 ) // 1, snapped up to the low boundary
(1..10).isValueBefore( -3 ) // true
(1..10).isValueAfter( 50 ) // true
Half-bounded ranges clamp to whichever boundary actually exists:
(5..).clamp( 2 ) // 5, no upper bound to snap to
(..10).clamp( 50 ) // 10
That's the real feature. Now the 14 matchers.
The 14 matchers
| Matcher | Checks |
|---|---|
toBeRange() | actual is a Range |
toContainValue( value ) | value falls within the range, respecting exclusivity and step-reachability |
toContainRange( other ) | other range is fully inside actual |
toBeInRange( range ) | (on a scalar) value is inside range |
toBeBeforeRange( other ) | actual ends before other begins |
toBeAfterRange( other ) | actual begins after other ends |
toBeBounded() | both getFrom() and getTo() are present |
toBeUnbounded() | neither endpoint is present |
toBeHalfBounded() | exactly one endpoint is present |
toBeIterable() | the range has a starting point and a stepper, so it can be enumerated |
toBeAscending() | step is positive |
toBeDescending() | step is negative |
toHaveStep( n ) | range advances by the given step |
toClampTo( value, expected ) | clamping value into the range yields expected |
All 14 support not and an optional custom failure message. Each one is a thin, readable wrapper over a real Range member method (contains(), isBounded(), isIterable(), clamp(), and so on), so once you know the type, the matchers need no separate memorizing.
A realistic example: pagination
A paginator is a range in disguise: a page has a start offset, an end offset, a page size (the step), and a clamped "don't go past the last page" rule. Here's a small SUT and a spec exercising the matchers against it, including the exclusive-boundary and half-bounded cases the earlier version of this example skipped.
Paginator.bx (the SUT)
/**
* Computes page windows over a total record count.
*/
class {
property name="totalRecords" type="numeric";
property name="pageSize" type="numeric";
function init( numeric totalRecords, numeric pageSize = 10 ){
variables.totalRecords = arguments.totalRecords
variables.pageSize = arguments.pageSize
return this
}
/**
* Returns the last valid page number.
*/
function lastPage(){
return max( 1, ceiling( variables.totalRecords / variables.pageSize ) )
}
/**
* Returns a Range representing valid page numbers, 1 through lastPage(), inclusive.
*/
function pageRange(){
return 1..lastPage()
}
/**
* Returns a Range of page numbers strictly after the first page,
* useful for "has a previous page" checks. Demonstrates an exclusive start.
*/
function pagesAfterFirst(){
return 1>..lastPage()
}
/**
* Returns a Range of record offsets (0-based) for the given page,
* stepped by pageSize.
*/
function offsetRange(){
return ( 0..( variables.totalRecords - 1 ) ).step( variables.pageSize )
}
/**
* Returns an open-ended range starting at the current last page,
* representing "any future page once more records arrive."
*/
function futurePageRange(){
return lastPage()..
}
/**
* Clamps an arbitrary requested page number into the valid page range.
*/
function clampPage( numeric requestedPage ){
return pageRange().clamp( arguments.requestedPage )
}
}
PaginatorSpec.bx (the spec)
/**
* Range Expectations in action: pagination
*/
class extends="testbox.system.BaseSpec" {
function run() {
describe( "Paginator", () => {
beforeEach( () => {
variables.paginator = new Paginator( totalRecords = 95, pageSize = 10 )
} )
it( "produces a bounded, ascending page range", () => {
var pages = paginator.pageRange()
expect( pages ).toBeRange()
expect( pages ).toBeBounded()
expect( pages ).toBeAscending()
expect( pages ).toContainValue( 1 )
expect( pages ).toContainValue( 10 )
} )
it( "computes the correct last page for an uneven total", () => {
var pages = paginator.pageRange()
expect( paginator.lastPage() ).toBe( 10 )
expect( pages ).notToContainValue( 11 )
} )
it( "excludes page 1 from the exclusive-start range", () => {
var afterFirst = paginator.pagesAfterFirst()
expect( afterFirst ).notToContainValue( 1 )
expect( afterFirst ).toContainValue( 2 )
expect( afterFirst ).toContainValue( 10 )
} )
it( "offset range steps by the page size, and respects step-reachability", () => {
var offsets = paginator.offsetRange()
expect( offsets ).toBeRange()
expect( offsets ).toHaveStep( 10 )
expect( offsets ).toContainValue( 90 )
// 95 is within bounds but not on the step, so it must not match
expect( offsets ).notToContainValue( 95 )
expect( offsets ).notToContainValue( 91 )
} )
it( "the future page range is half-bounded and iterable with a break", () => {
var future = paginator.futurePageRange()
expect( future ).toBeHalfBounded()
expect( future ).toBeIterable()
expect( future ).toContainValue( 10 )
expect( future ).toContainValue( 999 )
} )
it( "clamps a page request below the first page", () => {
expect( paginator.pageRange() ).toClampTo( 0, 1 )
expect( paginator.clampPage( 0 ) ).toBe( 1 )
} )
it( "clamps a page request past the last page", () => {
expect( paginator.pageRange() ).toClampTo( 999, 10 )
expect( paginator.clampPage( 999 ) ).toBe( 10 )
} )
it( "leaves an in-bounds page request untouched", () => {
expect( paginator.pageRange() ).toClampTo( 5, 5 )
} )
it( "one page's offset range ends before the next begins", () => {
var page1 = 0..9
var page2 = 10..19
expect( page1 ).toBeBeforeRange( page2 )
expect( page2 ).toBeAfterRange( page1 )
} )
it( "a single-page dataset yields a fully bounded, iterable range", () => {
var small = new Paginator( totalRecords = 3, pageSize = 10 )
expect( small.pageRange() ).toBeBounded()
expect( small.pageRange() ).toBeIterable()
expect( small.lastPage() ).toBe( 1 )
} )
} )
}
}
Run it:
box testbox run runner=PaginatorSpec.bx
BoxLang Only
Native Range and the .. operator are BoxLang exclusive features. The 14 matchers ship in TestBox 7.1 for every engine, but the type they were built for only exists here. If your pagination, windowing, retry backoff, or date-bucketing logic lives in BoxLang, this is the difference between an if ladder full of off-by-one risk and a handful of readable, step-aware assertions. For the complete picture, including custom stepping units, IRangeable, and the full member method list, see the BoxLang Ranges documentation.
Next up, Part 3: deep-path assertions for nested JSON-shaped data, no more six lines of chained structKeyExists().
box install testbox@7.1.0
Add Your Comment