reform 1.2.0.beta2 → 1.2.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +2 -1
- data/CHANGES.md +53 -7
- data/LICENSE.txt +1 -1
- data/README.md +48 -8
- data/database.sqlite3 +0 -0
- data/lib/reform/contract.rb +58 -4
- data/lib/reform/contract/setup.rb +16 -22
- data/lib/reform/contract/validate.rb +18 -21
- data/lib/reform/form/active_model.rb +6 -8
- data/lib/reform/form/json.rb +1 -1
- data/lib/reform/form/save.rb +29 -59
- data/lib/reform/form/sync.rb +52 -70
- data/lib/reform/form/validate.rb +37 -44
- data/lib/reform/representer.rb +6 -10
- data/lib/reform/version.rb +1 -1
- data/test/active_record_test.rb +68 -0
- data/test/benchmarking.rb +26 -0
- data/test/contract_test.rb +40 -0
- data/test/custom_validation_test.rb +1 -1
- data/test/empty_test.rb +31 -3
- data/test/form_composition_test.rb +1 -1
- data/test/from_test.rb +150 -0
- data/test/model_validations_test.rb +2 -2
- data/test/nested_form_test.rb +4 -4
- data/test/read_only_test.rb +0 -25
- data/test/readable_test.rb +32 -0
- data/test/reform_test.rb +0 -21
- data/test/virtual_test.rb +26 -0
- data/test/writeable_test.rb +30 -0
- metadata +14 -6
- data/test/as_test.rb +0 -75
data/lib/reform/form/json.rb
CHANGED
data/lib/reform/form/save.rb
CHANGED
@@ -1,23 +1,8 @@
|
|
1
1
|
module Reform::Form::Save
|
2
|
-
module RecursiveSave
|
3
|
-
def to_hash(*)
|
4
|
-
# process output from InputRepresenter {title: "Mint Car", hit: <Form>}
|
5
|
-
# and just call sync! on nested forms.
|
6
|
-
nested_forms do |attr|
|
7
|
-
attr.merge!(
|
8
|
-
:instance => lambda { |fragment, *| fragment },
|
9
|
-
:serialize => lambda { |object, args| object.save! unless args.binding[:save] === false },
|
10
|
-
)
|
11
|
-
end
|
12
|
-
|
13
|
-
super
|
14
|
-
end
|
15
|
-
end
|
16
|
-
|
17
2
|
# Returns the result of that save invocation on the model.
|
18
3
|
def save(options={}, &block)
|
19
4
|
# DISCUSS: we should never hit @mapper here (which writes to the models) when a block is passed.
|
20
|
-
return
|
5
|
+
return yield to_nested_hash if block_given?
|
21
6
|
|
22
7
|
sync_models # recursion
|
23
8
|
save!(options)
|
@@ -25,15 +10,10 @@ module Reform::Form::Save
|
|
25
10
|
|
26
11
|
def save!(options={}) # FIXME.
|
27
12
|
result = save_model
|
28
|
-
mapper.new(fields).extend(RecursiveSave).to_hash # save! on all nested forms. # TODO: only include nested forms here.
|
29
13
|
|
30
|
-
|
31
|
-
if names.size > 0
|
32
|
-
representer = save_dynamic_representer.new(fields) # should be done once, on class instance level.
|
14
|
+
save_representer.new(fields).to_hash # save! on all nested forms.
|
33
15
|
|
34
|
-
|
35
|
-
representer.to_hash(options.merge :include => names)
|
36
|
-
end
|
16
|
+
dynamic_save!(options)
|
37
17
|
|
38
18
|
result
|
39
19
|
end
|
@@ -43,54 +23,44 @@ module Reform::Form::Save
|
|
43
23
|
end
|
44
24
|
|
45
25
|
|
46
|
-
|
47
|
-
|
48
|
-
|
26
|
+
require "active_support/hash_with_indifferent_access" # DISCUSS: replace?
|
27
|
+
def to_nested_hash(*)
|
28
|
+
ActiveSupport::HashWithIndifferentAccess.new(nested_hash_representer.new(fields).to_hash)
|
29
|
+
end
|
30
|
+
alias_method :to_hash, :to_nested_hash
|
31
|
+
# NOTE: it is not recommended using #to_hash and #to_nested_hash in your code, consider them private.
|
32
|
+
|
33
|
+
private
|
34
|
+
def save_representer
|
35
|
+
self.class.representer(:save) do |dfn|
|
49
36
|
dfn.merge!(
|
50
|
-
:
|
51
|
-
|
52
|
-
options.user_options[options.binding.name.to_sym].call(object, options) },
|
53
|
-
:representable => true
|
37
|
+
:instance => lambda { |form, *| form },
|
38
|
+
:serialize => lambda { |form, args| form.save! unless args.binding[:save] === false }
|
54
39
|
)
|
55
40
|
end
|
56
41
|
end
|
57
42
|
|
43
|
+
def nested_hash_representer
|
44
|
+
self.class.representer(:nested_hash, :all => true) do |dfn|
|
45
|
+
dfn.merge!(:serialize => lambda { |form, args| form.to_nested_hash }) if dfn[:form]
|
58
46
|
|
59
|
-
|
60
|
-
def to_hash(*)
|
61
|
-
# Transform form data into a nested hash for #save.
|
62
|
-
nested_forms do |attr|
|
63
|
-
attr.merge!(
|
64
|
-
:serialize => lambda { |object, args| object.to_nested_hash }
|
65
|
-
)
|
66
|
-
end
|
67
|
-
|
68
|
-
representable_attrs.each do |attr|
|
69
|
-
attr.merge!(:as => attr[:private_name] || attr.name)
|
70
|
-
end
|
71
|
-
|
72
|
-
super
|
47
|
+
dfn.merge!(:as => dfn[:private_name] || dfn.name)
|
73
48
|
end
|
74
49
|
end
|
75
50
|
|
51
|
+
def dynamic_save!(options)
|
52
|
+
names = options.keys & changed.keys.map(&:to_sym)
|
53
|
+
return if names.size == 0
|
76
54
|
|
77
|
-
|
78
|
-
def to_nested_hash(*)
|
79
|
-
map = mapper.new(fields).extend(NestedHash)
|
80
|
-
|
81
|
-
ActiveSupport::HashWithIndifferentAccess.new(map.to_hash)
|
55
|
+
dynamic_save_representer.new(fields).to_hash(options.merge(:include => names))
|
82
56
|
end
|
83
|
-
alias_method :to_hash, :to_nested_hash
|
84
|
-
# NOTE: it is not recommended using #to_hash and #to_nested_hash in your code, consider
|
85
|
-
# them private.
|
86
57
|
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
58
|
+
def dynamic_save_representer
|
59
|
+
self.class.representer(:dynamic_save, :all => true) do |dfn|
|
60
|
+
dfn.merge!(
|
61
|
+
:serialize => lambda { |object, options| options.user_options[options.binding.name.to_sym].call(object, options) },
|
62
|
+
:representable => true
|
63
|
+
)
|
92
64
|
end
|
93
|
-
|
94
|
-
yield to_nested_hash # new behaviour.
|
95
65
|
end
|
96
66
|
end
|
data/lib/reform/form/sync.rb
CHANGED
@@ -2,113 +2,95 @@
|
|
2
2
|
# 1. assign scalars to model (respecting virtual, excluded attributes)
|
3
3
|
# 2. call sync! on nested
|
4
4
|
module Reform::Form::Sync
|
5
|
-
|
6
|
-
|
7
|
-
module Writer
|
8
|
-
def from_hash(*)
|
9
|
-
# process output from InputRepresenter {title: "Mint Car", hit: <Form>}
|
10
|
-
# and just call sync! on nested forms.
|
11
|
-
nested_forms do |attr|
|
12
|
-
attr.merge!(
|
13
|
-
:instance => lambda { |fragment, *| fragment },
|
14
|
-
# FIXME: do we allow options for #sync for nested forms?
|
15
|
-
:deserialize => lambda { |object, *| model = object.sync!({}) } # sync! returns the synced model.
|
16
|
-
# representable's :setter will do collection=([..]) or property=(..) for us on the model.
|
17
|
-
)
|
18
|
-
end
|
19
|
-
|
20
|
-
super
|
21
|
-
end
|
5
|
+
def sync_models(options={})
|
6
|
+
sync!(options)
|
22
7
|
end
|
8
|
+
alias_method :sync, :sync_models
|
23
9
|
|
24
|
-
|
25
|
-
|
26
|
-
|
10
|
+
# reading from fields allows using readers in form for presentation
|
11
|
+
# and writers still pass to fields in #validate????
|
12
|
+
def sync!(options) # semi-public.
|
13
|
+
options = Reform::Representer::Options[options.merge(:form => self)] # options local for this form, only.
|
27
14
|
|
28
|
-
|
29
|
-
|
15
|
+
input = sync_hash(options)
|
16
|
+
# if aliased_model was a proper Twin, we could do changed? stuff there.
|
30
17
|
|
31
|
-
|
32
|
-
# puts "~~ #{value}~ #{options.user_options.inspect}"
|
18
|
+
options.delete(:exclude) # TODO: can we use 2 options?
|
33
19
|
|
34
|
-
|
35
|
-
options.user_options[options.binding.name.to_sym].call(value, options)
|
36
|
-
next
|
37
|
-
end
|
20
|
+
dynamic_sync_representer.new(aliased_model).from_hash(input, options) # sync properties to Song.
|
38
21
|
|
39
|
-
|
40
|
-
|
41
|
-
end
|
22
|
+
model
|
23
|
+
end
|
42
24
|
|
43
|
-
|
44
|
-
end
|
25
|
+
private
|
45
26
|
|
46
|
-
|
27
|
+
# Transforms form input into what actually gets written to model.
|
28
|
+
# output: {title: "Mint Car", hit: <Form>}
|
29
|
+
def input_representer
|
30
|
+
self.class.representer(:input) do |dfn|
|
31
|
+
dfn.merge!(
|
32
|
+
:representable => false,
|
33
|
+
:prepare => lambda { |obj, *| obj }
|
34
|
+
)
|
47
35
|
end
|
48
36
|
end
|
49
37
|
|
50
|
-
#
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
:
|
58
|
-
:
|
38
|
+
# Writes input to model.
|
39
|
+
def sync_representer
|
40
|
+
self.class.representer(:sync, :all => true) do |dfn|
|
41
|
+
if dfn[:form]
|
42
|
+
dfn.merge!(
|
43
|
+
:instance => lambda { |fragment, *| fragment }, # use model's nested property for syncing.
|
44
|
+
# FIXME: do we allow options for #sync for nested forms?
|
45
|
+
:deserialize => lambda { |object, *| model = object.sync!({}) } # sync! returns the synced model.
|
46
|
+
# representable's :setter will do collection=([..]) or property=(..) for us on the model.
|
59
47
|
)
|
60
48
|
end
|
61
|
-
|
62
|
-
super
|
63
49
|
end
|
64
50
|
end
|
65
51
|
|
52
|
+
# This representer inherits from sync_representer and add functionality on top of that.
|
53
|
+
# It allows running custom dynamic blocks for properties when syncing.
|
54
|
+
def dynamic_sync_representer
|
55
|
+
self.class.representer(:dynamic_sync, superclass: sync_representer, :all => true) do |dfn|
|
56
|
+
next unless setter = dfn[:sync]
|
66
57
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
# reading from fields allows using readers in form for presentation
|
73
|
-
# and writers still pass to fields in #validate????
|
74
|
-
def sync!(options) # semi-public.
|
75
|
-
options = Reform::Representer::Options[options.merge(:form => self)] # options local for this form, only.
|
76
|
-
|
77
|
-
input = sync_hash(options)
|
78
|
-
# if aliased_model was a proper Twin, we could do changed? stuff there.
|
79
|
-
# setter_module = Class.new(self.class.representer_class)
|
80
|
-
# setter_module.send :include, Setter
|
81
|
-
|
82
|
-
options.delete(:exclude) # TODO: can we use 2 options?
|
58
|
+
setter_proc = lambda do |value, options|
|
59
|
+
if options.binding[:sync] == true # sync: true will call the runtime lambda from the options hash.
|
60
|
+
options.user_options[options.binding.name.to_sym].call(value, options)
|
61
|
+
next
|
62
|
+
end
|
83
63
|
|
84
|
-
|
64
|
+
# evaluate the :sync block in form context (should we do that everywhere?).
|
65
|
+
options.user_options[:form].instance_exec(value, options, &setter)
|
66
|
+
end
|
85
67
|
|
86
|
-
|
68
|
+
dfn.merge!(:setter => setter_proc)
|
69
|
+
end
|
87
70
|
end
|
88
71
|
|
89
|
-
|
72
|
+
|
90
73
|
# API: semi-public.
|
91
74
|
module SyncHash
|
92
75
|
# This hash goes into the Writer that writes properties back to the model. It only contains "writeable" attributes.
|
93
76
|
def sync_hash(options)
|
94
|
-
input_representer
|
95
|
-
input_representer.to_hash(options)
|
77
|
+
input_representer.new(fields).to_hash(options)
|
96
78
|
end
|
97
79
|
end
|
98
80
|
include SyncHash
|
99
81
|
|
100
82
|
|
101
|
-
# Excludes :virtual properties from #sync in this form.
|
102
|
-
module
|
83
|
+
# Excludes :virtual and readonly properties from #sync in this form.
|
84
|
+
module Writeable
|
103
85
|
def sync_hash(options)
|
104
|
-
readonly_fields = mapper.fields { |dfn| dfn[:
|
86
|
+
readonly_fields = mapper.fields { |dfn| dfn[:_writeable] == false }
|
105
87
|
|
106
88
|
options.exclude!(readonly_fields.map(&:to_sym))
|
107
89
|
|
108
90
|
super
|
109
91
|
end
|
110
92
|
end
|
111
|
-
include
|
93
|
+
include Writeable
|
112
94
|
|
113
95
|
|
114
96
|
# This will skip unchanged properties in #sync. To use this for all nested form do as follows.
|
data/lib/reform/form/validate.rb
CHANGED
@@ -1,48 +1,5 @@
|
|
1
1
|
# Mechanics for writing to forms in #validate.
|
2
2
|
module Reform::Form::Validate
|
3
|
-
module Update
|
4
|
-
# IDEA: what if Populate was a Decorator that simply knows how to setup the Form object graph, nothing more? That would decouple
|
5
|
-
# the population from the validation (good and bad as less customizable).
|
6
|
-
|
7
|
-
# Go through all nested forms and call form.update!(hash).
|
8
|
-
def from_hash(*)
|
9
|
-
nested_forms do |attr|
|
10
|
-
attr.merge!(
|
11
|
-
# set parse_strategy: sync> # DISCUSS: that kills the :setter directive, which usually sucks. at least document this in :populator.
|
12
|
-
:collection => attr[:collection], # TODO: Def#merge! doesn't consider :collection if it's already set in attr YET.
|
13
|
-
:parse_strategy => :sync, # just use nested objects as they are.
|
14
|
-
|
15
|
-
# :getter grabs nested forms directly from fields bypassing the reader method which could possibly be overridden for presentation.
|
16
|
-
:getter => lambda { |options| fields.send(options.binding.name) },
|
17
|
-
:deserialize => lambda { |object, params, args| object.update!(params) },
|
18
|
-
)
|
19
|
-
|
20
|
-
# TODO: :populator now is just an alias for :instance. handle in ::property.
|
21
|
-
attr.merge!(:instance => attr[:populator]) if attr[:populator]
|
22
|
-
|
23
|
-
attr.merge!(:instance => Populator::PopulateIfEmpty.new) if attr[:populate_if_empty]
|
24
|
-
end
|
25
|
-
|
26
|
-
# FIXME: solve this with a dedicated Populate Decorator per Form.
|
27
|
-
representable_attrs.each do |attr|
|
28
|
-
attr.merge!(:parse_filter => Representable::Coercion::Coercer.new(attr[:coercion_type])) if attr[:coercion_type]
|
29
|
-
|
30
|
-
attr.merge!(:skip_if => Skip::AllBlank.new) if attr[:skip_if] == :all_blank
|
31
|
-
attr.merge!(:skip_parse => attr[:skip_if]) if attr[:skip_if]
|
32
|
-
end
|
33
|
-
|
34
|
-
|
35
|
-
representable_attrs.each do |attr|
|
36
|
-
next if attr[:form]
|
37
|
-
|
38
|
-
attr.merge!(:parse_filter => Changed.new)
|
39
|
-
end
|
40
|
-
|
41
|
-
super
|
42
|
-
end
|
43
|
-
end
|
44
|
-
|
45
|
-
|
46
3
|
module Populator
|
47
4
|
# This might change soon (e.g. moved into disposable).
|
48
5
|
class PopulateIfEmpty
|
@@ -130,10 +87,46 @@ private
|
|
130
87
|
def deserialize!(params)
|
131
88
|
# using self here will call the form's setters like title= which might be overridden.
|
132
89
|
# from_hash(params, parent_form: self)
|
133
|
-
|
90
|
+
# Go through all nested forms and call form.update!(hash).
|
91
|
+
populate_representer.new(self).send(deserialize_method, params, :parent_form => self)
|
134
92
|
end
|
135
93
|
|
136
94
|
def deserialize_method
|
137
95
|
:from_hash
|
138
96
|
end
|
97
|
+
|
98
|
+
# IDEA: what if Populate was a Decorator that simply knows how to setup the Form object graph, nothing more? That would decouple
|
99
|
+
# the population from the validation (good and bad as less customizable).
|
100
|
+
|
101
|
+
# Don't get scared by this method. All this does is create a new representer class for this form.
|
102
|
+
# It then configures each property so the population of the form can happen in #validate.
|
103
|
+
# A lot of this code is simply renaming from Reform's API to representable's. # FIXME: unify that?
|
104
|
+
def populate_representer
|
105
|
+
self.class.representer(:populate, :all => true) do |dfn|
|
106
|
+
if dfn[:form]
|
107
|
+
dfn.merge!(
|
108
|
+
# set parse_strategy: sync> # DISCUSS: that kills the :setter directive, which usually sucks. at least document this in :populator.
|
109
|
+
:collection => dfn[:collection], # TODO: Def#merge! doesn't consider :collection if it's already set in dfn YET.
|
110
|
+
:parse_strategy => :sync, # just use nested objects as they are.
|
111
|
+
|
112
|
+
# :getter grabs nested forms directly from fields bypassing the reader method which could possibly be overridden for presentation.
|
113
|
+
:getter => lambda { |options| fields.send(options.binding.name) },
|
114
|
+
:deserialize => lambda { |object, params, args| object.update!(params) },
|
115
|
+
)
|
116
|
+
|
117
|
+
# TODO: :populator now is just an alias for :instance. handle in ::property.
|
118
|
+
dfn.merge!(:instance => dfn[:populator]) if dfn[:populator]
|
119
|
+
|
120
|
+
dfn.merge!(:instance => Populator::PopulateIfEmpty.new) if dfn[:populate_if_empty]
|
121
|
+
end
|
122
|
+
|
123
|
+
|
124
|
+
dfn.merge!(:parse_filter => Representable::Coercion::Coercer.new(dfn[:coercion_type])) if dfn[:coercion_type]
|
125
|
+
|
126
|
+
dfn.merge!(:skip_if => Skip::AllBlank.new) if dfn[:skip_if] == :all_blank
|
127
|
+
dfn.merge!(:skip_parse => dfn[:skip_if]) if dfn[:skip_if]
|
128
|
+
|
129
|
+
dfn.merge!(:parse_filter => Changed.new) unless dfn[:form] # TODO: make changed? work for nested forms.
|
130
|
+
end
|
131
|
+
end
|
139
132
|
end
|
data/lib/reform/representer.rb
CHANGED
@@ -38,10 +38,12 @@ module Reform
|
|
38
38
|
representable_attrs.find_all(&block).map(&:name)
|
39
39
|
end
|
40
40
|
|
41
|
-
def
|
42
|
-
|
43
|
-
|
44
|
-
|
41
|
+
def self.each(only_form=true, &block)
|
42
|
+
definitions = representable_attrs
|
43
|
+
definitions = representable_attrs.find_all { |attr| attr[:form] } if only_form
|
44
|
+
|
45
|
+
definitions.each(&block)
|
46
|
+
self
|
45
47
|
end
|
46
48
|
|
47
49
|
def self.for(options)
|
@@ -59,12 +61,6 @@ module Reform
|
|
59
61
|
end
|
60
62
|
|
61
63
|
private
|
62
|
-
def clone_config!
|
63
|
-
# TODO: representable_attrs.clone! which does exactly what's done below.
|
64
|
-
attrs = Representable::Config.new
|
65
|
-
attrs.inherit!(representable_attrs) # since in every use case we modify Config we clone.
|
66
|
-
@representable_attrs = attrs
|
67
|
-
end
|
68
64
|
|
69
65
|
# Inline forms always get saved in :extend.
|
70
66
|
def self.build_inline(base, features, name, options, &block)
|
data/lib/reform/version.rb
CHANGED
data/test/active_record_test.rb
CHANGED
@@ -105,6 +105,7 @@ end
|
|
105
105
|
|
106
106
|
class PopulateWithActiveRecordTest < MiniTest::Spec
|
107
107
|
class AlbumForm < Reform::Form
|
108
|
+
|
108
109
|
property :title
|
109
110
|
|
110
111
|
collection :songs, :populate_if_empty => Song do
|
@@ -188,6 +189,73 @@ class PopulateWithActiveRecordTest < MiniTest::Spec
|
|
188
189
|
album.songs[1].title.must_equal "Check For A Pulse"
|
189
190
|
album.songs[1].persisted?.must_equal true # TODO: with << strategy, this shouldn't be saved.
|
190
191
|
end
|
192
|
+
|
193
|
+
describe 'using nested_models_attributes to modify nested collection' do
|
194
|
+
class ActiveModelAlbumForm < Reform::Form
|
195
|
+
include Reform::Form::ActiveModel
|
196
|
+
include Reform::Form::ActiveModel::FormBuilderMethods
|
197
|
+
|
198
|
+
property :title
|
199
|
+
|
200
|
+
collection :songs, :populate_if_empty => Song do
|
201
|
+
property :title
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
let (:album) { Album.create(:title => 'Greatest Hits') }
|
206
|
+
let (:form) { ActiveModelAlbumForm.new(album) }
|
207
|
+
|
208
|
+
it do
|
209
|
+
form.validate('songs_attributes' => {'0' => {'title' => 'Tango'}})
|
210
|
+
|
211
|
+
# form populated.
|
212
|
+
form.songs.size.must_equal 1
|
213
|
+
form.songs[0].model.must_be_kind_of Song
|
214
|
+
form.songs[0].title.must_equal 'Tango'
|
215
|
+
|
216
|
+
# model NOT populated.
|
217
|
+
album.songs.must_equal []
|
218
|
+
|
219
|
+
form.save
|
220
|
+
|
221
|
+
# nested model persisted.
|
222
|
+
first_song = album.songs[0]
|
223
|
+
assert first_song.id > 0
|
224
|
+
|
225
|
+
# form populated.
|
226
|
+
form.songs.size.must_equal 1
|
227
|
+
|
228
|
+
# model also populated.
|
229
|
+
album.songs.size.must_equal 1
|
230
|
+
album.songs[0].title.must_equal 'Tango'
|
231
|
+
|
232
|
+
form.validate('songs_attributes' => {'0' => {'id' => first_song.id, 'title' => 'Tango nuevo'}, '1' => {'title' => 'Waltz'}})
|
233
|
+
|
234
|
+
# form populated.
|
235
|
+
form.songs.size.must_equal 2
|
236
|
+
form.songs[0].model.must_be_kind_of Song
|
237
|
+
form.songs[1].model.must_be_kind_of Song
|
238
|
+
form.songs[0].title.must_equal 'Tango nuevo'
|
239
|
+
form.songs[1].title.must_equal 'Waltz'
|
240
|
+
|
241
|
+
# model NOT populated.
|
242
|
+
album.songs.size.must_equal 1
|
243
|
+
album.songs[0].title.must_equal 'Tango'
|
244
|
+
|
245
|
+
form.save
|
246
|
+
|
247
|
+
# form populated.
|
248
|
+
form.songs.size.must_equal 2
|
249
|
+
|
250
|
+
# model also populated.
|
251
|
+
album.songs.size.must_equal 2
|
252
|
+
album.songs[0].id.must_equal first_song.id
|
253
|
+
album.songs[0].persisted?.must_equal true
|
254
|
+
album.songs[1].persisted?.must_equal true
|
255
|
+
album.songs[0].title.must_equal 'Tango nuevo'
|
256
|
+
album.songs[1].title.must_equal 'Waltz'
|
257
|
+
end
|
258
|
+
end
|
191
259
|
end
|
192
260
|
|
193
261
|
# it do
|