iknow_view_models 3.5.2 → 3.6.2

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 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