Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7db09a8
Fix TOCTOU race in WhenPropertyChanged/WhenValueChanged
dwcullop Jun 13, 2026
f06f762
Deep chain: drainer-based re-walk eliminates concurrent-mutation race
dwcullop Jun 13, 2026
cc90ea5
Simplify deep-chain via recursive Switch composition
dwcullop Jun 13, 2026
0fbcb19
Serialize deep-chain via SharedDeliveryQueue
dwcullop Jun 13, 2026
8707b9c
Address PR feedback: dedup gating, exception routing, test hygiene
dwcullop Jun 13, 2026
0620887
Tighten regression-test budgets for CI runners
dwcullop Jun 13, 2026
e2bb62d
Refactor ObservablePropertyFactory: extract Emitter and DeepChainSubs…
dwcullop Jun 13, 2026
9dc38fe
Symmetric SinglePropertySubscription parallel to DeepChainSubscription
dwcullop Jun 13, 2026
54a59d9
SinglePropertySubscription: route downstream OnNext throws to OnError
dwcullop Jun 13, 2026
38fee59
Collapse ProcessSignal branches via shared isInitial computation
dwcullop Jun 13, 2026
50b7875
Add multi-threaded torture and AutoRefresh integration tests
dwcullop Jun 13, 2026
2ec2c33
Strengthen deep-chain torture invariants
dwcullop Jun 13, 2026
a98ab1e
Use AsAggregator in the AutoRefresh integration test
dwcullop Jun 13, 2026
d073972
Remove dedup; route PropertyChanged events without equality guard
dwcullop Jun 13, 2026
1643e88
Address Jake's PR feedback: simplify race tests, split single-threade…
dwcullop Jun 14, 2026
c241cdc
Adopt Jake's exact implementation for Shallow_ConcurrentMutationDurin…
dwcullop Jun 14, 2026
7f8dacd
Drop cache-side TOCTOU rationale from integration test comment
dwcullop Jun 14, 2026
85213ac
Skip AutoRefresh+Filter integration tests; add dual-subscriber variant
dwcullop Jun 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 275 additions & 0 deletions src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved.
// Roland Pheasant licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

using System;
using System.Collections.Generic;
using System.ComponentModel;

using DynamicData.Binding;

using FluentAssertions;

using Xunit;

namespace DynamicData.Tests.Binding;

/// <summary>
/// Single-threaded contract tests for <see cref="NotifyPropertyChangedEx.WhenPropertyChanged{TObject, TProperty}"/>:
/// handler attachment ordering, no-dedup semantics, deep-chain re-walks on swaps.
/// </summary>
public sealed class WhenPropertyChangedBehaviorFixture
{
[Fact]
public void Shallow_NotifyInitialFalse_SubscribesHandlerBeforeReturning()
{
// notifyOnInitialValue=false: Subscribe must return only after the PropertyChanged handler
// is attached. A setter that fires immediately after Subscribe returns must reach the
// observer.
var model = new TestModel { Value = 10 };
var emissions = new List<int>();

using var sub = model.WhenPropertyChanged(m => m.Value, notifyOnInitialValue: false)
.Subscribe(pv => emissions.Add(pv.Value));

model.Value = 20;

emissions.Should().Equal(new[] { 20 });
}

[Fact]
public void Shallow_NotifyInitialTrue_DoesNotDedupSameValuedEvents()
{
var model = new TestModel { Value = 10 };
var emissions = new List<int>();

using var sub = model.WhenPropertyChanged(m => m.Value, notifyOnInitialValue: true)
.Subscribe(pv => emissions.Add(pv.Value));

model.Value = 10;
model.Value = 10;
model.Value = 10;

emissions.Should().Equal(new[] { 10, 10, 10, 10 });
}

[Fact]
public void Shallow_NotifyInitialFalse_DoesNotDedupSameValuedEvents()
{
var model = new TestModel { Value = 10 };
var emissions = new List<int>();

using var sub = model.WhenPropertyChanged(m => m.Value, notifyOnInitialValue: false)
.Subscribe(pv => emissions.Add(pv.Value));

model.Value = 42;
model.Value = 42;

emissions.Should().Equal(new[] { 42, 42 });
}

[Fact]
public void DeepChain_NotifyInitialTrue_DoesNotDedupSameValuedEvents()
{
var parent = new ParentModel { Child = new ChildModel { Age = 1 } };
var emissions = new List<int>();

using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: true)
.Subscribe(pv => emissions.Add(pv.Value));

parent.Child!.Age = 1;
parent.Child!.Age = 1;
parent.Child!.Age = 1;

emissions.Should().Equal(new[] { 1, 1, 1, 1 });
}

[Fact]
public void DeepChain_NotifyInitialFalse_DoesNotDedupSameValuedEvents()
{
var parent = new ParentModel { Child = new ChildModel { Age = 1 } };
var emissions = new List<int>();

using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: false)
.Subscribe(pv => emissions.Add(pv.Value));

parent.Child!.Age = 7;
parent.Child!.Age = 7;

emissions.Should().Equal(new[] { 7, 7 });
}

[Fact]
public void DeepChain_PostSwap_LeafEventOnNewChild_Captured()
{
// After parent.Child is reassigned, the leaf-level subscription must be re-attached
// against the new child. A subsequent leaf mutation on the new child must be captured.
var parent = new ParentModel { Child = new ChildModel { Age = 10 } };
var emissions = new List<int>();

using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: true)
.Subscribe(pv => emissions.Add(pv.Value));

var newChild = new ChildModel { Age = 20 };
parent.Child = newChild;
newChild.Age = 30;

emissions.Should().Equal(new[] { 10, 20, 30 });
}

[Fact]
public void DeepChain_MidChainSwap_DeeperLevelsRetargetCorrectly()
{
// Mid-chain swap on a 4-level chain. When level 3 is reassigned, the leaf subscription
// must re-attach against the new subtree; events on the old subtree must be ignored
// (its notifier subscription was disposed).
var l1 = new Level1
{
Child = new Level2
{
Child = new Level3
{
Child = new Level4 { Leaf = 10 },
},
},
};

var emissions = new List<int>();
using var sub = l1.WhenPropertyChanged(x => x.Child!.Child!.Child!.Leaf, notifyOnInitialValue: true)
.Subscribe(pv => emissions.Add(pv.Value));

emissions.Should().Equal(new[] { 10 }, "initial emission");

var originalLeaf = l1.Child!.Child!.Child!;

var newL4 = new Level4 { Leaf = 20 };
l1.Child!.Child!.Child = newL4;

emissions.Should().Equal(new[] { 10, 20 }, "mid-chain swap emits the new leaf value");

newL4.Leaf = 30;
emissions.Should().Equal(new[] { 10, 20, 30 }, "leaf event on new subtree is captured");

originalLeaf.Leaf = 999;
emissions.Should().Equal(new[] { 10, 20, 30 }, "leaf event on detached subtree is ignored");
}

private sealed class TestModel : INotifyPropertyChanged
{
private int _value;

public event PropertyChangedEventHandler? PropertyChanged;

public int Value
{
get => _value;
set
{
_value = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
}
}
}

private sealed class ParentModel : INotifyPropertyChanged
{
private ChildModel? _child;

public event PropertyChangedEventHandler? PropertyChanged;

public ChildModel? Child
{
get => _child;
set
{
_child = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
}
}
}

private sealed class ChildModel : INotifyPropertyChanged
{
private int _age;

public event PropertyChangedEventHandler? PropertyChanged;

public int Age
{
get => _age;
set
{
_age = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Age)));
}
}
}

private sealed class Level1 : INotifyPropertyChanged
{
private Level2? _child;

public event PropertyChangedEventHandler? PropertyChanged;

public Level2? Child
{
get => _child;
set
{
_child = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
}
}
}

private sealed class Level2 : INotifyPropertyChanged
{
private Level3? _child;

public event PropertyChangedEventHandler? PropertyChanged;

public Level3? Child
{
get => _child;
set
{
_child = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
}
}
}

private sealed class Level3 : INotifyPropertyChanged
{
private Level4? _child;

public event PropertyChangedEventHandler? PropertyChanged;

public Level4? Child
{
get => _child;
set
{
_child = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
}
}
}

private sealed class Level4 : INotifyPropertyChanged
{
private int _leaf;

public event PropertyChangedEventHandler? PropertyChanged;

public int Leaf
{
get => _leaf;
set
{
_leaf = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Leaf)));
}
}
}
}
Loading
Loading