iknow_view_models 3.5.3 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/iknow_view_models/version.rb +1 -1
- data/lib/view_model/record.rb +13 -5
- data/test/helpers/test_access_control.rb +18 -0
- data/test/unit/view_model/record_test.rb +209 -100
- metadata +2 -2
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 573266179096880a34febada583570484e420b91a242920198cfa119192c3fbc
         | 
| 4 | 
            +
              data.tar.gz: c024eb9398a4b0d49103d1806379d0f20312f7c6965e9fe2751b09bb720f2fc3
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: 353f42c901c4d52cba6bbe459cc6266c2e59f3a56ca432bac616cd4534c95116d0a0e27b8329799717259640e098c704c4f6b63c83713cd42cb3096c6ca6c5b3
         | 
| 7 | 
            +
              data.tar.gz: 4e74aa987e3ff26dd9b14bb3fbebc291ce86087dca8f8bcc93ef9b320d7d7ed22f150ce4f3153e27c51f6a2f8d6561fe53cdcb7ac45734caf156f50b10a543d1
         | 
    
        data/lib/view_model/record.rb
    CHANGED
    
    | @@ -359,12 +359,20 @@ class ViewModel::Record < ViewModel | |
| 359 359 |  | 
| 360 360 | 
             
                  attribute_changed!(vm_attr_name)
         | 
| 361 361 |  | 
| 362 | 
            -
                   | 
| 363 | 
            -
                     | 
| 364 | 
            -
             | 
| 365 | 
            -
             | 
| 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 | 
            -
             | 
| 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?
         | 
| @@ -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
         | 
| @@ -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 | 
            -
                   | 
| 63 | 
            -
             | 
| 64 | 
            -
                   | 
| 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 | 
            -
                 | 
| 100 | 
            -
                let(: | 
| 101 | 
            -
                let(:default_model_values) { default_values }
         | 
| 118 | 
            +
                # Default values for each model attribute, nil if absent
         | 
| 119 | 
            +
                let(:model_defaults) { {} }
         | 
| 102 120 |  | 
| 103 | 
            -
                 | 
| 104 | 
            -
             | 
| 105 | 
            -
             | 
| 106 | 
            -
             | 
| 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)
         | 
| 107 134 | 
             
                end
         | 
| 108 135 |  | 
| 109 | 
            -
                 | 
| 110 | 
            -
             | 
| 111 | 
            -
             | 
| 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)
         | 
| 139 | 
            +
                end
         | 
| 140 | 
            +
             | 
| 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 | 
            -
                   | 
| 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: [ | 
| 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( | 
| 143 | 
            -
                        assert_equal( | 
| 144 | 
            -
                        refute( | 
| 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( | 
| 158 | 
            -
                        assert( | 
| 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( | 
| 171 | 
            -
                        assert_equal( | 
| 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 | 
            -
                     | 
| 223 | 
            +
                    update_view = subject_view.merge('simple' => 'changed')
         | 
| 185 224 |  | 
| 186 | 
            -
                    vm = viewmodel_class.deserialize_from_view( | 
| 225 | 
            +
                    vm = viewmodel_class.deserialize_from_view(update_view, deserialize_context: update_context)
         | 
| 187 226 |  | 
| 188 | 
            -
                    assert( | 
| 189 | 
            -
                    assert_equal('changed',  | 
| 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 =  | 
| 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 =  | 
| 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( | 
| 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 | 
            -
                     | 
| 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( | 
| 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(: | 
| 245 | 
            -
                  let(: | 
| 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( | 
| 253 | 
            -
                    vm = viewmodel_class.new( | 
| 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(: | 
| 262 | 
            -
                  let(: | 
| 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 =  | 
| 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 =  | 
| 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 | 
            -
                   | 
| 290 | 
            -
             | 
| 332 | 
            +
                  describe 'asserting the default' do
         | 
| 333 | 
            +
                    include CanSerialize
         | 
| 334 | 
            +
                    include CanDeserializeToExisting
         | 
| 291 335 |  | 
| 292 | 
            -
             | 
| 293 | 
            -
             | 
| 294 | 
            -
             | 
| 295 | 
            -
             | 
| 296 | 
            -
             | 
| 297 | 
            -
                     | 
| 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 | 
            -
             | 
| 301 | 
            -
             | 
| 302 | 
            -
                      viewmodel_class.deserialize_from_view( | 
| 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 | 
            -
                   | 
| 308 | 
            -
                     | 
| 309 | 
            -
             | 
| 310 | 
            -
             | 
| 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 =  | 
| 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(: | 
| 344 | 
            -
                  let(: | 
| 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 =  | 
| 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( | 
| 369 | 
            -
                    assert_equal(10,  | 
| 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
         | 
| @@ -398,77 +477,102 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 398 477 | 
             
                  end
         | 
| 399 478 |  | 
| 400 479 | 
             
                  describe 'with nested viewmodel' do
         | 
| 401 | 
            -
                    let(: | 
| 402 | 
            -
                    let(: | 
| 480 | 
            +
                    let(:subject_nested_model) { nested_model_class.new('member') }
         | 
| 481 | 
            +
                    let(:subject_nested_view)  { view_base.merge('_type' => 'Nested', 'member' => 'member') }
         | 
| 403 482 |  | 
| 404 483 | 
             
                    let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class } } }
         | 
| 405 484 |  | 
| 406 | 
            -
                    let(: | 
| 407 | 
            -
                    let(: | 
| 485 | 
            +
                    let(:subject_view_attributes)  { { nested: subject_nested_view } }
         | 
| 486 | 
            +
                    let(:subject_model_attributes) { { nested: subject_nested_model } }
         | 
| 408 487 |  | 
| 409 488 | 
             
                    let(:update_context) do
         | 
| 410 | 
            -
                      TestDeserializeContext.new( | 
| 411 | 
            -
             | 
| 489 | 
            +
                      TestDeserializeContext.new(
         | 
| 490 | 
            +
                        targets: [subject_model, subject_nested_model],
         | 
| 491 | 
            +
                        access_control: access_control)
         | 
| 412 492 | 
             
                    end
         | 
| 413 493 |  | 
| 414 494 | 
             
                    include CanSerialize
         | 
| 415 | 
            -
             | 
| 495 | 
            +
             | 
| 496 | 
            +
                    it 'can deserialize to a new model' do
         | 
| 497 | 
            +
                      vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
         | 
| 498 | 
            +
                      assert_equal(subject_model, vm.model)
         | 
| 499 | 
            +
                      refute(subject_model.equal?(vm.model))
         | 
| 500 | 
            +
             | 
| 501 | 
            +
                      assert_equal(subject_nested_model, vm.model.nested)
         | 
| 502 | 
            +
                      refute(subject_nested_model.equal?(vm.model.nested))
         | 
| 503 | 
            +
             | 
| 504 | 
            +
                      assert_edited(vm, new: true, changed_attributes: ['nested'], changed_nested_children: true)
         | 
| 505 | 
            +
                    end
         | 
| 506 | 
            +
             | 
| 416 507 | 
             
                    include CanDeserializeToExisting
         | 
| 417 508 |  | 
| 418 509 | 
             
                    it 'can update the nested value' do
         | 
| 419 | 
            -
                      new_view =  | 
| 510 | 
            +
                      new_view = subject_view.merge('nested' => subject_nested_view.merge('member' => 'changed'))
         | 
| 420 511 |  | 
| 421 512 | 
             
                      vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
         | 
| 422 513 |  | 
| 423 | 
            -
                      assert( | 
| 424 | 
            -
                      assert( | 
| 514 | 
            +
                      assert(subject_model.equal?(vm.model), 'returned model was not the same')
         | 
| 515 | 
            +
                      assert(subject_nested_model.equal?(vm.model.nested), 'returned nested model was not the same')
         | 
| 425 516 |  | 
| 426 | 
            -
                      assert_equal('changed',  | 
| 517 | 
            +
                      assert_equal('changed', subject_model.nested.member)
         | 
| 427 518 |  | 
| 428 519 | 
             
                      assert_unchanged(vm)
         | 
| 520 | 
            +
             | 
| 521 | 
            +
                      # The parent is itself not `changed?`, but it must record that its children are
         | 
| 522 | 
            +
                      change = access_control.all_changes(vm.to_reference)[0]
         | 
| 523 | 
            +
                      assert_equal(ViewModel::Changes.new(changed_nested_children: true), change)
         | 
| 524 | 
            +
             | 
| 429 525 | 
             
                      assert_edited(vm.nested, changed_attributes: [:member])
         | 
| 430 526 | 
             
                    end
         | 
| 431 527 |  | 
| 432 528 | 
             
                    it 'can replace the nested value' do
         | 
| 433 529 | 
             
                      # The value will be unified if it is different after deserialization
         | 
| 434 | 
            -
                      new_view =  | 
| 530 | 
            +
                      new_view = subject_view.merge('nested' => subject_nested_view.merge('member' => 'changed'))
         | 
| 435 531 |  | 
| 436 | 
            -
                      partial_update_context = TestDeserializeContext.new(targets: [ | 
| 532 | 
            +
                      partial_update_context = TestDeserializeContext.new(targets: [subject_model],
         | 
| 437 533 | 
             
                                                                          access_control: access_control)
         | 
| 438 534 |  | 
| 439 535 | 
             
                      vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: partial_update_context)
         | 
| 440 536 |  | 
| 441 | 
            -
                      assert( | 
| 442 | 
            -
                      refute( | 
| 537 | 
            +
                      assert(subject_model.equal?(vm.model), 'returned model was not the same')
         | 
| 538 | 
            +
                      refute(subject_nested_model.equal?(vm.model.nested), 'returned nested model was the same')
         | 
| 443 539 |  | 
| 444 | 
            -
                      assert_edited(vm, new: false, changed_attributes: [:nested])
         | 
| 540 | 
            +
                      assert_edited(vm, new: false, changed_attributes: [:nested], changed_nested_children: true)
         | 
| 445 541 | 
             
                      assert_edited(vm.nested, new: true, changed_attributes: [:member])
         | 
| 446 542 | 
             
                    end
         | 
| 447 543 | 
             
                  end
         | 
| 448 544 |  | 
| 449 545 | 
             
                  describe 'with array of nested viewmodel' do
         | 
| 450 | 
            -
                    let(: | 
| 451 | 
            -
                    let(: | 
| 546 | 
            +
                    let(:subject_nested_model_1) { nested_model_class.new('member1') }
         | 
| 547 | 
            +
                    let(:subject_nested_view_1)  { view_base.merge('_type' => 'Nested', 'member' => 'member1') }
         | 
| 452 548 |  | 
| 453 | 
            -
                    let(: | 
| 454 | 
            -
                    let(: | 
| 549 | 
            +
                    let(:subject_nested_model_2) { nested_model_class.new('member2') }
         | 
| 550 | 
            +
                    let(:subject_nested_view_2)  { view_base.merge('_type' => 'Nested', 'member' => 'member2') }
         | 
| 455 551 |  | 
| 456 552 | 
             
                    let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class, array: true } } }
         | 
| 457 553 |  | 
| 458 | 
            -
                    let(: | 
| 459 | 
            -
                    let(: | 
| 554 | 
            +
                    let(:subject_view_attributes)  { { nested: [subject_nested_view_1, subject_nested_view_2] } }
         | 
| 555 | 
            +
                    let(:subject_model_attributes) { { nested: [subject_nested_model_1, subject_nested_model_2] } }
         | 
| 460 556 |  | 
| 461 557 | 
             
                    let(:update_context) {
         | 
| 462 | 
            -
                      TestDeserializeContext.new(targets: [ | 
| 558 | 
            +
                      TestDeserializeContext.new(targets: [subject_model, subject_nested_model_1, subject_nested_model_2],
         | 
| 463 559 | 
             
                                                 access_control: access_control)
         | 
| 464 560 | 
             
                    }
         | 
| 465 561 |  | 
| 466 562 | 
             
                    include CanSerialize
         | 
| 467 | 
            -
             | 
| 563 | 
            +
             | 
| 564 | 
            +
                    it 'can deserialize to a new model' do
         | 
| 565 | 
            +
                      vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
         | 
| 566 | 
            +
                      assert_equal(subject_model, vm.model)
         | 
| 567 | 
            +
                      refute(subject_model.equal?(vm.model))
         | 
| 568 | 
            +
             | 
| 569 | 
            +
                      assert_edited(vm, new: true, changed_attributes: ['nested'], changed_nested_children: true)
         | 
| 570 | 
            +
                    end
         | 
| 571 | 
            +
             | 
| 468 572 | 
             
                    include CanDeserializeToExisting
         | 
| 469 573 |  | 
| 470 574 | 
             
                    it 'rejects change to attribute' do
         | 
| 471 | 
            -
                      new_view =  | 
| 575 | 
            +
                      new_view = subject_view.merge('nested' => 'terrible')
         | 
| 472 576 | 
             
                      ex = assert_raises(ViewModel::DeserializationError::InvalidAttributeType) do
         | 
| 473 577 | 
             
                        viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
         | 
| 474 578 | 
             
                      end
         | 
| @@ -478,32 +582,37 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 478 582 | 
             
                    end
         | 
| 479 583 |  | 
| 480 584 | 
             
                    it 'can edit a nested value' do
         | 
| 481 | 
            -
                       | 
| 482 | 
            -
                      vm = viewmodel_class.deserialize_from_view( | 
| 483 | 
            -
                      assert( | 
| 585 | 
            +
                      subject_view['nested'][0]['member'] = 'changed'
         | 
| 586 | 
            +
                      vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: update_context)
         | 
| 587 | 
            +
                      assert(subject_model.equal?(vm.model), 'returned model was not the same')
         | 
| 484 588 | 
             
                      assert_equal(2, vm.model.nested.size)
         | 
| 485 | 
            -
                      assert( | 
| 486 | 
            -
                      assert( | 
| 589 | 
            +
                      assert(subject_nested_model_1.equal?(vm.model.nested[0]))
         | 
| 590 | 
            +
                      assert(subject_nested_model_2.equal?(vm.model.nested[1]))
         | 
| 487 591 |  | 
| 488 592 | 
             
                      assert_unchanged(vm)
         | 
| 593 | 
            +
             | 
| 594 | 
            +
                      # The parent is itself not `changed?`, but it must record that its children are
         | 
| 595 | 
            +
                      change = access_control.all_changes(vm.to_reference)[0]
         | 
| 596 | 
            +
                      assert_equal(ViewModel::Changes.new(changed_nested_children: true), change)
         | 
| 597 | 
            +
             | 
| 489 598 | 
             
                      assert_edited(vm.nested[0], changed_attributes: [:member])
         | 
| 490 599 | 
             
                    end
         | 
| 491 600 |  | 
| 492 601 | 
             
                    it 'can append a nested value' do
         | 
| 493 | 
            -
                       | 
| 602 | 
            +
                      subject_view['nested'] << view_base.merge('_type' => 'Nested', 'member' => 'member3')
         | 
| 494 603 |  | 
| 495 | 
            -
                      vm = viewmodel_class.deserialize_from_view( | 
| 604 | 
            +
                      vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: update_context)
         | 
| 496 605 |  | 
| 497 | 
            -
                      assert( | 
| 606 | 
            +
                      assert(subject_model.equal?(vm.model), 'returned model was not the same')
         | 
| 498 607 | 
             
                      assert_equal(3, vm.model.nested.size)
         | 
| 499 | 
            -
                      assert( | 
| 500 | 
            -
                      assert( | 
| 608 | 
            +
                      assert(subject_nested_model_1.equal?(vm.model.nested[0]))
         | 
| 609 | 
            +
                      assert(subject_nested_model_2.equal?(vm.model.nested[1]))
         | 
| 501 610 |  | 
| 502 611 | 
             
                      vm.model.nested.each_with_index do |nvm, i|
         | 
| 503 612 | 
             
                        assert_equal("member#{i + 1}", nvm.member)
         | 
| 504 613 | 
             
                      end
         | 
| 505 614 |  | 
| 506 | 
            -
                      assert_edited(vm, changed_attributes: [:nested])
         | 
| 615 | 
            +
                      assert_edited(vm, changed_attributes: [:nested], changed_nested_children: true)
         | 
| 507 616 | 
             
                      assert_edited(vm.nested[2], new: true, changed_attributes: [:member])
         | 
| 508 617 | 
             
                    end
         | 
| 509 618 | 
             
                  end
         | 
    
        metadata
    CHANGED
    
    | @@ -1,14 +1,14 @@ | |
| 1 1 | 
             
            --- !ruby/object:Gem::Specification
         | 
| 2 2 | 
             
            name: iknow_view_models
         | 
| 3 3 | 
             
            version: !ruby/object:Gem::Version
         | 
| 4 | 
            -
              version: 3. | 
| 4 | 
            +
              version: 3.6.0
         | 
| 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- | 
| 11 | 
            +
            date: 2021-12-17 00:00:00.000000000 Z
         | 
| 12 12 | 
             
            dependencies:
         | 
| 13 13 | 
             
            - !ruby/object:Gem::Dependency
         | 
| 14 14 | 
             
              name: activerecord
         |