Last active
March 17, 2026 17:53
-
-
Save cobysy/fde9da758261af410774f4cc4d2dc5d9 to your computer and use it in GitHub Desktop.
DeepFreeze.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| public interface IFreezable | |
| { | |
| bool IsFrozen { get; } | |
| void Freeze(); | |
| void Unfreeze(); | |
| void DeepFreeze(); | |
| void DeepUnfreeze(); | |
| } | |
| public abstract class FreezableBase : IFreezable | |
| { | |
| // Must be virtual so Castle DynamicProxy can override the getter on the generated proxy | |
| // subclass. Without virtual, the proxy reads its own never-updated backing field while | |
| // Freeze() sets the state on the target object, causing IsFrozen to always return false. | |
| [JsonIgnore] | |
| [Newtonsoft.Json.JsonIgnore] | |
| public virtual bool IsFrozen { get; private set; } | |
| // Must be virtual so Castle DynamicProxy intercepts the call and routes it through | |
| // FreezeInterceptor before forwarding to this implementation on the target object. | |
| public virtual void Freeze() | |
| { | |
| this.IsFrozen = true; | |
| } | |
| public virtual void Unfreeze() | |
| { | |
| this.IsFrozen = false; | |
| } | |
| // Must be virtual so Castle DynamicProxy intercepts the call on the proxy and forwards | |
| // it to this implementation on the target object. | |
| public virtual void DeepFreeze() | |
| { | |
| // ReferenceEqualityComparer ensures that identity (not equality) is used when | |
| // tracking visited objects, which prevents infinite loops in object graphs that | |
| // contain circular references or shared child instances. | |
| var visited = new HashSet<object>(comparer: ReferenceEqualityComparer.Instance); | |
| this.DeepFreeze(visited: visited); | |
| } | |
| // Must be virtual so Castle DynamicProxy intercepts the call on the proxy and forwards | |
| // it to this implementation on the target object. | |
| public virtual void DeepUnfreeze() | |
| { | |
| // ReferenceEqualityComparer ensures that identity (not equality) is used when | |
| // tracking visited objects, which prevents infinite loops in object graphs that | |
| // contain circular references or shared child instances. | |
| var visited = new HashSet<object>(comparer: ReferenceEqualityComparer.Instance); | |
| this.DeepUnfreeze(visited: visited); | |
| } | |
| // Internal overload that accepts a visited set for circular reference tracking | |
| internal virtual void DeepFreeze( | |
| HashSet<object> visited) | |
| { | |
| // If this object has already been processed, skip it to prevent infinite recursion | |
| if (!visited.Add(this)) | |
| { | |
| return; | |
| } | |
| this.Freeze(); | |
| // Reflection is used so subclasses do not need to override DeepFreeze to register | |
| // their own properties — any IFreezable property is discovered and frozen automatically. | |
| PropertyInfo[] freezableProps = this.GetType().GetProperties() | |
| .Where(p => p.CanRead && p.GetValue(this) is IFreezable) | |
| .ToArray(); | |
| foreach (PropertyInfo prop in freezableProps) | |
| { | |
| if (prop.GetValue(this) is IFreezable freezable) | |
| { | |
| // If the freezable is a FreezableBase, use the internal overload to maintain visited tracking | |
| if (freezable is FreezableBase freezableBase) | |
| { | |
| freezableBase.DeepFreeze(visited: visited); | |
| } | |
| else | |
| { | |
| // For non-FreezableBase implementations, just call the public method | |
| freezable.DeepFreeze(); | |
| } | |
| } | |
| } | |
| // IList and IEnumerable are used to find collections that may contain IFreezable items. | |
| // Both generic and non-generic collections need to be processed. | |
| PropertyInfo[] collectionProps = this.GetType().GetProperties() | |
| .Where(p => | |
| { | |
| if (!p.CanRead) | |
| { | |
| return false; | |
| } | |
| object? value = p.GetValue(this); | |
| // Check for both generic IEnumerable<T> and non-generic IList | |
| return value is IList || value is IEnumerable enumerable && value.GetType().IsGenericType; | |
| } | |
| ) | |
| .ToArray(); | |
| foreach (PropertyInfo prop in collectionProps) | |
| { | |
| object? value = prop.GetValue(this); | |
| if (value is IEnumerable enumerable) | |
| { | |
| // Convert to list for processing | |
| List<object> items = enumerable.Cast<object>().ToList(); | |
| foreach (object? item in items) | |
| { | |
| if (item is IFreezable freezableItem) | |
| { | |
| // If the freezable is a FreezableBase, use the internal overload to maintain visited tracking | |
| if (freezableItem is FreezableBase freezableBase) | |
| { | |
| freezableBase.DeepFreeze(visited: visited); | |
| } | |
| else | |
| { | |
| // For non-FreezableBase implementations, just call the public method | |
| freezableItem.DeepFreeze(); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Internal overload that accepts a visited set for circular reference tracking | |
| internal virtual void DeepUnfreeze( | |
| HashSet<object> visited) | |
| { | |
| // If this object has already been processed, skip it to prevent infinite recursion | |
| if (!visited.Add(this)) | |
| { | |
| return; | |
| } | |
| this.Unfreeze(); | |
| // Reflection is used so subclasses do not need to override DeepUnfreeze to register | |
| // their own properties — any IFreezable property is discovered and unfrozen automatically. | |
| PropertyInfo[] freezableProps = this.GetType().GetProperties() | |
| .Where(p => p.CanRead && p.GetValue(this) is IFreezable) | |
| .ToArray(); | |
| foreach (PropertyInfo prop in freezableProps) | |
| { | |
| if (prop.GetValue(this) is IFreezable freezable) | |
| { | |
| // If the freezable is a FreezableBase, use the internal overload to maintain visited tracking | |
| if (freezable is FreezableBase freezableBase) | |
| { | |
| freezableBase.DeepUnfreeze(visited: visited); | |
| } | |
| else | |
| { | |
| // For non-FreezableBase implementations, just call the public method | |
| freezable.DeepUnfreeze(); | |
| } | |
| } | |
| } | |
| // IList and IEnumerable are used to find collections that may contain IFreezable items. | |
| // Both generic and non-generic collections need to be processed. | |
| PropertyInfo[] collectionProps = this.GetType().GetProperties() | |
| .Where(p => | |
| { | |
| if (!p.CanRead) | |
| { | |
| return false; | |
| } | |
| object? value = p.GetValue(this); | |
| // Check for both generic IEnumerable<T> and non-generic IList | |
| return value is IList || value is IEnumerable enumerable && value.GetType().IsGenericType; | |
| } | |
| ) | |
| .ToArray(); | |
| foreach (PropertyInfo prop in collectionProps) | |
| { | |
| object? value = prop.GetValue(this); | |
| if (value is IEnumerable enumerable) | |
| { | |
| // Convert to list for processing | |
| List<object> items = enumerable.Cast<object>().ToList(); | |
| foreach (object? item in items) | |
| { | |
| if (item is IFreezable freezableItem) | |
| { | |
| // If the freezable is a FreezableBase, use the internal overload to maintain visited tracking | |
| if (freezableItem is FreezableBase freezableBase) | |
| { | |
| freezableBase.DeepUnfreeze(visited: visited); | |
| } | |
| else | |
| { | |
| // For non-FreezableBase implementations, just call the public method | |
| freezableItem.DeepUnfreeze(); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Creates Castle DynamicProxy wrappers around FreezableBase objects so that property | |
| // setters can be intercepted at runtime without modifying the original class hierarchy. | |
| // The proxy subclasses the concrete type and routes every virtual member through | |
| // FreezeInterceptor, which throws when a setter is called on a frozen object. | |
| public static class FreezableFactory | |
| { | |
| // ProxyGenerator is expensive to create and caches generated proxy types internally, | |
| // so a single shared instance avoids repeated type-generation overhead. | |
| private static readonly ProxyGenerator _proxyGenerator = new(); | |
| // FreezeInterceptor holds no state — one instance is safe to share across all proxies. | |
| private static readonly FreezeInterceptor _interceptor = new(); | |
| public static T? Wrap<T>( | |
| T? target) where T : class, IFreezable | |
| { | |
| if (target == null) | |
| { | |
| return null; | |
| } | |
| // target.GetType() is used instead of typeof(T) because T may be inferred as the | |
| // IFreezable interface when called from a recursive context. Passing an interface | |
| // to CreateClassProxyWithTarget throws ArgumentException — it requires a class. | |
| // The cast back to T is safe because the generated proxy subclasses the concrete type. | |
| return (T)_proxyGenerator.CreateClassProxyWithTarget( | |
| target.GetType(), | |
| Array.Empty<Type>(), | |
| target: target, | |
| options: ProxyGenerationOptions.Default, | |
| Array.Empty<object>(), | |
| _interceptor | |
| ); | |
| } | |
| public static T? WrapAndFreeze<T>( | |
| T? target) where T : class, IFreezable | |
| { | |
| T? wrapped = Wrap(target: target); | |
| if (wrapped == null) | |
| { | |
| return null; | |
| } | |
| wrapped.Freeze(); | |
| return wrapped; | |
| } | |
| public static T DeepFreeze<T>( | |
| T target) where T : class, IFreezable | |
| { | |
| // ReferenceEqualityComparer ensures that identity (not equality) is used when | |
| // tracking visited objects, which prevents infinite loops in object graphs that | |
| // contain circular references or shared child instances. | |
| var visited = new HashSet<object>(comparer: ReferenceEqualityComparer.Instance); | |
| // Dictionary to maintain the mapping between original objects and their wrapped proxies | |
| // This is needed to return the correct wrapped proxy when encountering circular references | |
| var originalToWrapped = new Dictionary<object, IFreezable>(comparer: ReferenceEqualityComparer.Instance); | |
| var wrapped = (T)DeepWrap(target: target, visited: visited, originalToWrapped: originalToWrapped); | |
| wrapped.DeepFreeze(); | |
| return wrapped; | |
| } | |
| private static IFreezable DeepWrap( | |
| IFreezable target, | |
| HashSet<object> visited, | |
| Dictionary<object, IFreezable> originalToWrapped) | |
| { | |
| if (target is null) | |
| { | |
| throw new ArgumentNullException(nameof(target)); | |
| } | |
| // If we've already processed this object, return its wrapped proxy to preserve circular references | |
| if (originalToWrapped.TryGetValue(key: target, out IFreezable? existingWrapped)) | |
| { | |
| return existingWrapped; | |
| } | |
| // If this target is already visited but not in our mapping, it means we're encountering | |
| // a wrapped proxy during traversal - return it as-is to avoid re-wrapping | |
| if (visited.Contains(item: target)) | |
| { | |
| return target; | |
| } | |
| // Use the runtime type of the target when creating the proxy so that we always | |
| // subclass the concrete implementation rather than the IFreezable interface. | |
| // The dynamic dispatch ensures the generic constraint (class, IFreezable) is | |
| // satisfied using the concrete runtime type. | |
| IFreezable wrapped = Wrap((dynamic)target) | |
| ?? throw new InvalidOperationException("Wrap returned null for a non-null target."); | |
| // Store the mapping from original to wrapped BEFORE processing child properties | |
| // This ensures circular references can find the wrapped version | |
| originalToWrapped[key: target] = wrapped; | |
| // Both the original target and its proxy are tracked so that encountering either | |
| // one during recursive traversal is recognised as already-visited. Without tracking | |
| // the proxy, a second traversal path to the same object would wrap it again. | |
| visited.Add(item: target); | |
| visited.Add(item: wrapped); | |
| // Wrap each IFreezable property value so that freeze/unfreeze operations propagate | |
| // to nested objects. CanWrite is required because the wrapped proxy is set back | |
| // onto the property so the parent always holds the proxy, not the raw object. | |
| Type runtimeType = target.GetType(); | |
| IList<PropertyInfo> freezableProps = runtimeType | |
| .GetProperties() | |
| .Where(p => | |
| { | |
| if (!p.CanRead) | |
| { | |
| return false; | |
| } | |
| return p.CanWrite && p.GetValue(obj: target) is IFreezable; | |
| } | |
| ).ToList(); | |
| foreach (PropertyInfo prop in freezableProps) | |
| { | |
| object? value = prop.GetValue(obj: target); | |
| if (value is IFreezable freezable) | |
| { | |
| IFreezable wrappedValue = DeepWrap(target: freezable, visited: visited, originalToWrapped: originalToWrapped); | |
| prop.SetValue(obj: wrapped, value: wrappedValue); | |
| } | |
| } | |
| // Collections are not proxied as a whole — only their IFreezable elements are | |
| // individually wrapped and replaced in-place. The list itself (e.g. List<Person>) | |
| // is left as-is because Castle cannot meaningfully proxy a sealed generic type. | |
| PropertyInfo[] listProps = runtimeType.GetProperties() | |
| .Where(p => p.CanRead && p.GetValue(obj: target) is IList) | |
| .ToArray(); | |
| foreach (PropertyInfo prop in listProps) | |
| { | |
| object? value = prop.GetValue(obj: target); | |
| if (value is IList list) | |
| { | |
| // First, ensure that all IFreezable elements in the collection are themselves | |
| // wrapped, so subsequent access to those elements respects freezing semantics. | |
| for (var i = 0; i < list.Count; i++) | |
| { | |
| if (list[index: i] is IFreezable freezableItem && !visited.Contains(list[index: i])) | |
| { | |
| list[index: i] = DeepWrap(target: freezableItem, visited: visited, originalToWrapped: originalToWrapped); | |
| } | |
| } | |
| // If the property is declared as a writable list and the underlying instance is | |
| // mutable, wrap it in a freezable-aware collection so that callers cannot mutate | |
| // the collection via Add/Remove/Clear *while the owner is frozen*, but can do so | |
| // again after Unfreeze()/DeepUnfreeze(). | |
| if (prop.CanWrite && !list.IsReadOnly && target is IFreezable owner) | |
| { | |
| Type propertyType = prop.PropertyType; | |
| // Non-generic IList properties are wrapped in a non-generic, freezable-aware | |
| // IList implementation that checks the owner's IsFrozen flag on mutation. | |
| if (propertyType == typeof(IList)) | |
| { | |
| IList wrappedList = new FreezableNonGenericListWrapper(inner: list, owner: owner); | |
| prop.SetValue(obj: wrapped, value: wrappedList); | |
| } | |
| // For generic list/collection-typed properties (e.g. IList<T>, ICollection<T>, | |
| // IReadOnlyList<T>), wrap the list in a FreezableListWrapper<T> so callers | |
| // cannot mutate it while the owner is frozen. | |
| else if (propertyType.IsGenericType) | |
| { | |
| Type genericDefinition = propertyType.GetGenericTypeDefinition(); | |
| if (genericDefinition == typeof(IList<>) || | |
| genericDefinition == typeof(ICollection<>) || | |
| genericDefinition == typeof(IReadOnlyList<>)) | |
| { | |
| Type elementType = propertyType.GetGenericArguments()[0]; | |
| Type wrapperType = typeof(FreezableListWrapper<>).MakeGenericType(elementType); | |
| // Construct FreezableListWrapper<T>(IList<T>, IFreezable). | |
| object? wrappedList = Activator.CreateInstance( | |
| type: wrapperType, | |
| list, | |
| owner | |
| ); | |
| // Ensure the constructed wrapper can be assigned to the declared | |
| // property type (e.g. IList<T> / ICollection<T> / IReadOnlyList<T>). | |
| if (wrappedList != null && propertyType.IsInstanceOfType(o: wrappedList)) | |
| { | |
| prop.SetValue(obj: wrapped, value: wrappedList); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| return wrapped; | |
| } | |
| /// <summary> | |
| /// Non-generic IList wrapper that gates all mutating operations on the owning IFreezable's | |
| /// IsFrozen state. When the owner is frozen, mutations throw; when unfrozen, mutations | |
| /// delegate to the underlying list. | |
| /// </summary> | |
| private sealed class FreezableNonGenericListWrapper(IList inner, IFreezable owner) : IList | |
| { | |
| private readonly IList _inner = inner ?? throw new ArgumentNullException(nameof(inner)); | |
| private readonly IFreezable _owner = owner ?? throw new ArgumentNullException(nameof(owner)); | |
| public int Add( | |
| object? value) | |
| { | |
| this.EnsureNotFrozen(); | |
| return this._inner.Add(value: value); | |
| } | |
| public void Clear() | |
| { | |
| this.EnsureNotFrozen(); | |
| this._inner.Clear(); | |
| } | |
| public bool Contains( | |
| object? value) | |
| { | |
| return this._inner.Contains(value: value); | |
| } | |
| public int IndexOf( | |
| object? value) | |
| { | |
| return this._inner.IndexOf(value: value); | |
| } | |
| public void Insert( | |
| int index, | |
| object? value) | |
| { | |
| this.EnsureNotFrozen(); | |
| this._inner.Insert(index: index, value: value); | |
| } | |
| public bool IsFixedSize => this._inner.IsFixedSize; | |
| public bool IsReadOnly => this._inner.IsReadOnly; | |
| public void Remove( | |
| object? value) | |
| { | |
| this.EnsureNotFrozen(); | |
| this._inner.Remove(value: value); | |
| } | |
| public void RemoveAt( | |
| int index) | |
| { | |
| this.EnsureNotFrozen(); | |
| this._inner.RemoveAt(index: index); | |
| } | |
| public object? this[ | |
| int index] | |
| { | |
| get => this._inner[index: index]; | |
| set | |
| { | |
| this.EnsureNotFrozen(); | |
| this._inner[index: index] = value; | |
| } | |
| } | |
| public void CopyTo( | |
| Array array, | |
| int index) | |
| { | |
| this._inner.CopyTo(array: array, index: index); | |
| } | |
| public int Count => this._inner.Count; | |
| public bool IsSynchronized => this._inner.IsSynchronized; | |
| public object SyncRoot => this._inner.SyncRoot; | |
| public IEnumerator GetEnumerator() | |
| { | |
| return this._inner.GetEnumerator(); | |
| } | |
| private void EnsureNotFrozen() | |
| { | |
| if (this._owner.IsFrozen) | |
| { | |
| throw new InvalidOperationException("Cannot modify a collection owned by a frozen object."); | |
| } | |
| } | |
| } | |
| /// <summary> | |
| /// Generic list wrapper that gates mutating operations on the owning IFreezable's IsFrozen | |
| /// state, while exposing both IList<T> and IReadOnlyList<T> semantics. | |
| /// </summary> | |
| private sealed class FreezableListWrapper<T> : IList<T>, IReadOnlyList<T> | |
| { | |
| private readonly IList<T> _inner; | |
| private readonly IFreezable _owner; | |
| public FreezableListWrapper( | |
| IList<T> inner, | |
| IFreezable owner) | |
| { | |
| this._inner = inner ?? throw new ArgumentNullException(nameof(inner)); | |
| this._owner = owner ?? throw new ArgumentNullException(nameof(owner)); | |
| } | |
| public T this[ | |
| int index] | |
| { | |
| get => this._inner[index: index]; | |
| set | |
| { | |
| this.EnsureNotFrozen(); | |
| this._inner[index: index] = value; | |
| } | |
| } | |
| public int Count => this._inner.Count; | |
| public bool IsReadOnly => this._inner.IsReadOnly; | |
| public void Add( | |
| T item) | |
| { | |
| this.EnsureNotFrozen(); | |
| this._inner.Add(item: item); | |
| } | |
| public void Clear() | |
| { | |
| this.EnsureNotFrozen(); | |
| this._inner.Clear(); | |
| } | |
| public bool Contains( | |
| T item) | |
| { | |
| return this._inner.Contains(item: item); | |
| } | |
| public void CopyTo( | |
| T[] array, | |
| int arrayIndex) | |
| { | |
| this._inner.CopyTo(array: array, arrayIndex: arrayIndex); | |
| } | |
| public IEnumerator<T> GetEnumerator() | |
| { | |
| return this._inner.GetEnumerator(); | |
| } | |
| public int IndexOf( | |
| T item) | |
| { | |
| return this._inner.IndexOf(item: item); | |
| } | |
| public void Insert( | |
| int index, | |
| T item) | |
| { | |
| this.EnsureNotFrozen(); | |
| this._inner.Insert(index: index, item: item); | |
| } | |
| public bool Remove( | |
| T item) | |
| { | |
| this.EnsureNotFrozen(); | |
| return this._inner.Remove(item: item); | |
| } | |
| public void RemoveAt( | |
| int index) | |
| { | |
| this.EnsureNotFrozen(); | |
| this._inner.RemoveAt(index: index); | |
| } | |
| IEnumerator IEnumerable.GetEnumerator() | |
| { | |
| return this._inner.GetEnumerator(); | |
| } | |
| private void EnsureNotFrozen() | |
| { | |
| if (this._owner.IsFrozen) | |
| { | |
| throw new InvalidOperationException("Cannot modify a collection owned by a frozen object."); | |
| } | |
| } | |
| } | |
| } | |
| public class FreezeInterceptor : IInterceptor | |
| { | |
| public void Intercept( | |
| IInvocation invocation) | |
| { | |
| if (invocation.Method.Name.StartsWith("set_") && | |
| invocation.InvocationTarget is IFreezable freezable && | |
| freezable.IsFrozen) | |
| { | |
| string propertyName = invocation.Method.Name.Substring(startIndex: 4); | |
| throw new InvalidOperationException( | |
| $"Cannot set property '{propertyName}': object is frozen" | |
| ); | |
| } | |
| invocation.Proceed(); | |
| } | |
| } | |
| public class FreezableTests | |
| { | |
| private static Person CreateTestPerson() | |
| { | |
| var person = new Person | |
| { | |
| Name = "John", | |
| Age = 30, | |
| HomeAddress = new Address | |
| { | |
| Street = "123 Main St", | |
| City = "Springfield" | |
| } | |
| }; | |
| person.Children.Add(new Person { Name = "Alice", Age = 8 }); | |
| person.Hobbies.Add("Reading"); | |
| person.Hobbies.Add("Gaming"); | |
| return FreezableFactory.DeepFreeze(target: person); | |
| } | |
| [Fact] | |
| public void FreezableBase_Freeze_SetsFrozenState() | |
| { | |
| Person person = CreateTestPerson(); | |
| person.Freeze(); | |
| person.IsFrozen.Should().BeTrue(); | |
| } | |
| [Fact] | |
| public void FreezableBase_Freeze_ThrowsOnPropertySet() | |
| { | |
| Person person = CreateTestPerson(); | |
| person.Freeze(); | |
| Action act = () => { person.Age = 31; }; | |
| act.Should().Throw<InvalidOperationException>(); | |
| } | |
| [Fact] | |
| public void FreezableBase_Unfreeze_ClearsFrozenState() | |
| { | |
| Person person = CreateTestPerson(); | |
| person.Freeze(); | |
| person.Unfreeze(); | |
| person.IsFrozen.Should().BeFalse(); | |
| } | |
| [Fact] | |
| public void FreezableBase_Unfreeze_AllowsPropertySet() | |
| { | |
| Person person = CreateTestPerson(); | |
| person.Freeze(); | |
| person.Unfreeze(); | |
| person.Age = 31; | |
| person.Age.Should().Be(expected: 31); | |
| } | |
| [Fact] | |
| public void FreezableBase_DeepFreeze_FreezesNestedObjects() | |
| { | |
| Person person = CreateTestPerson(); | |
| person.DeepFreeze(); | |
| person.IsFrozen.Should().BeTrue(); | |
| person.HomeAddress.IsFrozen.Should().BeTrue(); | |
| ((Person)person.Children[index: 0])?.IsFrozen.Should().BeTrue(); | |
| } | |
| [Fact] | |
| public void FreezableBase_DeepFreeze_ThrowsOnNestedPropertySet() | |
| { | |
| Person person = CreateTestPerson(); | |
| person.DeepFreeze(); | |
| Action act = () => { person.HomeAddress.City = "New City"; }; | |
| act.Should().Throw<InvalidOperationException>(); | |
| } | |
| [Fact] | |
| public void FreezableBase_DeepUnfreeze_UnfreezesNestedObjects() | |
| { | |
| Person person = CreateTestPerson(); | |
| person.DeepFreeze(); | |
| person.DeepUnfreeze(); | |
| person.IsFrozen.Should().BeFalse(); | |
| person.HomeAddress.IsFrozen.Should().BeFalse(); | |
| ((Person)person.Children[index: 0])?.IsFrozen.Should().BeFalse(); | |
| } | |
| [Fact] | |
| public void FreezableBase_DeepUnfreeze_AllowsNestedPropertySet() | |
| { | |
| Person person = CreateTestPerson(); | |
| person.DeepFreeze(); | |
| person.DeepUnfreeze(); | |
| person.HomeAddress.City = "New City"; | |
| person.HomeAddress.City.Should().Be("New City"); | |
| } | |
| [Fact] | |
| public void FreezableBase_JsonSerialization_SerializesFrozenObject() | |
| { | |
| Person person = CreateTestPerson(); | |
| person.Freeze(); | |
| string json = JsonSerializer.Serialize(value: person, new JsonSerializerOptions { WriteIndented = true }); | |
| json.Should().NotBeNullOrEmpty(); | |
| } | |
| [Fact] | |
| public void FreezableBase_JsonSerialization_DeserializesAsUnfrozen() | |
| { | |
| Person person = CreateTestPerson(); | |
| person.Freeze(); | |
| string json = JsonSerializer.Serialize(value: person); | |
| var deserialized = JsonSerializer.Deserialize<Person>(json: json); | |
| deserialized?.IsFrozen.Should().BeFalse(); | |
| } | |
| [Fact] | |
| public void FreezableBase_JsonSerialization_DeserializedObjectAllowsPropertySet() | |
| { | |
| Person person = CreateTestPerson(); | |
| person.Freeze(); | |
| string json = JsonSerializer.Serialize(value: person); | |
| var deserialized = JsonSerializer.Deserialize<Person>(json: json); | |
| deserialized!.Name = "Modified After Deserialize"; | |
| deserialized.Name.Should().Be("Modified After Deserialize"); | |
| } | |
| [Fact] | |
| public void FreezableBase_FreezeCycles_MaintainCorrectState() | |
| { | |
| Person person = CreateTestPerson(); | |
| for (var i = 0; i < 3; i++) | |
| { | |
| person.Freeze(); | |
| person.IsFrozen.Should().BeTrue(); | |
| person.Unfreeze(); | |
| person.IsFrozen.Should().BeFalse(); | |
| person.Name = $"Cycle {i + 1}"; | |
| person.Name.Should().Be($"Cycle {i + 1}"); | |
| } | |
| } | |
| [Fact] | |
| public void FreezableBase_DeepFreezeUnfreezeCycle_AllowsPropertySetAfterUnfreeze() | |
| { | |
| Person person = CreateTestPerson(); | |
| person.DeepFreeze(); | |
| person.DeepUnfreeze(); | |
| person.Name = "Final Name"; | |
| person.HomeAddress.City = "Final City"; | |
| person.Name.Should().Be("Final Name"); | |
| person.HomeAddress.City.Should().Be("Final City"); | |
| } | |
| [Fact] | |
| public void FreezableBase_Freeze_ThrowsOnCollectionMutation() | |
| { | |
| // Arrange | |
| Person person = CreateTestPerson(); | |
| person.Freeze(); | |
| // Act | |
| Action addChild = () => person.Children.Add(new Person { Name = "Bob", Age = 5 }); | |
| Action removeChild = () => person.Children.RemoveAt(index: 0); | |
| Action addHobby = () => person.Hobbies.Add("Cooking"); | |
| Action removeHobby = () => person.Hobbies.RemoveAt(index: 0); | |
| // Assert | |
| addChild.Should().Throw<InvalidOperationException>(); | |
| removeChild.Should().Throw<InvalidOperationException>(); | |
| addHobby.Should().Throw<InvalidOperationException>(); | |
| removeHobby.Should().Throw<InvalidOperationException>(); | |
| } | |
| [Fact] | |
| public void FreezableFactory_DeepWrap_ReusesProxies_ForSharedAndCircularReferences() | |
| { | |
| // Arrange: create a graph with shared and circular references | |
| var shared = new TestNode(); | |
| var root = new TestNode | |
| { | |
| Left = shared, | |
| Right = shared | |
| }; | |
| // Introduce a circular reference from the shared node back to the root | |
| shared.Left = root; | |
| // Act | |
| TestNode wrapped = FreezableFactory.DeepFreeze(target: root); | |
| // Assert: shared references should still be shared in the wrapped graph | |
| wrapped.Left.Should().BeSameAs(expected: wrapped.Right); | |
| // Circular reference should be preserved and should use the wrapped instance | |
| wrapped.Left!.Left.Should().BeSameAs(expected: wrapped); | |
| // Wrapped graph should not contain the original raw instances | |
| wrapped.Left.Should().NotBeSameAs(unexpected: shared); | |
| wrapped.Should().NotBeSameAs(unexpected: root); | |
| } | |
| public class Person : FreezableBase | |
| { | |
| public virtual string Name { get; set; } | |
| public virtual int Age { get; set; } | |
| public virtual Address HomeAddress { get; set; } | |
| public virtual IList Children { get; set; } = new List<Person>(); | |
| public virtual IList Hobbies { get; set; } = new List<string>(); | |
| } | |
| public class Address : FreezableBase | |
| { | |
| public virtual string Street { get; set; } | |
| public virtual string City { get; set; } | |
| public virtual string ZipCode { get; set; } | |
| } | |
| public class TestNode : FreezableBase | |
| { | |
| public TestNode? Left { get; set; } | |
| public TestNode? Right { get; set; } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment