reform 2.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.travis.yml +11 -0
- data/CHANGES.md +415 -0
- data/Gemfile +19 -0
- data/LICENSE.txt +22 -0
- data/README.md +339 -0
- data/Rakefile +15 -0
- data/TODO.md +45 -0
- data/gemfiles/Gemfile.disposable-0.3 +6 -0
- data/lib/reform.rb +8 -0
- data/lib/reform/contract.rb +77 -0
- data/lib/reform/contract/errors.rb +43 -0
- data/lib/reform/contract/validate.rb +33 -0
- data/lib/reform/form.rb +94 -0
- data/lib/reform/form/call.rb +23 -0
- data/lib/reform/form/coercion.rb +3 -0
- data/lib/reform/form/composition.rb +34 -0
- data/lib/reform/form/dry.rb +67 -0
- data/lib/reform/form/module.rb +27 -0
- data/lib/reform/form/mongoid.rb +37 -0
- data/lib/reform/form/orm.rb +26 -0
- data/lib/reform/form/populator.rb +123 -0
- data/lib/reform/form/prepopulate.rb +24 -0
- data/lib/reform/form/validate.rb +60 -0
- data/lib/reform/mongoid.rb +4 -0
- data/lib/reform/validation.rb +40 -0
- data/lib/reform/validation/groups.rb +73 -0
- data/lib/reform/version.rb +3 -0
- data/reform.gemspec +29 -0
- data/test/benchmarking.rb +26 -0
- data/test/call_test.rb +23 -0
- data/test/changed_test.rb +41 -0
- data/test/coercion_test.rb +66 -0
- data/test/composition_test.rb +149 -0
- data/test/contract_test.rb +77 -0
- data/test/default_test.rb +22 -0
- data/test/deprecation_test.rb +27 -0
- data/test/deserialize_test.rb +104 -0
- data/test/errors_test.rb +165 -0
- data/test/feature_test.rb +65 -0
- data/test/fixtures/dry_error_messages.yml +44 -0
- data/test/form_option_test.rb +24 -0
- data/test/form_test.rb +57 -0
- data/test/from_test.rb +75 -0
- data/test/inherit_test.rb +119 -0
- data/test/module_test.rb +142 -0
- data/test/parse_pipeline_test.rb +15 -0
- data/test/populate_test.rb +270 -0
- data/test/populator_skip_test.rb +28 -0
- data/test/prepopulator_test.rb +112 -0
- data/test/read_only_test.rb +3 -0
- data/test/readable_test.rb +30 -0
- data/test/readonly_test.rb +14 -0
- data/test/reform_test.rb +223 -0
- data/test/save_test.rb +89 -0
- data/test/setup_test.rb +48 -0
- data/test/skip_if_test.rb +74 -0
- data/test/skip_setter_and_getter_test.rb +54 -0
- data/test/test_helper.rb +49 -0
- data/test/validate_test.rb +420 -0
- data/test/validation/dry_test.rb +60 -0
- data/test/validation/dry_validation_test.rb +352 -0
- data/test/validation/errors.yml +4 -0
- data/test/virtual_test.rb +24 -0
- data/test/writeable_test.rb +29 -0
- metadata +265 -0
@@ -0,0 +1,26 @@
|
|
1
|
+
module Reform::Form::ORM
|
2
|
+
def model_for_property(name)
|
3
|
+
return model unless is_a?(Reform::Form::Composition) # i am too lazy for proper inheritance. there should be a ActiveRecord::Composition that handles this.
|
4
|
+
|
5
|
+
model_name = options_for(name)[:on]
|
6
|
+
model[model_name]
|
7
|
+
end
|
8
|
+
|
9
|
+
module UniquenessValidator
|
10
|
+
# when calling validates it should create the Vali instance already and set @klass there! # TODO: fix this in AM.
|
11
|
+
def validate(form)
|
12
|
+
property = attributes.first
|
13
|
+
|
14
|
+
# here is the thing: why does AM::UniquenessValidator require a filled-out record to work properly? also, why do we need to set
|
15
|
+
# the class? it would be way easier to pass #validate a hash of attributes and get back an errors hash.
|
16
|
+
# the class for the finder could either be infered from the record or set in the validator instance itself in the call to ::validates.
|
17
|
+
record = form.model_for_property(property)
|
18
|
+
record.send("#{property}=", form.send(property))
|
19
|
+
|
20
|
+
@klass = record.class # this is usually done in the super-sucky #setup method.
|
21
|
+
super(record).tap do |res|
|
22
|
+
form.errors.add(property, record.errors.first.last) if record.errors.present?
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
# Implements the :populator option.
|
2
|
+
#
|
3
|
+
# populator: -> (fragment:, model:, :binding)
|
4
|
+
# populator: -> (fragment:, collection:, index:, binding:)
|
5
|
+
#
|
6
|
+
# For collections, the entire collection and the currently deserialised index is passed in.
|
7
|
+
class Reform::Form::Populator
|
8
|
+
def initialize(user_proc)
|
9
|
+
@user_proc = user_proc # the actual `populator: ->{}` block from the user, via ::property.
|
10
|
+
@value = Declarative::Option(user_proc, instance_exec: true) # we can now process Callable, procs, :symbol.
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(input, options)
|
14
|
+
model = get(options)
|
15
|
+
twin = call!(options.merge(model: model, collection: model))
|
16
|
+
|
17
|
+
return twin if twin == Representable::Pipeline::Stop
|
18
|
+
|
19
|
+
# this kinda sucks. the proc may call self.composer = Artist.new, but there's no way we can
|
20
|
+
# return the twin instead of the model from the #composer= setter.
|
21
|
+
twin = get(options) unless options[:binding].array?
|
22
|
+
|
23
|
+
# we always need to return a twin/form here so we can call nested.deserialize().
|
24
|
+
handle_fail(twin, options)
|
25
|
+
|
26
|
+
twin
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
def call!(options)
|
31
|
+
form = options[:represented]
|
32
|
+
|
33
|
+
deprecate_positional_args(form, @user_proc, options) do
|
34
|
+
@value.(form, options)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def handle_fail(twin, options)
|
39
|
+
raise "[Reform] Your :populator did not return a Reform::Form instance for `#{options[:binding].name}`." if options[:binding][:nested] && !twin.is_a?(Reform::Form)
|
40
|
+
end
|
41
|
+
|
42
|
+
def get(options)
|
43
|
+
Representable::GetValue.(nil, options)
|
44
|
+
end
|
45
|
+
|
46
|
+
def deprecate_positional_args(form, proc, options) # TODO: remove in 2.2.
|
47
|
+
arity = proc.is_a?(Symbol) ? form.method(proc).arity : proc.arity
|
48
|
+
return yield if arity == 1
|
49
|
+
warn "[Reform] Positional arguments for :populator and friends are deprecated. Please use ->(options) and enjoy the rest of your day. Learn more at http://trailblazerb.org/gems/reform/upgrading-guide.html#to-21"
|
50
|
+
args = []
|
51
|
+
args << options[:index] if options[:index]
|
52
|
+
args << options[:representable_options]
|
53
|
+
form.instance_exec(options[:fragment], options[:model], *args, &proc)
|
54
|
+
end
|
55
|
+
|
56
|
+
|
57
|
+
class IfEmpty < self # Populator
|
58
|
+
def call!(options)
|
59
|
+
binding, twin, index, fragment = options[:binding], options[:model], options[:index], options[:fragment] # TODO: remove once we drop 2.0.
|
60
|
+
form = options[:represented]
|
61
|
+
|
62
|
+
if binding.array?
|
63
|
+
item = twin.original[index] and return item
|
64
|
+
|
65
|
+
new_index = [index, twin.count].min # prevents nil items with initially empty/smaller collections and :skip_if's.
|
66
|
+
# this means the fragment index and populated nested form index might be different.
|
67
|
+
|
68
|
+
twin.insert(new_index, run!(form, fragment, options)) # form.songs.insert(Song.new)
|
69
|
+
else
|
70
|
+
return if twin
|
71
|
+
|
72
|
+
form.send(binding.setter, run!(form, fragment, options)) # form.artist=(Artist.new)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
def run!(form, fragment, options)
|
78
|
+
return @user_proc.new if @user_proc.is_a?(Class) # handle populate_if_empty: Class. this excludes using Callables, though.
|
79
|
+
|
80
|
+
deprecate_positional_args(form, @user_proc, options) do
|
81
|
+
@value.(form, options)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def deprecate_positional_args(form, proc, options) # TODO: remove in 2.2.
|
86
|
+
arity = proc.is_a?(Symbol) ? form.method(proc).arity : proc.arity
|
87
|
+
return yield if arity == 1
|
88
|
+
warn "[Reform] Positional arguments for :prepopulate and friends are deprecated. Please use ->(options) and enjoy the rest of your day. Learn more at http://trailblazerb.org/gems/reform/upgrading-guide.html#to-21"
|
89
|
+
|
90
|
+
@value.(form, options[:fragment], options[:user_options])
|
91
|
+
end
|
92
|
+
|
93
|
+
end
|
94
|
+
|
95
|
+
# Sync (default) blindly grabs the corresponding form twin and returns it. This might imply that nil is returned,
|
96
|
+
# and in turn #validate! is called on nil.
|
97
|
+
class Sync < self
|
98
|
+
def call!(options)
|
99
|
+
if options[:binding].array?
|
100
|
+
return options[:model][options[:index]]
|
101
|
+
else
|
102
|
+
options[:model]
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# This function is added to the deserializer's pipeline.
|
108
|
+
#
|
109
|
+
# When deserializing, the representer will call this function and thereby delegate the
|
110
|
+
# entire population process to the form. The form's :internal_populator will run its
|
111
|
+
# :populator option function and return the new/existing form instance.
|
112
|
+
# The deserializing representer will then continue on that returned form.
|
113
|
+
#
|
114
|
+
# Goal of this indirection is to leave all population logic in the form, while the
|
115
|
+
# representer really just traverses an incoming document and dispatches business logic
|
116
|
+
# (which population is) to the form.
|
117
|
+
class External
|
118
|
+
def call(input, options)
|
119
|
+
options[:represented].class.definitions.
|
120
|
+
get(options[:binding][:name])[:internal_populator].(input, options)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# prepopulate!(options)
|
2
|
+
# prepopulator: ->(model, user_options)
|
3
|
+
module Reform::Form::Prepopulate
|
4
|
+
def prepopulate!(options={})
|
5
|
+
prepopulate_local!(options) # call #prepopulate! on local properties.
|
6
|
+
prepopulate_nested!(options) # THEN call #prepopulate! on nested forms.
|
7
|
+
|
8
|
+
self
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
def prepopulate_local!(options)
|
13
|
+
schema.each do |dfn|
|
14
|
+
next unless block = dfn[:prepopulator]
|
15
|
+
Declarative::Option(block, instance_exec: true).(self, options)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def prepopulate_nested!(options)
|
20
|
+
schema.each(twin: true) do |dfn|
|
21
|
+
Disposable::Twin::PropertyProcessor.new(dfn, self).() { |form| form.prepopulate!(options) }
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# Mechanics for writing to forms in #validate.
|
2
|
+
module Reform::Form::Validate
|
3
|
+
module Skip
|
4
|
+
class AllBlank
|
5
|
+
include Uber::Callable
|
6
|
+
|
7
|
+
def call(form, options)
|
8
|
+
params = options[:input]
|
9
|
+
# TODO: Schema should provide property names as plain list.
|
10
|
+
properties = options[:binding][:nested].definitions.collect { |dfn| dfn[:name] }
|
11
|
+
|
12
|
+
properties.each { |name| (!params[name].nil? && params[name] != "") and return false }
|
13
|
+
true # skip
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
def validate(params)
|
20
|
+
# allow an external deserializer.
|
21
|
+
block_given? ? yield(params) : deserialize(params)
|
22
|
+
|
23
|
+
super() # run the actual validation on self.
|
24
|
+
end
|
25
|
+
|
26
|
+
def deserialize(params)
|
27
|
+
params = deserialize!(params)
|
28
|
+
deserializer.new(self).from_hash(params)
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
# Meant to return params processable by the representer. This is the hook for munching date fields, etc.
|
33
|
+
def deserialize!(params)
|
34
|
+
# NOTE: it is completely up to the form user how they want to deserialize (e.g. using an external JSON-API representer).
|
35
|
+
# use the deserializer as an external instance to operate on the Twin API,
|
36
|
+
# e.g. adding new items in collections using #<< etc.
|
37
|
+
# DISCUSS: using self here will call the form's setters like title= which might be overridden.
|
38
|
+
params
|
39
|
+
end
|
40
|
+
|
41
|
+
# Default deserializer for hash.
|
42
|
+
# This is input-specific, e.g. Hash, JSON, or XML.
|
43
|
+
def deserializer(source=self.class, options={}) # called on top-level, only, for now.
|
44
|
+
deserializer = Disposable::Rescheme.from(source,
|
45
|
+
{
|
46
|
+
include: [Representable::Hash::AllowSymbols, Representable::Hash],
|
47
|
+
superclass: Representable::Decorator,
|
48
|
+
definitions_from: lambda { |inline| inline.definitions },
|
49
|
+
options_from: :deserializer,
|
50
|
+
exclude_options: [:default, :populator] # Reform must not copy Disposable/Reform-only options that might confuse representable.
|
51
|
+
}.merge(options)
|
52
|
+
)
|
53
|
+
|
54
|
+
deserializer
|
55
|
+
end
|
56
|
+
|
57
|
+
|
58
|
+
class DeserializeError < RuntimeError
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# Adds ::validates and friends, and #valid? to the object.
|
2
|
+
# This is completely form-independent.
|
3
|
+
module Reform::Validation
|
4
|
+
module ClassMethods
|
5
|
+
def validation_groups
|
6
|
+
@groups ||= Groups.new(validation_group_class) # TODO: inheritable_attr with Inheritable::Hash
|
7
|
+
end
|
8
|
+
|
9
|
+
# DSL.
|
10
|
+
def validation(name=:default, options={}, &block)
|
11
|
+
heritage.record(:validation, name, options, &block)
|
12
|
+
|
13
|
+
group = validation_groups.add(name, options)
|
14
|
+
|
15
|
+
group.instance_exec(&block)
|
16
|
+
end
|
17
|
+
|
18
|
+
def validates(*args, &block)
|
19
|
+
validation(:default, inherit: true) { validates *args, &block }
|
20
|
+
end
|
21
|
+
|
22
|
+
def validate(*args, &block)
|
23
|
+
validation(:default, inherit: true) { validate *args, &block }
|
24
|
+
end
|
25
|
+
|
26
|
+
def validates_with(*args, &block)
|
27
|
+
validation(:default, inherit: true) { validates_with *args, &block }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.included(includer)
|
32
|
+
includer.extend(ClassMethods)
|
33
|
+
end
|
34
|
+
|
35
|
+
def valid?
|
36
|
+
Groups::Result.new(self.class.validation_groups).(to_nested_hash, errors, self)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
require "reform/validation/groups"
|
@@ -0,0 +1,73 @@
|
|
1
|
+
module Reform::Validation
|
2
|
+
# A Group is a set of native validations, targeting a validation backend (AM, Lotus, Dry).
|
3
|
+
# Group receives configuration via #validates and #validate and translates that to its
|
4
|
+
# internal backend.
|
5
|
+
#
|
6
|
+
# The #call method will run those validations on the provided objects.
|
7
|
+
|
8
|
+
# Set of Validation::Group objects.
|
9
|
+
# This implements adding, iterating, and finding groups, including "inheritance" and insertions.
|
10
|
+
class Groups < Array
|
11
|
+
def initialize(group_class)
|
12
|
+
@group_class = group_class
|
13
|
+
end
|
14
|
+
|
15
|
+
def add(name, options)
|
16
|
+
if options[:inherit]
|
17
|
+
return self[name] if self[name]
|
18
|
+
end
|
19
|
+
|
20
|
+
i = index_for(options)
|
21
|
+
|
22
|
+
self.insert(i, [name, group = @group_class.new, options]) # Group.new
|
23
|
+
group
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def index_for(options)
|
29
|
+
return find_index { |el| el.first == options[:after] } + 1 if options[:after]
|
30
|
+
size # default index: append.
|
31
|
+
end
|
32
|
+
|
33
|
+
def [](name)
|
34
|
+
cfg = find { |cfg| cfg.first == name }
|
35
|
+
return unless cfg
|
36
|
+
cfg[1]
|
37
|
+
end
|
38
|
+
|
39
|
+
|
40
|
+
# Runs all validations groups according to their rules and returns result.
|
41
|
+
# Populates errors passed into #call.
|
42
|
+
class Result # DISCUSS: could be in Groups.
|
43
|
+
def initialize(groups)
|
44
|
+
@groups = groups
|
45
|
+
end
|
46
|
+
|
47
|
+
def call(fields, errors, form)
|
48
|
+
result = true
|
49
|
+
results = {}
|
50
|
+
|
51
|
+
@groups.each do |cfg|
|
52
|
+
name, group, options = cfg
|
53
|
+
depends_on = options[:if]
|
54
|
+
|
55
|
+
if evaluate_if(depends_on, results, form)
|
56
|
+
# puts "evaluating #{group.instance_variable_get(:@validator).instance_variable_get(:@checker).inspect}"
|
57
|
+
results[name] = group.(fields, errors, form).empty? # validate.
|
58
|
+
end
|
59
|
+
|
60
|
+
result &= errors.empty?
|
61
|
+
end
|
62
|
+
|
63
|
+
result
|
64
|
+
end
|
65
|
+
|
66
|
+
def evaluate_if(depends_on, results, form)
|
67
|
+
return true if depends_on.nil?
|
68
|
+
return results[depends_on] if depends_on.is_a?(Symbol)
|
69
|
+
form.instance_exec(results, &depends_on)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
data/reform.gemspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
lib = File.expand_path('../lib', __FILE__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
require 'reform/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "reform"
|
7
|
+
spec.version = Reform::VERSION
|
8
|
+
spec.authors = ["Nick Sutterer", "Garrett Heinlen"]
|
9
|
+
spec.email = ["apotonick@gmail.com", "heinleng@gmail.com"]
|
10
|
+
spec.description = %q{Form object decoupled from models.}
|
11
|
+
spec.summary = %q{Form object decoupled from models with validation, population and presentation.}
|
12
|
+
spec.homepage = "https://github.com/apotonick/reform"
|
13
|
+
spec.license = "MIT"
|
14
|
+
|
15
|
+
spec.files = `git ls-files`.split($/)
|
16
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
17
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
18
|
+
spec.require_paths = ["lib"]
|
19
|
+
|
20
|
+
spec.add_dependency "disposable", ">= 0.4.1"
|
21
|
+
spec.add_dependency "representable", ">= 2.4.0", "< 3.1.0"
|
22
|
+
|
23
|
+
spec.add_development_dependency "bundler"
|
24
|
+
spec.add_development_dependency "rake"
|
25
|
+
spec.add_development_dependency "minitest"
|
26
|
+
spec.add_development_dependency "dry-types"
|
27
|
+
spec.add_development_dependency "multi_json"
|
28
|
+
spec.add_development_dependency "dry-validation", ">= 0.10.0"
|
29
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'reform'
|
2
|
+
require 'ostruct'
|
3
|
+
require 'benchmark'
|
4
|
+
|
5
|
+
class BandForm < Reform::Form
|
6
|
+
property :name, validates: {presence: true}
|
7
|
+
|
8
|
+
collection :songs do
|
9
|
+
property :title, validates: {presence: true}
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
songs = 50.times.collect { OpenStruct.new(title: "Be Stag") }
|
14
|
+
band = OpenStruct.new(name: "Teenage Bottlerock", songs: songs)
|
15
|
+
|
16
|
+
songs_params = 50.times.collect { {title: "Commando"} }
|
17
|
+
|
18
|
+
time = Benchmark.measure do
|
19
|
+
100.times.each do
|
20
|
+
form = BandForm.new(band)
|
21
|
+
form.validate("name" => "Ramones", "songs" => songs_params)
|
22
|
+
form.save
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
puts time
|
data/test/call_test.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
class CallTest < Minitest::Spec
|
4
|
+
Song = Struct.new(:title)
|
5
|
+
|
6
|
+
class SongForm < Reform::Form
|
7
|
+
property :title
|
8
|
+
|
9
|
+
validation do
|
10
|
+
key(:title).required
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
let (:form) { SongForm.new(Song.new) }
|
15
|
+
|
16
|
+
it { form.(title: "True North").success?.must_equal true }
|
17
|
+
it { form.(title: "True North").failure?.must_equal false }
|
18
|
+
it { form.(title: "").success?.must_equal false }
|
19
|
+
it { form.(title: "").failure?.must_equal true }
|
20
|
+
|
21
|
+
it { form.(title: "True North").errors.messages.must_equal({}) }
|
22
|
+
it { form.(title: "").errors.messages.must_equal({:title=>["must be filled"]}) }
|
23
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
require 'reform/form/coercion'
|
3
|
+
|
4
|
+
class ChangedTest < MiniTest::Spec
|
5
|
+
Song = Struct.new(:title, :album, :composer)
|
6
|
+
Album = Struct.new(:name, :songs, :artist)
|
7
|
+
Artist = Struct.new(:name)
|
8
|
+
|
9
|
+
class AlbumForm < Reform::Form
|
10
|
+
property :name
|
11
|
+
|
12
|
+
collection :songs do
|
13
|
+
property :title
|
14
|
+
|
15
|
+
property :composer do
|
16
|
+
property :name
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
let (:song_with_composer) { Song.new("Resist Stance", nil, composer) }
|
22
|
+
let (:composer) { Artist.new("Greg Graffin") }
|
23
|
+
let (:album) { Album.new("The Dissent Of Man", [song_with_composer]) }
|
24
|
+
|
25
|
+
let (:form) { AlbumForm.new(album) }
|
26
|
+
|
27
|
+
# nothing changed after setup.
|
28
|
+
it do
|
29
|
+
form.changed?(:name).must_equal false
|
30
|
+
form.songs[0].changed?(:title).must_equal false
|
31
|
+
form.songs[0].composer.changed?(:name).must_equal false
|
32
|
+
end
|
33
|
+
|
34
|
+
# after validate, things might have changed.
|
35
|
+
it do
|
36
|
+
form.validate("name" => "Out Of Bounds", "songs" => [{"composer" => {"name" => "Ingemar Jansson & Mikael Danielsson"}}])
|
37
|
+
form.changed?(:name).must_equal true
|
38
|
+
form.songs[0].changed?(:title).must_equal false
|
39
|
+
form.songs[0].composer.changed?(:name).must_equal true
|
40
|
+
end
|
41
|
+
end
|