From c3684f515014cf8674ee4be458fe7e2542121019 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Jan 2026 19:50:20 -0500 Subject: [PATCH] feat(configmanager): add PipelineFormViewModel and ScheduleFormViewModel Add form ViewModels for editing pipeline configurations in the ConfigManager. ScheduleFormViewModel wraps ScheduleModel for schedule editing. PipelineFormViewModel wraps PipelineModel with schedule sub-ViewModels. --- .../ViewModels/Forms/PipelineFormViewModel.cs | 172 +++++++++++ .../ViewModels/Forms/ScheduleFormViewModel.cs | 86 ++++++ .../Forms/PipelineFormViewModelTests.cs | 287 ++++++++++++++++++ 3 files changed, 545 insertions(+) create mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/PipelineFormViewModel.cs create mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ScheduleFormViewModel.cs create mode 100644 NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/PipelineFormViewModelTests.cs diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/PipelineFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/PipelineFormViewModel.cs new file mode 100644 index 0000000..824e980 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/PipelineFormViewModel.cs @@ -0,0 +1,172 @@ +using JdeScoping.ConfigManager.Models; + +namespace JdeScoping.ConfigManager.ViewModels.Forms; + +/// +/// ViewModel for editing a pipeline configuration. +/// +public class PipelineFormViewModel : ViewModelBase +{ + private readonly PipelineModel _model; + private readonly Action _onChanged; + + public PipelineFormViewModel(string name, PipelineModel model, Action onChanged) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + _model = model ?? throw new ArgumentNullException(nameof(model)); + _onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged)); + + // Initialize schedule view models + _model.Schedules.Mass ??= new ScheduleModel(); + _model.Schedules.Daily ??= new ScheduleModel(); + _model.Schedules.Hourly ??= new ScheduleModel(); + + MassSchedule = new ScheduleFormViewModel(_model.Schedules.Mass, _onChanged); + DailySchedule = new ScheduleFormViewModel(_model.Schedules.Daily, _onChanged); + HourlySchedule = new ScheduleFormViewModel(_model.Schedules.Hourly, _onChanged); + } + + /// + /// Gets the pipeline name. + /// + public string Name { get; } + + /// + /// Gets or sets the source connection name. + /// + public string Connection + { + get => _model.Source.Connection; + set + { + if (_model.Source.Connection != value) + { + _model.Source.Connection = value; + OnPropertyChanged(); + _onChanged(); + } + } + } + + /// + /// Gets or sets the source query. + /// + public string Query + { + get => _model.Source.Query; + set + { + if (_model.Source.Query != value) + { + _model.Source.Query = value; + OnPropertyChanged(); + _onChanged(); + } + } + } + + /// + /// Gets or sets the optional mass query. + /// + public string? MassQuery + { + get => _model.Source.MassQuery; + set + { + if (_model.Source.MassQuery != value) + { + _model.Source.MassQuery = value; + OnPropertyChanged(); + _onChanged(); + } + } + } + + /// + /// Gets or sets the destination table name. + /// + public string DestinationTable + { + get => _model.Destination.Table; + set + { + if (_model.Destination.Table != value) + { + _model.Destination.Table = value; + OnPropertyChanged(); + _onChanged(); + } + } + } + + /// + /// Gets or sets the match columns as newline-separated text. + /// + public string MatchColumnsText + { + get => string.Join("\n", _model.Destination.MatchColumns); + set + { + var columns = value.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (!_model.Destination.MatchColumns.SequenceEqual(columns)) + { + _model.Destination.MatchColumns = columns; + OnPropertyChanged(); + _onChanged(); + } + } + } + + /// + /// Gets or sets the exclude from update columns as newline-separated text. + /// + public string ExcludeFromUpdateText + { + get => string.Join("\n", _model.Destination.ExcludeFromUpdate); + set + { + var columns = value.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (!_model.Destination.ExcludeFromUpdate.SequenceEqual(columns)) + { + _model.Destination.ExcludeFromUpdate = columns; + OnPropertyChanged(); + _onChanged(); + } + } + } + + /// + /// Gets or sets the post scripts as newline-separated text. + /// + public string PostScriptsText + { + get => _model.PostScripts != null ? string.Join("\n", _model.PostScripts) : string.Empty; + set + { + var scripts = string.IsNullOrWhiteSpace(value) + ? null + : value.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (_model.PostScripts?.SequenceEqual(scripts ?? []) != true) + { + _model.PostScripts = scripts; + OnPropertyChanged(); + _onChanged(); + } + } + } + + /// + /// Gets the mass schedule view model. + /// + public ScheduleFormViewModel MassSchedule { get; } + + /// + /// Gets the daily schedule view model. + /// + public ScheduleFormViewModel DailySchedule { get; } + + /// + /// Gets the hourly schedule view model. + /// + public ScheduleFormViewModel HourlySchedule { get; } +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ScheduleFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ScheduleFormViewModel.cs new file mode 100644 index 0000000..a88e8d1 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ScheduleFormViewModel.cs @@ -0,0 +1,86 @@ +using JdeScoping.ConfigManager.Models; + +namespace JdeScoping.ConfigManager.ViewModels.Forms; + +/// +/// ViewModel for editing a schedule configuration. +/// +public class ScheduleFormViewModel : ViewModelBase +{ + private readonly ScheduleModel _model; + private readonly Action _onChanged; + + public ScheduleFormViewModel(ScheduleModel model, Action onChanged) + { + _model = model ?? throw new ArgumentNullException(nameof(model)); + _onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged)); + } + + /// + /// Gets or sets whether this schedule is enabled. + /// + public bool Enabled + { + get => _model.Enabled; + set + { + if (_model.Enabled != value) + { + _model.Enabled = value; + OnPropertyChanged(); + _onChanged(); + } + } + } + + /// + /// Gets or sets the interval in minutes. + /// + public int IntervalMinutes + { + get => _model.IntervalMinutes; + set + { + if (_model.IntervalMinutes != value) + { + _model.IntervalMinutes = value; + OnPropertyChanged(); + _onChanged(); + } + } + } + + /// + /// Gets or sets whether to purge before sync. + /// + public bool PrePurge + { + get => _model.PrePurge; + set + { + if (_model.PrePurge != value) + { + _model.PrePurge = value; + OnPropertyChanged(); + _onChanged(); + } + } + } + + /// + /// Gets or sets whether to reindex after sync. + /// + public bool ReIndex + { + get => _model.ReIndex; + set + { + if (_model.ReIndex != value) + { + _model.ReIndex = value; + OnPropertyChanged(); + _onChanged(); + } + } + } +} diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/PipelineFormViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/PipelineFormViewModelTests.cs new file mode 100644 index 0000000..285fba9 --- /dev/null +++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/PipelineFormViewModelTests.cs @@ -0,0 +1,287 @@ +using JdeScoping.ConfigManager.Models; +using JdeScoping.ConfigManager.ViewModels.Forms; + +namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms; + +public class PipelineFormViewModelTests +{ + [Fact] + public void Constructor_InitializesFromModel() + { + // Arrange + var model = new PipelineModel + { + Source = new PipelineSource + { + Connection = "jde", + Query = "SELECT * FROM Test" + }, + Destination = new PipelineDestination + { + Table = "TestTable", + MatchColumns = ["Id", "Name"] + } + }; + + // Act + var sut = new PipelineFormViewModel("TestPipeline", model, () => { }); + + // Assert + sut.Name.ShouldBe("TestPipeline"); + sut.Connection.ShouldBe("jde"); + sut.Query.ShouldBe("SELECT * FROM Test"); + sut.DestinationTable.ShouldBe("TestTable"); + sut.MatchColumnsText.ShouldBe("Id\nName"); + } + + [Fact] + public void PropertyChange_UpdatesModelAndInvokesOnChanged() + { + // Arrange + var model = new PipelineModel(); + var changedInvoked = false; + var sut = new PipelineFormViewModel("Test", model, () => changedInvoked = true); + + // Act + sut.Connection = "cms"; + + // Assert + model.Source.Connection.ShouldBe("cms"); + changedInvoked.ShouldBeTrue(); + } + + [Fact] + public void MatchColumnsText_SplitsIntoArray() + { + // Arrange + var model = new PipelineModel(); + var sut = new PipelineFormViewModel("Test", model, () => { }); + + // Act + sut.MatchColumnsText = "Col1\nCol2\nCol3"; + + // Assert + model.Destination.MatchColumns.Length.ShouldBe(3); + model.Destination.MatchColumns[0].ShouldBe("Col1"); + } + + [Fact] + public void Schedules_AreInitialized() + { + // Arrange + var model = new PipelineModel + { + Schedules = new PipelineSchedules + { + Mass = new ScheduleModel { Enabled = true, IntervalMinutes = 10080 } + } + }; + + // Act + var sut = new PipelineFormViewModel("Test", model, () => { }); + + // Assert + sut.MassSchedule.ShouldNotBeNull(); + sut.MassSchedule.Enabled.ShouldBeTrue(); + sut.MassSchedule.IntervalMinutes.ShouldBe(10080); + } + + [Fact] + public void NullSchedules_AreInitializedToDefault() + { + // Arrange + var model = new PipelineModel + { + Schedules = new PipelineSchedules + { + Mass = null, + Daily = null, + Hourly = null + } + }; + + // Act + var sut = new PipelineFormViewModel("Test", model, () => { }); + + // Assert + sut.MassSchedule.ShouldNotBeNull(); + sut.DailySchedule.ShouldNotBeNull(); + sut.HourlySchedule.ShouldNotBeNull(); + } + + [Fact] + public void ExcludeFromUpdateText_JoinsAndSplits() + { + // Arrange + var model = new PipelineModel + { + Destination = new PipelineDestination + { + ExcludeFromUpdate = ["CreatedDate", "ModifiedDate"] + } + }; + var sut = new PipelineFormViewModel("Test", model, () => { }); + + // Assert - verify getter joins correctly + sut.ExcludeFromUpdateText.ShouldBe("CreatedDate\nModifiedDate"); + + // Act - verify setter splits correctly + sut.ExcludeFromUpdateText = "Col1\nCol2"; + model.Destination.ExcludeFromUpdate.Length.ShouldBe(2); + model.Destination.ExcludeFromUpdate[0].ShouldBe("Col1"); + } + + [Fact] + public void PostScriptsText_HandlesNullable() + { + // Arrange + var model = new PipelineModel { PostScripts = null }; + var sut = new PipelineFormViewModel("Test", model, () => { }); + + // Assert - null should return empty string + sut.PostScriptsText.ShouldBe(string.Empty); + + // Act - set some scripts + sut.PostScriptsText = "script1.sql\nscript2.sql"; + model.PostScripts.ShouldNotBeNull(); + model.PostScripts!.Length.ShouldBe(2); + + // Act - clear scripts by setting empty + sut.PostScriptsText = ""; + model.PostScripts.ShouldBeNull(); + } + + [Fact] + public void MassQuery_Property_ReadsAndWrites() + { + // Arrange + var model = new PipelineModel + { + Source = new PipelineSource + { + MassQuery = "SELECT * FROM Test WHERE All = 1" + } + }; + var changedInvoked = false; + var sut = new PipelineFormViewModel("Test", model, () => changedInvoked = true); + + // Assert - verify getter + sut.MassQuery.ShouldBe("SELECT * FROM Test WHERE All = 1"); + + // Act - verify setter + sut.MassQuery = "SELECT * FROM NewTable"; + model.Source.MassQuery.ShouldBe("SELECT * FROM NewTable"); + changedInvoked.ShouldBeTrue(); + } + + [Fact] + public void PropertyChange_RaisesPropertyChanged() + { + // Arrange + var model = new PipelineModel(); + var sut = new PipelineFormViewModel("Test", model, () => { }); + var propertyChangedRaised = false; + sut.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(PipelineFormViewModel.Query)) + propertyChangedRaised = true; + }; + + // Act + sut.Query = "SELECT * FROM NewQuery"; + + // Assert + propertyChangedRaised.ShouldBeTrue(); + } + + [Fact] + public void ScheduleChange_InvokesOnChanged() + { + // Arrange + var model = new PipelineModel(); + var changedInvoked = false; + var sut = new PipelineFormViewModel("Test", model, () => changedInvoked = true); + + // Act - change schedule property (Enabled defaults to true, so set to false) + sut.MassSchedule.Enabled = false; + + // Assert + changedInvoked.ShouldBeTrue(); + } +} + +public class ScheduleFormViewModelTests +{ + [Fact] + public void Constructor_InitializesFromModel() + { + // Arrange + var model = new ScheduleModel + { + Enabled = true, + IntervalMinutes = 1440, + PrePurge = true, + ReIndex = false + }; + + // Act + var sut = new ScheduleFormViewModel(model, () => { }); + + // Assert + sut.Enabled.ShouldBeTrue(); + sut.IntervalMinutes.ShouldBe(1440); + sut.PrePurge.ShouldBeTrue(); + sut.ReIndex.ShouldBeFalse(); + } + + [Fact] + public void PropertyChange_UpdatesModelAndInvokesOnChanged() + { + // Arrange + var model = new ScheduleModel(); + var changedInvoked = false; + var sut = new ScheduleFormViewModel(model, () => changedInvoked = true); + + // Act + sut.IntervalMinutes = 120; + + // Assert + model.IntervalMinutes.ShouldBe(120); + changedInvoked.ShouldBeTrue(); + } + + [Fact] + public void PropertyChange_RaisesPropertyChanged() + { + // Arrange + var model = new ScheduleModel(); + var sut = new ScheduleFormViewModel(model, () => { }); + var propertyChangedRaised = false; + sut.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(ScheduleFormViewModel.PrePurge)) + propertyChangedRaised = true; + }; + + // Act + sut.PrePurge = true; + + // Assert + propertyChangedRaised.ShouldBeTrue(); + } + + [Fact] + public void SameValue_DoesNotInvokeOnChanged() + { + // Arrange + var model = new ScheduleModel { Enabled = true }; + var changedInvoked = false; + var sut = new ScheduleFormViewModel(model, () => changedInvoked = true); + + // Act - set same value + sut.Enabled = true; + + // Assert + changedInvoked.ShouldBeFalse(); + } +}