Skip to content
Merged
105 changes: 105 additions & 0 deletions PowerKit.Tests/Extensions/NotifyPropertyChangedExtensionsTests.cs
Comment thread
Tyrrrz marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using FluentAssertions;
using PowerKit.Extensions;
using Xunit;

namespace PowerKit.Tests.Extensions;

file class FakeNotifyPropertyChanged : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;

public string? StringProperty
{
get;
set
{
field = value;
OnPropertyChanged();
}
}

public int IntProperty
{
get;
set
{
field = value;
OnPropertyChanged();
}
}

public void RaiseAllPropertiesChanged() =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty));

private void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

public class NotifyPropertyChangedExtensionsTests
{
[Fact]
public void WatchProperty_Test()
{
// Arrange
var obj = new FakeNotifyPropertyChanged { StringProperty = "initial" };
var values = new List<string?>();

// Act
var sub = obj.WatchProperty(x => x.StringProperty, v => values.Add(v), true);

obj.StringProperty = "hello";
obj.IntProperty = 42;
obj.RaiseAllPropertiesChanged();
sub.Dispose();
obj.StringProperty = "world";

// Assert
values.Should().Equal("initial", "hello", "hello");
}

[Fact]
public void WatchProperties_Test()
{
// Arrange
var obj = new FakeNotifyPropertyChanged();
var callCount = 0;

// Act
var sub = obj.WatchProperties(
[x => x.StringProperty, x => x.IntProperty],
() => callCount++,
true
);

obj.StringProperty = "hello";
obj.IntProperty = 42;
obj.RaiseAllPropertiesChanged();
sub.Dispose();
obj.StringProperty = "world";

// Assert
callCount.Should().Be(4);
}

[Fact]
public void WatchAllProperties_Test()
{
// Arrange
var obj = new FakeNotifyPropertyChanged();
var callCount = 0;

// Act
var sub = obj.WatchAllProperties(() => callCount++, true);
obj.StringProperty = "hello";
obj.IntProperty = 42;
sub.Dispose();
obj.StringProperty = "world";

// Assert
callCount.Should().Be(3);
}
}
144 changes: 144 additions & 0 deletions PowerKit/Extensions/NotifyPropertyChangedExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;

namespace PowerKit.Extensions;

#if !POWERKIT_INCLUDE_COVERAGE
[ExcludeFromCodeCoverage]
#endif
internal static class NotifyPropertyChangedExtensions
{
extension<TOwner>(TOwner owner)
where TOwner : INotifyPropertyChanged
{
/// <summary>
/// Subscribes to changes of the specified property on the owner object.
/// The returned <see cref="IDisposable" /> can be disposed to unsubscribe.
/// </summary>
public IDisposable WatchProperty<TProperty>(
Expression<Func<TOwner, TProperty>> propertyExpression,
Action<TProperty> callback,
bool watchInitialValue = false
)
{
var memberExpression =
propertyExpression.Body as MemberExpression
// The compiler implicitly wraps value types in a conversion expression when
// the expression is typed to return a reference type.
?? (propertyExpression.Body as UnaryExpression)?.Operand as MemberExpression;

if (
memberExpression?.Member is not PropertyInfo property
|| !property.DeclaringType!.IsAssignableFrom(typeof(TOwner))
)
{
throw new ArgumentException(
"Provided expression must reference a property of the owner type.",
nameof(propertyExpression)
);
Comment thread
Tyrrrz marked this conversation as resolved.
}

var getValue = propertyExpression.Compile();

void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (
string.IsNullOrWhiteSpace(args.PropertyName)
|| string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal)
)
{
callback(getValue(owner));
}
}

owner.PropertyChanged += OnPropertyChanged;

if (watchInitialValue)
{
callback(getValue(owner));
}

return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
}
Comment thread
Tyrrrz marked this conversation as resolved.

/// <summary>
/// Subscribes to changes of the specified properties on the owner object.
/// The returned <see cref="IDisposable" /> can be disposed to unsubscribe.
/// </summary>
public IDisposable WatchProperties(
IEnumerable<Expression<Func<TOwner, object?>>> propertyExpressions,
Action callback,
bool watchInitialValue = false
)
{
var properties = propertyExpressions
.Select(expression =>
{
var memberExpression =
expression.Body as MemberExpression
// Because the expression is typed to return an object, the compiler will
// implicitly wrap it in a conversion unary expression if it's of any other type.
?? (expression.Body as UnaryExpression)?.Operand as MemberExpression;

if (
memberExpression?.Member is not PropertyInfo property
|| !property.DeclaringType!.IsAssignableFrom(typeof(TOwner))
)
{
throw new ArgumentException(
"Provided expression must reference a property of the owner type.",
nameof(propertyExpressions)
);
Comment thread
Tyrrrz marked this conversation as resolved.
}

return property;
})
Comment thread
Tyrrrz marked this conversation as resolved.
.ToArray();

void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (
string.IsNullOrWhiteSpace(args.PropertyName)
|| properties.Any(p =>
string.Equals(args.PropertyName, p.Name, StringComparison.Ordinal)
)
)
{
callback();
}
}

owner.PropertyChanged += OnPropertyChanged;

if (watchInitialValue)
{
callback();
}

return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
}
Comment thread
Tyrrrz marked this conversation as resolved.

/// <summary>
/// Subscribes to changes of all properties on the owner object.
/// The returned <see cref="IDisposable" /> can be disposed to unsubscribe.
/// </summary>
public IDisposable WatchAllProperties(Action callback, bool watchInitialValue = false)
{
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback();
owner.PropertyChanged += OnPropertyChanged;

if (watchInitialValue)
{
callback();
}

return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
}
Comment thread
Tyrrrz marked this conversation as resolved.
}
}