Skip to content
Merged
255 changes: 255 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,255 @@
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;

private string? _stringValue;
private int _intValue;

public string? StringValue
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
{
get => _stringValue;
set
{
_stringValue = value;
OnPropertyChanged();
}
}

public int IntValue
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
{
get => _intValue;
set
{
_intValue = value;
OnPropertyChanged();
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
}
}

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_FiresOnChange_Test()
{
// Arrange
var obj = new FakeNotifyPropertyChanged { StringValue = "initial" };
var received = new List<string?>();
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated

// Act
using var _ = obj.WatchProperty(x => x.StringValue, v => received.Add(v));
obj.StringValue = "hello";
obj.StringValue = "world";

// Assert
received.Should().Equal("hello", "world");
}

[Fact]
public void WatchProperty_WatchInitialValue_Test()
{
// Arrange
var obj = new FakeNotifyPropertyChanged { StringValue = "initial" };
var received = new List<string?>();

// Act
using var _ = obj.WatchProperty(
x => x.StringValue,
v => received.Add(v),
watchInitialValue: true
);
obj.StringValue = "hello";

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

[Fact]
public void WatchProperty_DoesNotFireForOtherProperties_Test()
{
// Arrange
var obj = new FakeNotifyPropertyChanged();
var received = new List<string?>();

// Act
using var _ = obj.WatchProperty(x => x.StringValue, v => received.Add(v));
obj.IntValue = 42;

// Assert
received.Should().BeEmpty();
}

[Fact]
public void WatchProperty_FiresOnAllPropertiesChanged_Test()
{
// Arrange
var obj = new FakeNotifyPropertyChanged { StringValue = "initial" };
var received = new List<string?>();

// Act
using var _ = obj.WatchProperty(x => x.StringValue, v => received.Add(v));
obj.RaiseAllPropertiesChanged();

// Assert
received.Should().Equal("initial");
}

[Fact]
public void WatchProperty_Unsubscribes_Test()
{
// Arrange
var obj = new FakeNotifyPropertyChanged();
var received = new List<string?>();

// Act
var subscription = obj.WatchProperty(x => x.StringValue, v => received.Add(v));
obj.StringValue = "hello";
subscription.Dispose();
obj.StringValue = "world";

// Assert
received.Should().Equal("hello");
}

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

// Act & assert
var act = () => obj.WatchProperty(x => x.StringValue!.ToUpper(), _ => { });
act.Should().Throw<ArgumentException>();
}

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

// Act
using var _ = obj.WatchProperties(
[x => x.StringValue, x => (object?)x.IntValue],
() => callCount++
);
obj.StringValue = "hello";
obj.IntValue = 42;

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

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

// Act
using var _ = obj.WatchProperties(
[x => x.StringValue, x => (object?)x.IntValue],
() => callCount++,
watchInitialValue: true
);

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

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

// Act
using var _ = obj.WatchProperties([x => x.StringValue], () => callCount++);
obj.RaiseAllPropertiesChanged();

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

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

// Act
var subscription = obj.WatchProperties([x => x.StringValue], () => callCount++);
obj.StringValue = "hello";
subscription.Dispose();
obj.StringValue = "world";

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

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

// Act
using var _ = obj.WatchAllProperties(() => callCount++);
obj.StringValue = "hello";
obj.IntValue = 42;

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

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

// Act
using var _ = obj.WatchAllProperties(() => callCount++, watchInitialValue: true);

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

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

// Act
var subscription = obj.WatchAllProperties(() => callCount++);
obj.StringValue = "hello";
subscription.Dispose();
obj.StringValue = "world";

// Assert
callCount.Should().Be(1);
}
}
123 changes: 123 additions & 0 deletions PowerKit/Extensions/NotifyPropertyChangedExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#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;
if (memberExpression?.Member is not PropertyInfo property)
throw new ArgumentException(
"Provided expression must reference a property.",
nameof(propertyExpression)
);
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
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.
}
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)
throw new ArgumentException(
"Provided expression must reference a property.",
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.
}
Comment thread
Tyrrrz marked this conversation as resolved.
}
}