Compare commits

..

1 Commits

Author SHA1 Message Date
Samuel
fd5e3e87c7 Optimize range iteration
For loops support a range syntax for iterating a fixed number of times,
which looks like this.

```liquid
{% for i in range (1..100) %}
        ...
{% endfor %}
```

Previously, we converted these ranges to arrays using `to_a`, which
initialized an array containing each number in the range. Since all we
use these ranges for is iteration, this is far less efficient than
using a range iterator.

Doing this means that iterating over ranges now takes O(1) rather than O(n)
memory. See the PR for more benchmarks.

* Remove to_a cast on ranges
* Add ReversableRange iterator
* Add custom range-specific slicing logic
2019-07-09 10:57:39 -04:00
24 changed files with 383 additions and 140 deletions

View File

@@ -18,6 +18,6 @@ group :test do
gem 'rubocop', '~> 0.53.0' gem 'rubocop', '~> 0.53.0'
platform :mri do platform :mri do
gem 'liquid-c', github: 'Shopify/liquid-c', ref: '9168659de45d6d576fce30c735f857e597fa26f6' gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'reversable-range'
end end
end end

View File

@@ -74,6 +74,7 @@ require 'liquid/condition'
require 'liquid/utils' require 'liquid/utils'
require 'liquid/tokenizer' require 'liquid/tokenizer'
require 'liquid/parse_context' require 'liquid/parse_context'
require 'liquid/reversable_range'
# Load all the tags of the standard library # Load all the tags of the standard library
# #

View File

@@ -12,12 +12,12 @@ module Liquid
# #
# context['bob'] #=> nil class Context # context['bob'] #=> nil class Context
class Context class Context
attr_reader :scope, :errors, :registers, :environments, :resource_limits attr_reader :scopes, :errors, :registers, :environments, :resource_limits
attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters
def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil) def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil)
@environments = [environments].flatten @environments = [environments].flatten
@scope = outer_scope || {} @scopes = [(outer_scope || {})]
@registers = registers @registers = registers
@errors = [] @errors = []
@partial = false @partial = false
@@ -25,6 +25,8 @@ module Liquid
@resource_limits = resource_limits || ResourceLimits.new(Template.default_resource_limits) @resource_limits = resource_limits || ResourceLimits.new(Template.default_resource_limits)
squash_instance_assigns_with_environments squash_instance_assigns_with_environments
@this_stack_used = false
self.exception_renderer = Template.default_exception_renderer self.exception_renderer = Template.default_exception_renderer
if rethrow_errors if rethrow_errors
self.exception_renderer = ->(e) { raise } self.exception_renderer = ->(e) { raise }
@@ -33,8 +35,6 @@ module Liquid
@interrupts = [] @interrupts = []
@filters = [] @filters = []
@global_filter = nil @global_filter = nil
@stack_level = 0
end end
def warnings def warnings
@@ -86,9 +86,21 @@ module Liquid
strainer.invoke(method, *args).to_liquid strainer.invoke(method, *args).to_liquid
end end
# Push new local scope on the stack. use <tt>Context#stack</tt> instead
def push(new_scope = {})
@scopes.unshift(new_scope)
raise StackLevelError, "Nesting too deep".freeze if @scopes.length > Block::MAX_DEPTH
end
# Merge a hash of variables in the current local scope # Merge a hash of variables in the current local scope
def merge(new_scopes) def merge(new_scopes)
new_scopes.each { |k, v| self[k] = v } @scopes[0].merge!(new_scopes)
end
# Pop from the stack. use <tt>Context#stack</tt> instead
def pop
raise ContextError if @scopes.size == 1
@scopes.shift
end end
# Pushes a new local scope on the stack, pops it at the end of the block # Pushes a new local scope on the stack, pops it at the end of the block
@@ -99,20 +111,32 @@ module Liquid
# end # end
# #
# context['var] #=> nil # context['var] #=> nil
def stack(*variable_names) def stack(new_scope = nil)
@stack_level += 1 old_stack_used = @this_stack_used
raise StackLevelError, "Nesting too deep".freeze if @stack_level > Block::MAX_DEPTH if new_scope
push(new_scope)
@this_stack_used = true
else
@this_stack_used = false
end
begin
yield yield
ensure ensure
@stack_level -= 1 pop if @this_stack_used
@this_stack_used = old_stack_used
end end
def clear_instance_assigns
@scopes[0] = {}
end end
# Only allow String, Numeric, Hash, Array, Proc, Boolean or <tt>Liquid::Drop</tt> # Only allow String, Numeric, Hash, Array, Proc, Boolean or <tt>Liquid::Drop</tt>
def []=(key, value) def []=(key, value)
(@scope[key] ||= [nil]) << value unless @this_stack_used
@this_stack_used = true
push({})
end
@scopes[0][key] = value
end end
# Look up variable, either resolve directly after considering the name. We can directly handle # Look up variable, either resolve directly after considering the name. We can directly handle
@@ -127,29 +151,6 @@ module Liquid
evaluate(Expression.parse(expression)) evaluate(Expression.parse(expression))
end end
def unset(key)
if @scope[key].size <= 1
@scope.delete(key)
else
@scope[key].pop
end
end
def set_root(key, val)
@scope[key] ||= []
@scope[key][0] = val
end
def set_level(key, val, int)
@scope[key] ||= []
@scope[key][int] = val
end
def create_level(key)
(@scope[key] ||= [nil]) << nil
@scope[key].size - 1
end
def key?(key) def key?(key)
self[key] != nil self[key] != nil
end end
@@ -160,23 +161,27 @@ module Liquid
# Fetches an object starting at the local scope and then moving up the hierachy # Fetches an object starting at the local scope and then moving up the hierachy
def find_variable(key, raise_on_not_found: true) def find_variable(key, raise_on_not_found: true)
trigger = false # This was changed from find() to find_index() because this is a very hot
value = @scope[key] # path and find_index() is optimized in MRI to reduce object allocation
scope = @scope unless value.nil? index = @scopes.find_index { |s| s.key?(key) }
trigger = true unless value.nil? scope = @scopes[index] if index
variable = nil
if scope.nil? if scope.nil?
index = @environments.find_index do |e| @environments.each do |e|
variable = lookup_and_evaluate(e, key, raise_on_not_found: raise_on_not_found) variable = lookup_and_evaluate(e, key, raise_on_not_found: raise_on_not_found)
# When lookup returned a value OR there is no value but the lookup also did not raise # When lookup returned a value OR there is no value but the lookup also did not raise
# then it is the value we are looking for. # then it is the value we are looking for.
!variable.nil? || @strict_variables && raise_on_not_found if !variable.nil? || @strict_variables && raise_on_not_found
scope = e
break
end
end
end end
scope = @environments[index || -1] scope ||= @environments.last || @scopes.last
end variable ||= lookup_and_evaluate(scope, key, raise_on_not_found: raise_on_not_found)
variable ||= lookup_and_evaluate(scope, key, trigger, raise_on_not_found: raise_on_not_found)
variable = variable.to_liquid variable = variable.to_liquid
variable.context = self if variable.respond_to?(:context=) variable.context = self if variable.respond_to?(:context=)
@@ -184,16 +189,12 @@ module Liquid
variable variable
end end
def lookup_and_evaluate(obj, key, trigger = false, raise_on_not_found: true) def lookup_and_evaluate(obj, key, raise_on_not_found: true)
if @strict_variables && raise_on_not_found && obj.respond_to?(:key?) && !obj.key?(key) if @strict_variables && raise_on_not_found && obj.respond_to?(:key?) && !obj.key?(key)
raise Liquid::UndefinedVariable, "undefined variable #{key}" raise Liquid::UndefinedVariable, "undefined variable #{key}"
end end
value = if trigger == true value = obj[key]
obj[key][-1]
else
obj[key]
end
if value.is_a?(Proc) && obj.respond_to?(:[]=) if value.is_a?(Proc) && obj.respond_to?(:[]=)
obj[key] = (value.arity == 0) ? value.call : value.call(self) obj[key] = (value.arity == 0) ? value.call : value.call(self)
@@ -212,10 +213,10 @@ module Liquid
end end
def squash_instance_assigns_with_environments def squash_instance_assigns_with_environments
@scope.each_key do |k| @scopes.last.each_key do |k|
@environments.each do |env| @environments.each do |env|
if env.key?(k) if env.key?(k)
@scope[k] = [lookup_and_evaluate(env, k)] scopes.last[k] = lookup_and_evaluate(env, k)
break break
end end
end end

View File

@@ -6,7 +6,7 @@ module Liquid
if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate) if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate)
new(start_obj, end_obj) new(start_obj, end_obj)
else else
start_obj.to_i..end_obj.to_i ReversableRange.new(start_obj.to_i, end_obj.to_i)
end end
end end
@@ -18,7 +18,7 @@ module Liquid
def evaluate(context) def evaluate(context)
start_int = to_integer(context.evaluate(@start_obj)) start_int = to_integer(context.evaluate(@start_obj))
end_int = to_integer(context.evaluate(@end_obj)) end_int = to_integer(context.evaluate(@end_obj))
start_int..end_int ReversableRange.new(start_int, end_int)
end end
private private

View File

@@ -0,0 +1,77 @@
module Liquid
class ReversableRange
include Enumerable
def initialize(min, max)
@min = min
@max = max
@reversed = false
end
def each
if reversed
index = max
while index >= min
yield index
index -= 1
end
else
index = min
while index <= max
yield index
index += 1
end
end
end
def reverse!
@reversed = !reversed
self
end
def empty?
max < min
end
def size
difference = max - min
if difference > 0
difference + 1
else
0
end
end
def load_slice(from, to = nil)
to ||= max
slice_max = [max, to].min
slice_min = [min, from].max
range = ReversableRange.new(slice_min, slice_max)
range.reverse! if reversed
range
end
def ==(other)
other.is_a?(self.class) &&
other.min == min &&
other.max == max &&
other.reversed == reversed
end
def to_s
if reversed
"#{max}..#{min}"
else
"#{min}..#{max}"
end
end
def to_liquid
self
end
protected
attr_reader :min, :max, :reversed
end
end

View File

@@ -24,7 +24,7 @@ module Liquid
def render(context) def render(context)
val = @from.render(context) val = @from.render(context)
context.set_root(@to, val) context.scopes.last[@to] = val
context.resource_limits.assign_score += assign_score_of(val) context.resource_limits.assign_score += assign_score_of(val)
''.freeze ''.freeze
end end

View File

@@ -24,7 +24,7 @@ module Liquid
def render(context) def render(context)
output = super output = super
context.set_root(@to, output) context.scopes.last[@to] = output
context.resource_limits.assign_score += output.bytesize context.resource_limits.assign_score += output.bytesize
''.freeze ''.freeze
end end

View File

@@ -39,6 +39,7 @@ module Liquid
end end
def render(context) def render(context)
context.stack do
execute_else_block = true execute_else_block = true
output = '' output = ''
@@ -50,9 +51,9 @@ module Liquid
output << block.attachment.render(context) output << block.attachment.render(context)
end end
end end
output output
end end
end
private private

View File

@@ -34,6 +34,7 @@ module Liquid
def render(context) def render(context)
context.registers[:cycle] ||= {} context.registers[:cycle] ||= {}
context.stack do
key = context.evaluate(@name) key = context.evaluate(@name)
iteration = context.registers[:cycle][key].to_i iteration = context.registers[:cycle][key].to_i
result = context.evaluate(@variables[iteration]) result = context.evaluate(@variables[iteration])
@@ -42,6 +43,7 @@ module Liquid
context.registers[:cycle][key] = iteration context.registers[:cycle][key] = iteration
result result
end end
end
private private

View File

@@ -133,7 +133,6 @@ module Liquid
end end
collection = context.evaluate(@collection_name) collection = context.evaluate(@collection_name)
collection = collection.to_a if collection.is_a?(Range)
limit_value = context.evaluate(@limit) limit_value = context.evaluate(@limit)
to = if limit_value.nil? to = if limit_value.nil?
@@ -145,30 +144,30 @@ module Liquid
segment = Utils.slice_collection(collection, from, to) segment = Utils.slice_collection(collection, from, to)
segment.reverse! if @reversed segment.reverse! if @reversed
offsets[@name] = from + segment.length offsets[@name] = from + segment.size
segment segment
end end
def render_segment(context, segment) def render_segment(context, segment)
for_stack = context.registers[:for_stack] ||= [] for_stack = context.registers[:for_stack] ||= []
length = segment.length length = segment.size
result = '' result = ''
context.stack('forloop', @variable_name) do context.stack do
loop_vars = Liquid::ForloopDrop.new(@name, length, for_stack[-1]) loop_vars = Liquid::ForloopDrop.new(@name, length, for_stack[-1])
for_stack.push(loop_vars) for_stack.push(loop_vars)
begin begin
context['forloop'.freeze] = loop_vars context['forloop'.freeze] = loop_vars
level = context.create_level(@variable_name)
segment.each do |item| segment.each do |item|
context.set_level(@variable_name, item, level) context[@variable_name] = item
result << @for_block.render(context) result << @for_block.render(context)
loop_vars.send(:increment!) loop_vars.send(:increment!)
# Handle any interrupts if they exist. # Handle any interrupts if they exist.
if context.interrupt? if context.interrupt?
interrupt = context.pop_interrupt interrupt = context.pop_interrupt
@@ -176,10 +175,7 @@ module Liquid
next if interrupt.is_a? ContinueInterrupt next if interrupt.is_a? ContinueInterrupt
end end
end end
context.unset(@variable_name)
context.unset('forloop'.freeze)
ensure ensure
for_stack.pop for_stack.pop
end end
end end

View File

@@ -40,6 +40,7 @@ module Liquid
end end
def render(context) def render(context)
context.stack do
@blocks.each do |block| @blocks.each do |block|
if block.evaluate(context) if block.evaluate(context)
return block.attachment.render(context) return block.attachment.render(context)
@@ -47,6 +48,7 @@ module Liquid
end end
''.freeze ''.freeze
end end
end
private private

View File

@@ -1,6 +1,7 @@
module Liquid module Liquid
class Ifchanged < Block class Ifchanged < Block
def render(context) def render(context)
context.stack do
output = super output = super
if output != context.registers[:ifchanged] if output != context.registers[:ifchanged]
@@ -11,6 +12,7 @@ module Liquid
end end
end end
end end
end
Template.register_tag('ifchanged'.freeze, Ifchanged) Template.register_tag('ifchanged'.freeze, Ifchanged)
end end

View File

@@ -60,7 +60,7 @@ module Liquid
begin begin
context.template_name = template_name context.template_name = template_name
context.partial = true context.partial = true
context.stack(context_variable_name, *@attributes.keys) do context.stack do
@attributes.each do |key, value| @attributes.each do |key, value|
context[key] = context.evaluate(value) context[key] = context.evaluate(value)
end end

View File

@@ -31,7 +31,7 @@ module Liquid
cols = context.evaluate(@attributes['cols'.freeze]).to_i cols = context.evaluate(@attributes['cols'.freeze]).to_i
result = "<tr class=\"row1\">\n" result = "<tr class=\"row1\">\n"
context.stack('tablerowloop', @variable_name) do context.stack do
tablerowloop = Liquid::TablerowloopDrop.new(length, cols) tablerowloop = Liquid::TablerowloopDrop.new(length, cols)
context['tablerowloop'.freeze] = tablerowloop context['tablerowloop'.freeze] = tablerowloop

View File

@@ -7,6 +7,7 @@ module Liquid
# #
class Unless < If class Unless < If
def render(context) def render(context)
context.stack do
# First condition is interpreted backwards ( if not ) # First condition is interpreted backwards ( if not )
first_block = @blocks.first first_block = @blocks.first
unless first_block.evaluate(context) unless first_block.evaluate(context)
@@ -23,6 +24,7 @@ module Liquid
''.freeze ''.freeze
end end
end end
end
Template.register_tag('unless'.freeze, Unless) Template.register_tag('unless'.freeze, Unless)
end end

View File

@@ -1,6 +1,8 @@
module Liquid module Liquid
module Utils module Utils
def self.slice_collection(collection, from, to) def self.slice_collection(collection, from, to)
return collection.load_slice(from, to) if collection.is_a?(ReversableRange)
if (from != 0 || !to.nil?) && collection.respond_to?(:load_slice) if (from != 0 || !to.nil?) && collection.respond_to?(:load_slice)
collection.load_slice(from, to) collection.load_slice(from, to)
else else

View File

@@ -15,7 +15,7 @@ class CommentForm < Liquid::Block
def render(context) def render(context)
article = context[@variable_name] article = context[@variable_name]
context.stack('form') do context.stack do
context['form'] = { context['form'] = {
'posted_successfully?' => context.registers[:posted_successfully], 'posted_successfully?' => context.registers[:posted_successfully],
'errors' => context['comment.errors'], 'errors' => context['comment.errors'],

View File

@@ -24,7 +24,7 @@ class Paginate < Liquid::Block
def render(context) def render(context)
@context = context @context = context
context.stack('paginate') do context.stack do
current_page = context['current_page'].to_i current_page = context['current_page'].to_i
pagination = { pagination = {

View File

@@ -1,6 +1,14 @@
require 'test_helper' require 'test_helper'
class ContextDrop < Liquid::Drop class ContextDrop < Liquid::Drop
def scopes
@context.scopes.size
end
def scopes_as_array
(1..@context.scopes.size).to_a
end
def loop_pos def loop_pos
@context['forloop.index'] @context['forloop.index']
end end
@@ -186,6 +194,31 @@ class DropsTest < Minitest::Test
end end
end end
def test_scope
assert_equal '1', Liquid::Template.parse('{{ context.scopes }}').render!('context' => ContextDrop.new)
assert_equal '2', Liquid::Template.parse('{%for i in dummy%}{{ context.scopes }}{%endfor%}').render!('context' => ContextDrop.new, 'dummy' => [1])
assert_equal '3', Liquid::Template.parse('{%for i in dummy%}{%for i in dummy%}{{ context.scopes }}{%endfor%}{%endfor%}').render!('context' => ContextDrop.new, 'dummy' => [1])
end
def test_scope_though_proc
assert_equal '1', Liquid::Template.parse('{{ s }}').render!('context' => ContextDrop.new, 's' => proc{ |c| c['context.scopes'] })
assert_equal '2', Liquid::Template.parse('{%for i in dummy%}{{ s }}{%endfor%}').render!('context' => ContextDrop.new, 's' => proc{ |c| c['context.scopes'] }, 'dummy' => [1])
assert_equal '3', Liquid::Template.parse('{%for i in dummy%}{%for i in dummy%}{{ s }}{%endfor%}{%endfor%}').render!('context' => ContextDrop.new, 's' => proc{ |c| c['context.scopes'] }, 'dummy' => [1])
end
def test_scope_with_assigns
assert_equal 'variable', Liquid::Template.parse('{% assign a = "variable"%}{{a}}').render!('context' => ContextDrop.new)
assert_equal 'variable', Liquid::Template.parse('{% assign a = "variable"%}{%for i in dummy%}{{a}}{%endfor%}').render!('context' => ContextDrop.new, 'dummy' => [1])
assert_equal 'test', Liquid::Template.parse('{% assign header_gif = "test"%}{{header_gif}}').render!('context' => ContextDrop.new)
assert_equal 'test', Liquid::Template.parse("{% assign header_gif = 'test'%}{{header_gif}}").render!('context' => ContextDrop.new)
end
def test_scope_from_tags
assert_equal '1', Liquid::Template.parse('{% for i in context.scopes_as_array %}{{i}}{% endfor %}').render!('context' => ContextDrop.new, 'dummy' => [1])
assert_equal '12', Liquid::Template.parse('{%for a in dummy%}{% for i in context.scopes_as_array %}{{i}}{% endfor %}{% endfor %}').render!('context' => ContextDrop.new, 'dummy' => [1])
assert_equal '123', Liquid::Template.parse('{%for a in dummy%}{%for a in dummy%}{% for i in context.scopes_as_array %}{{i}}{% endfor %}{% endfor %}{% endfor %}').render!('context' => ContextDrop.new, 'dummy' => [1])
end
def test_access_context_from_drop def test_access_context_from_drop
assert_equal '123', Liquid::Template.parse('{%for a in dummy%}{{ context.loop_pos }}{% endfor %}').render!('context' => ContextDrop.new, 'dummy' => [1, 2, 3]) assert_equal '123', Liquid::Template.parse('{%for a in dummy%}{{ context.loop_pos }}{% endfor %}').render!('context' => ContextDrop.new, 'dummy' => [1, 2, 3])
end end

View File

@@ -405,11 +405,11 @@ class StandardFiltersTest < Minitest::Test
def test_map_over_drops_returning_procs def test_map_over_drops_returning_procs
drops = [ drops = [
{ {
"proc" => ->{ "foo" } "proc" => ->{ "foo" },
}, },
{ {
"proc" => ->{ "bar" } "proc" => ->{ "bar" },
} },
] ]
templ = '{{ drops | map: "proc" }}' templ = '{{ drops | map: "proc" }}'
assert_template_result "foobar", templ, "drops" => drops assert_template_result "foobar", templ, "drops" => drops

View File

@@ -36,6 +36,10 @@ HERE
assert_template_result('321', '{%for item in array reversed %}{{item}}{%endfor%}', assigns) assert_template_result('321', '{%for item in array reversed %}{{item}}{%endfor%}', assigns)
end end
def test_for_range_reversed
assert_template_result('321', '{%for item in (1..3) reversed %}{{item}}{%endfor%}', {})
end
def test_for_with_range def test_for_with_range
assert_template_result(' 1 2 3 ', '{%for item in (1..3) %} {{item}} {%endfor%}') assert_template_result(' 1 2 3 ', '{%for item in (1..3) %} {{item}} {%endfor%}')
@@ -368,23 +372,6 @@ HERE
assert_template_result(expected, template, assigns) assert_template_result(expected, template, assigns)
end end
def test_overwriting_internal_variable
template = <<-HEREDOC
{% assign forloop = 'first' %}
{% for item in items %}
{{ forloop }}
{% assign forloop = 'second' %}
{{ forloop }}
{% endfor %}
{{ forloop }}
HEREDOC
result = Liquid::Template.parse(template).render('items' => '1')
assert_equal 'Liquid::ForloopDrop Liquid::ForloopDrop second', result.split.map(&:strip).join(' ')
end
class LoaderDrop < Liquid::Drop class LoaderDrop < Liquid::Drop
attr_accessor :each_called, :load_slice_called attr_accessor :each_called, :load_slice_called

View File

@@ -176,7 +176,7 @@ class IfElseTagTest < Minitest::Test
[false, true, true] => true, [false, true, true] => true,
[false, true, false] => false, [false, true, false] => false,
[false, false, true] => false, [false, false, true] => false,
[false, false, false] => false [false, false, false] => false,
} }
tests.each do |vals, expected| tests.each do |vals, expected|

View File

@@ -102,6 +102,21 @@ class ContextUnitTest < Minitest::Test
assert_nil @context['does_not_exist'] assert_nil @context['does_not_exist']
end end
def test_scoping
@context.push
@context.pop
assert_raises(Liquid::ContextError) do
@context.pop
end
assert_raises(Liquid::ContextError) do
@context.push
@context.pop
@context.pop
end
end
def test_length_query def test_length_query
@context['numbers'] = [1, 2, 3, 4] @context['numbers'] = [1, 2, 3, 4]
@@ -155,12 +170,18 @@ class ContextUnitTest < Minitest::Test
def test_add_item_in_outer_scope def test_add_item_in_outer_scope
@context['test'] = 'test' @context['test'] = 'test'
@context.push
@context.stack('test') do assert_equal 'test', @context['test']
@context.pop
assert_equal 'test', @context['test'] assert_equal 'test', @context['test']
end end
def test_add_item_in_inner_scope
@context.push
@context['test'] = 'test'
assert_equal 'test', @context['test'] assert_equal 'test', @context['test']
@context.pop
assert_nil @context['test']
end end
def test_hierachical_data def test_hierachical_data
@@ -328,9 +349,9 @@ class ContextUnitTest < Minitest::Test
def test_ranges def test_ranges
@context.merge("test" => '5') @context.merge("test" => '5')
assert_equal (1..5), @context['(1..5)'] assert_equal ReversableRange.new(1, 5), @context['(1..5)']
assert_equal (1..5), @context['(1..test)'] assert_equal ReversableRange.new(1, 5), @context['(1..test)']
assert_equal (5..5), @context['(test..test)'] assert_equal ReversableRange.new(5, 5), @context['(test..test)']
end end
def test_cents_through_drop_nestedly def test_cents_through_drop_nestedly

View File

@@ -0,0 +1,116 @@
require 'test_helper'
class ReversableRangeTest < Minitest::Test
include Liquid
def test_each_iterates_through_items_in_the_range
actual_items = []
ReversableRange.new(1, 10).each { |item| actual_items << item }
expected_items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
assert_equal expected_items, actual_items
end
def test_implements_enumerable
actual_items = ReversableRange.new(1, 10).select(&:even?)
expected_items = [2, 4, 6, 8, 10]
assert_equal expected_items, actual_items
end
def test_is_not_empty_max_greater_than_min
range = ReversableRange.new(9, 10)
refute_predicate range, :empty?
end
def test_is_not_empty_max_equal_to_min
range = ReversableRange.new(10, 10)
refute_predicate range, :empty?
end
def test_is_empty_if_not_reversed_and_max_less_than_min
range = ReversableRange.new(10, 9)
assert_predicate range, :empty?
end
def test_ranges_with_the_same_max_and_min_have_one_element
actual_items = ReversableRange.new(1337, 1337).to_a
expected_items = [1337]
assert_equal expected_items, actual_items
end
def test_load_slice_returns_a_sub_range
actual_items = ReversableRange.new(1, 10).load_slice(5, 8).to_a
expected_items = [5, 6, 7, 8]
assert_equal expected_items, actual_items
end
def test_load_slice_returns_a_reversed_sub_range_if_reversed
range = ReversableRange.new(1, 10)
range.reverse!
actual_items = range.load_slice(5, 8).to_a
expected_items = [8, 7, 6, 5]
assert_equal expected_items, actual_items
end
def test_is_equal_to_other_if_also_a_reversable_range_and_has_same_properties
one = ReversableRange.new(1, 10)
one.reverse!
two = ReversableRange.new(1, 10)
two.reverse!
assert_equal one, two
end
def test_is_not_equal_to_a_non_reversable_range
range = ReversableRange.new(1, 10)
range.reverse!
refute_equal range, :something_else
end
def test_is_not_equal_if_ranges_have_different_mins
one = ReversableRange.new(1, 10)
two = ReversableRange.new(2, 10)
refute_equal one, two
end
def test_is_not_equal_if_ranges_have_different_maxes
one = ReversableRange.new(1, 10)
two = ReversableRange.new(1, 11)
refute_equal one, two
end
def test_is_not_equal_if_only_one_is_reversed
one = ReversableRange.new(1, 10)
two = ReversableRange.new(1, 10)
two.reverse!
refute_equal one, two
end
def test_to_s_mirrors_rubys_range_syntax
range = ReversableRange.new(1, 10)
assert_equal '1..10', range.to_s
end
def test_to_s_reverses_when_reversed
range = ReversableRange.new(1, 10)
range.reverse!
assert_equal '10..1', range.to_s
end
def test_size
range = ReversableRange.new(1, 10)
assert_equal 10, range.size
end
end