From 3301f5119977dd0a0bddda8615580a3717e70c28 Mon Sep 17 00:00:00 2001 From: patrickdemooij9 Date: Tue, 28 Jul 2026 16:49:11 +0200 Subject: [PATCH 1/5] Cache the MediaWithCrops --- .../MediaPickerWithCropsValueConverter.cs | 41 ++++- .../MediaCropsBenchmark.cs | 170 ++++++++++++++++++ 2 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs index e1a305f3fb04..3e6d5047fc2e 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs @@ -1,3 +1,6 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; @@ -17,6 +20,8 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; [DefaultPropertyValueConverter] public class MediaPickerWithCropsValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { + private static readonly ConcurrentDictionary> _mediaWithCropsFactories = new(); + private readonly IJsonSerializer _jsonSerializer; private readonly IPublishedMediaCache _publishedMediaCache; private readonly IPublishedUrlProvider _publishedUrlProvider; @@ -134,9 +139,7 @@ public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType localCrops.ApplyConfiguration(configuration); - // TODO: This should be optimized/cached, as calling Activator.CreateInstance is slow - Type mediaWithCropsType = typeof(MediaWithCrops<>).MakeGenericType(mediaItem.GetType()); - var mediaWithCrops = (MediaWithCrops)Activator.CreateInstance(mediaWithCropsType, mediaItem, _publishedValueFallback, localCrops)!; + MediaWithCrops mediaWithCrops = CreateMediaWithCrops(mediaItem, _publishedValueFallback, localCrops); mediaItems.Add(mediaWithCrops); @@ -201,7 +204,7 @@ public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType } if (isMultiple == false && converted is MediaWithCrops mediaWithCrops) { - return new [] { ToApiMedia(mediaWithCrops) }; + return new[] { ToApiMedia(mediaWithCrops) }; } return Array.Empty(); @@ -209,4 +212,34 @@ public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType private bool IsMultipleDataType(PublishedDataType dataType) => dataType.ConfigurationAs()?.Multiple ?? false; + + private static MediaWithCrops CreateMediaWithCrops( + IPublishedContent mediaItem, + IPublishedValueFallback publishedValueFallback, + ImageCropperValue localCrops) + { + Func factory = + _mediaWithCropsFactories.GetOrAdd(mediaItem.GetType(), static mediaType => + { + Type closedType = typeof(MediaWithCrops<>).MakeGenericType(mediaType); + + ConstructorInfo ctor = closedType.GetConstructor( + [mediaType, typeof(IPublishedValueFallback), typeof(ImageCropperValue)])!; + + ParameterExpression contentParam = Expression.Parameter(typeof(IPublishedContent), "content"); + ParameterExpression fallbackParam = Expression.Parameter(typeof(IPublishedValueFallback), "fallback"); + ParameterExpression cropsParam = Expression.Parameter(typeof(ImageCropperValue), "crops"); + + NewExpression newExpr = Expression.New( + ctor, + Expression.Convert(contentParam, mediaType), + fallbackParam, + cropsParam); + + return Expression.Lambda>( + newExpr, contentParam, fallbackParam, cropsParam).Compile(); + }); + + return factory(mediaItem, publishedValueFallback, localCrops); + } } diff --git a/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs b/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs new file mode 100644 index 000000000000..08fb60bd15d6 --- /dev/null +++ b/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs @@ -0,0 +1,170 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Buffers; +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; +using BenchmarkDotNet.Attributes; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Extensions; +using Umbraco.Tests.Benchmarks.Config; + +namespace Umbraco.Tests.Benchmarks +{ + [QuickRunWithMemoryDiagnoserConfig] + public class MediaCropsBenchmark + { + private sealed class StubPublishedContent : IPublishedContent + { + public int Id => 1; + public string Name => "Test"; + public string? UrlSegment => "test"; + public int SortOrder => 0; + public int Level => 1; + public string Path => "-1,1"; + public int? TemplateId => null; + public int CreatorId => 0; + public DateTime CreateDate => DateTime.MinValue; + public int WriterId => 0; + public DateTime UpdateDate => DateTime.MinValue; + public IReadOnlyDictionary Cultures => new Dictionary(); + public PublishedItemType ItemType => PublishedItemType.Media; + + [Obsolete("Use extension methods.")] + public IPublishedContent? Parent => null; + + [Obsolete("Use extension methods.")] + public IEnumerable Children => Enumerable.Empty(); + + public bool IsDraft(string? culture = null) => false; + public bool IsPublished(string? culture = null) => true; + + public IPublishedContentType ContentType => null!; + public Guid Key => Guid.Empty; + public IEnumerable Properties => Enumerable.Empty(); + public IPublishedProperty? GetProperty(string alias) => null; + } + + private sealed class StubPublishedValueFallback : IPublishedValueFallback + { + public bool TryGetValue(IPublishedProperty property, string? culture, string? segment, Fallback fallback, object? defaultValue, out object? value) + { value = defaultValue; return false; } + + public bool TryGetValue(IPublishedProperty property, string? culture, string? segment, Fallback fallback, T? defaultValue, out T? value) + { value = defaultValue; return false; } + + public bool TryGetValue(IPublishedElement content, string alias, string? culture, string? segment, Fallback fallback, object? defaultValue, out object? value) + { value = defaultValue; return false; } + + public bool TryGetValue(IPublishedElement content, string alias, string? culture, string? segment, Fallback fallback, T? defaultValue, out T? value) + { value = defaultValue; return false; } + + public bool TryGetValue(IPublishedContent content, string alias, string? culture, string? segment, Fallback fallback, object? defaultValue, out object? value, out IPublishedProperty? noValueProperty) + { value = defaultValue; noValueProperty = null; return false; } + + public bool TryGetValue(IPublishedContent content, string alias, string? culture, string? segment, Fallback fallback, T defaultValue, out T? value, out IPublishedProperty? noValueProperty) + { value = defaultValue; noValueProperty = null; return false; } + } + + // ------------------------------------------------------------------------- + // Shared state + // ------------------------------------------------------------------------- + + private static readonly IPublishedContent MediaItem = new StubPublishedContent(); + private static readonly IPublishedValueFallback Fallback = new StubPublishedValueFallback(); + private static readonly ImageCropperValue LocalCrops = new() { Src = "/media/test.jpg" }; + private static readonly IPublishedContent[] TenMediaItems = Enumerable.Range(0, 10).Select(_ => new StubPublishedContent()).ToArray(); + + private static readonly ConcurrentDictionary> _factories = new(); + + // ------------------------------------------------------------------------- + // After: compiled Expression delegate (production code path) + // ------------------------------------------------------------------------- + + [Benchmark(Baseline = true, Description = "After: single item (compiled delegate, warm)")] + public MediaWithCrops After_Single() => + CreateMediaWithCropsNew(_factories, MediaItem, Fallback, LocalCrops); + + [Benchmark(Description = "After: ten items (compiled delegate, warm)")] + public MediaWithCrops After_Ten() + { + MediaWithCrops last = null!; + foreach (IPublishedContent item in TenMediaItems) + { + last = CreateMediaWithCropsNew(_factories, item, Fallback, LocalCrops); + } + return last; + } + + // ------------------------------------------------------------------------- + // Before: Activator.CreateInstance (original code path) + // ------------------------------------------------------------------------- + + [Benchmark(Description = "Before: single item (Activator.CreateInstance)")] + public MediaWithCrops Before_Single() => CreateMediaWithCropsOld(MediaItem, Fallback, LocalCrops); + + [Benchmark(Description = "Before: ten items (Activator.CreateInstance)")] + public MediaWithCrops Before_Ten() + { + MediaWithCrops last = null!; + foreach (IPublishedContent item in TenMediaItems) + { + last = CreateMediaWithCropsOld(item, Fallback, LocalCrops); + } + return last; + } + + // ------------------------------------------------------------------------- + // Old implementation + // ------------------------------------------------------------------------- + + private static MediaWithCrops CreateMediaWithCropsOld( + IPublishedContent mediaItem, + IPublishedValueFallback publishedValueFallback, + ImageCropperValue localCrops) + { + Type mediaType = mediaItem.GetType(); + Type closedType = typeof(MediaWithCrops<>).MakeGenericType(mediaType); + return (MediaWithCrops)Activator.CreateInstance(closedType, mediaItem, publishedValueFallback, localCrops)!; + } + + // ------------------------------------------------------------------------- + // New implementation + // ------------------------------------------------------------------------- + + private static MediaWithCrops CreateMediaWithCropsNew( + ConcurrentDictionary> factories, + IPublishedContent mediaItem, + IPublishedValueFallback publishedValueFallback, + ImageCropperValue localCrops) + { + Func factory = + factories.GetOrAdd(mediaItem.GetType(), static mediaType => CompileFactory(mediaType)); + return factory(mediaItem, publishedValueFallback, localCrops); + } + + private static Func + CompileFactory(Type mediaType) + { + Type closedType = typeof(MediaWithCrops<>).MakeGenericType(mediaType); + ConstructorInfo ctor = closedType.GetConstructor( + [mediaType, typeof(IPublishedValueFallback), typeof(ImageCropperValue)]); + + ParameterExpression contentParam = Expression.Parameter(typeof(IPublishedContent), "content"); + ParameterExpression fallbackParam = Expression.Parameter(typeof(IPublishedValueFallback), "fallback"); + ParameterExpression cropsParam = Expression.Parameter(typeof(ImageCropperValue), "crops"); + + NewExpression newExpr = Expression.New( + ctor, + Expression.Convert(contentParam, mediaType), + fallbackParam, + cropsParam); + + return Expression.Lambda>( + newExpr, contentParam, fallbackParam, cropsParam).Compile(); + } + } +} From c99cace56be4d0ede7e640e316281e2a14512dcb Mon Sep 17 00:00:00 2001 From: patrickdemooij9 Date: Wed, 29 Jul 2026 09:14:43 +0200 Subject: [PATCH 2/5] Replace with ConstructorInvoker --- .../MediaPickerWithCropsValueConverter.cs | 21 +++----------- .../MediaCropsBenchmark.cs | 28 +++++-------------- 2 files changed, 11 insertions(+), 38 deletions(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs index 3e6d5047fc2e..8957f82d377b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs @@ -20,7 +20,7 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; [DefaultPropertyValueConverter] public class MediaPickerWithCropsValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { - private static readonly ConcurrentDictionary> _mediaWithCropsFactories = new(); + private static readonly ConcurrentDictionary _mediaWithCropsFactories = new(); private readonly IJsonSerializer _jsonSerializer; private readonly IPublishedMediaCache _publishedMediaCache; @@ -218,28 +218,15 @@ private static MediaWithCrops CreateMediaWithCrops( IPublishedValueFallback publishedValueFallback, ImageCropperValue localCrops) { - Func factory = + ConstructorInvoker factory = _mediaWithCropsFactories.GetOrAdd(mediaItem.GetType(), static mediaType => { Type closedType = typeof(MediaWithCrops<>).MakeGenericType(mediaType); - ConstructorInfo ctor = closedType.GetConstructor( [mediaType, typeof(IPublishedValueFallback), typeof(ImageCropperValue)])!; - - ParameterExpression contentParam = Expression.Parameter(typeof(IPublishedContent), "content"); - ParameterExpression fallbackParam = Expression.Parameter(typeof(IPublishedValueFallback), "fallback"); - ParameterExpression cropsParam = Expression.Parameter(typeof(ImageCropperValue), "crops"); - - NewExpression newExpr = Expression.New( - ctor, - Expression.Convert(contentParam, mediaType), - fallbackParam, - cropsParam); - - return Expression.Lambda>( - newExpr, contentParam, fallbackParam, cropsParam).Compile(); + return ConstructorInvoker.Create(ctor); }); - return factory(mediaItem, publishedValueFallback, localCrops); + return (MediaWithCrops) factory.Invoke(mediaItem, publishedValueFallback, localCrops); } } diff --git a/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs b/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs index 08fb60bd15d6..1d28d2dd736d 100644 --- a/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs +++ b/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs @@ -78,7 +78,7 @@ public bool TryGetValue(IPublishedContent content, string alias, string? cult private static readonly ImageCropperValue LocalCrops = new() { Src = "/media/test.jpg" }; private static readonly IPublishedContent[] TenMediaItems = Enumerable.Range(0, 10).Select(_ => new StubPublishedContent()).ToArray(); - private static readonly ConcurrentDictionary> _factories = new(); + private static readonly ConcurrentDictionary _factories = new(); // ------------------------------------------------------------------------- // After: compiled Expression delegate (production code path) @@ -136,35 +136,21 @@ private static MediaWithCrops CreateMediaWithCropsOld( // ------------------------------------------------------------------------- private static MediaWithCrops CreateMediaWithCropsNew( - ConcurrentDictionary> factories, + ConcurrentDictionary factories, IPublishedContent mediaItem, IPublishedValueFallback publishedValueFallback, ImageCropperValue localCrops) { - Func factory = - factories.GetOrAdd(mediaItem.GetType(), static mediaType => CompileFactory(mediaType)); - return factory(mediaItem, publishedValueFallback, localCrops); + ConstructorInvoker factory = factories.GetOrAdd(mediaItem.GetType(), static mediaType => CompileFactory(mediaType)); + return (MediaWithCrops)factory.Invoke(mediaItem, publishedValueFallback, localCrops); } - private static Func - CompileFactory(Type mediaType) + private static ConstructorInvoker CompileFactory(Type mediaType) { Type closedType = typeof(MediaWithCrops<>).MakeGenericType(mediaType); ConstructorInfo ctor = closedType.GetConstructor( - [mediaType, typeof(IPublishedValueFallback), typeof(ImageCropperValue)]); - - ParameterExpression contentParam = Expression.Parameter(typeof(IPublishedContent), "content"); - ParameterExpression fallbackParam = Expression.Parameter(typeof(IPublishedValueFallback), "fallback"); - ParameterExpression cropsParam = Expression.Parameter(typeof(ImageCropperValue), "crops"); - - NewExpression newExpr = Expression.New( - ctor, - Expression.Convert(contentParam, mediaType), - fallbackParam, - cropsParam); - - return Expression.Lambda>( - newExpr, contentParam, fallbackParam, cropsParam).Compile(); + [mediaType, typeof(IPublishedValueFallback), typeof(ImageCropperValue)])!; + return ConstructorInvoker.Create(ctor); } } } From fb8f5671540f421aa58457a5202770a355f28954 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 6 Aug 2026 10:05:30 +0100 Subject: [PATCH 3/5] Removed unused usings. --- .../ValueConverters/MediaPickerWithCropsValueConverter.cs | 1 - tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs index 8957f82d377b..ce427db11325 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs @@ -1,5 +1,4 @@ using System.Collections.Concurrent; -using System.Linq.Expressions; using System.Reflection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.DeliveryApi; diff --git a/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs b/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs index 1d28d2dd736d..71ed310fa696 100644 --- a/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs +++ b/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs @@ -3,7 +3,6 @@ using System.Buffers; using System.Collections.Concurrent; -using System.Linq.Expressions; using System.Reflection; using BenchmarkDotNet.Attributes; using Umbraco.Cms.Core.Models; From 20ed9a41bd615c4d74da907a950f31e87929d456 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 6 Aug 2026 10:08:12 +0100 Subject: [PATCH 4/5] Code formatting. --- .../ValueConverters/MediaPickerWithCropsValueConverter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs index ce427db11325..45d6fbe0af6c 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs @@ -226,6 +226,6 @@ private static MediaWithCrops CreateMediaWithCrops( return ConstructorInvoker.Create(ctor); }); - return (MediaWithCrops) factory.Invoke(mediaItem, publishedValueFallback, localCrops); + return (MediaWithCrops)factory.Invoke(mediaItem, publishedValueFallback, localCrops); } } From ecf725b63ef8a78fd622a1a4b5223e2d30f84653 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 6 Aug 2026 10:20:01 +0100 Subject: [PATCH 5/5] Additional test coverage. --- .../MediaCropsBenchmark.cs | 2 - ...MediaPickerWithCropsValueConverterTests.cs | 89 ++++++++++++++++++- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs b/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs index 71ed310fa696..40c6296f1c85 100644 --- a/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs +++ b/tests/Umbraco.Tests.Benchmarks/MediaCropsBenchmark.cs @@ -1,14 +1,12 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Buffers; using System.Collections.Concurrent; using System.Reflection; using BenchmarkDotNet.Attributes; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PropertyEditors.ValueConverters; -using Umbraco.Extensions; using Umbraco.Tests.Benchmarks.Config; namespace Umbraco.Tests.Benchmarks diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs index cad05ed653a0..027cc2faedc6 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs @@ -2,6 +2,7 @@ using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PropertyEditors; @@ -296,6 +297,66 @@ public void MediaPickerWithCropsValueConverter_InMultiMode_ConvertsInvalidValueT Assert.IsEmpty(result); } + [Test] + public void MediaPickerWithCropsValueConverter_InSingleMode_ConvertsValueToStronglyTypedMediaWithCrops() + { + var publishedPropertyType = SetupMediaPropertyType(false); + + TestMediaModelOne? media = null; + var mediaKey = SetupMedia("My media", ".jpg", 200, 400, "My alt text", 800, asModel: inner => media = new TestMediaModelOne(inner)); + + var valueConverter = MediaPickerWithCropsValueConverter(); + var inter = SerializeMediaWithCropsDtos(mediaKey); + + var result = valueConverter.ConvertIntermediateToObject(Mock.Of(), publishedPropertyType, PropertyCacheLevel.Element, inter, false); + + Assert.AreEqual(typeof(MediaWithCrops), result.GetType()); + Assert.AreSame(media, ((MediaWithCrops)result).Content); + } + + [Test] + public void MediaPickerWithCropsValueConverter_InMultiMode_ConvertsEachValueToItsOwnStronglyTypedMediaWithCrops() + { + var publishedPropertyType = SetupMediaPropertyType(true); + + TestMediaModelOne? firstMedia = null; + TestMediaModelTwo? secondMedia = null; + var firstMediaKey = SetupMedia("First media", ".jpg", 200, 400, "First alt text", 800, asModel: inner => firstMedia = new TestMediaModelOne(inner)); + var secondMediaKey = SetupMedia("Second media", ".png", 300, 600, "Second alt text", 900, asModel: inner => secondMedia = new TestMediaModelTwo(inner)); + + var valueConverter = MediaPickerWithCropsValueConverter(); + var inter = SerializeMediaWithCropsDtos(firstMediaKey, secondMediaKey); + + // convert twice; the first pass populates the constructor cache, the second one exercises it + for (var iteration = 0; iteration < 2; iteration++) + { + var result = valueConverter.ConvertIntermediateToObject(Mock.Of(), publishedPropertyType, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + + var mediaWithCrops = result.ToArray(); + Assert.AreEqual(2, mediaWithCrops.Length); + + Assert.AreEqual(typeof(MediaWithCrops), mediaWithCrops[0].GetType()); + Assert.AreEqual(typeof(MediaWithCrops), mediaWithCrops[1].GetType()); + + Assert.AreSame(firstMedia, ((MediaWithCrops)mediaWithCrops[0]).Content); + Assert.AreSame(secondMedia, ((MediaWithCrops)mediaWithCrops[1]).Content); + } + } + + private string SerializeMediaWithCropsDtos(params Guid[] mediaKeys) + { + var serializer = new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + return serializer.Serialize(mediaKeys.Select(mediaKey => + new MediaPicker3PropertyEditor.MediaPicker3PropertyValueEditor.MediaWithCropsDto + { + Key = Guid.NewGuid(), + MediaKey = mediaKey, + Crops = Array.Empty(), + FocalPoint = new ImageCropperValue.ImageCropperFocalPoint { Left = .2m, Top = .4m } + }).ToArray()); + } + private IPublishedPropertyType SetupMediaPropertyType(bool multiSelect) { var publishedDataType = new PublishedDataType(123, "test", "test", new Lazy(() => new MediaPicker3Configuration @@ -316,7 +377,7 @@ private IPublishedPropertyType SetupMediaPropertyType(bool multiSelect) return publishedPropertyType.Object; } - private Guid SetupMedia(string name, string extension, int width, int height, string altText, int bytes, ImageCropperValue? imageCropperValue = null) + private Guid SetupMedia(string name, string extension, int width, int height, string altText, int bytes, ImageCropperValue? imageCropperValue = null, Func? asModel = null) { var publishedMediaType = new Mock(); publishedMediaType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Media); @@ -344,15 +405,17 @@ void AddProperty(string alias, object value) AddProperty(Constants.Conventions.Media.File, imageCropperValue); AddProperty("altText", altText); + IPublishedContent mediaItem = asModel is null ? media.Object : asModel(media.Object); + PublishedMediaCacheMock .Setup(pcc => pcc.GetById(mediaKey)) - .Returns(media.Object); + .Returns(mediaItem); PublishedMediaCacheMock .Setup(pcc => pcc.GetById(It.IsAny(), mediaKey)) - .Returns(media.Object); + .Returns(mediaItem); PublishedUrlProviderMock - .Setup(p => p.GetMediaUrl(media.Object, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(p => p.GetMediaUrl(mediaItem, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(name.ToLowerInvariant().Replace(" ", "-")); return mediaKey; @@ -402,4 +465,22 @@ private void ValidateCrop( Assert.AreEqual(expectedY1, actual.Coordinates.Y1); Assert.AreEqual(expectedY2, actual.Coordinates.Y2); } + + // two distinct media model types, shaped like the models ModelsBuilder generates, so the converter + // has to close MediaWithCrops<> over a different type per media item + private sealed class TestMediaModelOne : PublishedContentWrapped + { + public TestMediaModelOne(IPublishedContent content) + : base(content) + { + } + } + + private sealed class TestMediaModelTwo : PublishedContentWrapped + { + public TestMediaModelTwo(IPublishedContent content) + : base(content) + { + } + } }