trailblazer 0.3.3 → 1.0.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +1 -1
- data/CHANGES.md +12 -0
- data/Gemfile +2 -1
- data/README.md +73 -38
- data/Rakefile +1 -1
- data/lib/trailblazer/autoloading.rb +4 -1
- data/lib/trailblazer/endpoint.rb +7 -13
- data/lib/trailblazer/operation.rb +54 -40
- data/lib/trailblazer/operation/builder.rb +26 -0
- data/lib/trailblazer/operation/collection.rb +1 -2
- data/lib/trailblazer/operation/controller.rb +36 -48
- data/lib/trailblazer/operation/dispatch.rb +11 -11
- data/lib/trailblazer/operation/model.rb +50 -0
- data/lib/trailblazer/operation/model/dsl.rb +29 -0
- data/lib/trailblazer/operation/model/external.rb +34 -0
- data/lib/trailblazer/operation/policy.rb +87 -0
- data/lib/trailblazer/operation/policy/guard.rb +34 -0
- data/lib/trailblazer/operation/representer.rb +33 -12
- data/lib/trailblazer/operation/resolver.rb +30 -0
- data/lib/trailblazer/operation/responder.rb +0 -1
- data/lib/trailblazer/operation/worker.rb +24 -7
- data/lib/trailblazer/version.rb +1 -1
- data/test/collection_test.rb +2 -1
- data/test/{crud_test.rb → model_test.rb} +17 -35
- data/test/operation/builder_test.rb +41 -0
- data/test/operation/dsl/callback_test.rb +108 -0
- data/test/operation/dsl/contract_test.rb +104 -0
- data/test/operation/dsl/representer_test.rb +143 -0
- data/test/operation/external_model_test.rb +71 -0
- data/test/operation/guard_test.rb +97 -0
- data/test/operation/policy_test.rb +97 -0
- data/test/operation/resolver_test.rb +83 -0
- data/test/operation_test.rb +7 -75
- data/test/rails/__respond_test.rb +20 -0
- data/test/rails/controller_test.rb +4 -102
- data/test/rails/endpoint_test.rb +7 -47
- data/test/rails/fake_app/controllers.rb +16 -21
- data/test/rails/fake_app/rails_app.rb +5 -0
- data/test/rails/fake_app/song/operations.rb +11 -4
- data/test/rails/respond_test.rb +95 -0
- data/test/responder_test.rb +6 -6
- data/test/rollback_test.rb +2 -2
- data/test/worker_test.rb +13 -9
- data/trailblazer.gemspec +2 -2
- metadata +38 -15
- data/lib/trailblazer/operation/crud.rb +0 -82
- data/lib/trailblazer/rails/railtie.rb +0 -34
- data/test/rails/fake_app/views/bands/show.html.erb +0 -1
@@ -0,0 +1,30 @@
|
|
1
|
+
require "trailblazer/operation/model/external"
|
2
|
+
require "trailblazer/operation/policy"
|
3
|
+
|
4
|
+
class Trailblazer::Operation
|
5
|
+
# Provides builds-> (model, policy, params).
|
6
|
+
module Resolver
|
7
|
+
def self.included(includer)
|
8
|
+
includer.class_eval do
|
9
|
+
include Policy # ::build_policy
|
10
|
+
include Model::External # ::build_operation_class
|
11
|
+
|
12
|
+
extend BuildOperation # ::build_operation
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
module BuildOperation
|
17
|
+
def build_operation(params, options={})
|
18
|
+
model = model!(params)
|
19
|
+
policy = policy_config.call(params[:current_user], model)
|
20
|
+
build_operation_class(model, policy, params).
|
21
|
+
new(params, options.merge(model: model, policy: policy))
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def initialize(params, options)
|
26
|
+
@policy = options[:policy]
|
27
|
+
super
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -7,7 +7,7 @@ class Trailblazer::Operation
|
|
7
7
|
# Works with Reform 2, only.
|
8
8
|
module Worker
|
9
9
|
def self.included(base)
|
10
|
-
base.send(:include, Sidekiq::Worker)
|
10
|
+
base.send(:include, Sidekiq::Worker)
|
11
11
|
base.extend(ClassMethods)
|
12
12
|
end
|
13
13
|
|
@@ -17,10 +17,28 @@ class Trailblazer::Operation
|
|
17
17
|
return perform_async(serializable(params))
|
18
18
|
end
|
19
19
|
|
20
|
-
|
20
|
+
super(params)
|
21
|
+
end
|
22
|
+
|
23
|
+
def new(*args)
|
24
|
+
return super if args.any?
|
25
|
+
# sidekiq behavior: (not a big fan of this)
|
26
|
+
self
|
27
|
+
end
|
28
|
+
|
29
|
+
def perform(params) # called by Sidekiq.
|
30
|
+
build_operation(params).perform
|
31
|
+
end
|
32
|
+
|
33
|
+
def jid=(jid)
|
34
|
+
puts "@@@@@ #{jid.inspect}"
|
21
35
|
end
|
22
36
|
|
23
37
|
private
|
38
|
+
def perform_async(*args)
|
39
|
+
client_push('class' => self, 'args' => args) # calls class.new.perform(params)
|
40
|
+
end
|
41
|
+
|
24
42
|
def background? # TODO: make configurable.
|
25
43
|
true
|
26
44
|
# if Rails.env == "production" or Rails.env == "staging"
|
@@ -32,16 +50,15 @@ class Trailblazer::Operation
|
|
32
50
|
end
|
33
51
|
|
34
52
|
|
35
|
-
#
|
36
|
-
def perform(params)
|
53
|
+
def perform#(params)
|
37
54
|
# the serialized params hash from Sidekiq contains a Op::UploadedFile hash.
|
38
55
|
|
39
56
|
# the following code is basically what happens in a controller.
|
40
57
|
# this is a bug in Rails, it doesn't work without requiring as/hash/ina
|
41
58
|
# params = ActiveSupport::HashWithIndifferentAccess.new_from_hash_copying_default(params) # TODO: this might make it ultra-slow as Reform converts it back to strings.
|
42
|
-
params = params.with_indifferent_access
|
43
|
-
|
44
|
-
run
|
59
|
+
params = @params.with_indifferent_access
|
60
|
+
@params = deserializable(params)
|
61
|
+
run
|
45
62
|
end
|
46
63
|
|
47
64
|
private
|
data/lib/trailblazer/version.rb
CHANGED
data/test/collection_test.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
require "test_helper"
|
2
2
|
require "trailblazer/operation/collection"
|
3
|
+
require "trailblazer/operation/model"
|
3
4
|
|
4
5
|
class CollectionTest < MiniTest::Spec
|
5
6
|
Song = Struct.new(:title, :id) do
|
@@ -14,7 +15,7 @@ class CollectionTest < MiniTest::Spec
|
|
14
15
|
|
15
16
|
|
16
17
|
class CreateOperation < Trailblazer::Operation
|
17
|
-
include
|
18
|
+
include Model
|
18
19
|
model Song
|
19
20
|
action :create
|
20
21
|
|
@@ -1,7 +1,7 @@
|
|
1
1
|
require 'test_helper'
|
2
2
|
require 'trailblazer/operation'
|
3
3
|
|
4
|
-
class
|
4
|
+
class ModelTest < MiniTest::Spec
|
5
5
|
Song = Struct.new(:title, :id) do
|
6
6
|
class << self
|
7
7
|
attr_accessor :find_result # TODO: eventually, replace with AR test.
|
@@ -14,7 +14,7 @@ class CrudTest < MiniTest::Spec
|
|
14
14
|
end
|
15
15
|
|
16
16
|
class CreateOperation < Trailblazer::Operation
|
17
|
-
include
|
17
|
+
include Model
|
18
18
|
model Song
|
19
19
|
action :create
|
20
20
|
|
@@ -32,9 +32,9 @@ class CrudTest < MiniTest::Spec
|
|
32
32
|
|
33
33
|
|
34
34
|
# creates model for you.
|
35
|
-
it { CreateOperation
|
35
|
+
it { CreateOperation.(song: {title: "Blue Rondo a la Turk"}).model.title.must_equal "Blue Rondo a la Turk" }
|
36
36
|
# exposes #model.
|
37
|
-
it { CreateOperation
|
37
|
+
it { CreateOperation.(song: {title: "Blue Rondo a la Turk"}).model.must_be_instance_of Song }
|
38
38
|
|
39
39
|
class ModifyingCreateOperation < CreateOperation
|
40
40
|
def process(params)
|
@@ -47,8 +47,8 @@ class CrudTest < MiniTest::Spec
|
|
47
47
|
end
|
48
48
|
|
49
49
|
# lets you modify model.
|
50
|
-
it { ModifyingCreateOperation
|
51
|
-
it { ModifyingCreateOperation
|
50
|
+
it { ModifyingCreateOperation.(song: {title: "Blue Rondo a la Turk"}).model.title.must_equal "Blue Rondo a la Turk" }
|
51
|
+
it { ModifyingCreateOperation.(song: {title: "Blue Rondo a la Turk"}).model.genre.must_equal "Punkrock" }
|
52
52
|
|
53
53
|
# Update
|
54
54
|
class UpdateOperation < CreateOperation
|
@@ -57,10 +57,10 @@ class CrudTest < MiniTest::Spec
|
|
57
57
|
|
58
58
|
# finds model and updates.
|
59
59
|
it do
|
60
|
-
song = CreateOperation
|
60
|
+
song = CreateOperation.(song: {title: "Anchor End"}).model
|
61
61
|
Song.find_result = song
|
62
62
|
|
63
|
-
UpdateOperation
|
63
|
+
UpdateOperation.(id: song.id, song: {title: "The Rip"}).model.title.must_equal "The Rip"
|
64
64
|
song.title.must_equal "The Rip"
|
65
65
|
end
|
66
66
|
|
@@ -71,16 +71,16 @@ class CrudTest < MiniTest::Spec
|
|
71
71
|
|
72
72
|
# finds model and updates.
|
73
73
|
it do
|
74
|
-
song = CreateOperation
|
74
|
+
song = CreateOperation.(song: {title: "Anchor End"}).model
|
75
75
|
Song.find_result = song
|
76
76
|
|
77
|
-
FindOperation
|
77
|
+
FindOperation.(id: song.id, song: {title: "The Rip"}).model.title.must_equal "The Rip"
|
78
78
|
song.title.must_equal "The Rip"
|
79
79
|
end
|
80
80
|
|
81
81
|
|
82
82
|
class DefaultCreateOperation < Trailblazer::Operation
|
83
|
-
include
|
83
|
+
include Model
|
84
84
|
model Song
|
85
85
|
|
86
86
|
def process(params)
|
@@ -89,7 +89,7 @@ class CrudTest < MiniTest::Spec
|
|
89
89
|
end
|
90
90
|
|
91
91
|
# uses :create as default if not set via ::action.
|
92
|
-
it { DefaultCreateOperation
|
92
|
+
it { DefaultCreateOperation.({}).model.must_equal Song.new }
|
93
93
|
|
94
94
|
# model Song, :action
|
95
95
|
class ModelUpdateOperation < CreateOperation
|
@@ -99,7 +99,7 @@ class CrudTest < MiniTest::Spec
|
|
99
99
|
# allows ::model, :action.
|
100
100
|
it do
|
101
101
|
Song.find_result = song = Song.new
|
102
|
-
ModelUpdateOperation
|
102
|
+
ModelUpdateOperation.({id: 1, song: {title: "Mercy Day For Mr. Vengeance"}}).model.must_equal song
|
103
103
|
end
|
104
104
|
|
105
105
|
|
@@ -111,13 +111,13 @@ class CrudTest < MiniTest::Spec
|
|
111
111
|
end
|
112
112
|
end
|
113
113
|
|
114
|
-
it { SetupModelOperation
|
114
|
+
it { SetupModelOperation.(song: {title: "Emily Kane"}).model.params.must_equal "{:song=>{:title=>\"Emily Kane\"}}" }
|
115
115
|
|
116
116
|
|
117
117
|
|
118
118
|
# no call to ::model raises error.
|
119
119
|
class NoModelOperation < Trailblazer::Operation
|
120
|
-
include
|
120
|
+
include Model
|
121
121
|
|
122
122
|
def process(params)
|
123
123
|
self
|
@@ -125,29 +125,11 @@ class CrudTest < MiniTest::Spec
|
|
125
125
|
end
|
126
126
|
|
127
127
|
# uses :create as default if not set via ::action.
|
128
|
-
it { assert_raises(RuntimeError){ NoModelOperation
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
# contract infers model_name.
|
133
|
-
# TODO: this a Rails/ActiveModel-specific test.
|
134
|
-
class ContractKnowsModelNameOperation < Trailblazer::Operation
|
135
|
-
include CRUD
|
136
|
-
model Song
|
137
|
-
include CRUD::ActiveModel
|
138
|
-
|
139
|
-
contract do
|
140
|
-
include Reform::Form::ActiveModel # this usually happens in Reform::Form::Rails.
|
141
|
-
property :title
|
142
|
-
end
|
143
|
-
end
|
144
|
-
|
145
|
-
it { ContractKnowsModelNameOperation.present(song: {title: "Direct Hit"}).contract.class.model_name.to_s.must_equal "CrudTest::Song" }
|
146
|
-
|
128
|
+
it { assert_raises(RuntimeError){ NoModelOperation.({}) } }
|
147
129
|
|
148
130
|
# allow passing validate(params, model, contract_class)
|
149
131
|
class OperationWithPrivateContract < Trailblazer::Operation
|
150
|
-
include
|
132
|
+
include Model
|
151
133
|
model Song
|
152
134
|
|
153
135
|
class Contract < Reform::Form
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
class OperationBuilderTest < MiniTest::Spec
|
4
|
+
class ParentOperation < Trailblazer::Operation
|
5
|
+
def process(params)
|
6
|
+
end
|
7
|
+
|
8
|
+
class Sub < self
|
9
|
+
end
|
10
|
+
|
11
|
+
builds do |params|
|
12
|
+
Sub if params[:sub]
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
it { ParentOperation.run({}).last.class.must_equal ParentOperation }
|
17
|
+
it { ParentOperation.run({sub: true}).last.class.must_equal ParentOperation::Sub }
|
18
|
+
it { ParentOperation.({}).class.must_equal ParentOperation }
|
19
|
+
it { ParentOperation.({sub: true}).class.must_equal ParentOperation::Sub }
|
20
|
+
end
|
21
|
+
|
22
|
+
class OperationBuilderClassTest < MiniTest::Spec
|
23
|
+
class SuperOperation < Trailblazer::Operation
|
24
|
+
builds do |params|
|
25
|
+
self::Sub if params[:sub] # Sub is defined in ParentOperation.
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class ParentOperation < Trailblazer::Operation
|
30
|
+
def process(params)
|
31
|
+
end
|
32
|
+
|
33
|
+
class Sub < self
|
34
|
+
end
|
35
|
+
|
36
|
+
self.builder_class = SuperOperation.builder_class
|
37
|
+
end
|
38
|
+
|
39
|
+
it { ParentOperation.({}).class.must_equal ParentOperation }
|
40
|
+
it { ParentOperation.({sub: true}).class.must_equal ParentOperation::Sub }
|
41
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
require "trailblazer/operation/dispatch"
|
3
|
+
|
4
|
+
|
5
|
+
class DslCallbackTest < MiniTest::Spec
|
6
|
+
module SongProcess
|
7
|
+
def process(params)
|
8
|
+
contract(OpenStruct.new).validate(params)
|
9
|
+
dispatch!
|
10
|
+
end
|
11
|
+
|
12
|
+
def _invocations
|
13
|
+
@_invocations ||= []
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.included(includer)
|
17
|
+
includer.contract do
|
18
|
+
property :title
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
describe "inheritance across operations" do
|
24
|
+
class Operation < Trailblazer::Operation
|
25
|
+
include Dispatch
|
26
|
+
include SongProcess
|
27
|
+
|
28
|
+
callback do
|
29
|
+
on_change :default!
|
30
|
+
end
|
31
|
+
|
32
|
+
class Admin < self
|
33
|
+
callback do
|
34
|
+
on_change :admin_default!
|
35
|
+
end
|
36
|
+
|
37
|
+
callback(:after_save) { on_change :after_save! }
|
38
|
+
|
39
|
+
def admin_default!(*); _invocations << :admin_default!; end
|
40
|
+
def after_save!(*); _invocations << :after_save!; end
|
41
|
+
|
42
|
+
def process(*)
|
43
|
+
super
|
44
|
+
dispatch!(:after_save)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def default!(*); _invocations << :default!; end
|
49
|
+
end
|
50
|
+
|
51
|
+
it { Operation.({"title"=> "Love-less"})._invocations.must_equal([:default!]) }
|
52
|
+
it { Operation::Admin.({"title"=> "Love-less"})._invocations.must_equal([:default!, :admin_default!, :after_save!]) }
|
53
|
+
end
|
54
|
+
|
55
|
+
describe "Op.callback" do
|
56
|
+
it { Operation.callback(:default).must_equal Operation.callbacks[:default] }
|
57
|
+
end
|
58
|
+
|
59
|
+
describe "Op.callback :after_save, AfterSaveCallback" do
|
60
|
+
class AfterSaveCallback < Disposable::Callback::Group
|
61
|
+
on_change :after_save!
|
62
|
+
end
|
63
|
+
|
64
|
+
class OpWithExternalCallback < Trailblazer::Operation
|
65
|
+
include Dispatch
|
66
|
+
include SongProcess
|
67
|
+
callback :after_save, AfterSaveCallback
|
68
|
+
|
69
|
+
def process(params)
|
70
|
+
contract(OpenStruct.new).validate(params)
|
71
|
+
dispatch!(:after_save)
|
72
|
+
end
|
73
|
+
|
74
|
+
def after_save!(*); _invocations << :after_save!; end
|
75
|
+
end
|
76
|
+
|
77
|
+
it { OpWithExternalCallback.("title"=>"Thunder Rising")._invocations.must_equal([:after_save!]) }
|
78
|
+
end
|
79
|
+
|
80
|
+
describe "Op.callback :after_save, AfterSaveCallback do .. end" do
|
81
|
+
class DefaultCallback < Disposable::Callback::Group
|
82
|
+
on_change :default!
|
83
|
+
end
|
84
|
+
|
85
|
+
class OpUsingCallback < Trailblazer::Operation
|
86
|
+
include Dispatch
|
87
|
+
include SongProcess
|
88
|
+
callback :default, DefaultCallback
|
89
|
+
def default!(*); _invocations << :default!; end
|
90
|
+
end
|
91
|
+
|
92
|
+
class OpExtendingCallback < Trailblazer::Operation
|
93
|
+
include Dispatch
|
94
|
+
include SongProcess
|
95
|
+
callback :default, DefaultCallback do
|
96
|
+
on_change :after_save!
|
97
|
+
end
|
98
|
+
|
99
|
+
def default!(*); _invocations << :default!; end
|
100
|
+
def after_save!(*); _invocations << :after_save!; end
|
101
|
+
end
|
102
|
+
|
103
|
+
# this operation copies DefaultCallback and shouldn't run #after_save!.
|
104
|
+
it { OpUsingCallback.(title: "Thunder Rising")._invocations.must_equal([:default!]) }
|
105
|
+
# this operation copies DefaultCallback, extends it and runs #after_save!.
|
106
|
+
it { OpExtendingCallback.(title: "Thunder Rising")._invocations.must_equal([:default!, :after_save!]) }
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
# ::contract builds Reform::Form class
|
4
|
+
class DslContractTest < MiniTest::Spec
|
5
|
+
module SongProcess
|
6
|
+
def process(params)
|
7
|
+
validate(params, @model = OpenStruct.new)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
describe "inheritance across operations" do
|
12
|
+
# inheritance
|
13
|
+
class Operation < Trailblazer::Operation
|
14
|
+
contract do
|
15
|
+
property :title
|
16
|
+
property :band
|
17
|
+
end
|
18
|
+
|
19
|
+
class JSON < self
|
20
|
+
contract do # inherit Contract
|
21
|
+
property :genre, validates: {presence: true}
|
22
|
+
property :band, virtual: true
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# inherits subclassed Contract.
|
28
|
+
it { Operation.contract_class.wont_equal Operation::JSON.contract_class }
|
29
|
+
|
30
|
+
it do
|
31
|
+
form = Operation.contract_class.new(OpenStruct.new)
|
32
|
+
form.validate({})#.must_equal true
|
33
|
+
form.errors.to_s.must_equal "{}"
|
34
|
+
|
35
|
+
form = Operation::JSON.contract_class.new(OpenStruct.new)
|
36
|
+
form.validate({})#.must_equal true
|
37
|
+
form.errors.to_s.must_equal "{:genre=>[\"can't be blank\"]}"
|
38
|
+
end
|
39
|
+
|
40
|
+
# allows overriding options
|
41
|
+
it do
|
42
|
+
form = Operation::JSON.contract_class.new(song = OpenStruct.new)
|
43
|
+
form.validate({genre: "Punkrock", band: "Osker"}).must_equal true
|
44
|
+
form.sync
|
45
|
+
|
46
|
+
song.genre.must_equal "Punkrock"
|
47
|
+
song.band.must_equal nil
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
describe "Op.contract" do
|
52
|
+
it { Operation.contract.must_equal Operation.contract_class }
|
53
|
+
end
|
54
|
+
|
55
|
+
describe "Op.contract CommentForm" do
|
56
|
+
class SongForm < Reform::Form
|
57
|
+
property :songTitle, validates: {presence: true}
|
58
|
+
end
|
59
|
+
|
60
|
+
class OpWithExternalContract < Trailblazer::Operation
|
61
|
+
contract SongForm
|
62
|
+
include SongProcess
|
63
|
+
end
|
64
|
+
|
65
|
+
it { OpWithExternalContract.("songTitle"=> "Monsterparty").contract.songTitle.must_equal "Monsterparty" }
|
66
|
+
end
|
67
|
+
|
68
|
+
describe "Op.contract CommentForm do .. end" do
|
69
|
+
class DifferentSongForm < Reform::Form
|
70
|
+
property :songTitle, validates: {presence: true}
|
71
|
+
end
|
72
|
+
|
73
|
+
class OpNotExtendingContract < Trailblazer::Operation
|
74
|
+
contract DifferentSongForm
|
75
|
+
include SongProcess
|
76
|
+
end
|
77
|
+
|
78
|
+
class OpExtendingContract < Trailblazer::Operation
|
79
|
+
contract DifferentSongForm do
|
80
|
+
property :genre
|
81
|
+
end
|
82
|
+
include SongProcess
|
83
|
+
end
|
84
|
+
|
85
|
+
# this operation copies DifferentSongForm and shouldn't have `genre`.
|
86
|
+
it do
|
87
|
+
contract = OpNotExtendingContract.("songTitle"=>"Monsterparty", "genre"=>"Punk").contract
|
88
|
+
contract.songTitle.must_equal "Monsterparty"
|
89
|
+
assert_raises(NoMethodError) { contract.genre }
|
90
|
+
end
|
91
|
+
|
92
|
+
# this operation copies DifferentSongForm and extends it with the property `genre`.
|
93
|
+
it do
|
94
|
+
contract = OpExtendingContract.("songTitle"=>"Monsterparty", "genre"=>"Punk").contract
|
95
|
+
contract.songTitle.must_equal "Monsterparty"
|
96
|
+
contract.genre.must_equal "Punk"
|
97
|
+
end
|
98
|
+
|
99
|
+
# of course, the original contract wasn't modified, either.
|
100
|
+
it do
|
101
|
+
assert_raises(NoMethodError) { DifferentSongForm.new(OpenStruct.new).genre }
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|