diff --git a/CITATION.cff b/CITATION.cff index 565733bf..90b37c64 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below." type: software title: "WPF Framework" -version: "1.0.1" +version: "1.0.2" date-released: "2026-07-10" license: 0BSD repository-code: "https://github.com/USACE-RMC/WPF-Framework" diff --git a/Directory.Build.props b/Directory.Build.props index 1579c77b..c1985c6c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,9 +14,9 @@ https://github.com/USACE-RMC/WPF-Framework https://github.com/USACE-RMC/WPF-Framework git - 1.0.1 - 1.0.1.0 - 1.0.1.0 + 1.0.2 + 1.0.2.0 + 1.0.2.0 false false diff --git a/README.md b/README.md index ede7edeb..227bf453 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ dotnet test WPF-Framework.sln ### Build Packages ```bash -.\scripts\pack-wpf-framework.ps1 -Configuration Release -Version 1.0.1 +.\scripts\pack-wpf-framework.ps1 -Configuration Release -Version 1.0.2 ``` This creates and validates the following NuGet packages in `artifacts/packages/`: diff --git a/codemeta.json b/codemeta.json index dc43e75f..6bf12afa 100644 --- a/codemeta.json +++ b/codemeta.json @@ -3,7 +3,7 @@ "@type": "SoftwareSourceCode", "name": "WPF Framework", "description": "A free and open-source .NET 10 application framework for building Windows desktop project management applications. Provides a docking application shell with theming, project explorer, undo/redo, and specialized controls for charting, numeric input, databases, expression parsing, and directed acyclic graphs.", - "version": "1.0.1", + "version": "1.0.2", "dateCreated": "2025-12-29", "dateModified": "2026-07-10", "license": "https://spdx.org/licenses/0BSD", diff --git a/docs/getting-started.md b/docs/getting-started.md index 31d7b06f..2531b3ac 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -30,7 +30,7 @@ For NuGet consumption, reference the Controls bundle. It brings in Core, Models, ```xml - + ``` diff --git a/scripts/pack-wpf-framework.ps1 b/scripts/pack-wpf-framework.ps1 index 2055bbf1..9b667100 100644 --- a/scripts/pack-wpf-framework.ps1 +++ b/scripts/pack-wpf-framework.ps1 @@ -77,10 +77,10 @@ $expectedEntries = @{ } $expectedReleaseNotes = @{ - "RMC.Wpf.Framework.Core" = "Coordinated WPF Framework 1.0.1 release; no package-specific functional changes." - "RMC.Wpf.Framework.Models" = "Coordinated WPF Framework 1.0.1 release; no package-specific functional changes." - "RMC.Wpf.Framework.Support" = "Version 1.0.1 protects settings and runtime data during updates, supports transactional rollback, checksum enforcement, and safe replacement of the installed updater payload." - "RMC.Wpf.Framework.Controls" = "Version 1.0.1 improves irregular time-series ordering validation and defaults new OxyPlot line annotations to editable text." + "RMC.Wpf.Framework.Core" = "Coordinated WPF Framework 1.0.2 release; no package-specific functional changes." + "RMC.Wpf.Framework.Models" = "Coordinated WPF Framework 1.0.2 release; no package-specific functional changes." + "RMC.Wpf.Framework.Support" = "Version 1.0.2 carries forward protected, transactional self-updates and ships a version-aligned updater payload." + "RMC.Wpf.Framework.Controls" = "Version 1.0.2 improves time-series table validation performance, preserves ordering checks after edits, and avoids repeated full-table scans while rebuilding large series." } foreach ($packageId in $expectedEntries.Keys) { diff --git a/src/GenericControls/DataGrid/ValidationDataGrid.cs b/src/GenericControls/DataGrid/ValidationDataGrid.cs index 4a91f944..83b3f534 100644 --- a/src/GenericControls/DataGrid/ValidationDataGrid.cs +++ b/src/GenericControls/DataGrid/ValidationDataGrid.cs @@ -27,6 +27,39 @@ namespace GenericControls public sealed class ValidationDataGrid : CopyPasteDataGrid { + /// + /// Restores validation when a suspension scope is disposed. + /// + private sealed class ValidationSuspension : IDisposable + { + private ValidationDataGrid _owner; + private readonly bool _validateOnResume; + + /// + /// Initializes a new validation suspension scope. + /// + /// The grid whose validation is suspended. + /// Whether to request validation after the outermost scope is disposed. + public ValidationSuspension(ValidationDataGrid owner, bool validateOnResume) + { + _owner = owner; + _validateOnResume = validateOnResume; + } + + /// + /// Releases this suspension scope and optionally requests deferred validation. + /// + public void Dispose() + { + ValidationDataGrid owner = _owner; + if (owner == null) + return; + + _owner = null; + owner.ResumeValidation(_validateOnResume); + } + } + #region Construction /// /// Initializes a new instance of the class. @@ -68,6 +101,10 @@ public ValidationDataGrid() private Dictionary _propertyTypes = new Dictionary(); + private int _validationSuspensionCount; + private bool _operationValidationSuppressed; + private bool _validationRequestedOnResume; + /// /// Identifies the dependency property. @@ -113,7 +150,7 @@ public Brush ErrorCellBackgroundBrush /// /// Gets a value indicating whether validation should be suppressed. /// - public bool SuppressValidation { get; private set; } = false; + public bool SuppressValidation => _operationValidationSuppressed || _validationSuspensionCount > 0; /// @@ -125,6 +162,51 @@ public Brush ErrorCellBackgroundBrush #region Methods + /// + /// Suspends row validation until the returned scope is disposed. + /// + /// Whether to validate once after the outermost suspension scope is disposed. + /// An exception-safe scope that restores validation when disposed. + /// + /// Scopes may be nested. Validation requested while suspended is deferred until both + /// scoped and grid-operation suppression have ended. + /// + public IDisposable SuspendValidation(bool validateOnResume = true) + { + _validationSuspensionCount++; + return new ValidationSuspension(this, validateOnResume); + } + + /// + /// Releases one validation suspension and runs any requested validation after the outermost scope. + /// + /// Whether this scope requests validation on resume. + private void ResumeValidation(bool validateOnResume) + { + if (_validationSuspensionCount <= 0) + return; + + if (validateOnResume) + { + _validationRequestedOnResume = true; + } + + _validationSuspensionCount--; + TryRunDeferredValidation(); + } + + /// + /// Runs a deferred validation request when no suppression remains active. + /// + private void TryRunDeferredValidation() + { + if (SuppressValidation || !_validationRequestedOnResume) + return; + + _validationRequestedOnResume = false; + ValidateTable(); + } + /// /// Determines whether the BeginEdit command can execute. /// @@ -450,7 +532,10 @@ private void Grid_RowsAdded(int startrow, int numrows) public void ValidateTable() { if (SuppressValidation == true) + { + _validationRequestedOnResume = true; return; + } if (ItemsSource == null) return; IList itemsList = (IList)ItemsSource; @@ -458,16 +543,21 @@ public void ValidateTable() return; // Do bulk validation PerformingBulkValidation = true; - DataGridRowItem rowitem; - for (int i = 0, loopTo = itemsList.Count - 1; i <= loopTo; i++) + try { - if (!(itemsList[i] is DataGridRowItem)) - continue; - rowitem = (DataGridRowItem)itemsList[i]; - rowitem.ForceValidation(); + DataGridRowItem rowitem; + for (int i = 0, loopTo = itemsList.Count - 1; i <= loopTo; i++) + { + if (!(itemsList[i] is DataGridRowItem)) + continue; + rowitem = (DataGridRowItem)itemsList[i]; + rowitem.ForceValidation(); + } + } + finally + { + PerformingBulkValidation = false; } - PerformingBulkValidation = false; - SuppressValidation = false; // Validate the first row. // This fires the unique rule if it is used. // Guard with the same is-check used in the loop: non-DataGridRowItem sources @@ -486,7 +576,7 @@ public void ValidateTable() /// Reference to a flag that can cancel the paste operation. private void Grid_PreviewPasteData(string[][] clipboardData, ref bool cancelPaste) { - SuppressValidation = true; + _operationValidationSuppressed = true; } /// @@ -494,8 +584,9 @@ private void Grid_PreviewPasteData(string[][] clipboardData, ref bool cancelPast /// private void Grid_DataPasted() { - SuppressValidation = false; - ValidateTable(); + _operationValidationSuppressed = false; + _validationRequestedOnResume = true; + TryRunDeferredValidation(); } /// @@ -505,7 +596,7 @@ private void Grid_DataPasted() /// Reference to a flag that can cancel the delete operation. private void Grid_PreviewDeleteRows(List rowindices, ref bool cancel) { - SuppressValidation = true; + _operationValidationSuppressed = true; } /// @@ -514,11 +605,12 @@ private void Grid_PreviewDeleteRows(List rowindices, ref bool cancel) /// The indices of the rows that were deleted. private void Grid_RowsDeleted(List rowindices) { - SuppressValidation = false; - ValidateTable(); + _operationValidationSuppressed = false; + _validationRequestedOnResume = true; + TryRunDeferredValidation(); } #endregion } -} \ No newline at end of file +} diff --git a/src/NumericControls/Data/Time Series Editor/TimeSeriesRowItem.cs b/src/NumericControls/Data/Time Series Editor/TimeSeriesRowItem.cs index 4993ba64..042bda52 100644 --- a/src/NumericControls/Data/Time Series Editor/TimeSeriesRowItem.cs +++ b/src/NumericControls/Data/Time Series Editor/TimeSeriesRowItem.cs @@ -34,7 +34,8 @@ public TimeSeriesRowItem() : base(null) { } /// The time series ordinate to be wrapped by this row item. /// The time series collection that contains the ordinate, used for clone-and-replace on edits. /// The positional index of this ordinate within the series, used for O(1) replacement. - public TimeSeriesRowItem(ObservableCollection observableCollection, SeriesOrdinate ordinate, TimeSeries series, int index) : base(observableCollection) + /// The parent validation grid used to honor bulk validation suppression. + public TimeSeriesRowItem(ObservableCollection observableCollection, SeriesOrdinate ordinate, TimeSeries series, int index, ValidationDataGrid parentDataGrid = null) : base(observableCollection, parentDataGrid) { _ordinate = ordinate; _series = series; @@ -136,6 +137,24 @@ private void ReplaceOrdinate(SeriesOrdinate newOrdinate) } } + /// + /// Determines whether this ordinate violates strict ascending date-time order. + /// + /// when the current date-time is less than or equal to its predecessor; otherwise, . + /// + /// The stored positional index makes this check O(1). Searching the parent row collection + /// for every row makes full-table validation O(n²) for large irregular series. + /// + private bool IsDateTimeOutOfOrder() + { + if (_series == null || _series.TimeInterval != TimeInterval.Irregular) + return false; + if (_index <= 0 || _index >= _series.Count) + return false; + + return _ordinate.Index <= _series[_index - 1].Index; + } + /// /// Adds validation rules for the time series row item properties. /// Validates that irregular date-time entries are in ascending data order @@ -144,16 +163,9 @@ private void ReplaceOrdinate(SeriesOrdinate newOrdinate) public override void AddValidationRules() { AddRule(nameof(DateTime), - () => _series != null - && _series.TimeInterval == TimeInterval.Irregular - && OrderRule( - row => row.DateTime.Ticks, - nameof(DateTime), - ascending: true, - canBeEqual: false), - "Date/time values must be in ascending data order. Grid sorting does not reorder the time series used by the plot.", - new[] { nameof(DateTime) }); - AddRule(nameof(Value), () => double.IsInfinity(Value), "The value must be a finite number.", new[] { nameof(Value) }); + IsDateTimeOutOfOrder, + "Date/time values must be in ascending data order. Grid sorting does not reorder the time series used by the plot."); + AddRule(nameof(Value), () => double.IsInfinity(Value), "The value must be a finite number."); } /// diff --git a/src/NumericControls/Data/Time Series Editor/TimeSeriesTable.xaml.cs b/src/NumericControls/Data/Time Series Editor/TimeSeriesTable.xaml.cs index 93286152..3eb0cf2a 100644 --- a/src/NumericControls/Data/Time Series Editor/TimeSeriesTable.xaml.cs +++ b/src/NumericControls/Data/Time Series Editor/TimeSeriesTable.xaml.cs @@ -85,15 +85,25 @@ public partial class TimeSeriesTable : UserControl /// private void RebuildRowItems() { - _rowItems.Clear(); - if (Series != null) + using (TimeSeriesDataGrid?.SuspendValidation(validateOnResume: false)) { - for (int i = 0; i < Series.Count; i++) + var rebuiltRowItems = new ObservableCollection(); + if (Series != null) { - _rowItems.Add(new TimeSeriesRowItem(_rowItems, Series[i], Series, i)); + for (int i = 0; i < Series.Count; i++) + { + rebuiltRowItems.Add(new TimeSeriesRowItem(rebuiltRowItems, Series[i], Series, i, TimeSeriesDataGrid)); + } + } + + // Populate while detached so the DataGrid receives one ItemsSource change instead + // of one CollectionChanged notification for every ordinate in a large series. + _rowItems = rebuiltRowItems; + if (TimeSeriesDataGrid != null) + { + TimeSeriesDataGrid.ItemsSource = _rowItems; } } - ValidateRows(); } /// @@ -131,9 +141,10 @@ private void Series_CollectionChanged(object sender, NotifyCollectionChangedEven { var rowItem = (TimeSeriesRowItem)_rowItems[rowIndex]; rowItem.SetOrdinate((SeriesOrdinate)e.NewItems[i]); + rowItem.ForceValidation(); } } - ValidateRows(); + ValidateFollowingDateTime(e.NewStartingIndex, e.NewItems.Count); } else if (e.Action == NotifyCollectionChangedAction.Reset) { @@ -142,6 +153,28 @@ private void Series_CollectionChanged(object sender, NotifyCollectionChangedEven } } + /// + /// Revalidates the first unchanged row following a contiguous replacement range. + /// + /// The first replaced series index. + /// The number of replaced ordinates. + /// + /// Replaced rows are validated by the replace handler. Only the following row has an + /// additional ordering relationship affected by the replacement. + /// + private void ValidateFollowingDateTime(int startingIndex, int replacedCount) + { + if (Series == null || Series.TimeInterval != TimeInterval.Irregular) + return; + + int followingIndex = startingIndex + replacedCount; + if (followingIndex >= 0 && followingIndex < _rowItems.Count + && _rowItems[followingIndex] is TimeSeriesRowItem followingRow) + { + followingRow.ValidateProperty(nameof(TimeSeriesRowItem.DateTime)); + } + } + #endregion /// @@ -177,16 +210,16 @@ private static void SetData(DependencyObject d, DependencyPropertyChangedEventAr thisControl.DateTimeColumn.CellStyle = (Style)thisControl.TryFindResource("DateTime_CellStyleDisabled"); if (e.NewValue == null) { - thisControl._rowItems.Clear(); - thisControl.RefreshGridAndValidateRows(); + thisControl.RebuildRowItems(); + if (thisControl.IsLoaded) thisControl.ValidateRows(); return; } TimeSeries newSeries = e.NewValue as TimeSeries; if (newSeries == null) { - thisControl._rowItems.Clear(); + thisControl.RebuildRowItems(); thisControl.TimeSeriesDataGrid.IsEnabled = false; - thisControl.RefreshGridAndValidateRows(); + if (thisControl.IsLoaded) thisControl.ValidateRows(); return; } @@ -200,6 +233,10 @@ private static void SetData(DependencyObject d, DependencyPropertyChangedEventAr // Build RowItems from the new series thisControl.RebuildRowItems(); + // ValidationDataGrid performs the initial pass from its Loaded handler. Series + // assignments after loading validate once here after the complete table is bound. + if (thisControl.IsLoaded) thisControl.ValidateRows(); + // Subscribe to CollectionChanged for undo replay sync newSeries.CollectionChanged += thisControl.Series_CollectionChanged; thisControl._previousSeries = newSeries; diff --git a/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.csproj b/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.csproj index 236e6ceb..cad8a745 100644 --- a/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.csproj +++ b/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.csproj @@ -3,7 +3,7 @@ net10.0-windows true false - 1.0.1 + 1.0.2 RMC.Wpf.Framework.Controls.nuspec $(MSBuildProjectDirectory) configuration=$(Configuration);version=$(Version);rmcNumericsPackageDependencyVersion=$(RmcNumericsPackageDependencyVersion) diff --git a/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.nuspec b/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.nuspec index ea05ed1c..675f7e20 100644 --- a/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.nuspec +++ b/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.nuspec @@ -10,7 +10,7 @@ README.md WPF controls, framework UI, DAG controls, database controls, numeric controls, OxyPlot controls, and AvalonDock assemblies for the RMC WPF Framework. - Version 1.0.1 improves irregular time-series ordering validation and defaults new OxyPlot line annotations to editable text. + Version 1.0.2 improves time-series table validation performance, preserves ordering checks after edits, and avoids repeated full-table scans while rebuilding large series. RMC WPF framework controls AvalonDock OxyPlot numeric database DAG diff --git a/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.csproj b/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.csproj index 8f231e79..1f96a19f 100644 --- a/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.csproj +++ b/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.csproj @@ -3,7 +3,7 @@ net10.0-windows true false - 1.0.1 + 1.0.2 RMC.Wpf.Framework.Core.nuspec $(MSBuildProjectDirectory) configuration=$(Configuration);version=$(Version) diff --git a/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.nuspec b/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.nuspec index 701a6940..c3b56d3a 100644 --- a/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.nuspec +++ b/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.nuspec @@ -10,7 +10,7 @@ README.md Core interfaces, project model contracts, undo/redo infrastructure, messaging, and WPF themes for the RMC WPF Framework. - Coordinated WPF Framework 1.0.1 release; no package-specific functional changes. + Coordinated WPF Framework 1.0.2 release; no package-specific functional changes. RMC WPF framework themes undo-redo diff --git a/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.csproj b/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.csproj index f2fd1102..a4038bc9 100644 --- a/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.csproj +++ b/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.csproj @@ -3,7 +3,7 @@ net10.0-windows true false - 1.0.1 + 1.0.2 RMC.Wpf.Framework.Models.nuspec $(MSBuildProjectDirectory) configuration=$(Configuration);version=$(Version);closedXmlVersion=$(ClosedXmlVersion);documentFormatOpenXmlVersion=$(DocumentFormatOpenXmlVersion);excelNumberFormatVersion=$(ExcelNumberFormatVersion);fastMemberVersion=$(FastMemberVersion);sourceGearSqlite3Version=$(SourceGearSqlite3Version);systemDataSQLiteVersion=$(SystemDataSQLiteVersion) diff --git a/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.nuspec b/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.nuspec index d610aa44..5ff8f9d3 100644 --- a/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.nuspec +++ b/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.nuspec @@ -10,7 +10,7 @@ README.md Model, graph, database, expression-parser, and vendored OxyPlot assemblies for the RMC WPF Framework. - Coordinated WPF Framework 1.0.1 release; no package-specific functional changes. + Coordinated WPF Framework 1.0.2 release; no package-specific functional changes. RMC WPF framework database expression-parser DAG OxyPlot diff --git a/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.csproj b/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.csproj index 240a3cea..4bd69ee1 100644 --- a/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.csproj +++ b/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.csproj @@ -3,7 +3,7 @@ net10.0-windows true false - 1.0.1 + 1.0.2 $(NoWarn);NU5100 RMC.Wpf.Framework.Support.nuspec diff --git a/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.nuspec b/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.nuspec index 1e6b0962..101bde80 100644 --- a/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.nuspec +++ b/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.nuspec @@ -10,7 +10,7 @@ README.md Software update services and deployable updater helper for the RMC WPF Framework. - Version 1.0.1 protects settings and runtime data during updates, supports transactional rollback, checksum enforcement, and safe replacement of the installed updater payload. + Version 1.0.2 carries forward protected, transactional self-updates and ships a version-aligned updater payload. RMC WPF framework software-update updater diff --git a/src/SoftwareUpdate.Updater/SoftwareUpdate.Updater.csproj b/src/SoftwareUpdate.Updater/SoftwareUpdate.Updater.csproj index 87d6fc91..656d6e65 100644 --- a/src/SoftwareUpdate.Updater/SoftwareUpdate.Updater.csproj +++ b/src/SoftwareUpdate.Updater/SoftwareUpdate.Updater.csproj @@ -8,9 +8,9 @@ enable enable latest - 1.0.1 - 1.0.1.0 - 1.0.1.0 + 1.0.2 + 1.0.2.0 + 1.0.2.0 diff --git a/tests/NumericControls.Tests/RowItems/TimeSeriesRowItemTests.cs b/tests/NumericControls.Tests/RowItems/TimeSeriesRowItemTests.cs index a6d0688e..2c13fc40 100644 --- a/tests/NumericControls.Tests/RowItems/TimeSeriesRowItemTests.cs +++ b/tests/NumericControls.Tests/RowItems/TimeSeriesRowItemTests.cs @@ -103,5 +103,22 @@ public void DateTimeRule_RegularSeriesOutOfOrder_NoValidationError() Assert.False(Row(rows, 1).RuleMap[nameof(TimeSeriesRowItem.DateTime)].HasError); } + + /// + /// Verifies ordering validation uses the stored series index without searching a parent row collection. + /// + [Fact] + public void DateTimeRule_WithoutParentRowCollection_UsesSeriesIndex() + { + var start = new DateTime(2020, 1, 1); + var series = new TimeSeries(TimeInterval.Irregular); + series.Add(new SeriesOrdinate(start.AddDays(1), 1d)); + series.Add(new SeriesOrdinate(start, 2d)); + var row = new TimeSeriesRowItem(null, series[1], series, 1); + + row.ForceValidation(); + + Assert.True(row.RuleMap[nameof(TimeSeriesRowItem.DateTime)].HasError); + } } } diff --git a/tests/NumericControls.Tests/TimeSeriesTableTests.cs b/tests/NumericControls.Tests/TimeSeriesTableTests.cs index 6bc74ed4..fbcc4e44 100644 --- a/tests/NumericControls.Tests/TimeSeriesTableTests.cs +++ b/tests/NumericControls.Tests/TimeSeriesTableTests.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Reflection; using System.Windows; +using System.Windows.Threading; using GenericControls; using Numerics.Data; using NumericControls; @@ -18,12 +19,15 @@ public class TimeSeriesTableTests /// private static bool _resourcesLoaded; + private static readonly object _dispatcherGate = new(); + private static Dispatcher? _dispatcher; + /// /// Ensures a WPF application exists for control construction. /// private static void EnsureApplication() { - var application = Application.Current ?? new Application(); + var application = Application.Current ?? throw new InvalidOperationException("The test dispatcher must own the WPF application."); if (_resourcesLoaded) return; @@ -34,6 +38,42 @@ private static void EnsureApplication() _resourcesLoaded = true; } + /// + /// Runs a test body on the single STA dispatcher that owns the process-wide WPF application. + /// + /// The test body to run. + private static void RunOnDispatcher(Action body) + { + Dispatcher dispatcher; + lock (_dispatcherGate) + { + if (_dispatcher == null) + { + using var ready = new ManualResetEventSlim(false); + Dispatcher? capturedDispatcher = null; + var thread = new Thread(() => + { + _ = new Application(); + capturedDispatcher = Dispatcher.CurrentDispatcher; + ready.Set(); + Dispatcher.Run(); + }) + { + IsBackground = true, + Name = "TimeSeriesTableTestsDispatcher" + }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + ready.Wait(); + _dispatcher = capturedDispatcher; + } + + dispatcher = _dispatcher!; + } + + dispatcher.Invoke(body); + } + /// /// Creates a sample irregular time series. /// @@ -51,32 +91,143 @@ private static TimeSeries CreateSampleSeries() /// /// Verifies that preview delete maps a visually sorted row index to the correct backing row. /// - [StaFact] + [Fact] public void PreviewDeleteRows_AfterValueSort_RemovesVisualRowFromBackingSeries() { - EnsureApplication(); - var series = CreateSampleSeries(); - var table = new TimeSeriesTable { Series = series }; - var grid = Assert.IsType(table.FindName("TimeSeriesDataGrid")); + RunOnDispatcher(() => + { + EnsureApplication(); + var series = CreateSampleSeries(); + var table = new TimeSeriesTable { Series = series }; + var grid = Assert.IsType(table.FindName("TimeSeriesDataGrid")); + + grid.Items.SortDescriptions.Clear(); + grid.Items.SortDescriptions.Add(new SortDescription(nameof(TimeSeriesRowItem.Value), ListSortDirection.Descending)); + grid.Items.Refresh(); + + Assert.Equal(30d, ((TimeSeriesRowItem)grid.Items[0]).Value); + + var deleteMethod = typeof(TimeSeriesTable).GetMethod( + "TimeSeriesDataGrid_PreviewDeleteRows", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(deleteMethod); + + object?[] arguments = { new List { 0 }, false }; + deleteMethod.Invoke(table, arguments); + + var values = Enumerable.Range(0, series.Count).Select(i => series[i].Value).ToList(); + Assert.True((bool)arguments[1]!); + Assert.Equal(2, series.Count); + Assert.DoesNotContain(30d, values); + }); + } + + /// + /// Verifies that initial row validation is deferred until the data grid has loaded. + /// + [Fact] + public void SeriesAssignment_BeforeLoad_DefersValidationUntilGridLoaded() + { + RunOnDispatcher(() => + { + EnsureApplication(); + var start = new DateTime(2020, 1, 1); + var series = new TimeSeries(TimeInterval.Irregular); + series.Add(new SeriesOrdinate(start.AddDays(1), 1d)); + series.Add(new SeriesOrdinate(start, 2d)); + var table = new TimeSeriesTable { Series = series }; + var grid = Assert.IsType(table.FindName("TimeSeriesDataGrid")); + var secondRow = Assert.IsType(grid.Items[1]); + + Assert.False(secondRow.RuleMap[nameof(TimeSeriesRowItem.DateTime)].HasError); + + grid.RaiseEvent(new RoutedEventArgs(FrameworkElement.LoadedEvent)); + + Assert.True(secondRow.RuleMap[nameof(TimeSeriesRowItem.DateTime)].HasError); + }); + } + + /// + /// Verifies that changing a date-time revalidates the following row's ordering relationship. + /// + [Fact] + public void DateTimeEdit_RevalidatesFollowingRow() + { + RunOnDispatcher(() => + { + EnsureApplication(); + var series = CreateSampleSeries(); + var table = new TimeSeriesTable { Series = series }; + var grid = Assert.IsType(table.FindName("TimeSeriesDataGrid")); + grid.RaiseEvent(new RoutedEventArgs(FrameworkElement.LoadedEvent)); + var firstRow = Assert.IsType(grid.Items[0]); + var secondRow = Assert.IsType(grid.Items[1]); + + firstRow.DateTime = series[1].Index.AddDays(1); + + Assert.True(secondRow.RuleMap[nameof(TimeSeriesRowItem.DateTime)].HasError); + }); + } + + /// + /// Verifies direct series replacements revalidate replaced rows and the following row. + /// + [Fact] + public void SeriesReplace_RevalidatesReplacedRowsAndFollowingRow() + { + RunOnDispatcher(() => + { + EnsureApplication(); + var series = CreateSampleSeries(); + var table = new TimeSeriesTable { Series = series }; + var grid = Assert.IsType(table.FindName("TimeSeriesDataGrid")); + grid.RaiseEvent(new RoutedEventArgs(FrameworkElement.LoadedEvent)); + var firstRow = Assert.IsType(grid.Items[0]); + var secondRow = Assert.IsType(grid.Items[1]); - grid.Items.SortDescriptions.Clear(); - grid.Items.SortDescriptions.Add(new SortDescription(nameof(TimeSeriesRowItem.Value), ListSortDirection.Descending)); - grid.Items.Refresh(); + Assert.False(firstRow.RuleMap[nameof(TimeSeriesRowItem.DateTime)].HasError); + Assert.False(secondRow.RuleMap[nameof(TimeSeriesRowItem.DateTime)].HasError); - Assert.Equal(30d, ((TimeSeriesRowItem)grid.Items[0]).Value); + series[1] = new SeriesOrdinate(series[0].Index.AddDays(-1), series[1].Value); - var deleteMethod = typeof(TimeSeriesTable).GetMethod( - "TimeSeriesDataGrid_PreviewDeleteRows", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(deleteMethod); + Assert.True(secondRow.RuleMap[nameof(TimeSeriesRowItem.DateTime)].HasError); - object?[] arguments = { new List { 0 }, false }; - deleteMethod.Invoke(table, arguments); + series[1] = new SeriesOrdinate(series[0].Index.AddDays(1), series[1].Value); - var values = Enumerable.Range(0, series.Count).Select(i => series[i].Value).ToList(); - Assert.True((bool)arguments[1]!); - Assert.Equal(2, series.Count); - Assert.DoesNotContain(30d, values); + Assert.False(secondRow.RuleMap[nameof(TimeSeriesRowItem.DateTime)].HasError); + + series[0] = new SeriesOrdinate(series[1].Index.AddDays(1), series[0].Value); + + Assert.False(firstRow.RuleMap[nameof(TimeSeriesRowItem.DateTime)].HasError); + Assert.True(secondRow.RuleMap[nameof(TimeSeriesRowItem.DateTime)].HasError); + }); + } + + /// + /// Verifies validation suspension scopes nest and restore the grid state safely. + /// + [Fact] + public void SuspendValidation_NestedScopes_RestoreValidationState() + { + RunOnDispatcher(() => + { + EnsureApplication(); + var table = new TimeSeriesTable(); + var grid = Assert.IsType(table.FindName("TimeSeriesDataGrid")); + + using (grid.SuspendValidation(validateOnResume: false)) + { + Assert.True(grid.SuppressValidation); + using (grid.SuspendValidation(validateOnResume: false)) + { + Assert.True(grid.SuppressValidation); + } + + Assert.True(grid.SuppressValidation); + } + + Assert.False(grid.SuppressValidation); + }); } } }