diff --git a/CHANGELOG.md b/CHANGELOG.md index 110ae39..56bffac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this repository are documented here. ### Added +- Added `DeferViewUpdates()` for nested, exception-safe view update batching across standard and data-bound list variants. - Added per-monitor DPI font rescaling with non-compounding transitions, virtual-list safety, and preservation of explicit column widths. - Added a dedicated MSTest regression suite for `ObjectListView2022`, targeting .NET Framework 4.8.1. - Added behavioral coverage for filtering, sorting, grouping, fast/virtual lists, tree lists, check-state handling, Munger access, hot-item behavior, context-menu handling, state persistence, and performance-sensitive paths. diff --git a/src/ObjectListView2022.Tests/DeferredViewUpdateTests.cs b/src/ObjectListView2022.Tests/DeferredViewUpdateTests.cs new file mode 100644 index 0000000..d172472 --- /dev/null +++ b/src/ObjectListView2022.Tests/DeferredViewUpdateTests.cs @@ -0,0 +1,237 @@ +using System.ComponentModel; +using System.Windows.Forms; +using BrightIdeasSoftware; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ObjectListView2022.Tests +{ + [STATestClass] + public class DeferredViewUpdateTests + { + [TestMethod] + public void DeferViewUpdates_DefersRebuildUntilScopeIsDisposed() + { + using var listView = CreateListView(); + listView.SetObjects(new[] { new Model("first") }); + Assert.AreEqual(1, listView.GetItemCount()); + + using (listView.DeferViewUpdates()) + { + listView.SetObjects(new[] { + new Model("first"), + new Model("second"), + new Model("third") + }); + + Assert.IsTrue(listView.Frozen); + Assert.AreEqual(1, listView.GetItemCount()); + } + + Assert.IsFalse(listView.Frozen); + Assert.AreEqual(3, listView.GetItemCount()); + } + + [TestMethod] + public void DeferViewUpdates_NestedScopesRebuildOnlyAfterOutermostScope() + { + using var listView = CreateListView(); + listView.SetObjects(new[] { new Model("first") }); + + using (listView.DeferViewUpdates()) + { + using (listView.DeferViewUpdates()) + { + listView.SetObjects(new[] { + new Model("first"), + new Model("second") + }); + } + + Assert.IsTrue(listView.Frozen); + Assert.AreEqual(1, listView.GetItemCount()); + } + + Assert.IsFalse(listView.Frozen); + Assert.AreEqual(2, listView.GetItemCount()); + } + + [TestMethod] + public void DeferViewUpdates_NestedScopesCanBeDisposedOutOfOrder() + { + using var listView = CreateListView(); + listView.SetObjects(new[] { new Model("first") }); + var firstScope = listView.DeferViewUpdates(); + var secondScope = listView.DeferViewUpdates(); + listView.SetObjects(new[] { + new Model("first"), + new Model("second") + }); + + firstScope.Dispose(); + + Assert.IsTrue(listView.Frozen); + Assert.AreEqual(1, listView.GetItemCount()); + + secondScope.Dispose(); + + Assert.IsFalse(listView.Frozen); + Assert.AreEqual(2, listView.GetItemCount()); + } + + [TestMethod] + public void DeferViewUpdates_PreservesExistingFreezeUntilCallerUnfreezes() + { + using var listView = CreateListView(); + listView.SetObjects(new[] { new Model("first") }); + listView.Freeze(); + + using (listView.DeferViewUpdates()) + { + listView.SetObjects(new[] { + new Model("first"), + new Model("second") + }); + } + + Assert.IsTrue(listView.Frozen); + Assert.AreEqual(1, listView.GetItemCount()); + + listView.Unfreeze(); + + Assert.IsFalse(listView.Frozen); + Assert.AreEqual(2, listView.GetItemCount()); + } + + [TestMethod] + public void DeferViewUpdates_UnfreezesWhenScopeExitsThroughException() + { + using var listView = CreateListView(); + + try + { + using (listView.DeferViewUpdates()) + { + Assert.IsTrue(listView.Frozen); + throw new TestException(); + } + } + catch (TestException) + { + } + + Assert.IsFalse(listView.Frozen); + } + + [TestMethod] + public void DeferViewUpdates_ScopeDisposeIsIdempotent() + { + using var listView = CreateListView(); + var scope = listView.DeferViewUpdates(); + + scope.Dispose(); + Assert.IsFalse(listView.Frozen); + + scope.Dispose(); + Assert.IsFalse(listView.Frozen); + } + + [TestMethod] + public void DeferViewUpdates_DisposingOwnerBeforeScopeDoesNotUnfreezeDisposedControl() + { + var listView = CreateListView(); + var scope = listView.DeferViewUpdates(); + Assert.IsTrue(listView.Frozen); + + listView.Dispose(); + scope.Dispose(); + + Assert.IsTrue(listView.IsDisposed); + } + + [TestMethod] + public void DataListView_DeferViewUpdatesDefersDataSourceRefreshUntilScopeIsDisposed() + { + using var listView = CreateDataListView(); + var rows = new BindingList { new Model("first") }; + listView.DataSource = rows; + Assert.AreEqual(1, listView.GetItemCount()); + + using (listView.DeferViewUpdates()) + { + rows.Add(new Model("second")); + + Assert.IsTrue(listView.Frozen); + Assert.AreEqual(1, listView.GetItemCount()); + } + + Assert.IsFalse(listView.Frozen); + Assert.AreEqual(2, listView.GetItemCount()); + } + + [TestMethod] + public void FastDataListView_DeferViewUpdatesDefersDataSourceRefreshUntilScopeIsDisposed() + { + using var listView = CreateFastDataListView(); + var rows = new BindingList { new Model("first") }; + listView.DataSource = rows; + Assert.AreEqual(1, listView.GetItemCount()); + + using (listView.DeferViewUpdates()) + { + rows.Add(new Model("second")); + + Assert.IsTrue(listView.Frozen); + Assert.AreEqual(1, listView.GetItemCount()); + } + + Assert.IsFalse(listView.Frozen); + Assert.AreEqual(2, listView.GetItemCount()); + } + + private static ObjectListView CreateListView() + { + var listView = new ObjectListView(); + listView.Columns.Add(new OLVColumn("Name", nameof(Model.Name))); + listView.CreateControl(); + return listView; + } + + private static DataListView CreateDataListView() + { + var listView = new DataListView + { + BindingContext = new BindingContext(), + AutoGenerateColumns = false + }; + listView.Columns.Add(new OLVColumn("Name", nameof(Model.Name))); + listView.CreateControl(); + return listView; + } + + private static FastDataListView CreateFastDataListView() + { + var listView = new FastDataListView + { + BindingContext = new BindingContext(), + AutoGenerateColumns = false + }; + listView.Columns.Add(new OLVColumn("Name", nameof(Model.Name))); + listView.CreateControl(); + return listView; + } + + private sealed class Model + { + public Model(string name) + { + Name = name; + } + + public string Name { get; } + } + + private sealed class TestException : System.Exception + { + } + } +} diff --git a/src/ObjectListView2022/ObjectListView.DeferredUpdates.cs b/src/ObjectListView2022/ObjectListView.DeferredUpdates.cs new file mode 100644 index 0000000..132a973 --- /dev/null +++ b/src/ObjectListView2022/ObjectListView.DeferredUpdates.cs @@ -0,0 +1,45 @@ +using System; + +namespace BrightIdeasSoftware +{ + public partial class ObjectListView + { + /// + /// Freeze view updates until the returned scope is disposed. + /// + /// + /// Scopes may be nested. The view is rebuilt when the outermost scope is disposed. + /// + /// A scope that unfreezes the view when disposed. + public IDisposable DeferViewUpdates() + { + Freeze(); + return new DeferredViewUpdateScope(this); + } + + private sealed class DeferredViewUpdateScope : IDisposable + { + private ObjectListView owner; + + public DeferredViewUpdateScope(ObjectListView owner) + { + this.owner = owner; + } + + public void Dispose() + { + var listView = owner; + if (listView == null) + { + return; + } + + owner = null; + if (!listView.IsDisposed && !listView.Disposing) + { + listView.Unfreeze(); + } + } + } + } +}