iknow_view_models 3.5.2 → 3.6.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f51edf25fab0aec533446c939c44975f76806871e1341518aa4470f32c23d839
4
- data.tar.gz: 140166cc68f15484a0291f4e8faa8b4e26c3233a2b3bcfacfa9a4972a2a5e0ef
3
+ metadata.gz: 267212f1572c3f81b65462be6c30e02a12a6562a29d01bd5e0c342a12396acbc
4
+ data.tar.gz: e8c4addd984351d6df889ba7d75abbe8d5bde7793a48791e24a1b42e7a3fdf42
5
5
  SHA512:
6
- metadata.gz: 27e85b3cd097d4f19e28d66d3500db4142dedd4a65a44731754afd35430d86945bb2e7f928df476e4aadb665d34518fa10211113f17b1ad0718727f957c8156f
7
- data.tar.gz: 7e740ea172d15e55b870336fd833a6138c3f5aaec97da84828813b6da57d9512bbb3cd98f404f24c58c7adf7b53d875639bd22561b200d429e74523770a3a483
6
+ metadata.gz: 6039dcc05bed20bce4681c322c3509be3f2a276c0a755fb3e27240eec74fd9eddef68f3c272df33554407853065a98e0f4d03a068c8c0e068cfc02be6f73ab67
7
+ data.tar.gz: 95fb89d93724d5028905612d846534916c4bdacd1bb88009ae434090b183e17d7311bb2709bfa81b08ff3dff45940ced0ce080ff861529c59cbb96671a661b64
data/.circleci/config.yml CHANGED
@@ -121,6 +121,11 @@ workflows:
121
121
  ruby-version: "3.0"
122
122
  pg-version: "12"
123
123
  gemfile: gemfiles/rails_6_1.gemfile
124
+ - test:
125
+ name: 'ruby 3.0 rails 7.0 pg 12'
126
+ ruby-version: "3.0"
127
+ pg-version: "12"
128
+ gemfile: gemfiles/rails_7_0.gemfile
124
129
  - publish:
125
130
  filters:
126
131
  branches:
data/Appraisals CHANGED
@@ -12,3 +12,8 @@ appraise 'rails-6-1' do
12
12
  gem 'activerecord', '~> 6.1.0'
13
13
  gem 'activesupport', '~> 6.1.0'
14
14
  end
15
+
16
+ appraise 'rails-7-0' do
17
+ gem 'activerecord', '~> 7.0.0'
18
+ gem 'activesupport', '~> 7.0.0'
19
+ end
data/Gemfile CHANGED
@@ -11,5 +11,6 @@ gem 'rubocop-iknow'
11
11
  gem 'minitest-ci'
12
12
 
13
13
  # Override gemspec for development version preferences
14
- gem 'activerecord', '~> 6.0.0'
15
- gem 'activesupport', '~> 6.0.0'
14
+ gem 'activerecord', '~> 7.0.0'
15
+ gem 'activesupport', '~> 7.0.0'
16
+ gem 'actionpack', '~> 7.0.0'
@@ -5,5 +5,6 @@ source 'https://rubygems.org'
5
5
  gem 'minitest-ci'
6
6
  gem 'activerecord', '~> 5.2.0'
7
7
  gem 'activesupport', '~> 5.2.0'
8
+ gem 'actionpack', '~> 5.2.0'
8
9
 
9
10
  gemspec path: '../'
@@ -5,5 +5,6 @@ source 'https://rubygems.org'
5
5
  gem 'minitest-ci'
6
6
  gem 'activerecord', '~> 6.0.0'
7
7
  gem 'activesupport', '~> 6.0.0'
8
+ gem 'actionpack', '~> 6.0.0'
8
9
 
9
10
  gemspec path: '../'
@@ -5,5 +5,6 @@ source 'https://rubygems.org'
5
5
  gem 'minitest-ci'
6
6
  gem 'activerecord', '~> 6.1.0'
7
7
  gem 'activesupport', '~> 6.1.0'
8
+ gem 'actionpack', '~> 6.1.0'
8
9
 
9
10
  gemspec path: '../'
@@ -0,0 +1,10 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem 'minitest-ci'
6
+ gem "activerecord", "~> 7.0.0"
7
+ gem "activesupport", "~> 7.0.0"
8
+ gem "actionpack", "~> 7.0.0"
9
+
10
+ gemspec path: '../'
@@ -21,6 +21,7 @@ Gem::Specification.new do |spec|
21
21
 
22
22
  spec.required_ruby_version = '>= 2.7'
23
23
 
24
+ spec.add_dependency 'actionpack', '>= 5.0'
24
25
  spec.add_dependency 'activerecord', '>= 5.0'
25
26
  spec.add_dependency 'activesupport', '>= 5.0'
26
27
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IknowViewModels
4
- VERSION = '3.5.2'
4
+ VERSION = '3.6.2'
5
5
  end
@@ -39,7 +39,7 @@ class ViewModel::AccessControl::Composed < ViewModel::AccessControl
39
39
  case
40
40
  when new_allow
41
41
  nil
42
- when self.allow_error && other.allow_error
42
+ when mergeable_error?(self.allow_error) && mergeable_error?(other.allow_error)
43
43
  self.allow_error.merge(other.allow_error)
44
44
  else
45
45
  self.allow_error || other.allow_error
@@ -48,6 +48,12 @@ class ViewModel::AccessControl::Composed < ViewModel::AccessControl
48
48
  ComposedResult.new(new_allow, other.veto, new_allow_error, other.veto_error)
49
49
  end
50
50
  end
51
+
52
+ private
53
+
54
+ def mergeable_error?(err)
55
+ err&.is_a?(NoRequiredConditionsError)
56
+ end
51
57
  end
52
58
 
53
59
  PermissionsCheck = Struct.new(:location, :reason, :error_type, :checker) do
@@ -196,22 +202,54 @@ class ViewModel::AccessControl::Composed < ViewModel::AccessControl
196
202
  protected
197
203
 
198
204
  def check_delegates(env, ifs, unlesses)
199
- vetoed_checker = unlesses.detect { |checker| checker.check(env) }
205
+ veto, veto_error = detect_veto(env, unlesses)
206
+ allow, allow_error = detect_allow(env, ifs)
207
+
208
+ ComposedResult.new(allow, veto, allow_error, veto_error)
209
+ end
210
+
211
+ private
212
+
213
+ def detect_veto(env, checkers)
214
+ checkers.each do |checker|
215
+ result = checker.check(env)
216
+ next unless result
200
217
 
201
- veto = vetoed_checker.present?
202
- if veto
203
- veto_error = vetoed_checker.error_type.new('Action not permitted because: ' +
204
- vetoed_checker.reason,
205
- env.view.blame_reference)
218
+ error =
219
+ if result.is_a?(StandardError)
220
+ result
221
+ else
222
+ checker.error_type.new('Action not permitted because: ' +
223
+ checker.reason,
224
+ env.view.blame_reference)
225
+ end
226
+
227
+ # short-circuit exit with failure
228
+ return true, error
206
229
  end
207
230
 
208
- allow = ifs.any? { |checker| checker.check(env) }
231
+ return false, nil
232
+ end
233
+
234
+ def detect_allow(env, checkers)
235
+ error = nil
236
+
237
+ checkers.each do |checker|
238
+ result = checker.check(env)
239
+ next unless result
209
240
 
210
- unless allow
211
- allow_error = NoRequiredConditionsError.new(env.view.blame_reference,
212
- ifs.map(&:name))
241
+ if result.is_a?(StandardError)
242
+ error ||= result
243
+ else
244
+ # short-circuit exit with success
245
+ return true, nil
246
+ end
213
247
  end
214
248
 
215
- ComposedResult.new(allow, veto, allow_error, veto_error)
249
+ error ||= NoRequiredConditionsError.new(
250
+ env.view.blame_reference,
251
+ checkers.map(&:name))
252
+
253
+ return false, error
216
254
  end
217
255
  end
@@ -149,6 +149,14 @@ class ViewModel::AccessControl
149
149
  def raise_if_error!(result)
150
150
  raise (result.error || yield) unless result.permit?
151
151
  end
152
+
153
+ # Called from composed access controls via the `env`, this is used to make the
154
+ # if/unless DSL more readable when returning a custom failure error.
155
+ def failure(err)
156
+ raise ArgumentError.new("Unexpected failure type: #{err}") unless err.is_a?(StandardError)
157
+
158
+ err
159
+ end
152
160
  end
153
161
 
154
162
  require 'view_model/access_control/open'
@@ -14,9 +14,9 @@ class ViewModel::ActiveRecord
14
14
  def release!
15
15
  model = viewmodel.model
16
16
  case association_data.direct_reflection.options[:dependent]
17
- when :delete
17
+ when :delete, :delete_all
18
18
  model.delete
19
- when :destroy
19
+ when :destroy, :destroy_async
20
20
  model.destroy
21
21
  end
22
22
  end
@@ -34,14 +34,36 @@ module ViewModel::MigratableView
34
34
  end
35
35
  end
36
36
 
37
+ protected
38
+
39
+ def migration_class(from, to)
40
+ @migration_classes.fetch([from, to]) do
41
+ raise ViewModel::Migration::NoPathError.new(self, from, to)
42
+ end
43
+ end
44
+
37
45
  private
38
46
 
39
47
  # Define a migration on this viewmodel
40
- def migrates(from:, to:, &block)
48
+ def migrates(from:, to:, inherit: nil, at: nil, &block)
41
49
  @migrations_lock.synchronize do
42
- builder = ViewModel::Migration::Builder.new
50
+ migration_superclass =
51
+ if inherit
52
+ raise ArgumentError.new('Must provide inherit version') unless at
53
+
54
+ inherit.migration_class(at - 1, at)
55
+ else
56
+ ViewModel::Migration
57
+ end
58
+
59
+ builder = ViewModel::Migration::Builder.new(migration_superclass)
43
60
  builder.instance_exec(&block)
44
- @migration_classes[[from, to]] = builder.build!
61
+
62
+ migration_class = builder.build!
63
+
64
+ const_set(:"Migration_#{from}_To_#{to}", migration_class)
65
+ @migration_classes[[from, to]] = migration_class
66
+
45
67
  @realized_migration_paths = false
46
68
  end
47
69
  end
@@ -15,13 +15,14 @@ class ViewModel::Migration
15
15
 
16
16
  # Tiny DSL for defining migration classes
17
17
  class Builder
18
- def initialize
18
+ def initialize(superclass = ViewModel::Migration)
19
+ @superclass = superclass
19
20
  @up_block = nil
20
21
  @down_block = nil
21
22
  end
22
23
 
23
24
  def build!
24
- migration = Class.new(ViewModel::Migration)
25
+ migration = Class.new(@superclass)
25
26
  migration.define_method(:up, &@up_block) if @up_block
26
27
  migration.define_method(:down, &@down_block) if @down_block
27
28
  migration
@@ -359,12 +359,20 @@ class ViewModel::Record < ViewModel
359
359
 
360
360
  attribute_changed!(vm_attr_name)
361
361
 
362
- if attr_data.using_viewmodel? && !value.nil?
363
- # Extract model from target viewmodel(s) to attach to our model
364
- value = attr_data.map_value(value) { |vm| vm.model }
365
- end
362
+ model_value =
363
+ if attr_data.using_viewmodel? && !value.nil?
364
+ # Extract model from target viewmodel(s) to attach to our model
365
+ attr_data.map_value(value) { |vm| vm.model }
366
+ else
367
+ value
368
+ end
369
+
370
+ model.public_send("#{attr_data.model_attr_name}=", model_value)
366
371
 
367
- model.public_send("#{attr_data.model_attr_name}=", value)
372
+ elsif new_model?
373
+ # Record attribute_changed for mutable values asserted on a new model, even where
374
+ # they match the ActiveRecord default.
375
+ attribute_changed!(vm_attr_name) unless attr_data.read_only? && !attr_data.write_once?
368
376
  end
369
377
 
370
378
  if attr_data.using_viewmodel?
@@ -65,8 +65,11 @@ class ViewModel::TestHelpers::ARVMBuilder
65
65
  ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS #{name.underscore.pluralize} CASCADE")
66
66
  namespace.send(:remove_const, name)
67
67
  namespace.send(:remove_const, viewmodel_name) if viewmodel
68
+
68
69
  # prevent cached old class from being used to resolve associations
69
- ActiveSupport::Dependencies::Reference.clear!
70
+ if ActiveSupport::VERSION::MAJOR < 7
71
+ ActiveSupport::Dependencies::Reference.clear!
72
+ end
70
73
  end
71
74
 
72
75
  private
data/nix/dependencies.nix CHANGED
@@ -1,5 +1,5 @@
1
1
  {pkgs}:
2
2
  {
3
- ruby = pkgs.ruby_2_7;
3
+ ruby = pkgs.ruby_3_0;
4
4
  postgresql = pkgs.postgresql_12;
5
5
  }
@@ -323,7 +323,11 @@ module ControllerTestControllers
323
323
  CONTROLLER_NAMES.each do |name|
324
324
  Object.send(:remove_const, name)
325
325
  end
326
- ActiveSupport::Dependencies::Reference.clear!
326
+
327
+ if ActiveSupport::VERSION::MAJOR < 7
328
+ ActiveSupport::Dependencies::Reference.clear!
329
+ end
330
+
327
331
  super
328
332
  end
329
333
  end
@@ -13,6 +13,7 @@ class TestAccessControl < ViewModel::AccessControl
13
13
  @editable_checks = []
14
14
  @visible_checks = []
15
15
  @valid_edit_checks = []
16
+ @changes = []
16
17
  end
17
18
 
18
19
  # Collect
@@ -33,6 +34,17 @@ class TestAccessControl < ViewModel::AccessControl
33
34
  ViewModel::AccessControl::Result.new(@can_view)
34
35
  end
35
36
 
37
+ def record_deserialize_changes(ref, changes)
38
+ @changes << [ref, changes]
39
+ end
40
+
41
+ # Collect all changes on after_deserialize, to allow inspecting changes that
42
+ # didn't result in `changed?`
43
+ after_deserialize do
44
+ ref = view.to_reference
45
+ record_deserialize_changes(ref, changes)
46
+ end
47
+
36
48
  # Query (also see attr_accessors)
37
49
 
38
50
  def valid_edit_refs
@@ -55,4 +67,10 @@ class TestAccessControl < ViewModel::AccessControl
55
67
  def was_edited?(ref)
56
68
  all_valid_edit_changes(ref).present?
57
69
  end
70
+
71
+ def all_changes(ref)
72
+ @changes
73
+ .select { |cref, _changes| cref == ref }
74
+ .map { |_cref, changes| changes }
75
+ end
58
76
  end
@@ -206,6 +206,55 @@ module ViewModelSpecHelpers
206
206
  end
207
207
  end
208
208
 
209
+ module SingleWithInheritedMigration
210
+ extend ActiveSupport::Concern
211
+ include ViewModelSpecHelpers::Base
212
+
213
+ def migration_bearing_viewmodel_class
214
+ define_viewmodel_class(
215
+ :MigrationBearingView,
216
+ namespace: namespace,
217
+ viewmodel_base: viewmodel_base,
218
+ model_base: model_base,
219
+ spec: ViewModel::TestHelpers::ARVMBuilder::Spec.new(
220
+ schema: ->(_) {},
221
+ model: ->(_) {},
222
+ viewmodel: ->(v) {
223
+ root!
224
+ self.schema_version = 2
225
+ migrates from: 1, to: 2 do
226
+ down do |view, _refs|
227
+ view['inherited_base'] = 'present'
228
+ end
229
+ end
230
+ }))
231
+ end
232
+
233
+ def model_attributes
234
+ migration_bearing_viewmodel_class = self.migration_bearing_viewmodel_class
235
+
236
+ super.merge(
237
+ schema: ->(t) { t.integer :new_field, default: 1, null: false },
238
+ viewmodel: ->(_v) {
239
+ self.schema_version = 2
240
+
241
+ attribute :new_field
242
+
243
+ migrates from: 1, to: 2, inherit: migration_bearing_viewmodel_class, at: 2 do
244
+ down do |view, refs|
245
+ super(view, refs)
246
+ view.delete('new_field')
247
+ end
248
+
249
+ up do |view, refs|
250
+ view.delete('inherited_base')
251
+ view['new_field'] = 100
252
+ end
253
+ end
254
+ })
255
+ end
256
+ end
257
+
209
258
  module ParentAndBelongsToChildWithMigration
210
259
  extend ActiveSupport::Concern
211
260
  include ViewModelSpecHelpers::ParentAndBelongsToChild
@@ -156,6 +156,54 @@ class ViewModel::AccessControlTest < ActiveSupport::TestCase
156
156
  assert_equal(2, ex.reasons.count)
157
157
  end
158
158
 
159
+ def test_veto_ordering
160
+ TestAccessControl.visible_if!('always') { true }
161
+
162
+ TestAccessControl.visible_unless!('car starts with i') do
163
+ view.car =~ /^i/
164
+ end
165
+
166
+ TestAccessControl.visible_unless!('car ends with e') do
167
+ view.car =~ /e$/
168
+ end
169
+
170
+ assert_serializes(ListView, List.create!(car: 'ok'))
171
+ refute_serializes(ListView, List.create!(car: 'invisible'), /not permitted.*car starts with i/)
172
+ end
173
+
174
+ def test_custom_error_if
175
+ TestAccessControl.visible_if!('car is visible1') do
176
+ view.car == 'visible1' ||
177
+ # In principle a failure() may return any error, but by returning an
178
+ # AccessControlError we make it possible to test with refute_serializes
179
+ failure(ViewModel::AccessControlError.new('Custom Error Message', view.blame_reference))
180
+ end
181
+
182
+ TestAccessControl.visible_if!('car is visible2') do
183
+ view.car == 'visible2' ||
184
+ # Only the first failure() recorded by a failed if check will be
185
+ # raised as the error.
186
+ failure(ViewModel::AccessControlError.new('Should not be seen', view.blame_reference))
187
+ end
188
+
189
+ assert_serializes(ListView, List.create!(car: 'visible1'))
190
+ assert_serializes(ListView, List.create!(car: 'visible2'))
191
+ refute_serializes(ListView, List.create!(car: 'bad'), /Custom Error Message/)
192
+ end
193
+
194
+ def test_custom_error_unless
195
+ TestAccessControl.visible_if!('always') { true }
196
+
197
+ TestAccessControl.visible_unless!('car is invisible') do
198
+ if view.car == 'invisible'
199
+ failure(ViewModel::AccessControlError.new('Custom Error Message', view.blame_reference))
200
+ end
201
+ end
202
+
203
+ assert_serializes(ListView, List.create!(car: 'ok'))
204
+ refute_serializes(ListView, List.create!(car: 'invisible'), /Custom Error Message/)
205
+ end
206
+
159
207
  def test_inheritance
160
208
  child_access_control = Class.new(ViewModel::AccessControl::Composed)
161
209
  child_access_control.include_from(TestAccessControl)
@@ -15,16 +15,11 @@ require 'view_model/active_record'
15
15
 
16
16
  DUMMY_RAILS_CACHE = ActiveSupport::Cache::MemoryStore.new
17
17
 
18
- module RailsDummyCache
19
- def cache
20
- DUMMY_RAILS_CACHE
21
- end
18
+ IknowCache.configure! do
19
+ logger ::ActiveRecord::Base.logger
20
+ cache DUMMY_RAILS_CACHE
22
21
  end
23
22
 
24
- # Ensure we have a dummy Rails, and then prepend our dummy cache
25
- module Rails; end
26
- Rails.singleton_class.prepend(RailsDummyCache)
27
-
28
23
  class ViewModel::ActiveRecord
29
24
  class CacheTest < ActiveSupport::TestCase
30
25
  using ViewModel::Utils::Collections
@@ -210,6 +210,69 @@ class ViewModel::ActiveRecord::Migration < ActiveSupport::TestCase
210
210
  end
211
211
  end
212
212
 
213
+ describe 'inherited migrations' do
214
+ include ViewModelSpecHelpers::SingleWithInheritedMigration
215
+
216
+ def new_model
217
+ model_class.new(name: 'm1')
218
+ end
219
+
220
+ let(:migration_versions) { { viewmodel_class => 1 } }
221
+
222
+ let(:v1_serialization_data) do
223
+ {
224
+ ViewModel::TYPE_ATTRIBUTE => viewmodel_class.view_name,
225
+ ViewModel::VERSION_ATTRIBUTE => 1,
226
+ ViewModel::ID_ATTRIBUTE => viewmodel.id,
227
+ 'name' => viewmodel.name,
228
+ 'inherited_base' => 'present',
229
+ }
230
+ end
231
+
232
+ let(:v1_serialization_references) { {} }
233
+
234
+ let(:v1_serialization) do
235
+ {
236
+ 'data' => v1_serialization_data,
237
+ 'references' => v1_serialization_references,
238
+ }
239
+ end
240
+
241
+ describe 'downwards' do
242
+ let(:migrator) { down_migrator }
243
+ let(:subject) { current_serialization.deep_dup }
244
+ let(:expected_result) do
245
+ v1_serialization.deep_merge({ 'data' => { ViewModel::MIGRATED_ATTRIBUTE => true } })
246
+ end
247
+
248
+ it 'migrates' do
249
+ migrate!
250
+ assert_equal(expected_result, subject)
251
+ end
252
+ end
253
+
254
+ describe 'upwards' do
255
+ let(:migrator) { up_migrator }
256
+ let(:subject) { v1_serialization.deep_dup }
257
+
258
+ let(:expected_result) do
259
+ current_serialization.deep_merge(
260
+ {
261
+ 'data' => {
262
+ ViewModel::MIGRATED_ATTRIBUTE => true,
263
+ 'new_field' => 100,
264
+ },
265
+ },
266
+ )
267
+ end
268
+
269
+ it 'migrates' do
270
+ migrate!
271
+ assert_equal(expected_result, subject)
272
+ end
273
+ end
274
+ end
275
+
213
276
  describe 'garbage collection' do
214
277
  include ViewModelSpecHelpers::ParentAndSharedBelongsToChild
215
278
 
@@ -57,11 +57,30 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
57
57
  let(:model_body) { nil }
58
58
  let(:viewmodel_body) { nil }
59
59
 
60
+ # Generate an ActiveModel-like keyword argument constructor.
61
+ def generate_model_constructor(model_class, model_defaults)
62
+ args = model_class.members
63
+ params = args.map do |arg_name|
64
+ "#{arg_name}: self.class.__constructor_default(:#{arg_name})"
65
+ end
66
+
67
+ <<-SRC
68
+ def initialize(#{params.join(", ")})
69
+ super(#{args.join(", ")})
70
+ end
71
+ SRC
72
+ end
73
+
60
74
  let(:model_class) do
61
75
  mb = model_body
62
- Struct.new(*attributes.keys) do
63
- class_eval(&mb) if mb
64
- end
76
+ mds = model_defaults
77
+
78
+ model = Struct.new(*attributes.keys)
79
+ constructor = generate_model_constructor(model, mds)
80
+ model.class_eval(constructor)
81
+ model.define_singleton_method(:__constructor_default) { |name| mds[name] }
82
+ model.class_eval(&mb) if mb
83
+ model
65
84
  end
66
85
 
67
86
  let(:viewmodel_class) do
@@ -96,21 +115,39 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
96
115
  end
97
116
  end
98
117
 
99
- let(:default_values) { {} }
100
- let(:default_view_values) { default_values }
101
- let(:default_model_values) { default_values }
118
+ # Default values for each model attribute, nil if absent
119
+ let(:model_defaults) { {} }
102
120
 
103
- let(:default_view) do
104
- attribute_names.each_with_object(view_base.dup) do |(model_attr_name, vm_attr_name), view|
105
- view[vm_attr_name] = default_view_values.fetch(vm_attr_name.to_sym, model_attr_name)
106
- end
121
+ # attribute values used to instantiate the subject model and subject view (if not overridden)
122
+ let(:subject_attributes) { {} }
123
+
124
+ # attribute values used to instantiate the subject model
125
+ let(:subject_model_attributes) { subject_attributes }
126
+
127
+ # attribute values used to deserialize the subject view: these are expected to
128
+ # deserialize to create a model equal to subject_model
129
+ let(:subject_view_attributes) { subject_attributes }
130
+
131
+ # Subject model to compare with or deserialize into
132
+ let(:subject_model) do
133
+ model_class.new(**subject_model_attributes)
134
+ end
135
+
136
+ # View that when deserialized into a new model will be equal to subject_model
137
+ let(:subject_view) do
138
+ view_base.merge(subject_view_attributes.stringify_keys)
107
139
  end
108
140
 
109
- let(:default_model) do
110
- attr_values = attribute_names.map do |model_attr_name, _vm_attr_name|
111
- default_model_values.fetch(model_attr_name.to_sym, model_attr_name)
141
+ # The expected result of serializing subject_model (depends on subject_view corresponding to subject_model)
142
+ let(:expected_view) do
143
+ view = subject_view.dup
144
+ attribute_names.each do |model_attr_name, vm_attr_name|
145
+ unless view.has_key?(vm_attr_name)
146
+ expected_value = subject_model_attributes.fetch(model_attr_name) { model_defaults[model_attr_name] }
147
+ view[vm_attr_name] = expected_value
148
+ end
112
149
  end
113
- model_class.new(*attr_values)
150
+ view
114
151
  end
115
152
 
116
153
  let(:access_control) { TestAccessControl.new(true, true, true) }
@@ -118,7 +155,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
118
155
  let(:create_context) { TestDeserializeContext.new(access_control: access_control) }
119
156
 
120
157
  # Prime our simplistic `resolve_viewmodel` with the desired models to update
121
- let(:update_context) { TestDeserializeContext.new(targets: [default_model], access_control: access_control) }
158
+ let(:update_context) { TestDeserializeContext.new(targets: [subject_model], access_control: access_control) }
122
159
 
123
160
  def assert_edited(vm, **changes)
124
161
  ref = vm.to_reference
@@ -139,9 +176,9 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
139
176
  def self.included(base)
140
177
  base.instance_eval do
141
178
  it 'can deserialize to a new model' do
142
- vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: create_context)
143
- assert_equal(default_model, vm.model)
144
- refute(default_model.equal?(vm.model))
179
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
180
+ assert_equal(subject_model, vm.model)
181
+ refute(subject_model.equal?(vm.model))
145
182
 
146
183
  all_view_attrs = attribute_names.map { |_mname, vname| vname }
147
184
  assert_edited(vm, new: true, changed_attributes: all_view_attrs)
@@ -154,8 +191,8 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
154
191
  def self.included(base)
155
192
  base.instance_eval do
156
193
  it 'can deserialize to existing model with no changes' do
157
- vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: update_context)
158
- assert(default_model.equal?(vm.model))
194
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: update_context)
195
+ assert(subject_model.equal?(vm.model))
159
196
 
160
197
  assert_unchanged(vm)
161
198
  end
@@ -167,8 +204,8 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
167
204
  def self.included(base)
168
205
  base.instance_eval do
169
206
  it 'can serialize to the expected view' do
170
- h = viewmodel_class.new(default_model).to_hash
171
- assert_equal(default_view, h)
207
+ h = viewmodel_class.new(subject_model).to_hash
208
+ assert_equal(expected_view, h)
172
209
  end
173
210
  end
174
211
  end
@@ -176,22 +213,24 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
176
213
 
177
214
  describe 'with simple attribute' do
178
215
  let(:attributes) { { simple: {} } }
216
+ let(:subject_attributes) { { simple: "simple" } }
217
+
179
218
  include CanSerialize
180
219
  include CanDeserializeToNew
181
220
  include CanDeserializeToExisting
182
221
 
183
222
  it 'can be updated' do
184
- new_view = default_view.merge('simple' => 'changed')
223
+ update_view = subject_view.merge('simple' => 'changed')
185
224
 
186
- vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
225
+ vm = viewmodel_class.deserialize_from_view(update_view, deserialize_context: update_context)
187
226
 
188
- assert(default_model.equal?(vm.model), 'returned model was not the same')
189
- assert_equal('changed', default_model.simple)
227
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
228
+ assert_equal('changed', subject_model.simple)
190
229
  assert_edited(vm, changed_attributes: [:simple])
191
230
  end
192
231
 
193
232
  it 'rejects unknown attributes' do
194
- view = default_view.merge('unknown' => 'illegal')
233
+ view = subject_view.merge('unknown' => 'illegal')
195
234
  ex = assert_raises(ViewModel::DeserializationError::UnknownAttribute) do
196
235
  viewmodel_class.deserialize_from_view(view, deserialize_context: create_context)
197
236
  end
@@ -199,7 +238,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
199
238
  end
200
239
 
201
240
  it 'rejects unknown versions' do
202
- view = default_view.merge(ViewModel::VERSION_ATTRIBUTE => 100)
241
+ view = subject_view.merge(ViewModel::VERSION_ATTRIBUTE => 100)
203
242
  ex = assert_raises(ViewModel::DeserializationError::SchemaVersionMismatch) do
204
243
  viewmodel_class.deserialize_from_view(view, deserialize_context: create_context)
205
244
  end
@@ -207,13 +246,15 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
207
246
 
208
247
  it 'edit checks when creating empty' do
209
248
  vm = viewmodel_class.deserialize_from_view(view_base, deserialize_context: create_context)
210
- refute(default_model.equal?(vm.model), 'returned model was the same')
249
+ refute(subject_model.equal?(vm.model), 'returned model was the same')
211
250
  assert_edited(vm, new: true)
212
251
  end
213
252
  end
214
253
 
215
254
  describe 'with validated simple attribute' do
216
255
  let(:attributes) { { validated: {} } }
256
+ let(:subject_attributes) { { validated: "validated" } }
257
+
217
258
  let(:viewmodel_body) do
218
259
  ->(_x) do
219
260
  def validate!
@@ -229,10 +270,10 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
229
270
  include CanDeserializeToExisting
230
271
 
231
272
  it 'rejects update when validation fails' do
232
- new_view = default_view.merge('validated' => 'naughty')
273
+ update_view = subject_view.merge('validated' => 'naughty')
233
274
 
234
275
  ex = assert_raises(ViewModel::DeserializationError::Validation) do
235
- viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
276
+ viewmodel_class.deserialize_from_view(update_view, deserialize_context: update_context)
236
277
  end
237
278
  assert_equal('validated', ex.attribute)
238
279
  assert_equal('was naughty', ex.reason)
@@ -241,16 +282,16 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
241
282
 
242
283
  describe 'with renamed attribute' do
243
284
  let(:attributes) { { modelname: { as: :viewname } } }
244
- let(:default_model_values) { { modelname: 'value' } }
245
- let(:default_view_values) { { viewname: 'value' } }
285
+ let(:subject_model_attributes) { { modelname: 'value' } }
286
+ let(:subject_view_attributes) { { viewname: 'value' } }
246
287
 
247
288
  include CanSerialize
248
289
  include CanDeserializeToNew
249
290
  include CanDeserializeToExisting
250
291
 
251
292
  it 'makes attributes available on their new names' do
252
- value(default_model.modelname).must_equal('value')
253
- vm = viewmodel_class.new(default_model)
293
+ value(subject_model.modelname).must_equal('value')
294
+ vm = viewmodel_class.new(subject_model)
254
295
  value(vm.viewname).must_equal('value')
255
296
  end
256
297
  end
@@ -258,15 +299,15 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
258
299
  describe 'with formatted attribute' do
259
300
  let(:attributes) { { moment: { format: IknowParams::Serializer::Time } } }
260
301
  let(:moment) { 1.week.ago.change(usec: 0) }
261
- let(:default_model_values) { { moment: moment } }
262
- let(:default_view_values) { { moment: moment.iso8601 } }
302
+ let(:subject_model_attributes) { { moment: moment } }
303
+ let(:subject_view_attributes) { { moment: moment.iso8601 } }
263
304
 
264
305
  include CanSerialize
265
306
  include CanDeserializeToNew
266
307
  include CanDeserializeToExisting
267
308
 
268
309
  it 'raises correctly on an unparseable value' do
269
- bad_view = default_view.tap { |v| v['moment'] = 'not a timestamp' }
310
+ bad_view = subject_view.merge('moment' => 'not a timestamp')
270
311
  ex = assert_raises(ViewModel::DeserializationError::Validation) do
271
312
  viewmodel_class.deserialize_from_view(bad_view, deserialize_context: create_context)
272
313
  end
@@ -275,7 +316,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
275
316
  end
276
317
 
277
318
  it 'raises correctly on an undeserializable value' do
278
- bad_model = default_model.tap { |m| m.moment = 2.7 }
319
+ bad_model = subject_model.tap { |m| m.moment = 2.7 }
279
320
  ex = assert_raises(ViewModel::SerializationError) do
280
321
  viewmodel_class.new(bad_model).to_hash
281
322
  end
@@ -285,36 +326,51 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
285
326
 
286
327
  describe 'with read-only attribute' do
287
328
  let(:attributes) { { read_only: { read_only: true } } }
329
+ let(:model_defaults) { { read_only: 'immutable' } }
330
+ let(:subject_attributes) { { read_only: 'immutable' } }
288
331
 
289
- include CanSerialize
290
- include CanDeserializeToExisting
332
+ describe 'asserting the default' do
333
+ include CanSerialize
334
+ include CanDeserializeToExisting
291
335
 
292
- it 'deserializes to new without the attribute' do
293
- new_view = default_view.tap { |v| v.delete('read_only') }
294
- vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: create_context)
295
- refute(default_model.equal?(vm.model))
296
- assert_nil(vm.model.read_only)
297
- assert_edited(vm, new: true)
298
- end
336
+ it 'deserializes to new with the attribute' do
337
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
338
+ assert_equal(subject_model, vm.model)
339
+ refute(subject_model.equal?(vm.model))
340
+ assert_edited(vm, new: true)
341
+ end
299
342
 
300
- it 'rejects deserialize from new' do
301
- ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
302
- viewmodel_class.deserialize_from_view(default_view, deserialize_context: create_context)
343
+ it 'deserializes to new without the attribute' do
344
+ new_view = subject_view.tap { |v| v.delete('read_only') }
345
+ vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: create_context)
346
+ assert_equal(subject_model, vm.model)
347
+ refute(subject_model.equal?(vm.model))
348
+ assert_edited(vm, new: true)
303
349
  end
304
- assert_equal('read_only', ex.attribute)
305
350
  end
306
351
 
307
- it 'rejects update if changed' do
308
- new_view = default_view.merge('read_only' => 'written')
309
- ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
310
- viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
352
+ describe 'attempting a change' do
353
+ let(:update_view) { subject_view.merge('read_only' => 'attempted change') }
354
+
355
+ it 'rejects deserialize from new' do
356
+ ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
357
+ viewmodel_class.deserialize_from_view(update_view, deserialize_context: create_context)
358
+ end
359
+ assert_equal('read_only', ex.attribute)
360
+ end
361
+
362
+ it 'rejects update' do
363
+ ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
364
+ viewmodel_class.deserialize_from_view(update_view, deserialize_context: update_context)
365
+ end
366
+ assert_equal('read_only', ex.attribute)
311
367
  end
312
- assert_equal('read_only', ex.attribute)
313
368
  end
314
369
  end
315
370
 
316
371
  describe 'with read-only write-once attribute' do
317
372
  let(:attributes) { { write_once: { read_only: true, write_once: true } } }
373
+ let(:subject_attributes) { { write_once: 'frozen' } }
318
374
  let(:model_body) do
319
375
  ->(_x) do
320
376
  # For the purposes of testing, we assume a record is new and can be
@@ -330,7 +386,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
330
386
  include CanDeserializeToExisting
331
387
 
332
388
  it 'rejects change to attribute' do
333
- new_view = default_view.merge('write_once' => 'written')
389
+ new_view = subject_view.merge('write_once' => 'written')
334
390
  ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
335
391
  viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
336
392
  end
@@ -338,10 +394,33 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
338
394
  end
339
395
  end
340
396
 
397
+ describe 'with unspecified attributes falling back to the model default' do
398
+ let(:attributes) { { value: {} } }
399
+ let(:model_defaults) { { value: 5 } }
400
+ let(:subject_view_attributes) { { } }
401
+ let(:subject_model_attributes) { { value: 5 } }
402
+
403
+ it 'can deserialize to a new model' do
404
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
405
+ assert_equal(subject_model, vm.model)
406
+ refute(subject_model.equal?(vm.model))
407
+ assert_edited(vm, new: true, changed_attributes: [])
408
+ end
409
+ end
410
+
411
+ describe 'with model defaults being asserted' do
412
+ let(:attributes) { { value: {} } }
413
+ let(:model_defaults) { { value: 5 } }
414
+ let(:subject_attributes) { { value: 5 } }
415
+
416
+ include CanDeserializeToNew
417
+ end
418
+
341
419
  describe 'with custom serialization' do
342
420
  let(:attributes) { { overridden: {} } }
343
- let(:default_view_values) { { overridden: 10 } }
344
- let(:default_model_values) { { overridden: 5 } }
421
+ let(:subject_model_attributes) { { overridden: 5 } }
422
+ let(:subject_view_attributes) { { overridden: 10 } }
423
+
345
424
  let(:viewmodel_body) do
346
425
  ->(_x) do
347
426
  def serialize_overridden(json, serialize_context:)
@@ -351,7 +430,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
351
430
  def deserialize_overridden(value, references:, deserialize_context:)
352
431
  before_value = model.overridden
353
432
  model.overridden = value.try { |v| Integer(v) / 2 }
354
- attribute_changed!(:overridden) unless before_value == model.overridden
433
+ attribute_changed!(:overridden) unless !new_model? && before_value == model.overridden
355
434
  end
356
435
  end
357
436
  end
@@ -361,12 +440,12 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
361
440
  include CanDeserializeToExisting
362
441
 
363
442
  it 'can be updated' do
364
- new_view = default_view.merge('overridden' => '20')
443
+ new_view = subject_view.merge('overridden' => '20')
365
444
 
366
445
  vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
367
446
 
368
- assert(default_model.equal?(vm.model), 'returned model was not the same')
369
- assert_equal(10, default_model.overridden)
447
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
448
+ assert_equal(10, subject_model.overridden)
370
449
 
371
450
  assert_edited(vm, changed_attributes: [:overridden])
372
451
  end
@@ -393,82 +472,111 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
393
472
  def teardown
394
473
  Object.send(:remove_const, :Nested)
395
474
  Object.send(:remove_const, :NestedView)
396
- ActiveSupport::Dependencies::Reference.clear!
475
+
476
+ if ActiveSupport::VERSION::MAJOR < 7
477
+ ActiveSupport::Dependencies::Reference.clear!
478
+ end
479
+
397
480
  super
398
481
  end
399
482
 
400
483
  describe 'with nested viewmodel' do
401
- let(:default_nested_model) { nested_model_class.new('member') }
402
- let(:default_nested_view) { view_base.merge('_type' => 'Nested', 'member' => 'member') }
484
+ let(:subject_nested_model) { nested_model_class.new('member') }
485
+ let(:subject_nested_view) { view_base.merge('_type' => 'Nested', 'member' => 'member') }
403
486
 
404
487
  let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class } } }
405
488
 
406
- let(:default_view_values) { { nested: default_nested_view } }
407
- let(:default_model_values) { { nested: default_nested_model } }
489
+ let(:subject_view_attributes) { { nested: subject_nested_view } }
490
+ let(:subject_model_attributes) { { nested: subject_nested_model } }
408
491
 
409
492
  let(:update_context) do
410
- TestDeserializeContext.new(targets: [default_model, default_nested_model],
411
- access_control: access_control)
493
+ TestDeserializeContext.new(
494
+ targets: [subject_model, subject_nested_model],
495
+ access_control: access_control)
412
496
  end
413
497
 
414
498
  include CanSerialize
415
- include CanDeserializeToNew
499
+
500
+ it 'can deserialize to a new model' do
501
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
502
+ assert_equal(subject_model, vm.model)
503
+ refute(subject_model.equal?(vm.model))
504
+
505
+ assert_equal(subject_nested_model, vm.model.nested)
506
+ refute(subject_nested_model.equal?(vm.model.nested))
507
+
508
+ assert_edited(vm, new: true, changed_attributes: ['nested'], changed_nested_children: true)
509
+ end
510
+
416
511
  include CanDeserializeToExisting
417
512
 
418
513
  it 'can update the nested value' do
419
- new_view = default_view.merge('nested' => default_nested_view.merge('member' => 'changed'))
514
+ new_view = subject_view.merge('nested' => subject_nested_view.merge('member' => 'changed'))
420
515
 
421
516
  vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
422
517
 
423
- assert(default_model.equal?(vm.model), 'returned model was not the same')
424
- assert(default_nested_model.equal?(vm.model.nested), 'returned nested model was not the same')
518
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
519
+ assert(subject_nested_model.equal?(vm.model.nested), 'returned nested model was not the same')
425
520
 
426
- assert_equal('changed', default_model.nested.member)
521
+ assert_equal('changed', subject_model.nested.member)
427
522
 
428
523
  assert_unchanged(vm)
524
+
525
+ # The parent is itself not `changed?`, but it must record that its children are
526
+ change = access_control.all_changes(vm.to_reference)[0]
527
+ assert_equal(ViewModel::Changes.new(changed_nested_children: true), change)
528
+
429
529
  assert_edited(vm.nested, changed_attributes: [:member])
430
530
  end
431
531
 
432
532
  it 'can replace the nested value' do
433
533
  # The value will be unified if it is different after deserialization
434
- new_view = default_view.merge('nested' => default_nested_view.merge('member' => 'changed'))
534
+ new_view = subject_view.merge('nested' => subject_nested_view.merge('member' => 'changed'))
435
535
 
436
- partial_update_context = TestDeserializeContext.new(targets: [default_model],
536
+ partial_update_context = TestDeserializeContext.new(targets: [subject_model],
437
537
  access_control: access_control)
438
538
 
439
539
  vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: partial_update_context)
440
540
 
441
- assert(default_model.equal?(vm.model), 'returned model was not the same')
442
- refute(default_nested_model.equal?(vm.model.nested), 'returned nested model was the same')
541
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
542
+ refute(subject_nested_model.equal?(vm.model.nested), 'returned nested model was the same')
443
543
 
444
- assert_edited(vm, new: false, changed_attributes: [:nested])
544
+ assert_edited(vm, new: false, changed_attributes: [:nested], changed_nested_children: true)
445
545
  assert_edited(vm.nested, new: true, changed_attributes: [:member])
446
546
  end
447
547
  end
448
548
 
449
549
  describe 'with array of nested viewmodel' do
450
- let(:default_nested_model_1) { nested_model_class.new('member1') }
451
- let(:default_nested_view_1) { view_base.merge('_type' => 'Nested', 'member' => 'member1') }
550
+ let(:subject_nested_model_1) { nested_model_class.new('member1') }
551
+ let(:subject_nested_view_1) { view_base.merge('_type' => 'Nested', 'member' => 'member1') }
452
552
 
453
- let(:default_nested_model_2) { nested_model_class.new('member2') }
454
- let(:default_nested_view_2) { view_base.merge('_type' => 'Nested', 'member' => 'member2') }
553
+ let(:subject_nested_model_2) { nested_model_class.new('member2') }
554
+ let(:subject_nested_view_2) { view_base.merge('_type' => 'Nested', 'member' => 'member2') }
455
555
 
456
556
  let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class, array: true } } }
457
557
 
458
- let(:default_view_values) { { nested: [default_nested_view_1, default_nested_view_2] } }
459
- let(:default_model_values) { { nested: [default_nested_model_1, default_nested_model_2] } }
558
+ let(:subject_view_attributes) { { nested: [subject_nested_view_1, subject_nested_view_2] } }
559
+ let(:subject_model_attributes) { { nested: [subject_nested_model_1, subject_nested_model_2] } }
460
560
 
461
561
  let(:update_context) {
462
- TestDeserializeContext.new(targets: [default_model, default_nested_model_1, default_nested_model_2],
562
+ TestDeserializeContext.new(targets: [subject_model, subject_nested_model_1, subject_nested_model_2],
463
563
  access_control: access_control)
464
564
  }
465
565
 
466
566
  include CanSerialize
467
- include CanDeserializeToNew
567
+
568
+ it 'can deserialize to a new model' do
569
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
570
+ assert_equal(subject_model, vm.model)
571
+ refute(subject_model.equal?(vm.model))
572
+
573
+ assert_edited(vm, new: true, changed_attributes: ['nested'], changed_nested_children: true)
574
+ end
575
+
468
576
  include CanDeserializeToExisting
469
577
 
470
578
  it 'rejects change to attribute' do
471
- new_view = default_view.merge('nested' => 'terrible')
579
+ new_view = subject_view.merge('nested' => 'terrible')
472
580
  ex = assert_raises(ViewModel::DeserializationError::InvalidAttributeType) do
473
581
  viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
474
582
  end
@@ -478,32 +586,37 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
478
586
  end
479
587
 
480
588
  it 'can edit a nested value' do
481
- default_view['nested'][0]['member'] = 'changed'
482
- vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: update_context)
483
- assert(default_model.equal?(vm.model), 'returned model was not the same')
589
+ subject_view['nested'][0]['member'] = 'changed'
590
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: update_context)
591
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
484
592
  assert_equal(2, vm.model.nested.size)
485
- assert(default_nested_model_1.equal?(vm.model.nested[0]))
486
- assert(default_nested_model_2.equal?(vm.model.nested[1]))
593
+ assert(subject_nested_model_1.equal?(vm.model.nested[0]))
594
+ assert(subject_nested_model_2.equal?(vm.model.nested[1]))
487
595
 
488
596
  assert_unchanged(vm)
597
+
598
+ # The parent is itself not `changed?`, but it must record that its children are
599
+ change = access_control.all_changes(vm.to_reference)[0]
600
+ assert_equal(ViewModel::Changes.new(changed_nested_children: true), change)
601
+
489
602
  assert_edited(vm.nested[0], changed_attributes: [:member])
490
603
  end
491
604
 
492
605
  it 'can append a nested value' do
493
- default_view['nested'] << view_base.merge('_type' => 'Nested', 'member' => 'member3')
606
+ subject_view['nested'] << view_base.merge('_type' => 'Nested', 'member' => 'member3')
494
607
 
495
- vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: update_context)
608
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: update_context)
496
609
 
497
- assert(default_model.equal?(vm.model), 'returned model was not the same')
610
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
498
611
  assert_equal(3, vm.model.nested.size)
499
- assert(default_nested_model_1.equal?(vm.model.nested[0]))
500
- assert(default_nested_model_2.equal?(vm.model.nested[1]))
612
+ assert(subject_nested_model_1.equal?(vm.model.nested[0]))
613
+ assert(subject_nested_model_2.equal?(vm.model.nested[1]))
501
614
 
502
615
  vm.model.nested.each_with_index do |nvm, i|
503
616
  assert_equal("member#{i + 1}", nvm.member)
504
617
  end
505
618
 
506
- assert_edited(vm, changed_attributes: [:nested])
619
+ assert_edited(vm, changed_attributes: [:nested], changed_nested_children: true)
507
620
  assert_edited(vm.nested[2], new: true, changed_attributes: [:member])
508
621
  end
509
622
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: iknow_view_models
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.5.2
4
+ version: 3.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - iKnow Team
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-10-28 00:00:00.000000000 Z
11
+ date: 2022-02-16 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: actionpack
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: activerecord
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -380,6 +394,7 @@ files:
380
394
  - gemfiles/rails_5_2.gemfile
381
395
  - gemfiles/rails_6_0.gemfile
382
396
  - gemfiles/rails_6_1.gemfile
397
+ - gemfiles/rails_7_0.gemfile
383
398
  - iknow_view_models.gemspec
384
399
  - lib/iknow_view_models.rb
385
400
  - lib/iknow_view_models/railtie.rb