reform 1.2.0.beta2 → 1.2.1
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 +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
|