Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions src/System.Windows.Forms/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@

// This is needed in order to Moq internal interfaces for testing
[assembly: InternalsVisibleTo($"DynamicProxyGenAssembly2, PublicKey={PublicKeys.Moq}")]

// TODO: Confirm the actual WiseTech CargoWise assembly name and strong-name public key before shipping.
// Replace "WiseTech.CargoWise" with the real assembly name and add a "PublicKey={PublicKeys.*}"
// token (adding a new PublicKeys constant if needed) once the key is confirmed.
// This entry grants CargoWise access to RelatedCurrencyManager.UseParentCurrentChanged,
// the supported .NET 10 replacement for the ZBindingContext reflection hack.
[assembly: InternalsVisibleTo("WiseTech.CargoWise")]
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,38 @@ internal class RelatedCurrencyManager : CurrencyManager
/// <para>
/// When <see langword="false"/> (the default) a <see cref="RelatedCurrencyManager"/>
/// refreshes from the parent manager's <see cref="BindingManagerBase.CurrentItemChanged"/>
/// event, which is the standard framework behavior.
/// event, which is the standard framework behavior. <see cref="BindingManagerBase.CurrentItemChanged"/>
/// fires on both parent position changes and parent item-property edits.
/// </para>
/// <para>
/// When set to <see langword="true"/> the related manager instead refreshes from the
/// parent's <see cref="BindingManagerBase.CurrentChanged"/> event. This reproduces a
/// behavior that consumers (for example CargoWise's <c>ZBindingContext</c>) previously
/// achieved through reflection, which is no longer possible in .NET 10 because the
/// <see cref="BindingContext"/> list-manager dictionary can no longer be intercepted.
/// parent's <see cref="BindingManagerBase.CurrentChanged"/> event, which fires only on
/// parent position changes — not on item-property edits. This is the supported .NET 10
/// replacement for a reflection-based event swap that consumers such as CargoWise's
/// <c>ZBindingContext</c> previously performed, which is no longer viable because the
/// <see cref="BindingContext"/> list-manager storage can no longer be intercepted.
/// </para>
/// <para>
/// This switch must be set before related currency managers are created (for example in
/// the binding context constructor) so that the desired event is wired up from the start.
/// Changing it after managers have been created only affects managers created or rebound
/// afterwards.
/// This switch is process-wide and must be set before related currency managers are
/// created (for example, in the binding context constructor) so that the desired event is
/// wired up from the start. Changing it after managers have been created only affects
/// managers that are created or rebound afterwards.
/// </para>
/// <para>
/// Example — setting the flag once in a custom binding context constructor
/// (the .NET 10 replacement for the <c>ZBindingContext</c> reflection hack):
/// </para>
/// <code language="csharp">
/// public class ZBindingContext : BindingContext
/// {
/// public ZBindingContext()
/// {
/// // .NET 10 replacement for the old reflection hack: drive related currency
/// // managers off the parent's CurrentChanged event instead of CurrentItemChanged.
/// RelatedCurrencyManager.UseParentCurrentChanged = true;
/// }
/// }
/// </code>
/// </remarks>
internal static bool UseParentCurrentChanged { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel;

namespace System.Windows.Forms.Tests;

// NB: doesn't require thread affinity
public class RelatedCurrencyManagerTests
{
/// <summary>
/// Verifies that <see cref="RelatedCurrencyManager.UseParentCurrentChanged"/> defaults to
/// <see langword="false"/>, preserving the standard framework behavior out of the box.
/// </summary>
[Fact]
public void UseParentCurrentChanged_DefaultValue_IsFalse()
{
bool originalValue = RelatedCurrencyManager.UseParentCurrentChanged;
try
{
// Reset to the documented default so the assertion is deterministic.
RelatedCurrencyManager.UseParentCurrentChanged = false;

Assert.False(RelatedCurrencyManager.UseParentCurrentChanged);
}
finally
{
RelatedCurrencyManager.UseParentCurrentChanged = originalValue;
}
}

/// <summary>
/// Default behavior: the child manager refreshes when the parent fires
/// <see cref="BindingManagerBase.CurrentItemChanged"/> (e.g. an item-property edit on
/// the parent list).
/// </summary>
[Fact]
public void UseParentCurrentChanged_False_RefreshesOnCurrentItemChanged()
{
bool originalValue = RelatedCurrencyManager.UseParentCurrentChanged;
try
{
RelatedCurrencyManager.UseParentCurrentChanged = false;

BindingList<ParentRow> parentList = [new ParentRow(), new ParentRow()];
BindingContext context = [];

CurrencyManager parentManager = (CurrencyManager)context[parentList];
CurrencyManager childManager = (CurrencyManager)context[parentList, nameof(ParentRow.Children)];

int refreshCount = 0;
childManager.CurrentChanged += (s, e) => refreshCount++;

// ResetItem fires ListChanged(ItemChanged) on the BindingList, which causes the
// parent CurrencyManager to fire OnCurrentItemChanged — but NOT OnCurrentChanged.
// Default mode is subscribed to CurrentItemChanged, so the child should refresh.
parentList.ResetItem(parentManager.Position);

Assert.Equal(1, refreshCount);
}
finally
{
RelatedCurrencyManager.UseParentCurrentChanged = originalValue;
}
}

/// <summary>
/// Opt-in behavior: when <see cref="RelatedCurrencyManager.UseParentCurrentChanged"/> is
/// <see langword="true"/> the child manager does <em>not</em> refresh on a parent item-property
/// edit that fires only <see cref="BindingManagerBase.CurrentItemChanged"/>.
/// </summary>
[Fact]
public void UseParentCurrentChanged_True_DoesNotRefreshOnCurrentItemChangedOnly()
{
bool originalValue = RelatedCurrencyManager.UseParentCurrentChanged;
try
{
RelatedCurrencyManager.UseParentCurrentChanged = true;

BindingList<ParentRow> parentList = [new ParentRow(), new ParentRow()];
BindingContext context = [];

CurrencyManager parentManager = (CurrencyManager)context[parentList];
CurrencyManager childManager = (CurrencyManager)context[parentList, nameof(ParentRow.Children)];

int refreshCount = 0;
childManager.CurrentChanged += (s, e) => refreshCount++;

// ResetItem fires ListChanged(ItemChanged) which causes the parent to fire only
// CurrentItemChanged, not CurrentChanged. Opt-in mode is subscribed to CurrentChanged,
// so the child must NOT refresh.
parentList.ResetItem(parentManager.Position);

Assert.Equal(0, refreshCount);
}
finally
{
RelatedCurrencyManager.UseParentCurrentChanged = originalValue;
}
}

/// <summary>
/// Default behavior: the child manager refreshes when the parent position changes
/// (which fires <see cref="BindingManagerBase.CurrentChanged"/> and consequently
/// <see cref="BindingManagerBase.CurrentItemChanged"/>).
/// </summary>
[Fact]
public void UseParentCurrentChanged_False_RefreshesOnParentPositionChange()
{
bool originalValue = RelatedCurrencyManager.UseParentCurrentChanged;
try
{
RelatedCurrencyManager.UseParentCurrentChanged = false;

BindingList<ParentRow> parentList = [new ParentRow(), new ParentRow()];
BindingContext context = [];

CurrencyManager parentManager = (CurrencyManager)context[parentList];
CurrencyManager childManager = (CurrencyManager)context[parentList, nameof(ParentRow.Children)];

int refreshCount = 0;
childManager.CurrentChanged += (s, e) => refreshCount++;

// Moving the parent position fires CurrentChanged (and CurrentItemChanged) on the
// parent. In default mode the child is subscribed to CurrentItemChanged so it refreshes.
parentManager.Position = 1;

Assert.Equal(1, refreshCount);
}
finally
{
RelatedCurrencyManager.UseParentCurrentChanged = originalValue;
}
}

/// <summary>
/// Opt-in behavior: the child manager also refreshes when the parent position changes,
/// because a position change fires <see cref="BindingManagerBase.CurrentChanged"/> which
/// the opt-in mode subscribes to.
/// </summary>
[Fact]
public void UseParentCurrentChanged_True_RefreshesOnParentPositionChange()
{
bool originalValue = RelatedCurrencyManager.UseParentCurrentChanged;
try
{
RelatedCurrencyManager.UseParentCurrentChanged = true;

BindingList<ParentRow> parentList = [new ParentRow(), new ParentRow()];
BindingContext context = [];

CurrencyManager parentManager = (CurrencyManager)context[parentList];
CurrencyManager childManager = (CurrencyManager)context[parentList, nameof(ParentRow.Children)];

int refreshCount = 0;
childManager.CurrentChanged += (s, e) => refreshCount++;

// Moving the parent position fires CurrentChanged on the parent. Opt-in mode is
// subscribed to CurrentChanged so the child must refresh exactly once.
parentManager.Position = 1;

Assert.Equal(1, refreshCount);
}
finally
{
RelatedCurrencyManager.UseParentCurrentChanged = originalValue;
}
}

/// <summary>
/// Symmetry: retrieving the same related manager from the <see cref="BindingContext"/>
/// cache twice does not produce a double subscription — the child refreshes exactly once
/// per parent event.
/// </summary>
[Fact]
public void UseParentCurrentChanged_False_CachedManagerHasNoDoubleSubscription()
{
bool originalValue = RelatedCurrencyManager.UseParentCurrentChanged;
try
{
RelatedCurrencyManager.UseParentCurrentChanged = false;

BindingList<ParentRow> parentList = [new ParentRow(), new ParentRow()];
BindingContext context = [];

CurrencyManager parentManager = (CurrencyManager)context[parentList];

// Retrieve the child manager twice; the second lookup must return the cached instance.
CurrencyManager childManager1 = (CurrencyManager)context[parentList, nameof(ParentRow.Children)];
CurrencyManager childManager2 = (CurrencyManager)context[parentList, nameof(ParentRow.Children)];
Assert.Same(childManager1, childManager2);

int refreshCount = 0;
childManager1.CurrentChanged += (s, e) => refreshCount++;

parentList.ResetItem(parentManager.Position);

// Must fire exactly once — not twice — confirming no stale double subscription.
Assert.Equal(1, refreshCount);
}
finally
{
RelatedCurrencyManager.UseParentCurrentChanged = originalValue;
}
}

/// <summary>
/// Symmetry: when the switch is <see langword="true"/>, rebinding the manager
/// (<see cref="RelatedCurrencyManager.Bind"/>) does not leave a stale
/// <see cref="BindingManagerBase.CurrentChanged"/> subscription on the old parent.
/// </summary>
[Fact]
public void UseParentCurrentChanged_True_RebindDoesNotLeaveStaleSubscription()
{
bool originalValue = RelatedCurrencyManager.UseParentCurrentChanged;
try
{
RelatedCurrencyManager.UseParentCurrentChanged = true;

BindingList<ParentRow> parentList = [new ParentRow(), new ParentRow()];
BindingContext context = [];

CurrencyManager parentManager = (CurrencyManager)context[parentList];
RelatedCurrencyManager childManager =
(RelatedCurrencyManager)context[parentList, nameof(ParentRow.Children)];

// Rebind to the same parent — Bind() unwires the old CurrentChanged subscription
// and wires a fresh one. The Bind() call itself fires an init refresh directly
// (not via the event), so add the counter handler after the rebind.
childManager.Bind(parentManager, nameof(ParentRow.Children));

int refreshCount = 0;
childManager.CurrentChanged += (s, e) => refreshCount++;

// Move the parent position: fires CurrentChanged (and CurrentItemChanged) on the parent.
// The child must refresh exactly once — not twice — confirming no double subscription.
parentManager.Position = 1;

Assert.Equal(1, refreshCount);
}
finally
{
RelatedCurrencyManager.UseParentCurrentChanged = originalValue;
}
}

private sealed class ParentRow
{
public List<ChildRow> Children { get; set; } = [];
}

private sealed class ChildRow
{
public string Name { get; set; } = string.Empty;
}
}