diff --git a/CSharpBible/Graphics/.github/copilot-instructions.md b/Avalonia_Apps/.github/copilot-instructions.md similarity index 69% rename from CSharpBible/Graphics/.github/copilot-instructions.md rename to Avalonia_Apps/.github/copilot-instructions.md index e4495db73..77988b00d 100644 --- a/CSharpBible/Graphics/.github/copilot-instructions.md +++ b/Avalonia_Apps/.github/copilot-instructions.md @@ -5,7 +5,11 @@ Apply these defaults when working in this repository unless the user explicitly ## General Guidelines - Document code thoroughly in English. - Validate changes with relevant builds and tests before finishing. -- If requirements are unclear, ask clarifying questions before starting implementation. +- If requirements are unclear, ask clarifying questions before starting implementation or planning refinement. +- Avoid UI text strings in core services. Use Enumerations instead, and keep UI-facing strings in the ViewModel/UI layer. +- Prefer one class/interface/struct per file. +- document changes in an DevOps-manner markdown prefered, extrapolate bugs, tasks, baglogs and features +- Use `DevOps` as the planning directory in this workspace, and treat `.Info.md` as the general planning description file. Team terminology around Azure DevOps backlog items may differ from generic 'story' naming. ## Testing - Use `MSTest` in the latest practical version for new or updated tests. @@ -17,7 +21,9 @@ Apply these defaults when working in this repository unless the user explicitly ## Architecture - Use MVVM architecture for UI components to separate concerns and improve testability, using CommunityToolkit.Mvvm for MVVM implementation. +- Prefer `NotifyPropertyChangedFor` over manual `OnPropertyChanged(nameof(...))` in CommunityToolkit.Mvvm observable properties where applicable. - Use Dependency Injection to manage dependencies and improve testability, using Microsoft.Extensions.DependencyInjection. +- UI-facing strings and summary formatting should stay in the ViewModel/UI layer, not in extracted application logic services. ## Naming Conventions - Distinguish between UI control naming and variable/field naming. @@ -33,7 +39,7 @@ Apply these defaults when working in this repository unless the user explicitly - Prefer meaningful domain names over type prefixes when the intent is already clear. - In UI code, use short 3-character prefixes for actual controls in views and code-behind, e.g. - `lst` for list controls - - `btn` for buttons + - `btn` for all kind of buttons, - `edt` for any keyboard input control - `lbl` for any text output control - Do not use UI control prefixes for ViewModel properties or other non-UI members. diff --git a/Avalonia_Apps/.github/upgrades/dotnet-upgrade-plan.md b/Avalonia_Apps/.github/upgrades/dotnet-upgrade-plan.md new file mode 100644 index 000000000..c4a8a1fd8 --- /dev/null +++ b/Avalonia_Apps/.github/upgrades/dotnet-upgrade-plan.md @@ -0,0 +1,33 @@ +# .NET 8.0 Upgrade Plan + +## Execution Steps + +Execute steps below sequentially one by one in the order they are listed. + +1. Validate that an .NET 8.0 SDK required for this upgrade is installed on the machine and if not, help to get it installed. +2. Ensure that the SDK version specified in global.json files is compatible with the .NET 8.0 upgrade. +3. Upgrade UWP_00_Test\UWP_00_Test.csproj + +## Settings + +This section contains settings and data used by execution steps. + +### Excluded projects + +Table below contains projects that do belong to the dependency graph for selected projects and should not be included in the upgrade. + +| Project name | Description | +|:-------------|:-----------:| + +### Project upgrade details +This section contains details about each project upgrade and modifications that need to be done in the project. + +#### UWP_00_Test\UWP_00_Test.csproj modifications + +Project properties changes: + - Include shared props: `..\MVVM_Tutorial.props` + - Enable nullable reference types: `nullable` set to `enable` + - Migrate project to SDK style targeting .NET 8 (Windows): adopt `Microsoft.NET.Sdk` with appropriate target framework if required by the app model + +Other changes: + - Review and adjust Windows application model dependencies as needed for .NET 8 (e.g., Windows App SDK / WinUI migration if applicable) diff --git a/Avalonia_Apps/AA05_CommandParCalc/AA05_CommandParCalc/App.axaml.cs b/Avalonia_Apps/AA05_CommandParCalc/AA05_CommandParCalc/App.axaml.cs index fbae35880..9933e996c 100644 --- a/Avalonia_Apps/AA05_CommandParCalc/AA05_CommandParCalc/App.axaml.cs +++ b/Avalonia_Apps/AA05_CommandParCalc/AA05_CommandParCalc/App.axaml.cs @@ -1,9 +1,6 @@ using System; -using System.Linq; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Data.Core; -using Avalonia.Data.Core.Plugins; using Avalonia.Markup.Xaml; using Avalonia.Platform; using AA05_CommandParCalc.Models; @@ -44,27 +41,11 @@ protected void InitDesktopApp(IClassicDesktopStyleApplicationLifetime desktop) Services = services.BuildServiceProvider(); - // Avoid duplicate validations from both Avalonia and the CommunityToolkit. - // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins - DisableAvaloniaDataAnnotationValidation(); desktop.MainWindow = new MainWindow { DataContext = Services.GetRequiredService() }; } - private void DisableAvaloniaDataAnnotationValidation() - { - // Get an array of plugins to remove - var dataValidationPluginsToRemove = - BindingPlugins.DataValidators.OfType().ToArray(); - - // remove each entry found - foreach (var plugin in dataValidationPluginsToRemove) - { - BindingPlugins.DataValidators.Remove(plugin); - } - } - public IServiceProvider? Services { get; private set; } } diff --git a/Avalonia_Apps/AA06_ValueConverter2/AA06_Converters4Tests/ValueConverter/Bool2VisibilityConverterTests.cs b/Avalonia_Apps/AA06_ValueConverter2/AA06_Converters4Tests/ValueConverter/Bool2VisibilityConverterTests.cs index 48bd6f42f..7df82247d 100644 --- a/Avalonia_Apps/AA06_ValueConverter2/AA06_Converters4Tests/ValueConverter/Bool2VisibilityConverterTests.cs +++ b/Avalonia_Apps/AA06_ValueConverter2/AA06_Converters4Tests/ValueConverter/Bool2VisibilityConverterTests.cs @@ -40,7 +40,7 @@ public class Bool2VisibilityConverterTests /// The value. /// The expected. /// - [DataTestMethod] + [TestMethod] [DataRow(10.5, true)] [DataRow(0.99, true)] [DataRow(true, true)] @@ -58,7 +58,7 @@ public void ConvertTest(object? value, bool expected) /// Defines the test method ConvertBackTest. /// /// - [DataTestMethod()] + [TestMethod()] [DataRow(true, true)] [DataRow(false, false)] [DataRow(false, null)] diff --git a/Avalonia_Apps/AA06_ValueConverter2/AA06_Converters4Tests/View/Converter/WindowPortToGridLinesTests.cs b/Avalonia_Apps/AA06_ValueConverter2/AA06_Converters4Tests/View/Converter/WindowPortToGridLinesTests.cs index 48ebbc218..91100eddc 100644 --- a/Avalonia_Apps/AA06_ValueConverter2/AA06_Converters4Tests/View/Converter/WindowPortToGridLinesTests.cs +++ b/Avalonia_Apps/AA06_ValueConverter2/AA06_Converters4Tests/View/Converter/WindowPortToGridLinesTests.cs @@ -8,7 +8,9 @@ namespace AA06_Converters_4.View.Converter.Tests; [TestClass()] public class WindowPortToGridLinesTests { +#pragma warning disable CS8618 // Ein Non-Nullable-Feld muss beim Beenden des Konstruktors einen Wert ungleich NULL enthalten. Fügen Sie ggf. den „erforderlichen“ Modifizierer hinzu, oder deklarieren Sie den Modifizierer als NULL-Werte zulassend. WindowPortToGridLines testVC; +#pragma warning restore CS8618 // Ein Non-Nullable-Feld muss beim Beenden des Konstruktors einen Wert ungleich NULL enthalten. Fügen Sie ggf. den „erforderlichen“ Modifizierer hinzu, oder deklarieren Sie den Modifizierer als NULL-Werte zulassend. ViewModels.SWindowPort wp; public static IEnumerable ConvertTestData @@ -34,7 +36,7 @@ public void WindowPortToGridLinesTest() Assert.Fail(); } - [DataTestMethod()] + [TestMethod()] [DynamicData(nameof(ConvertTestData))] public void ConvertTest(object o) { diff --git a/Avalonia_Apps/AA06_ValueConverter2/AA06_ValueConverter2/App.axaml.cs b/Avalonia_Apps/AA06_ValueConverter2/AA06_ValueConverter2/App.axaml.cs index 37d1854b6..3038b3ad9 100644 --- a/Avalonia_Apps/AA06_ValueConverter2/AA06_ValueConverter2/App.axaml.cs +++ b/Avalonia_Apps/AA06_ValueConverter2/AA06_ValueConverter2/App.axaml.cs @@ -1,9 +1,6 @@ using System; -using System.Linq; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Data.Core; -using Avalonia.Data.Core.Plugins; using Avalonia.Markup.Xaml; using Avalonia.Platform; using AA06_ValueConverter2.Models; @@ -46,25 +43,11 @@ protected void InitDesktopApp(IClassicDesktopStyleApplicationLifetime desktop) // Avoid duplicate validations from both Avalonia and the CommunityToolkit. // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins - DisableAvaloniaDataAnnotationValidation(); desktop.MainWindow = new MainWindow { DataContext = Services.GetRequiredService() }; } - private void DisableAvaloniaDataAnnotationValidation() - { - // Get an array of plugins to remove - var dataValidationPluginsToRemove = - BindingPlugins.DataValidators.OfType().ToArray(); - - // remove each entry found - foreach (var plugin in dataValidationPluginsToRemove) - { - BindingPlugins.DataValidators.Remove(plugin); - } - } - public IServiceProvider? Services { get; private set; } } diff --git a/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/AA09_DialogBoxes.csproj b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/AA09_DialogBoxes.csproj index 5b75d64e7..b4bf5c09b 100644 --- a/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/AA09_DialogBoxes.csproj +++ b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/AA09_DialogBoxes.csproj @@ -34,7 +34,6 @@ All - @@ -57,6 +56,9 @@ DialogWindow.axaml + + OverlayDialogControl.axaml + App.axaml diff --git a/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Messages/MessageBoxRequestMessage.cs b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Messages/MessageBoxRequestMessage.cs index fe86a4a9f..9b945546d 100644 --- a/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Messages/MessageBoxRequestMessage.cs +++ b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Messages/MessageBoxRequestMessage.cs @@ -1,14 +1,14 @@ -using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging.Messages; -using AA09_DialogBoxes.ViewModels; -using MsBox.Avalonia.Enums; namespace AA09_DialogBoxes.Messages; -public sealed class MessageBoxRequestMessage : AsyncRequestMessage +public enum MsgBoxResult { None, Yes, No } + +public sealed class MessageBoxRequestMessage : AsyncRequestMessage { public string Title { get; } public string Content { get; } + public MessageBoxRequestMessage(string title, string content) { Title = title; diff --git a/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Messages/OverlayMessageRequestMessage.cs b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Messages/OverlayMessageRequestMessage.cs new file mode 100644 index 000000000..fcb313af4 --- /dev/null +++ b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Messages/OverlayMessageRequestMessage.cs @@ -0,0 +1,15 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; + +namespace AA09_DialogBoxes.Messages; + +public sealed class OverlayMessageRequestMessage : AsyncRequestMessage +{ + public string Title { get; } + public string Content { get; } + + public OverlayMessageRequestMessage(string title, string content) + { + Title = title; + Content = content; + } +} diff --git a/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/ViewModels/DialogViewModel.cs b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/ViewModels/DialogViewModel.cs index 5799a1c1a..a3bf5a8b6 100644 --- a/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/ViewModels/DialogViewModel.cs +++ b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/ViewModels/DialogViewModel.cs @@ -17,7 +17,6 @@ using CommunityToolkit.Mvvm.Messaging; using AA09_DialogBoxes.Messages; using System.Threading.Tasks; -using MsBox.Avalonia.Enums; namespace AA09_DialogBoxes.ViewModels; @@ -55,7 +54,7 @@ private async Task OpenMsg() var request = new MessageBoxRequestMessage("Frage", "Willst Du Das ?"); WeakReferenceMessenger.Default.Send(request); var result = await request.Response; - Name = result == ButtonResult.Yes ? "42 Entwickler" : "Nö"; + Name = result == MsgBoxResult.Yes ? "42 Entwickler" : "Nö"; } [RelayCommand] diff --git a/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/ViewModels/MainWindowViewModel.cs b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/ViewModels/MainWindowViewModel.cs index 65a105d35..1b76b75cb 100644 --- a/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/ViewModels/MainWindowViewModel.cs +++ b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/ViewModels/MainWindowViewModel.cs @@ -17,19 +17,16 @@ using AA09_DialogBoxes.Messages; using Avalonia.ViewModels; using System.Threading.Tasks; -using MsBox.Avalonia.Enums; namespace AA09_DialogBoxes.ViewModels; -public enum MsgBoxResult { None, Yes, No } - public partial class MainWindowViewModel : ViewModelBase { #region Properties [ObservableProperty] - public partial string Name { get; set; }= ""; + public partial string Name { get; set;} = ""; [ObservableProperty] - public partial string Email { get; set; }= ""; + public partial string Email { get; set; } = ""; [ObservableProperty] private int cnt = 1; #endregion @@ -42,12 +39,21 @@ private async Task OpenMsg() var request = new MessageBoxRequestMessage("Frage", "Willst Du Das ?"); WeakReferenceMessenger.Default.Send(request); var result = await request.Response; - if (result == ButtonResult.Yes) + if (result == MsgBoxResult.Yes) Name = "42 Entwickler"; else Name = "Nö"; } + [RelayCommand] + private async Task OpenOverlayMsg() + { + var request = new OverlayMessageRequestMessage("Overlay Frage", "Soll der In-Window-Overlay-Dialog verwendet werden?"); + WeakReferenceMessenger.Default.Send(request); + var result = await request.Response; + Name = result == MsgBoxResult.Yes ? "Overlay: Ja" : "Overlay: Nein"; + } + [RelayCommand] private async Task OpenDialog() { diff --git a/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Views/DialogView.axaml b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Views/DialogView.axaml index d8c3eafa5..241008945 100644 --- a/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Views/DialogView.axaml +++ b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Views/DialogView.axaml @@ -10,8 +10,8 @@ - - + + diff --git a/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Views/MessageBoxWindow.axaml.cs b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Views/MessageBoxWindow.axaml.cs new file mode 100644 index 000000000..84c010754 --- /dev/null +++ b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Views/MessageBoxWindow.axaml.cs @@ -0,0 +1,44 @@ +using Avalonia.Controls; +using Avalonia.Input; +using AA09_DialogBoxes.Messages; + +namespace AA09_DialogBoxes.Views; + +public partial class MessageBoxWindow : Window +{ + public MessageBoxWindow(string title, string content) + { + InitializeComponent(); + Title = title; + MessageText.Text = content; + this.Opened += (_, _) => YesButton.Focus(); + this.KeyDown += MessageBoxWindow_KeyDown; + } + + private void MessageBoxWindow_KeyDown(object? sender, KeyEventArgs e) + { + switch (e.Key) + { + case Key.J: + case Key.Enter: + Close(MsgBoxResult.Yes); + e.Handled = true; + break; + case Key.N: + case Key.Escape: + Close(MsgBoxResult.No); + e.Handled = true; + break; + } + } + + private void OnYesClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + Close(MsgBoxResult.Yes); + } + + private void OnNoClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + Close(MsgBoxResult.No); + } +} diff --git a/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Views/OverlayDialogControl.axaml b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Views/OverlayDialogControl.axaml new file mode 100644 index 000000000..751857e7f --- /dev/null +++ b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Views/OverlayDialogControl.axaml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Views/OverlayDialogControl.axaml.cs b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Views/OverlayDialogControl.axaml.cs new file mode 100644 index 000000000..8bf61e898 --- /dev/null +++ b/Avalonia_Apps/AA09_DialogBoxes/AA09_DialogBoxes/Views/OverlayDialogControl.axaml.cs @@ -0,0 +1,131 @@ +using System; +using System.Threading.Tasks; +using AA09_DialogBoxes.Messages; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Styling; + +namespace AA09_DialogBoxes.Views; + +public partial class OverlayDialogControl : UserControl +{ + private TaskCompletionSource? _overlayCompletion; + private bool _isDraggingOverlay; + private Point _dragOffset; + + public OverlayDialogControl() + { + InitializeComponent(); + KeyDown += OverlayDialogControl_KeyDown; + } + + public Task ShowAsync(Window owner, string title, string content) + { + if (_overlayCompletion is { Task.IsCompleted: false }) + return _overlayCompletion.Task; + + _overlayCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); + + OverlayTitle.Text = title; + OverlayContent.Text = content; + UpdateBackdrop(owner.ActualThemeVariant); + IsVisible = true; + CenterOverlayDialog(owner.Bounds); + Focus(); + + return _overlayCompletion.Task; + } + + private void OverlayDialogControl_KeyDown(object? sender, KeyEventArgs e) + { + if (!IsVisible || _overlayCompletion is null) + return; + + switch (e.Key) + { + case Key.J: + case Key.Enter: + Finish(MsgBoxResult.Yes); + e.Handled = true; + break; + case Key.N: + case Key.Escape: + Finish(MsgBoxResult.No); + e.Handled = true; + break; + } + } + + private void UpdateBackdrop(ThemeVariant theme) + { + OverlayBackdrop.Background = theme == ThemeVariant.Dark + ? new SolidColorBrush(Color.Parse("#88000000")) + : new SolidColorBrush(Color.Parse("#88FFFFFF")); + } + + private void CenterOverlayDialog(Rect ownerBounds) + { + var dialogWidth = OverlayDialog.Bounds.Width > 0 ? OverlayDialog.Bounds.Width : OverlayDialog.Width; + var dialogHeight = OverlayDialog.Bounds.Height > 0 ? OverlayDialog.Bounds.Height : OverlayDialog.Height; + + var left = Math.Max(0, (ownerBounds.Width - dialogWidth) / 2d); + var top = Math.Max(0, (ownerBounds.Height - dialogHeight) / 2d); + Canvas.SetLeft(OverlayDialog, left); + Canvas.SetTop(OverlayDialog, top); + } + + private void Finish(MsgBoxResult result) + { + IsVisible = false; + _overlayCompletion?.TrySetResult(result); + _overlayCompletion = null; + } + + private void OverlayYes_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + => Finish(MsgBoxResult.Yes); + + private void OverlayNo_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + => Finish(MsgBoxResult.No); + + private void OverlayClose_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + => Finish(MsgBoxResult.No); + + private void OverlayDialog_PointerPressed(object? sender, PointerPressedEventArgs e) + { + if (sender is not Control control) + return; + + _isDraggingOverlay = true; + var pointerPos = e.GetPosition(OverlayCanvas); + var left = Canvas.GetLeft(OverlayDialog); + var top = Canvas.GetTop(OverlayDialog); + if (double.IsNaN(left)) left = 0; + if (double.IsNaN(top)) top = 0; + _dragOffset = new Point(pointerPos.X - left, pointerPos.Y - top); + e.Pointer.Capture(control); + } + + private void OverlayDialog_PointerMoved(object? sender, PointerEventArgs e) + { + if (!_isDraggingOverlay) + return; + + var pointerPos = e.GetPosition(OverlayCanvas); + var newLeft = pointerPos.X - _dragOffset.X; + var newTop = pointerPos.Y - _dragOffset.Y; + + var maxLeft = Math.Max(0, OverlayCanvas.Bounds.Width - OverlayDialog.Bounds.Width); + var maxTop = Math.Max(0, OverlayCanvas.Bounds.Height - OverlayDialog.Bounds.Height); + + Canvas.SetLeft(OverlayDialog, Math.Clamp(newLeft, 0, maxLeft)); + Canvas.SetTop(OverlayDialog, Math.Clamp(newTop, 0, maxTop)); + } + + private void OverlayDialog_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + _isDraggingOverlay = false; + e.Pointer.Capture(null); + } +} diff --git a/Avalonia_Apps/AA15_Labyrinth/AA15_LabyrinthTests/AA15_LabyrinthTests.csproj b/Avalonia_Apps/AA15_Labyrinth/AA15_LabyrinthTests/AA15_LabyrinthTests.csproj index 47341c74d..54d404bb2 100644 --- a/Avalonia_Apps/AA15_Labyrinth/AA15_LabyrinthTests/AA15_LabyrinthTests.csproj +++ b/Avalonia_Apps/AA15_Labyrinth/AA15_LabyrinthTests/AA15_LabyrinthTests.csproj @@ -1,4 +1,4 @@ - + net8.0;net9.0 diff --git a/Avalonia_Apps/AA15_Labyrinth/AA15a_Treppen/App.axaml.cs b/Avalonia_Apps/AA15_Labyrinth/AA15a_Treppen/App.axaml.cs index 5557b62ee..738f56d39 100644 --- a/Avalonia_Apps/AA15_Labyrinth/AA15a_Treppen/App.axaml.cs +++ b/Avalonia_Apps/AA15_Labyrinth/AA15a_Treppen/App.axaml.cs @@ -2,12 +2,9 @@ using AA15a_Treppen.Views; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Data.Core; -using Avalonia.Data.Core.Plugins; using Avalonia.Markup.Xaml; using Microsoft.Extensions.DependencyInjection; using Treppen.Base; -using System.Linq; namespace AA15a_Treppen { @@ -25,7 +22,6 @@ public override void OnFrameworkInitializationCompleted() { // Avoid duplicate validations from both Avalonia and the CommunityToolkit. // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins - DisableAvaloniaDataAnnotationValidation(); var sc = new ServiceCollection(); sc.AddSingleton(); sc.AddSingleton(); @@ -40,17 +36,5 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } - private void DisableAvaloniaDataAnnotationValidation() - { - // Get an array of plugins to remove - var dataValidationPluginsToRemove = - BindingPlugins.DataValidators.OfType().ToArray(); - - // remove each entry found - foreach (var plugin in dataValidationPluginsToRemove) - { - BindingPlugins.DataValidators.Remove(plugin); - } - } } } \ No newline at end of file diff --git a/Avalonia_Apps/AA16_UserControl/AA16_Usercontrol1/Directory.Packages.props b/Avalonia_Apps/AA16_UserControl/AA16_Usercontrol1/Directory.Packages.props index f6e12d300..0897ffd8b 100644 --- a/Avalonia_Apps/AA16_UserControl/AA16_Usercontrol1/Directory.Packages.props +++ b/Avalonia_Apps/AA16_UserControl/AA16_Usercontrol1/Directory.Packages.props @@ -3,10 +3,10 @@ true - - - - + + + + @@ -14,4 +14,8 @@ + + + + \ No newline at end of file diff --git a/Avalonia_Apps/AA19_FilterLists/AA19_FilterLists/Views/PersonView.axaml b/Avalonia_Apps/AA19_FilterLists/AA19_FilterLists/Views/PersonView.axaml index b1585f974..abbf84adf 100644 --- a/Avalonia_Apps/AA19_FilterLists/AA19_FilterLists/Views/PersonView.axaml +++ b/Avalonia_Apps/AA19_FilterLists/AA19_FilterLists/Views/PersonView.axaml @@ -12,9 +12,9 @@ @@ -61,7 +62,8 @@ - + + diff --git a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Views/RichTextEditView.axaml.cs b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Views/RichTextEditView.axaml.cs index e70c8e5ba..4c65394fc 100644 --- a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Views/RichTextEditView.axaml.cs +++ b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Views/RichTextEditView.axaml.cs @@ -1,5 +1,7 @@ using Avalonia.Controls; +using Avalonia.VisualTree; using System; +using System.Threading.Tasks; using AA25_RichTextEdit.ViewModels; using Avln_CommonDialogs.Base.Interfaces; @@ -17,29 +19,37 @@ private void RichTextEditView_AttachedToVisualTree(object? sender, Avalonia.Visu { if (DataContext is RichTextEditViewModel vm) { - vm.FileOpenDialog = DoFileDialog; - vm.FileSaveAsDialog = DoFileDialog; + vm.FileOpenDialog = DoOpenFileDialog; + vm.FileSaveAsDialog = DoSaveFileDialog; vm.dPrintDialog = DoPrintDialog; vm.CloseApp = DoClose; } } - private bool? DoFileDialog(string filename, IFileDialog par, Action? onAccept) + private async Task DoOpenFileDialog(string filename, IOpenFileDialog par, Action? onAccept) { - par.FileName = filename; - var window = this.GetVisualRoot() as Window; - bool? result = par.ShowDialog(window); - if (result ?? false) onAccept?.Invoke(par.FileName, par); + var files = await par.ShowAsync(); + bool result = files.Count > 0; + if (result) onAccept?.Invoke(files[0], par); return result; } - private bool? DoPrintDialog(IPrintDialog par, Action? onPrint) + private async Task DoSaveFileDialog(string filename, ISaveFileDialog par, Action? onAccept) { - bool? result = par.ShowAsync().GetAwaiter().GetResult(); - - if (result ?? false) onPrint?.Invoke(par, null); // Avalonia placeholder + par.InitialFileName = filename; + var file = await par.ShowAsync(); + bool result = file != null; + if (result) onAccept?.Invoke(file!, par); return result; } - private void DoClose() => (this)?.Close(); + private async Task DoPrintDialog(IPrintDialog par, Action? onPrint) + { + var session = await par.ShowAsync(); + bool result = session != null; + if (result) onPrint?.Invoke(par, session); + return result; + } + + private void DoClose() => (TopLevel.GetTopLevel(this) as Window)?.Close(); } diff --git a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/AppTests.cs b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/AppTests.cs index 67791c063..f314e5c0d 100644 --- a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/AppTests.cs +++ b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/AppTests.cs @@ -11,7 +11,7 @@ internal class TestApp : App { public void DoStartUp() { - OnStartup(null); + OnFrameworkInitializationCompleted(); } } [TestClass()] diff --git a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/Models/RichTextEditModelTests.cs b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/Models/RichTextEditModelTests.cs index c141261f1..a86598bf7 100644 --- a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/Models/RichTextEditModelTests.cs +++ b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/Models/RichTextEditModelTests.cs @@ -13,7 +13,7 @@ // *********************************************************************** using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.VisualStudio.TestTools.UnitTesting; -using MVVM.ViewModel; +using Avalonia.ViewModels; using NSubstitute; using System.ComponentModel; using BaseLib.Models.Interfaces; diff --git a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/Models/SimpleLogTests.cs b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/Models/SimpleLogTests.cs index 299fea26c..823996c3f 100644 --- a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/Models/SimpleLogTests.cs +++ b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/Models/SimpleLogTests.cs @@ -1,6 +1,6 @@ using BaseLib.Models.Interfaces; using Microsoft.VisualStudio.TestTools.UnitTesting; -using MVVM.ViewModel; +using Avalonia.ViewModels; using NSubstitute; using System; using System.Linq; @@ -10,7 +10,7 @@ namespace AA25_RichTextEdit.Models.Tests; [TestClass] public class SimpleLogTests:BaseTestViewModel { - private Action _gsOld; + private Action? _gsOld; private ISysTime? _sysTime; private SimpleLog simpleLog; @@ -29,10 +29,10 @@ public void TestInitialize() [TestCleanup] public void TestCleanup() { - SimpleLog.LogAction = _gsOld; + SimpleLog.LogAction = _gsOld!; } - [DataTestMethod] + [TestMethod] [DataRow("Test message",new[] { "08/24/2022 12:00:00: Msg: Test message\r\n" })] [DataRow(null, new[] { "08/24/2022 12:00:00: Msg: \r\n" })] [DataRow("Some other test", new[] { "08/24/2022 12:00:00: Msg: Some other test\r\n" })] diff --git a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/ViewModels/MainWindowViewModelTests.cs b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/ViewModels/MainWindowViewModelTests.cs index 1bb45a252..e9e58702a 100644 --- a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/ViewModels/MainWindowViewModelTests.cs +++ b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/ViewModels/MainWindowViewModelTests.cs @@ -13,7 +13,7 @@ // *********************************************************************** using Microsoft.VisualStudio.TestTools.UnitTesting; using System.ComponentModel; -using MVVM.ViewModel; +using Avalonia.ViewModels; /// /// The Tests namespace. diff --git a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/ViewModels/RichTextEditViewModelTests.cs b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/ViewModels/RichTextEditViewModelTests.cs index ccc50a3cd..64581f517 100644 --- a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/ViewModels/RichTextEditViewModelTests.cs +++ b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEditTests/ViewModels/RichTextEditViewModelTests.cs @@ -15,7 +15,7 @@ using System.ComponentModel; using Microsoft.VisualStudio.TestTools.UnitTesting; using NSubstitute; -using MVVM.ViewModel; +using Avalonia.ViewModels; using BaseLib.Helper; using AA25_RichTextEdit.Models; diff --git a/Avalonia_Apps/AA25_RichTextEdit/Avln_RichTextEdit/App.axaml.cs b/Avalonia_Apps/AA25_RichTextEdit/Avln_RichTextEdit/App.axaml.cs index 35704677c..f1237a75e 100644 --- a/Avalonia_Apps/AA25_RichTextEdit/Avln_RichTextEdit/App.axaml.cs +++ b/Avalonia_Apps/AA25_RichTextEdit/Avln_RichTextEdit/App.axaml.cs @@ -1,11 +1,8 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Data.Core; -using Avalonia.Data.Core.Plugins; using Avalonia.Markup.Xaml; using Avln_RichTextEdit.ViewModels; using Avln_RichTextEdit.Views; -using System.Linq; namespace Avln_RichTextEdit { @@ -22,7 +19,6 @@ public override void OnFrameworkInitializationCompleted() { // Avoid duplicate validations from both Avalonia and the CommunityToolkit. // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins - DisableAvaloniaDataAnnotationValidation(); desktop.MainWindow = new MainWindow { DataContext = new MainWindowViewModel(), @@ -32,17 +28,5 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } - private void DisableAvaloniaDataAnnotationValidation() - { - // Get an array of plugins to remove - var dataValidationPluginsToRemove = - BindingPlugins.DataValidators.OfType().ToArray(); - - // remove each entry found - foreach (var plugin in dataValidationPluginsToRemove) - { - BindingPlugins.DataValidators.Remove(plugin); - } - } } } \ No newline at end of file diff --git a/Avalonia_Apps/Avalonia_App01/Avalonia_App_01.Browser/Avalonia_App_01.Browser.csproj b/Avalonia_Apps/Avalonia_App01/Avalonia_App_01.Browser/Avalonia_App_01.Browser.csproj index 0e121a5ce..e91fa7bb8 100644 --- a/Avalonia_Apps/Avalonia_App01/Avalonia_App_01.Browser/Avalonia_App_01.Browser.csproj +++ b/Avalonia_Apps/Avalonia_App01/Avalonia_App_01.Browser/Avalonia_App_01.Browser.csproj @@ -1,7 +1,7 @@  - net8.0-browser + net10.0-browser Exe true enable diff --git a/Avalonia_Apps/Avalonia_App01/Avalonia_App_01.Browser/Program.cs b/Avalonia_Apps/Avalonia_App01/Avalonia_App_01.Browser/Program.cs index b1b50b9ea..9409927de 100644 --- a/Avalonia_Apps/Avalonia_App01/Avalonia_App_01.Browser/Program.cs +++ b/Avalonia_Apps/Avalonia_App01/Avalonia_App_01.Browser/Program.cs @@ -4,11 +4,18 @@ using Avalonia.Browser; using Avalonia_App_01; +[assembly: SupportedOSPlatform("browser")] + internal sealed partial class Program { - private static Task Main(string[] args) => BuildAvaloniaApp() - .WithInterFont() - .StartBrowserAppAsync("out"); + private static async Task Main(string[] args) + { + await BrowserAppBuilder.StartBrowserAppAsync( + BuildAvaloniaApp() + .WithInterFont(), + "out", + new BrowserPlatformOptions()); + } public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure(); diff --git a/Avalonia_Apps/Avalonia_App01/Avalonia_App_01/App.axaml.cs b/Avalonia_Apps/Avalonia_App01/Avalonia_App_01/App.axaml.cs index 5a81406c9..aa77f9159 100644 --- a/Avalonia_Apps/Avalonia_App01/Avalonia_App_01/App.axaml.cs +++ b/Avalonia_Apps/Avalonia_App01/Avalonia_App_01/App.axaml.cs @@ -1,8 +1,5 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Data.Core; -using Avalonia.Data.Core.Plugins; -using System.Linq; using Avalonia.Markup.Xaml; using Avalonia_App_01.ViewModels; using Avalonia_App_01.Views; @@ -22,7 +19,6 @@ public override void OnFrameworkInitializationCompleted() { // Avoid duplicate validations from both Avalonia and the CommunityToolkit. // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins - DisableAvaloniaDataAnnotationValidation(); desktop.MainWindow = new MainWindow { DataContext = new MainViewModel() @@ -39,16 +35,4 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } - private void DisableAvaloniaDataAnnotationValidation() - { - // Get an array of plugins to remove - var dataValidationPluginsToRemove = - BindingPlugins.DataValidators.OfType().ToArray(); - - // remove each entry found - foreach (var plugin in dataValidationPluginsToRemove) - { - BindingPlugins.DataValidators.Remove(plugin); - } - } } \ No newline at end of file diff --git a/Avalonia_Apps/Avalonia_App02/Avalonia_App02/App.axaml.cs b/Avalonia_Apps/Avalonia_App02/Avalonia_App02/App.axaml.cs index eb7a23e45..758990264 100644 --- a/Avalonia_Apps/Avalonia_App02/Avalonia_App02/App.axaml.cs +++ b/Avalonia_Apps/Avalonia_App02/Avalonia_App02/App.axaml.cs @@ -1,9 +1,6 @@ using System; -using System.Linq; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Data.Core; -using Avalonia.Data.Core.Plugins; using Avalonia.Markup.Xaml; using Avalonia.Platform; using Avalonia_App02.Models; @@ -46,25 +43,11 @@ protected void InitDesktopApp(IClassicDesktopStyleApplicationLifetime desktop) // Avoid duplicate validations from both Avalonia and the CommunityToolkit. // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins - DisableAvaloniaDataAnnotationValidation(); desktop.MainWindow = new MainWindow { DataContext = Services.GetRequiredService() }; } - private void DisableAvaloniaDataAnnotationValidation() - { - // Get an array of plugins to remove - var dataValidationPluginsToRemove = - BindingPlugins.DataValidators.OfType().ToArray(); - - // remove each entry found - foreach (var plugin in dataValidationPluginsToRemove) - { - BindingPlugins.DataValidators.Remove(plugin); - } - } - public IServiceProvider? Services { get; private set; } } \ No newline at end of file diff --git a/Avalonia_Apps/Avalonia_Apps.sln b/Avalonia_Apps/Avalonia_Apps.sln index 4b33baac8..e1904dfe1 100644 --- a/Avalonia_Apps/Avalonia_Apps.sln +++ b/Avalonia_Apps/Avalonia_Apps.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 VisualStudioVersion = 18.0.11201.2 @@ -223,7 +223,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AA22_AvlnCapTests", "AA22_A EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AA22_AvlnCap2", "AA22_AvlnCap\AA22_AvlnCap2\AA22_AvlnCap2.csproj", "{BC312318-3671-5E97-5256-467F6FAEFC0B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MVVM_25_RichTextEdit_net", "..\CSharpBible\MVVM_Tutorial\MVVM_25_RichTextEdit\MVVM_25_RichTextEdit_net.csproj", "{3BF6E365-19B9-A56C-890A-621ED3230455}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AA25_RichTextEdit", "AA25_RichTextEdit", "{3DB34546-9932-4FDF-A61C-E42E9C1FFDC9}" EndProject diff --git a/Avalonia_Apps/AvlnSamples/Avln_AnimationTiming/Avln_AnimationTiming.csproj b/Avalonia_Apps/AvlnSamples/Avln_AnimationTiming/Avln_AnimationTiming.csproj index 480c544f8..56defa431 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_AnimationTiming/Avln_AnimationTiming.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_AnimationTiming/Avln_AnimationTiming.csproj @@ -38,10 +38,10 @@ - - - - + + + + diff --git a/Avalonia_Apps/AvlnSamples/Avln_AnimationTimingTests/Avln_AnimationTimingTests.csproj b/Avalonia_Apps/AvlnSamples/Avln_AnimationTimingTests/Avln_AnimationTimingTests.csproj index cae31cbef..de53f4cc3 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_AnimationTimingTests/Avln_AnimationTimingTests.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_AnimationTimingTests/Avln_AnimationTimingTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Avalonia_Apps/AvlnSamples/Avln_Brushes/Avln_Brushes.csproj b/Avalonia_Apps/AvlnSamples/Avln_Brushes/Avln_Brushes.csproj index e4ae79327..a7ffd8017 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_Brushes/Avln_Brushes.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_Brushes/Avln_Brushes.csproj @@ -23,10 +23,10 @@ - - - - + + + + diff --git a/Avalonia_Apps/AvlnSamples/Avln_Complex_Layout/Avln_Complex_Layout.csproj b/Avalonia_Apps/AvlnSamples/Avln_Complex_Layout/Avln_Complex_Layout.csproj index 697a1ecb7..fce6bf589 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_Complex_Layout/Avln_Complex_Layout.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_Complex_Layout/Avln_Complex_Layout.csproj @@ -18,10 +18,10 @@ - - - - + + + + diff --git a/Avalonia_Apps/AvlnSamples/Avln_Complex_LayoutTests/Avln_Complex_LayoutTests.csproj b/Avalonia_Apps/AvlnSamples/Avln_Complex_LayoutTests/Avln_Complex_LayoutTests.csproj index b2280df7e..3a8825780 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_Complex_LayoutTests/Avln_Complex_LayoutTests.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_Complex_LayoutTests/Avln_Complex_LayoutTests.csproj @@ -4,10 +4,10 @@ false - - + + - + diff --git a/Avalonia_Apps/AvlnSamples/Avln_CustomAnimation/Avln_CustomAnimation.csproj b/Avalonia_Apps/AvlnSamples/Avln_CustomAnimation/Avln_CustomAnimation.csproj index 13e1fdab5..a2f932f93 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_CustomAnimation/Avln_CustomAnimation.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_CustomAnimation/Avln_CustomAnimation.csproj @@ -12,10 +12,10 @@ - - - - + + + + diff --git a/Avalonia_Apps/AvlnSamples/Avln_Geometry/Avln_Geometry.csproj b/Avalonia_Apps/AvlnSamples/Avln_Geometry/Avln_Geometry.csproj index 756b41da2..6d49f15f6 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_Geometry/Avln_Geometry.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_Geometry/Avln_Geometry.csproj @@ -25,10 +25,10 @@ - - - - + + + + diff --git a/Avalonia_Apps/AvlnSamples/Avln_Hello_World/Avln_Hello_World.csproj b/Avalonia_Apps/AvlnSamples/Avln_Hello_World/Avln_Hello_World.csproj index 2ad76b5b2..52fbf985a 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_Hello_World/Avln_Hello_World.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_Hello_World/Avln_Hello_World.csproj @@ -18,10 +18,10 @@ - - - - + + + + diff --git a/Avalonia_Apps/AvlnSamples/Avln_Hello_WorldTests/Avln_Hello_WorldTests.csproj b/Avalonia_Apps/AvlnSamples/Avln_Hello_WorldTests/Avln_Hello_WorldTests.csproj index bc4f7f52f..491092efc 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_Hello_WorldTests/Avln_Hello_WorldTests.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_Hello_WorldTests/Avln_Hello_WorldTests.csproj @@ -9,13 +9,13 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Avalonia_Apps/AvlnSamples/Avln_ImageView/Avln_ImageView.csproj b/Avalonia_Apps/AvlnSamples/Avln_ImageView/Avln_ImageView.csproj index e9a8a55ba..2109d3772 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_ImageView/Avln_ImageView.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_ImageView/Avln_ImageView.csproj @@ -13,10 +13,10 @@ - - - - + + + + diff --git a/Avalonia_Apps/AvlnSamples/Avln_IntegrationTestApp/Avln_IntegrationTestApp.csproj b/Avalonia_Apps/AvlnSamples/Avln_IntegrationTestApp/Avln_IntegrationTestApp.csproj index 78f2bcd89..6674f48b5 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_IntegrationTestApp/Avln_IntegrationTestApp.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_IntegrationTestApp/Avln_IntegrationTestApp.csproj @@ -22,10 +22,10 @@ - - - - + + + + diff --git a/Avalonia_Apps/AvlnSamples/Avln_MoveWindow/Avln_MoveWindow.csproj b/Avalonia_Apps/AvlnSamples/Avln_MoveWindow/Avln_MoveWindow.csproj index 70a31d004..a4649d15b 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_MoveWindow/Avln_MoveWindow.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_MoveWindow/Avln_MoveWindow.csproj @@ -18,10 +18,10 @@ - - - - + + + + diff --git a/Avalonia_Apps/AvlnSamples/Avln_MoveWindowTests/Avln_MoveWindowTests.csproj b/Avalonia_Apps/AvlnSamples/Avln_MoveWindowTests/Avln_MoveWindowTests.csproj index a4c6c6de7..67befede5 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_MoveWindowTests/Avln_MoveWindowTests.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_MoveWindowTests/Avln_MoveWindowTests.csproj @@ -9,13 +9,13 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Avalonia_Apps/AvlnSamples/Avln_RenderDemo/Avln_RenderDemo.csproj b/Avalonia_Apps/AvlnSamples/Avln_RenderDemo/Avln_RenderDemo.csproj index d0ab64785..52da7ab4b 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_RenderDemo/Avln_RenderDemo.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_RenderDemo/Avln_RenderDemo.csproj @@ -13,10 +13,10 @@ - - - - + + + + diff --git a/Avalonia_Apps/AvlnSamples/Avln_Sample_Template/Avln_Sample_Template.csproj b/Avalonia_Apps/AvlnSamples/Avln_Sample_Template/Avln_Sample_Template.csproj index 07111d778..a4b9d0733 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_Sample_Template/Avln_Sample_Template.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_Sample_Template/Avln_Sample_Template.csproj @@ -18,10 +18,10 @@ - - - - + + + + diff --git a/Avalonia_Apps/AvlnSamples/Avln_TextTestApp/Avln_TextTestApp.csproj b/Avalonia_Apps/AvlnSamples/Avln_TextTestApp/Avln_TextTestApp.csproj index 57cd58276..aae944e16 100644 --- a/Avalonia_Apps/AvlnSamples/Avln_TextTestApp/Avln_TextTestApp.csproj +++ b/Avalonia_Apps/AvlnSamples/Avln_TextTestApp/Avln_TextTestApp.csproj @@ -10,10 +10,10 @@ - - - - + + + + diff --git a/Avalonia_Apps/AvlnSamples/SampleControls/ControlSamples.Pack.csproj b/Avalonia_Apps/AvlnSamples/SampleControls/ControlSamples.Pack.csproj index b5db976f6..bbc992389 100644 --- a/Avalonia_Apps/AvlnSamples/SampleControls/ControlSamples.Pack.csproj +++ b/Avalonia_Apps/AvlnSamples/SampleControls/ControlSamples.Pack.csproj @@ -23,7 +23,7 @@ - + diff --git a/Avalonia_Apps/AvlnSamples/Styles_and_Templates/WPF_Samples.props b/Avalonia_Apps/AvlnSamples/Styles_and_Templates/WPF_Samples.props new file mode 100644 index 000000000..898682bd5 --- /dev/null +++ b/Avalonia_Apps/AvlnSamples/Styles_and_Templates/WPF_Samples.props @@ -0,0 +1,8 @@ + + + + $(UpDir)\.. + $(UpDir)\..\bin\$(MSBuildProjectName)\ + $(UpDir)\..\obj\$(MSBuildProjectName)\ + + \ No newline at end of file diff --git a/Avalonia_Apps/Directory.Build.targets b/Avalonia_Apps/Directory.Build.targets new file mode 100644 index 000000000..ce9a95003 --- /dev/null +++ b/Avalonia_Apps/Directory.Build.targets @@ -0,0 +1,7 @@ + + + + + + diff --git a/Avalonia_Apps/Libraries/Avln_BaseLib/Avln_BaseLib.csproj b/Avalonia_Apps/Libraries/Avln_BaseLib/Avln_BaseLib.csproj index 543962b82..de0b78814 100644 --- a/Avalonia_Apps/Libraries/Avln_BaseLib/Avln_BaseLib.csproj +++ b/Avalonia_Apps/Libraries/Avln_BaseLib/Avln_BaseLib.csproj @@ -26,8 +26,8 @@ - - + + diff --git a/Avalonia_Apps/Libraries/Avln_BaseLibTests/Avln_BaseLibTests.csproj b/Avalonia_Apps/Libraries/Avln_BaseLibTests/Avln_BaseLibTests.csproj index 72b06fdb1..99502597f 100644 --- a/Avalonia_Apps/Libraries/Avln_BaseLibTests/Avln_BaseLibTests.csproj +++ b/Avalonia_Apps/Libraries/Avln_BaseLibTests/Avln_BaseLibTests.csproj @@ -16,14 +16,14 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Avalonia_Apps/Libraries/BaseLib/Helper/ObjectHelper.cs b/Avalonia_Apps/Libraries/BaseLib/Helper/ObjectHelper.cs index 686e531de..a5d4405ce 100644 --- a/Avalonia_Apps/Libraries/BaseLib/Helper/ObjectHelper.cs +++ b/Avalonia_Apps/Libraries/BaseLib/Helper/ObjectHelper.cs @@ -17,7 +17,6 @@ using System.ComponentModel; using System.Globalization; using System.Linq; -using System.Runtime.CompilerServices; namespace BaseLib.Helper; diff --git a/Avalonia_Apps/Libraries/BaseLib/Helper/StringUtils.cs b/Avalonia_Apps/Libraries/BaseLib/Helper/StringUtils.cs index ccc56783a..4022fe7f8 100644 --- a/Avalonia_Apps/Libraries/BaseLib/Helper/StringUtils.cs +++ b/Avalonia_Apps/Libraries/BaseLib/Helper/StringUtils.cs @@ -18,7 +18,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Runtime.InteropServices; namespace BaseLib.Helper; diff --git a/Avalonia_Apps/Libraries/BaseLib/Models/FileProxy.cs b/Avalonia_Apps/Libraries/BaseLib/Models/FileProxy.cs new file mode 100644 index 000000000..09c1ab499 --- /dev/null +++ b/Avalonia_Apps/Libraries/BaseLib/Models/FileProxy.cs @@ -0,0 +1,39 @@ +using BaseLib.Models.Interfaces; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BaseLib.Models; + +public class FileProxy : IFile +{ + public bool Exists(string sPath) + => File.Exists(sPath); + public Stream OpenRead(string sPath) + => File.OpenRead(sPath); + public Stream OpenWrite(string sPath) + => File.OpenWrite(sPath); + public Stream Create(string sPath) + => File.Create(sPath); + public string ReadAllText(string sPath) + => File.ReadAllText(sPath); + public string ReadAllText(string sPath, Encoding encoding) + => File.ReadAllText(sPath, encoding); + public void WriteAllText(string sPath, string sContents) + => File.WriteAllText(sPath, sContents); + public void WriteAllText(string sPath, string sContents, Encoding encoding) + => File.WriteAllText(sPath, sContents, encoding); + public byte[] ReadAllBytes(string sPath) + => File.ReadAllBytes(sPath); + public void WriteAllBytes(string sPath, byte[] rgBytes) + => File.WriteAllBytes(sPath, rgBytes); + public void Delete(string sPath) + => File.Delete(sPath); + public void Copy(string sSourceFileName, string sDestFileName, bool xOverwrite) + => File.Copy(sSourceFileName, sDestFileName, xOverwrite); + public void Move(string sSourceFileName, string sDestFileName) + => File.Move(sSourceFileName, sDestFileName); +} diff --git a/Avalonia_Apps/Libraries/BaseLib/Models/Interfaces/IFile.cs b/Avalonia_Apps/Libraries/BaseLib/Models/Interfaces/IFile.cs new file mode 100644 index 000000000..eadf02be8 --- /dev/null +++ b/Avalonia_Apps/Libraries/BaseLib/Models/Interfaces/IFile.cs @@ -0,0 +1,103 @@ +using System.IO; +using System.Text; + +namespace BaseLib.Models.Interfaces; + +/// +/// Provides an abstraction over for testable file system access. +/// +public interface IFile +{ + /// + /// Determines whether the specified file exists. + /// + /// The file path. + /// if the file exists; otherwise . + bool Exists(string sPath); + + /// + /// Opens a file for reading. + /// + /// The file path. + /// A readable stream. + Stream OpenRead(string sPath); + + /// + /// Opens an existing file for writing. + /// + /// The file path. + /// A writable stream. + Stream OpenWrite(string sPath); + + /// + /// Creates or overwrites a file. + /// + /// The file path. + /// A writable stream. + Stream Create(string sPath); + + /// + /// Reads all text from a file using UTF-8 encoding. + /// + /// The file path. + /// The file content. + string ReadAllText(string sPath); + + /// + /// Reads all text from a file using the specified encoding. + /// + /// The file path. + /// The text encoding. + /// The file content. + string ReadAllText(string sPath, Encoding encoding); + + /// + /// Writes text to a file using UTF-8 encoding. + /// + /// The file path. + /// The text content. + void WriteAllText(string sPath, string sContents); + + /// + /// Writes text to a file using the specified encoding. + /// + /// The file path. + /// The text content. + /// The text encoding. + void WriteAllText(string sPath, string sContents, Encoding encoding); + + /// + /// Reads all bytes from a file. + /// + /// The file path. + /// The file content as bytes. + byte[] ReadAllBytes(string sPath); + + /// + /// Writes all bytes to a file. + /// + /// The file path. + /// The byte content. + void WriteAllBytes(string sPath, byte[] rgBytes); + + /// + /// Deletes the specified file. + /// + /// The file path. + void Delete(string sPath); + + /// + /// Copies a file to a new location. + /// + /// The source file path. + /// The destination file path. + /// to overwrite an existing destination file. + void Copy(string sSourceFileName, string sDestFileName, bool xOverwrite); + + /// + /// Moves a file to a new location. + /// + /// The source file path. + /// The destination file path. + void Move(string sSourceFileName, string sDestFileName); +} diff --git a/Avalonia_Apps/Libraries/BaseLibTests/BaseLibTests.csproj b/Avalonia_Apps/Libraries/BaseLibTests/BaseLibTests.csproj index 54f402aab..8d729d6ba 100644 --- a/Avalonia_Apps/Libraries/BaseLibTests/BaseLibTests.csproj +++ b/Avalonia_Apps/Libraries/BaseLibTests/BaseLibTests.csproj @@ -15,7 +15,7 @@ $(TargetFrameworks);net10.0 - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Avalonia_Apps/Packages.props b/Avalonia_Apps/Packages.props index 11f10dc58..d72aaafe6 100644 --- a/Avalonia_Apps/Packages.props +++ b/Avalonia_Apps/Packages.props @@ -6,20 +6,20 @@ - - - - + + + + - + - + - + - + @@ -31,9 +31,13 @@ - + - + + + + + \ No newline at end of file diff --git a/Avalonia_Apps/RenderImage.BaseTests/RenderImage.BaseTests.csproj b/Avalonia_Apps/RenderImage.BaseTests/RenderImage.BaseTests.csproj index d01ad3802..53de2cc54 100644 --- a/Avalonia_Apps/RenderImage.BaseTests/RenderImage.BaseTests.csproj +++ b/Avalonia_Apps/RenderImage.BaseTests/RenderImage.BaseTests.csproj @@ -8,8 +8,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/AboutExTests/AboutExTests.csproj b/CSharpBible/AboutExTests/AboutExTests.csproj index 0d43f6f74..18492525f 100644 --- a/CSharpBible/AboutExTests/AboutExTests.csproj +++ b/CSharpBible/AboutExTests/AboutExTests.csproj @@ -12,8 +12,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Basics/Basic_Del00_TemplateTests/Basic_Del00_TemplateTests.csproj b/CSharpBible/Basics/Basic_Del00_TemplateTests/Basic_Del00_TemplateTests.csproj index e1a8e3500..c5f6bc9db 100644 --- a/CSharpBible/Basics/Basic_Del00_TemplateTests/Basic_Del00_TemplateTests.csproj +++ b/CSharpBible/Basics/Basic_Del00_TemplateTests/Basic_Del00_TemplateTests.csproj @@ -13,8 +13,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Basics/Basic_Del01_ActionTests/Basic_Del01_ActionTests.csproj b/CSharpBible/Basics/Basic_Del01_ActionTests/Basic_Del01_ActionTests.csproj index f0d0c967d..2dfe0441e 100644 --- a/CSharpBible/Basics/Basic_Del01_ActionTests/Basic_Del01_ActionTests.csproj +++ b/CSharpBible/Basics/Basic_Del01_ActionTests/Basic_Del01_ActionTests.csproj @@ -13,8 +13,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Basics/Basic_Del02_FilterTests/Basic_Del02_FilterTests.csproj b/CSharpBible/Basics/Basic_Del02_FilterTests/Basic_Del02_FilterTests.csproj index 0baf77e5c..c4c3bd395 100644 --- a/CSharpBible/Basics/Basic_Del02_FilterTests/Basic_Del02_FilterTests.csproj +++ b/CSharpBible/Basics/Basic_Del02_FilterTests/Basic_Del02_FilterTests.csproj @@ -13,8 +13,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Basics/Basic_Del03_GeneralTests/Basic_Del03_GeneralTests.csproj b/CSharpBible/Basics/Basic_Del03_GeneralTests/Basic_Del03_GeneralTests.csproj index 5a812c3bb..4866b24ff 100644 --- a/CSharpBible/Basics/Basic_Del03_GeneralTests/Basic_Del03_GeneralTests.csproj +++ b/CSharpBible/Basics/Basic_Del03_GeneralTests/Basic_Del03_GeneralTests.csproj @@ -13,8 +13,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Basics/Basic_Del04_TestImposibleStuffTests/Basic_Del04_TestImposibleStuffTests.csproj b/CSharpBible/Basics/Basic_Del04_TestImposibleStuffTests/Basic_Del04_TestImposibleStuffTests.csproj index a35304b0a..dffc503a3 100644 --- a/CSharpBible/Basics/Basic_Del04_TestImposibleStuffTests/Basic_Del04_TestImposibleStuffTests.csproj +++ b/CSharpBible/Basics/Basic_Del04_TestImposibleStuffTests/Basic_Del04_TestImposibleStuffTests.csproj @@ -13,8 +13,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Calc/Directory.Packages.props b/CSharpBible/Calc/Directory.Packages.props index 442f4415d..4c99120d1 100644 --- a/CSharpBible/Calc/Directory.Packages.props +++ b/CSharpBible/Calc/Directory.Packages.props @@ -23,8 +23,8 @@ - - + + diff --git a/CSharpBible/ConsoleApps/TestConsoleTests/TestConsoleTests.csproj b/CSharpBible/ConsoleApps/TestConsoleTests/TestConsoleTests.csproj index 8007f278e..8fc972ff9 100644 --- a/CSharpBible/ConsoleApps/TestConsoleTests/TestConsoleTests.csproj +++ b/CSharpBible/ConsoleApps/TestConsoleTests/TestConsoleTests.csproj @@ -19,7 +19,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/DB/FoxCon/FoxCon.csproj b/CSharpBible/DB/FoxCon/FoxCon.csproj index 87686e905..67db10a4b 100644 --- a/CSharpBible/DB/FoxCon/FoxCon.csproj +++ b/CSharpBible/DB/FoxCon/FoxCon.csproj @@ -19,7 +19,7 @@ - + diff --git a/CSharpBible/Data/.github/copilot-instructions.md b/CSharpBible/Data/.github/copilot-instructions.md deleted file mode 100644 index e37d23d3c..000000000 --- a/CSharpBible/Data/.github/copilot-instructions.md +++ /dev/null @@ -1,43 +0,0 @@ -# Repository instructions for GitHub Copilot - -Apply these defaults when working in this repository unless the user explicitly asks otherwise: - -## General Guidelines -- Document code thoroughly in English. -- Validate changes with relevant builds and tests before finishing. -- If requirements are unclear, ask clarifying questions before starting implementation. -- Avoid UI text strings in core services. Use Enumerations instead, and keep UI-facing strings in the ViewModel/UI layer. -- Prefer one class/interface/struct per file. - -## Testing -- Use `MSTest` in the latest practical version for new or updated tests. -- Use `NSubstitute` for mocks, stubs, and substitutes in tests. -- Prefer `DataRow` for parameterized single-test scenarios. - -## Internationalization -- Keep I18N in mind when writing code, ensuring it can be easily adapted for different languages and regions. - -## Architecture -- Use MVVM architecture for UI components to separate concerns and improve testability, using CommunityToolkit.Mvvm for MVVM implementation. -- Prefer `NotifyPropertyChangedFor` over manual `OnPropertyChanged(nameof(...))` in CommunityToolkit.Mvvm observable properties where applicable. -- Use Dependency Injection to manage dependencies and improve testability, using Microsoft.Extensions.DependencyInjection. -- UI-facing strings and summary formatting should stay in the ViewModel/UI layer, not in extracted application logic services. - -## Naming Conventions -- Use PascalCase for class names, method names, and properties. -- Use _camelCase for local/private variables and parameters. -- Use 1 letter prefixes for type of variable, e.g. in model classes, use: - - `s` for string, - - `i` for all int (8-128bit), - - `u` for Unsigned Int (8-128 bit), - - `x` for bool, - - `f` for float/double, -- Use 3 letter prefixes for UI elements, use: - - `lst` for list, - - `btn` for all kind of buttons, - - `edt` for all kind of text-inputs, - - `chk` for checkboxes, - - `lbl` for all kind of text-displaying elements, - -## Nullability -- Use strict nullable reference types to indicate when a variable can be null, and handle nullability appropriately in code. diff --git a/CSharpBible/Data/Data.sln b/CSharpBible/Data/Data.sln index 283cdcc78..b430e9315 100644 --- a/CSharpBible/Data/Data.sln +++ b/CSharpBible/Data/Data.sln @@ -86,6 +86,65 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RepoMigrator.Tools.Pipeline EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RepoMigrator.App.State", "RepoMigrator\RepoMigrator.App.State\RepoMigrator.App.State.csproj", "{39D79860-9660-6BC9-FA71-859DCCFAA8DE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataAnalysis.Core", "DataAnalysis\DataAnalysis.Core\DataAnalysis.Core.csproj", "{5D098C1D-2635-123F-04FC-E3A7D2F1AEFC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DataAnalysis", "DataAnalysis", "{D5CAB17D-F57B-4423-B1CC-B5E4FFE9CBE1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TraceAnalysis", "TraceAnalysis", "{6BB16C4B-D416-4484-ACEF-1A47B631913E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataAnalysis.Core.Tests", "DataAnalysis\DataAnalysis.Core.Tests\DataAnalysis.Core.Tests.csproj", "{B8F7D539-BD4D-2CE5-D84C-E1ACF2AC562A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataAnalysis.WPF", "DataAnalysis\DataAnalysis.WPF\DataAnalysis.WPF.csproj", "{8AD9DDC7-BF8C-DEA5-00E4-DCB621D9E26C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataAnalysis.WPF.TestHarness", "DataAnalysis\DataAnalysis.WPF.TestHarness\DataAnalysis.WPF.TestHarness.csproj", "{21458ED8-7326-4CEB-E16E-23CF18752D92}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataConvert.Console", "DataAnalysis\DataConvert.Console\DataConvert.Console.csproj", "{1E6E7853-EC14-1EF7-4592-5A8C04EEEFEE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DevOps", "DevOps", "{BC5F1FA9-CC5A-4F9B-A765-E986E81F59B1}" + ProjectSection(SolutionItems) = preProject + DevOps\.info.md = DevOps\.info.md + DevOps\README.md = DevOps\README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Epics", "Epics", "{6A185ABC-B2A5-45C8-8402-1EE3A3E54102}" + ProjectSection(SolutionItems) = preProject + DevOps\Epics\830302-TraceAnalysis-and-AGV-reverse-simulation.md = DevOps\Epics\830302-TraceAnalysis-and-AGV-reverse-simulation.md + DevOps\Epics\README.md = DevOps\Epics\README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Features", "Features", "{E66EA522-9CAC-4E3D-988E-D55550FBA679}" + ProjectSection(SolutionItems) = preProject + DevOps\Features\830329-Filter-based-trace-intake-and-export-foundation.md = DevOps\Features\830329-Filter-based-trace-intake-and-export-foundation.md + DevOps\Features\README.md = DevOps\Features\README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Backlog-Items", "Backlog-Items", "{69FB2434-DA6E-4623-95FD-103AE1C4E8B4}" + ProjectSection(SolutionItems) = preProject + DevOps\BacklogItems\BI-830334-Create-CSV-output-filter.md = DevOps\BacklogItems\BI-830334-Create-CSV-output-filter.md + DevOps\BacklogItems\BI-830331-Define-canonical-trace-exchange-model.md = DevOps\BacklogItems\BI-830331-Define-canonical-trace-exchange-model.md + DevOps\BacklogItems\BI-830332-Create-pluggable-input-filters-for-initial-source-formats.md = DevOps\BacklogItems\BI-830332-Create-pluggable-input-filters-for-initial-source-formats.md + DevOps\BacklogItems\README.md = DevOps\BacklogItems\README.md + DevOps\BacklogItems\BI-830337-Create-Excel-output-filter.md = DevOps\BacklogItems\BI-830337-Create-Excel-output-filter.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tasks", "Tasks", "{08C40D64-DCC0-436D-A857-4EF3EA655486}" + ProjectSection(SolutionItems) = preProject + DevOps\Tasks\README.md = DevOps\Tasks\README.md + DevOps\Tasks\T-830302-001-Identify-first-supported-source-formats.md = DevOps\Tasks\T-830302-001-Identify-first-supported-source-formats.md + DevOps\Tasks\T-830302-002-Define-canonical-fields-and-optional-metadata.md = DevOps\Tasks\T-830302-002-Define-canonical-fields-and-optional-metadata.md + DevOps\Tasks\T-830302-003-Specify-input-filter-interface-and-selection-strategy.md = DevOps\Tasks\T-830302-003-Specify-input-filter-interface-and-selection-strategy.md + DevOps\Tasks\T-830302-004-Specify-CSV-column-mapping-and-export-behavior.md = DevOps\Tasks\T-830302-004-Specify-CSV-column-mapping-and-export-behavior.md + DevOps\Tasks\T-830395-Specify-Excel-workbook-layout-and-export-behavior.md = DevOps\Tasks\T-830395-Specify-Excel-workbook-layout-and-export-behavior.md + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TraceAnalysis.Base", "TraceAnalysis\TraceAnalysis.Base\TraceAnalysis.Base.csproj", "{65231580-7BC5-4956-B1BC-D62133F5D3B1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseLibTests", "..\Libraries\BaseLibTests\BaseLibTests.csproj", "{FDA82907-F072-6943-3484-8D582E6131F8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TraceAnalysis.Base.Tests", "TraceAnalysis\TraceAnalysis.Base.Tests\TraceAnalysis.Base.Tests.csproj", "{45044F5F-A9F4-4DC9-AB09-AAAF8A5484A2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TraceAnalysis.Filter.CSV", "TraceAnalysis\TraceAnalysis.Filter.CSV\TraceAnalysis.Filter.CSV.csproj", "{E0141A8E-6993-D1B2-C82A-1BC6B29377C6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -444,6 +503,114 @@ Global {39D79860-9660-6BC9-FA71-859DCCFAA8DE}.Release|x64.Build.0 = Release|Any CPU {39D79860-9660-6BC9-FA71-859DCCFAA8DE}.Release|x86.ActiveCfg = Release|Any CPU {39D79860-9660-6BC9-FA71-859DCCFAA8DE}.Release|x86.Build.0 = Release|Any CPU + {5D098C1D-2635-123F-04FC-E3A7D2F1AEFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D098C1D-2635-123F-04FC-E3A7D2F1AEFC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D098C1D-2635-123F-04FC-E3A7D2F1AEFC}.Debug|x64.ActiveCfg = Debug|Any CPU + {5D098C1D-2635-123F-04FC-E3A7D2F1AEFC}.Debug|x64.Build.0 = Debug|Any CPU + {5D098C1D-2635-123F-04FC-E3A7D2F1AEFC}.Debug|x86.ActiveCfg = Debug|Any CPU + {5D098C1D-2635-123F-04FC-E3A7D2F1AEFC}.Debug|x86.Build.0 = Debug|Any CPU + {5D098C1D-2635-123F-04FC-E3A7D2F1AEFC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D098C1D-2635-123F-04FC-E3A7D2F1AEFC}.Release|Any CPU.Build.0 = Release|Any CPU + {5D098C1D-2635-123F-04FC-E3A7D2F1AEFC}.Release|x64.ActiveCfg = Release|Any CPU + {5D098C1D-2635-123F-04FC-E3A7D2F1AEFC}.Release|x64.Build.0 = Release|Any CPU + {5D098C1D-2635-123F-04FC-E3A7D2F1AEFC}.Release|x86.ActiveCfg = Release|Any CPU + {5D098C1D-2635-123F-04FC-E3A7D2F1AEFC}.Release|x86.Build.0 = Release|Any CPU + {B8F7D539-BD4D-2CE5-D84C-E1ACF2AC562A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8F7D539-BD4D-2CE5-D84C-E1ACF2AC562A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8F7D539-BD4D-2CE5-D84C-E1ACF2AC562A}.Debug|x64.ActiveCfg = Debug|Any CPU + {B8F7D539-BD4D-2CE5-D84C-E1ACF2AC562A}.Debug|x64.Build.0 = Debug|Any CPU + {B8F7D539-BD4D-2CE5-D84C-E1ACF2AC562A}.Debug|x86.ActiveCfg = Debug|Any CPU + {B8F7D539-BD4D-2CE5-D84C-E1ACF2AC562A}.Debug|x86.Build.0 = Debug|Any CPU + {B8F7D539-BD4D-2CE5-D84C-E1ACF2AC562A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8F7D539-BD4D-2CE5-D84C-E1ACF2AC562A}.Release|Any CPU.Build.0 = Release|Any CPU + {B8F7D539-BD4D-2CE5-D84C-E1ACF2AC562A}.Release|x64.ActiveCfg = Release|Any CPU + {B8F7D539-BD4D-2CE5-D84C-E1ACF2AC562A}.Release|x64.Build.0 = Release|Any CPU + {B8F7D539-BD4D-2CE5-D84C-E1ACF2AC562A}.Release|x86.ActiveCfg = Release|Any CPU + {B8F7D539-BD4D-2CE5-D84C-E1ACF2AC562A}.Release|x86.Build.0 = Release|Any CPU + {8AD9DDC7-BF8C-DEA5-00E4-DCB621D9E26C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8AD9DDC7-BF8C-DEA5-00E4-DCB621D9E26C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8AD9DDC7-BF8C-DEA5-00E4-DCB621D9E26C}.Debug|x64.ActiveCfg = Debug|Any CPU + {8AD9DDC7-BF8C-DEA5-00E4-DCB621D9E26C}.Debug|x64.Build.0 = Debug|Any CPU + {8AD9DDC7-BF8C-DEA5-00E4-DCB621D9E26C}.Debug|x86.ActiveCfg = Debug|Any CPU + {8AD9DDC7-BF8C-DEA5-00E4-DCB621D9E26C}.Debug|x86.Build.0 = Debug|Any CPU + {8AD9DDC7-BF8C-DEA5-00E4-DCB621D9E26C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8AD9DDC7-BF8C-DEA5-00E4-DCB621D9E26C}.Release|Any CPU.Build.0 = Release|Any CPU + {8AD9DDC7-BF8C-DEA5-00E4-DCB621D9E26C}.Release|x64.ActiveCfg = Release|Any CPU + {8AD9DDC7-BF8C-DEA5-00E4-DCB621D9E26C}.Release|x64.Build.0 = Release|Any CPU + {8AD9DDC7-BF8C-DEA5-00E4-DCB621D9E26C}.Release|x86.ActiveCfg = Release|Any CPU + {8AD9DDC7-BF8C-DEA5-00E4-DCB621D9E26C}.Release|x86.Build.0 = Release|Any CPU + {21458ED8-7326-4CEB-E16E-23CF18752D92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21458ED8-7326-4CEB-E16E-23CF18752D92}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21458ED8-7326-4CEB-E16E-23CF18752D92}.Debug|x64.ActiveCfg = Debug|Any CPU + {21458ED8-7326-4CEB-E16E-23CF18752D92}.Debug|x64.Build.0 = Debug|Any CPU + {21458ED8-7326-4CEB-E16E-23CF18752D92}.Debug|x86.ActiveCfg = Debug|Any CPU + {21458ED8-7326-4CEB-E16E-23CF18752D92}.Debug|x86.Build.0 = Debug|Any CPU + {21458ED8-7326-4CEB-E16E-23CF18752D92}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21458ED8-7326-4CEB-E16E-23CF18752D92}.Release|Any CPU.Build.0 = Release|Any CPU + {21458ED8-7326-4CEB-E16E-23CF18752D92}.Release|x64.ActiveCfg = Release|Any CPU + {21458ED8-7326-4CEB-E16E-23CF18752D92}.Release|x64.Build.0 = Release|Any CPU + {21458ED8-7326-4CEB-E16E-23CF18752D92}.Release|x86.ActiveCfg = Release|Any CPU + {21458ED8-7326-4CEB-E16E-23CF18752D92}.Release|x86.Build.0 = Release|Any CPU + {1E6E7853-EC14-1EF7-4592-5A8C04EEEFEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E6E7853-EC14-1EF7-4592-5A8C04EEEFEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E6E7853-EC14-1EF7-4592-5A8C04EEEFEE}.Debug|x64.ActiveCfg = Debug|Any CPU + {1E6E7853-EC14-1EF7-4592-5A8C04EEEFEE}.Debug|x64.Build.0 = Debug|Any CPU + {1E6E7853-EC14-1EF7-4592-5A8C04EEEFEE}.Debug|x86.ActiveCfg = Debug|Any CPU + {1E6E7853-EC14-1EF7-4592-5A8C04EEEFEE}.Debug|x86.Build.0 = Debug|Any CPU + {1E6E7853-EC14-1EF7-4592-5A8C04EEEFEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E6E7853-EC14-1EF7-4592-5A8C04EEEFEE}.Release|Any CPU.Build.0 = Release|Any CPU + {1E6E7853-EC14-1EF7-4592-5A8C04EEEFEE}.Release|x64.ActiveCfg = Release|Any CPU + {1E6E7853-EC14-1EF7-4592-5A8C04EEEFEE}.Release|x64.Build.0 = Release|Any CPU + {1E6E7853-EC14-1EF7-4592-5A8C04EEEFEE}.Release|x86.ActiveCfg = Release|Any CPU + {1E6E7853-EC14-1EF7-4592-5A8C04EEEFEE}.Release|x86.Build.0 = Release|Any CPU + {65231580-7BC5-4956-B1BC-D62133F5D3B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65231580-7BC5-4956-B1BC-D62133F5D3B1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65231580-7BC5-4956-B1BC-D62133F5D3B1}.Debug|x64.ActiveCfg = Debug|Any CPU + {65231580-7BC5-4956-B1BC-D62133F5D3B1}.Debug|x64.Build.0 = Debug|Any CPU + {65231580-7BC5-4956-B1BC-D62133F5D3B1}.Debug|x86.ActiveCfg = Debug|Any CPU + {65231580-7BC5-4956-B1BC-D62133F5D3B1}.Debug|x86.Build.0 = Debug|Any CPU + {65231580-7BC5-4956-B1BC-D62133F5D3B1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65231580-7BC5-4956-B1BC-D62133F5D3B1}.Release|Any CPU.Build.0 = Release|Any CPU + {65231580-7BC5-4956-B1BC-D62133F5D3B1}.Release|x64.ActiveCfg = Release|Any CPU + {65231580-7BC5-4956-B1BC-D62133F5D3B1}.Release|x64.Build.0 = Release|Any CPU + {65231580-7BC5-4956-B1BC-D62133F5D3B1}.Release|x86.ActiveCfg = Release|Any CPU + {65231580-7BC5-4956-B1BC-D62133F5D3B1}.Release|x86.Build.0 = Release|Any CPU + {FDA82907-F072-6943-3484-8D582E6131F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FDA82907-F072-6943-3484-8D582E6131F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FDA82907-F072-6943-3484-8D582E6131F8}.Debug|x64.ActiveCfg = Debug|x64 + {FDA82907-F072-6943-3484-8D582E6131F8}.Debug|x64.Build.0 = Debug|x64 + {FDA82907-F072-6943-3484-8D582E6131F8}.Debug|x86.ActiveCfg = Debug|Any CPU + {FDA82907-F072-6943-3484-8D582E6131F8}.Debug|x86.Build.0 = Debug|Any CPU + {FDA82907-F072-6943-3484-8D582E6131F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FDA82907-F072-6943-3484-8D582E6131F8}.Release|Any CPU.Build.0 = Release|Any CPU + {FDA82907-F072-6943-3484-8D582E6131F8}.Release|x64.ActiveCfg = Release|x64 + {FDA82907-F072-6943-3484-8D582E6131F8}.Release|x64.Build.0 = Release|x64 + {FDA82907-F072-6943-3484-8D582E6131F8}.Release|x86.ActiveCfg = Release|Any CPU + {FDA82907-F072-6943-3484-8D582E6131F8}.Release|x86.Build.0 = Release|Any CPU + {45044F5F-A9F4-4DC9-AB09-AAAF8A5484A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {45044F5F-A9F4-4DC9-AB09-AAAF8A5484A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {45044F5F-A9F4-4DC9-AB09-AAAF8A5484A2}.Debug|x64.ActiveCfg = Debug|Any CPU + {45044F5F-A9F4-4DC9-AB09-AAAF8A5484A2}.Debug|x64.Build.0 = Debug|Any CPU + {45044F5F-A9F4-4DC9-AB09-AAAF8A5484A2}.Debug|x86.ActiveCfg = Debug|Any CPU + {45044F5F-A9F4-4DC9-AB09-AAAF8A5484A2}.Debug|x86.Build.0 = Debug|Any CPU + {45044F5F-A9F4-4DC9-AB09-AAAF8A5484A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {45044F5F-A9F4-4DC9-AB09-AAAF8A5484A2}.Release|Any CPU.Build.0 = Release|Any CPU + {45044F5F-A9F4-4DC9-AB09-AAAF8A5484A2}.Release|x64.ActiveCfg = Release|Any CPU + {45044F5F-A9F4-4DC9-AB09-AAAF8A5484A2}.Release|x64.Build.0 = Release|Any CPU + {45044F5F-A9F4-4DC9-AB09-AAAF8A5484A2}.Release|x86.ActiveCfg = Release|Any CPU + {45044F5F-A9F4-4DC9-AB09-AAAF8A5484A2}.Release|x86.Build.0 = Release|Any CPU + {E0141A8E-6993-D1B2-C82A-1BC6B29377C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0141A8E-6993-D1B2-C82A-1BC6B29377C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0141A8E-6993-D1B2-C82A-1BC6B29377C6}.Debug|x64.ActiveCfg = Debug|Any CPU + {E0141A8E-6993-D1B2-C82A-1BC6B29377C6}.Debug|x64.Build.0 = Debug|Any CPU + {E0141A8E-6993-D1B2-C82A-1BC6B29377C6}.Debug|x86.ActiveCfg = Debug|Any CPU + {E0141A8E-6993-D1B2-C82A-1BC6B29377C6}.Debug|x86.Build.0 = Debug|Any CPU + {E0141A8E-6993-D1B2-C82A-1BC6B29377C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0141A8E-6993-D1B2-C82A-1BC6B29377C6}.Release|Any CPU.Build.0 = Release|Any CPU + {E0141A8E-6993-D1B2-C82A-1BC6B29377C6}.Release|x64.ActiveCfg = Release|Any CPU + {E0141A8E-6993-D1B2-C82A-1BC6B29377C6}.Release|x64.Build.0 = Release|Any CPU + {E0141A8E-6993-D1B2-C82A-1BC6B29377C6}.Release|x86.ActiveCfg = Release|Any CPU + {E0141A8E-6993-D1B2-C82A-1BC6B29377C6}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -466,7 +633,9 @@ Global {9C6E06EC-4C60-4B02-AB2E-E24ACB81AA65} = {C72F7201-74F9-66F9-5C2C-ABF0F08448DC} {7446B1C7-C30B-41F4-AD7B-C21B6A805078} = {112ACC04-4DC7-4E32-80A4-508E79A28E0C} {62DE7992-D20A-43E3-B113-B299AD9D0092} = {112ACC04-4DC7-4E32-80A4-508E79A28E0C} + {A17E8BCA-5068-4FAF-B155-BACBAF5368DC} = {6BB16C4B-D416-4484-ACEF-1A47B631913E} {8CA6365C-0665-4D6F-8CC9-0A1339886316} = {51F6C20B-003C-430C-BED7-2A89F834E4C0} + {30EA08E2-CB3E-4049-B3C0-A77F1CBC7A3C} = {6BB16C4B-D416-4484-ACEF-1A47B631913E} {2780B100-CAB5-4C83-B158-ED7C737944B0} = {3C73C616-12F2-478C-9CAC-823780861BCD} {42156DBB-5FC0-3FE1-FC43-55400E7FDFAD} = {3C73C616-12F2-478C-9CAC-823780861BCD} {BFCB4F4A-F6A3-EB13-DB02-B0C1979AFDEA} = {3C73C616-12F2-478C-9CAC-823780861BCD} @@ -476,6 +645,20 @@ Global {521E30F6-FCC5-AB89-413C-475AFF2A6C2D} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {E526046E-41C0-8852-26F4-3224217E43E6} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {39D79860-9660-6BC9-FA71-859DCCFAA8DE} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {5D098C1D-2635-123F-04FC-E3A7D2F1AEFC} = {D5CAB17D-F57B-4423-B1CC-B5E4FFE9CBE1} + {B8F7D539-BD4D-2CE5-D84C-E1ACF2AC562A} = {D5CAB17D-F57B-4423-B1CC-B5E4FFE9CBE1} + {8AD9DDC7-BF8C-DEA5-00E4-DCB621D9E26C} = {D5CAB17D-F57B-4423-B1CC-B5E4FFE9CBE1} + {21458ED8-7326-4CEB-E16E-23CF18752D92} = {D5CAB17D-F57B-4423-B1CC-B5E4FFE9CBE1} + {1E6E7853-EC14-1EF7-4592-5A8C04EEEFEE} = {D5CAB17D-F57B-4423-B1CC-B5E4FFE9CBE1} + {BC5F1FA9-CC5A-4F9B-A765-E986E81F59B1} = {6BB16C4B-D416-4484-ACEF-1A47B631913E} + {6A185ABC-B2A5-45C8-8402-1EE3A3E54102} = {BC5F1FA9-CC5A-4F9B-A765-E986E81F59B1} + {E66EA522-9CAC-4E3D-988E-D55550FBA679} = {BC5F1FA9-CC5A-4F9B-A765-E986E81F59B1} + {69FB2434-DA6E-4623-95FD-103AE1C4E8B4} = {BC5F1FA9-CC5A-4F9B-A765-E986E81F59B1} + {08C40D64-DCC0-436D-A857-4EF3EA655486} = {BC5F1FA9-CC5A-4F9B-A765-E986E81F59B1} + {65231580-7BC5-4956-B1BC-D62133F5D3B1} = {6BB16C4B-D416-4484-ACEF-1A47B631913E} + {FDA82907-F072-6943-3484-8D582E6131F8} = {51F6C20B-003C-430C-BED7-2A89F834E4C0} + {45044F5F-A9F4-4DC9-AB09-AAAF8A5484A2} = {6BB16C4B-D416-4484-ACEF-1A47B631913E} + {E0141A8E-6993-D1B2-C82A-1BC6B29377C6} = {6BB16C4B-D416-4484-ACEF-1A47B631913E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {128BE64A-28F5-47C5-A045-2352EF09BFBB} diff --git a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core.Tests/DataAnalysis.Core.Tests.csproj b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core.Tests/DataAnalysis.Core.Tests.csproj index d26d35186..caadd8cd1 100644 --- a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core.Tests/DataAnalysis.Core.Tests.csproj +++ b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core.Tests/DataAnalysis.Core.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.App.State/RepoMigrator.App.State.csproj b/CSharpBible/Data/RepoMigrator/RepoMigrator.App.State/RepoMigrator.App.State.csproj index 05e9a5095..e32f201da 100644 --- a/CSharpBible/Data/RepoMigrator/RepoMigrator.App.State/RepoMigrator.App.State.csproj +++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.App.State/RepoMigrator.App.State.csproj @@ -12,7 +12,7 @@ - + diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/ViewModels/MainViewModel.cs b/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/ViewModels/MainViewModel.cs index 308060ffb..4d9d5bdfb 100644 --- a/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/ViewModels/MainViewModel.cs +++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/ViewModels/MainViewModel.cs @@ -32,7 +32,6 @@ public partial class MainViewModel : ObservableObject, IMigrationProgress private string? _sSelectedSvnFromRevisionId; private string? _sSelectedSvnToRevisionId; private string? _sCurrentChangeSetId; - private WorkflowStage _workflowStage = WorkflowStage.Setup; private string? _targetUser; private string? _targetPassword; private bool _xUsePipelinedMigration; @@ -214,6 +213,14 @@ public string? SelectedSvnToRevisionId [NotifyPropertyChangedFor(nameof(IsIdle))] [NotifyPropertyChangedFor(nameof(CanStartMigration))] public partial bool IsRunning { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSetupStage))] + [NotifyPropertyChangedFor(nameof(IsOptionsStage))] + [NotifyPropertyChangedFor(nameof(IsExecutionStage))] + [NotifyPropertyChangedFor(nameof(ShowCompactEndpointSummaries))] + public partial WorkflowStage WorkflowStage { get; private set; } = WorkflowStage.Setup; + public bool IsIdle => !IsRunning; public bool CanConfigureGitHistory => SourceType == RepoType.Git && TargetType == RepoType.Git; public bool CanConfigureSvnRevisions => SourceType == RepoType.Svn; @@ -365,20 +372,6 @@ public void Report(MigrationReportSeverity severity, MigrationReportMessage mess Append(FormatProgressMessage(message, arrAdditional)); } - public WorkflowStage WorkflowStage - { - get => _workflowStage; - private set - { - if (!SetProperty(ref _workflowStage, value)) - return; - - OnPropertyChanged(nameof(IsSetupStage)); - OnPropertyChanged(nameof(IsOptionsStage)); - OnPropertyChanged(nameof(IsExecutionStage)); - OnPropertyChanged(nameof(ShowCompactEndpointSummaries)); - } - } private void ApplyProgressState(MigrationReportMessage message, object?[] arrAdditional) { @@ -854,7 +847,7 @@ private void SetWorkflowStage(WorkflowStage workflowStage) if (_isLoadingInputState) return; - if (IsRunning && workflowStage != WorkflowStage.Execution) + if (IsRunning && _migration.IsRunning && workflowStage != WorkflowStage.Execution && WorkflowStage == WorkflowStage.Execution) return; WorkflowStage = workflowStage; diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/IMigrationService.cs b/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/IMigrationService.cs index a3f9ec61d..912c0ab6e 100644 --- a/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/IMigrationService.cs +++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/IMigrationService.cs @@ -5,6 +5,8 @@ namespace RepoMigrator.Core; /// public interface IMigrationService { + bool IsRunning { get; } + /// /// Migrates changes from a source repository endpoint to a target repository endpoint. /// diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Services/MigrationService.cs b/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Services/MigrationService.cs index 8f13dd35c..5baa97149 100644 --- a/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Services/MigrationService.cs +++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Services/MigrationService.cs @@ -7,7 +7,7 @@ namespace RepoMigrator.Core; public sealed class MigrationService : IMigrationService { private readonly IProviderFactory _factory; - + public bool IsRunning { get; private set; } public MigrationService(IProviderFactory factory) => _factory = factory; public async Task MigrateAsync( @@ -18,24 +18,32 @@ public async Task MigrateAsync( IMigrationProgress progress, CancellationToken ct) { - if (options.TransferMode == RepositoryTransferMode.NativeHistory) + try { - await using var src = _factory.Create(source.Type); - progress.Report(MigrationReportSeverity.Information, MigrationReportMessage.SourceOpening, src.Name); - await src.OpenAsync(source, ct); - progress.Report(MigrationReportSeverity.Information, MigrationReportMessage.NativeHistoryTransferStarting, src.Name, target.Type); - await src.TransferAsync(source, target, options, progress, ct); - progress.Report(MigrationReportSeverity.Information, MigrationReportMessage.MigrationCompleted); - return; - } + IsRunning = true; + if (options.TransferMode == RepositoryTransferMode.NativeHistory) + { + await using var src = _factory.Create(source.Type); + progress.Report(MigrationReportSeverity.Information, MigrationReportMessage.SourceOpening, src.Name); + await src.OpenAsync(source, ct); + progress.Report(MigrationReportSeverity.Information, MigrationReportMessage.NativeHistoryTransferStarting, src.Name, target.Type); + await src.TransferAsync(source, target, options, progress, ct); + progress.Report(MigrationReportSeverity.Information, MigrationReportMessage.MigrationCompleted); + return; + } - if (options.ExecutionMode == MigrationExecutionMode.Pipelined && CanUsePipelinedExecution(source, target)) - { - await MigrateSnapshotsPipelinedAsync(source, target, query, options, progress, ct); - return; - } + if (options.ExecutionMode == MigrationExecutionMode.Pipelined && CanUsePipelinedExecution(source, target)) + { + await MigrateSnapshotsPipelinedAsync(source, target, query, options, progress, ct); + return; + } - await MigrateSnapshotsSequentialAsync(source, target, query, options, progress, ct); + await MigrateSnapshotsSequentialAsync(source, target, query, options, progress, ct); + } + finally + { + IsRunning = false; + } } private async Task MigrateSnapshotsSequentialAsync( diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.Tests/RepoMigrator.Tests.csproj b/CSharpBible/Data/RepoMigrator/RepoMigrator.Tests/RepoMigrator.Tests.csproj index d5a311c3d..d2d3b8d44 100644 --- a/CSharpBible/Data/RepoMigrator/RepoMigrator.Tests/RepoMigrator.Tests.csproj +++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.Tests/RepoMigrator.Tests.csproj @@ -7,8 +7,8 @@ enable - - + + diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base.Tests/Filters/InputFilterSelectorTests.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base.Tests/Filters/InputFilterSelectorTests.cs new file mode 100644 index 000000000..fd176af60 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base.Tests/Filters/InputFilterSelectorTests.cs @@ -0,0 +1,159 @@ +using NSubstitute; +using TraceAnalysis.Base.Filters; +using TraceAnalysis.Base.Models.Interfaces; + +namespace TraceAnalysis.Base.Tests.Filters; + +[TestClass] +public class InputFilterSelectorTests +{ + [TestMethod] + public void Select_WhenManualOverrideMatches_ReturnsManualFilter() + { + var selector = new InputFilterSelector(); + var automaticFilter = CreateFilter("AutoFilter", priority: 1, canHandle: true, confidence: 100, isExactExtensionMatch: true); + var manualFilter = CreateFilter("ManualFilter", priority: 1, canHandle: true, confidence: 1, isExactExtensionMatch: false); + + var sourceDescriptor = new FilterSourceDescriptor( + sourceId: "sample", + suggestedExtension: ".csv", + manualFilterId: "ManualFilter"); + + var result = selector.Select(new[] { automaticFilter, manualFilter }, CreateSeekableStream(), sourceDescriptor); + + Assert.IsNotNull(result.SelectedFilter); + Assert.AreEqual("ManualFilter", result.SelectedFilter.FilterId); + } + + [TestMethod] + public void Select_WhenManualOverrideCannotHandle_UsesDeterministicRanking() + { + var selector = new InputFilterSelector(); + var automaticFilter = CreateFilter("AutoFilter", priority: 1, canHandle: true, confidence: 50, isExactExtensionMatch: true); + var manualFilter = CreateFilter("ManualFilter", priority: 1, canHandle: false, confidence: 200, isExactExtensionMatch: true); + + var sourceDescriptor = new FilterSourceDescriptor( + sourceId: "sample", + suggestedExtension: ".csv", + manualFilterId: "ManualFilter"); + + var result = selector.Select(new[] { automaticFilter, manualFilter }, CreateSeekableStream(), sourceDescriptor); + + Assert.IsNotNull(result.SelectedFilter); + Assert.AreEqual("AutoFilter", result.SelectedFilter.FilterId); + } + + [DataTestMethod] + [DataRow(90, 70, "FilterA")] + [DataRow(10, 40, "FilterB")] + public void Select_WhenConfidenceDiffers_ChoosesHigherConfidence(int filterAConfidence, int filterBConfidence, string expectedFilterId) + { + var selector = new InputFilterSelector(); + var filterA = CreateFilter("FilterA", priority: 1, canHandle: true, confidence: filterAConfidence, isExactExtensionMatch: true); + var filterB = CreateFilter("FilterB", priority: 1, canHandle: true, confidence: filterBConfidence, isExactExtensionMatch: true); + + var result = selector.Select(new[] { filterA, filterB }, CreateSeekableStream(), new FilterSourceDescriptor("sample", ".csv")); + + Assert.IsNotNull(result.SelectedFilter); + Assert.AreEqual(expectedFilterId, result.SelectedFilter.FilterId); + } + + [TestMethod] + public void Select_WhenConfidenceTies_ChoosesExactExtensionMatch() + { + var selector = new InputFilterSelector(); + var exactMatchFilter = CreateFilter("ExactFilter", priority: 1, canHandle: true, confidence: 100, isExactExtensionMatch: true); + var nonExactMatchFilter = CreateFilter("NonExactFilter", priority: 10, canHandle: true, confidence: 100, isExactExtensionMatch: false); + + var result = selector.Select(new[] { nonExactMatchFilter, exactMatchFilter }, CreateSeekableStream(), new FilterSourceDescriptor("sample", ".csv")); + + Assert.IsNotNull(result.SelectedFilter); + Assert.AreEqual("ExactFilter", result.SelectedFilter.FilterId); + } + + [TestMethod] + public void Select_WhenConfidenceAndExtensionTie_ChoosesHigherPriority() + { + var selector = new InputFilterSelector(); + var lowPriorityFilter = CreateFilter("LowPriority", priority: 1, canHandle: true, confidence: 100, isExactExtensionMatch: true); + var highPriorityFilter = CreateFilter("HighPriority", priority: 10, canHandle: true, confidence: 100, isExactExtensionMatch: true); + + var result = selector.Select(new[] { lowPriorityFilter, highPriorityFilter }, CreateSeekableStream(), new FilterSourceDescriptor("sample", ".csv")); + + Assert.IsNotNull(result.SelectedFilter); + Assert.AreEqual("HighPriority", result.SelectedFilter.FilterId); + } + + [TestMethod] + public void Select_WhenAllRankingDimensionsTie_ChoosesStableFilterIdOrder() + { + var selector = new InputFilterSelector(); + var filterB = CreateFilter("FilterB", priority: 1, canHandle: true, confidence: 100, isExactExtensionMatch: true); + var filterA = CreateFilter("FilterA", priority: 1, canHandle: true, confidence: 100, isExactExtensionMatch: true); + + var result = selector.Select(new[] { filterB, filterA }, CreateSeekableStream(), new FilterSourceDescriptor("sample", ".csv")); + + Assert.IsNotNull(result.SelectedFilter); + Assert.AreEqual("FilterA", result.SelectedFilter.FilterId); + } + + [TestMethod] + public void Select_WhenNoFilterMatches_ReturnsNullSelectionAndAllAnalyses() + { + var selector = new InputFilterSelector(); + var filterA = CreateFilter("FilterA", priority: 1, canHandle: false, confidence: 0, isExactExtensionMatch: false, decisionLines: ["A"]); + var filterB = CreateFilter("FilterB", priority: 1, canHandle: false, confidence: 0, isExactExtensionMatch: false, decisionLines: ["B"]); + + var result = selector.Select(new[] { filterA, filterB }, CreateSeekableStream(), new FilterSourceDescriptor("sample", ".csv")); + + Assert.IsNull(result.SelectedFilter); + Assert.AreEqual(2, result.Analyses.Count); + } + + [TestMethod] + public void Select_WithNonSeekableStream_PerformsAnalysisAndReturnsDecisionLines() + { + var selector = new InputFilterSelector(); + var filterA = CreateFilter("FilterA", priority: 1, canHandle: true, confidence: 10, isExactExtensionMatch: true, decisionLines: ["HeaderOk", "Delimiter=;" ]); + var filterB = CreateFilter("FilterB", priority: 1, canHandle: true, confidence: 5, isExactExtensionMatch: true, decisionLines: ["Fallback"]); + + using var stream = new NonSeekableReadStream(System.Text.Encoding.UTF8.GetBytes("timestamp;value\n2024-01-01T00:00:00.000Z;1\n")); + var result = selector.Select(new[] { filterA, filterB }, stream, new FilterSourceDescriptor("sample", ".csv")); + + Assert.IsNotNull(result.SelectedFilter); + Assert.AreEqual("FilterA", result.SelectedFilter.FilterId); + CollectionAssert.Contains(result.Analyses[0].DecisionLines.ToList(), "HeaderOk"); + } + + private static IAnalyzableInputFilter CreateFilter( + string filterId, + int priority, + bool canHandle, + int confidence, + bool isExactExtensionMatch, + IEnumerable? decisionLines = null) + { + var filter = Substitute.For(); + filter.FilterId.Returns(filterId); + filter.Priority.Returns(priority); + + filter.Analyze(Arg.Any(), Arg.Any()) + .Returns(new InputFilterAnalysisResult( + filterId: filterId, + canHandle: canHandle, + confidenceScore: confidence, + isExactExtensionMatch: isExactExtensionMatch, + decisionLines: decisionLines)); + + filter.CanHandle(Arg.Any(), Arg.Any()).Returns(canHandle); + filter.Read(Arg.Any(), Arg.Any()).Returns(Substitute.For()); + filter.Read(Arg.Any(), Arg.Any()).Returns(Substitute.For()); + + return filter; + } + + private static Stream CreateSeekableStream() + { + return new MemoryStream(System.Text.Encoding.UTF8.GetBytes("timestamp;value\n")); + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base.Tests/Filters/NonSeekableReadStream.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base.Tests/Filters/NonSeekableReadStream.cs new file mode 100644 index 000000000..3584d83ff --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base.Tests/Filters/NonSeekableReadStream.cs @@ -0,0 +1,60 @@ +namespace TraceAnalysis.Base.Tests.Filters; + +/// +/// Read-only stream wrapper that disables seeking to validate stream buffering behavior. +/// +public sealed class NonSeekableReadStream : Stream +{ + private readonly MemoryStream _innerStream; + + public NonSeekableReadStream(byte[] buffer) + { + _innerStream = new MemoryStream(buffer); + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => _innerStream.Length; + + public override long Position + { + get => _innerStream.Position; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + return _innerStream.Read(buffer, offset, count); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + _innerStream.Dispose(); + + base.Dispose(disposing); + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base.Tests/MSTestSettings.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base.Tests/MSTestSettings.cs new file mode 100644 index 000000000..aaf278c84 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base.Tests/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base.Tests/TraceAnalysis.Base.Tests.csproj b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base.Tests/TraceAnalysis.Base.Tests.csproj new file mode 100644 index 000000000..03773a6c4 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base.Tests/TraceAnalysis.Base.Tests.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + latest + enable + enable + true + + + + + + + + diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/FilterSourceDescriptor.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/FilterSourceDescriptor.cs new file mode 100644 index 000000000..92814dae4 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/FilterSourceDescriptor.cs @@ -0,0 +1,61 @@ +using System; + +namespace TraceAnalysis.Base.Filters +{ + /// + /// Describes a logical input source for stream-based filter selection without + /// forcing file-system specific behavior. + /// + public sealed class FilterSourceDescriptor + { + /// + /// Initializes a new instance of . + /// + /// Stable source identifier, for example a file path or logical stream id. + /// Optional extension hint (e.g. ".csv"). + /// Optional content type hint. + /// Optional display name for diagnostics. + /// Optional manual filter override identifier. + public FilterSourceDescriptor( + string sourceId, + string? suggestedExtension = null, + string? contentTypeHint = null, + string? displayName = null, + string? manualFilterId = null) + { + if (string.IsNullOrWhiteSpace(sourceId)) + throw new ArgumentException("A source identifier is required.", nameof(sourceId)); + + SourceId = sourceId; + SuggestedExtension = suggestedExtension; + ContentTypeHint = contentTypeHint; + DisplayName = displayName; + ManualFilterId = manualFilterId; + } + + /// + /// Stable source identifier, for example a file path or logical stream id. + /// + public string SourceId { get; } + + /// + /// Optional extension hint used for prefiltering candidates. + /// + public string? SuggestedExtension { get; } + + /// + /// Optional content type hint. + /// + public string? ContentTypeHint { get; } + + /// + /// Optional display label used in diagnostics. + /// + public string? DisplayName { get; } + + /// + /// Optional manual filter override identifier. + /// + public string? ManualFilterId { get; } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/IAnalyzableInputFilter.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/IAnalyzableInputFilter.cs new file mode 100644 index 000000000..89f3ed5e3 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/IAnalyzableInputFilter.cs @@ -0,0 +1,38 @@ +using System.IO; +using TraceAnalysis.Base.Models.Interfaces; + +namespace TraceAnalysis.Base.Filters +{ + /// + /// Extended input-filter contract that supports deterministic source analysis + /// and ranking-based selection. + /// + public interface IAnalyzableInputFilter : IInputFilter + { + /// + /// Stable filter identifier used for deterministic tie-breaking and manual selection. + /// + string FilterId { get; } + + /// + /// Configured filter priority used in deterministic ranking when confidence ties occur. + /// + int Priority { get; } + + /// + /// Analyses the source and returns deterministic filter-selection data. + /// + /// Source stream to inspect. + /// Logical source descriptor and selection hints. + /// Analysis result for deterministic ranking. + InputFilterAnalysisResult Analyze(Stream stream, FilterSourceDescriptor sourceDescriptor); + + /// + /// Reads the source stream into a canonical data set using a source descriptor. + /// + /// Source stream. + /// Logical source descriptor and selection hints. + /// Canonical trace data set with optional parse errors. + ITraceDataSet Read(Stream stream, FilterSourceDescriptor sourceDescriptor); + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/IInputFilter.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/IInputFilter.cs new file mode 100644 index 000000000..971d3b8f3 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/IInputFilter.cs @@ -0,0 +1,32 @@ +using System.IO; +using TraceAnalysis.Base.Models.Interfaces; + +namespace TraceAnalysis.Base.Filters +{ + /// + /// Contract for pluggable input filters that convert a source stream + /// into the canonical structure. + /// + public interface IInputFilter + { + /// + /// Determines whether this filter can handle the given stream. + /// Implementations should inspect the stream without permanently consuming its content. + /// + /// The source stream to inspect. + /// An identifier for the source, e.g. a file path or label. + /// true if this filter can process the stream; otherwise false. + bool CanHandle(Stream _stream, string _sourceId); + + /// + /// Reads the source stream and returns the canonical trace data set. + /// Parse errors are collected in rather than thrown. + /// + /// The source stream to read from. + /// An identifier for the source, e.g. a file path or label. + /// + /// A canonical with all imported records and any parse errors. + /// + ITraceDataSet Read(Stream _stream, string _sourceId); + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/IInputFilterSelector.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/IInputFilterSelector.cs new file mode 100644 index 000000000..891befdaa --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/IInputFilterSelector.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.IO; + +namespace TraceAnalysis.Base.Filters +{ + /// + /// Selects the most suitable for a given source + /// using deterministic ranking rules. + /// + public interface IInputFilterSelector + { + /// + /// Selects the best matching filter and returns full analysis details. + /// + /// Registered analyzable input filters. + /// Source stream used for inspection. + /// Logical source descriptor and hints. + /// Selection result including all analysis outputs. + InputFilterSelectionResult Select( + IEnumerable filters, + Stream stream, + FilterSourceDescriptor sourceDescriptor); + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/IOutputFilter.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/IOutputFilter.cs new file mode 100644 index 000000000..2a2365ba5 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/IOutputFilter.cs @@ -0,0 +1,19 @@ +using System.IO; +using TraceAnalysis.Base.Models.Interfaces; + +namespace TraceAnalysis.Base.Filters +{ + /// + /// Contract for pluggable output filters that serialize the canonical + /// structure to a target stream. + /// + public interface IOutputFilter + { + /// + /// Writes the canonical trace data set to the target stream. + /// + /// The canonical data set to export. + /// The target stream to write to. + void Write(ITraceDataSet _dataSet, Stream _stream); + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/InputFilterAnalysisResult.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/InputFilterAnalysisResult.cs new file mode 100644 index 000000000..ec8b9d3f8 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/InputFilterAnalysisResult.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace TraceAnalysis.Base.Filters +{ + /// + /// Represents the deterministic analysis output of an input filter for a specific source. + /// + public sealed class InputFilterAnalysisResult + { + /// + /// Initializes a new instance of . + /// + /// Unique filter identifier. + /// Indicates whether the filter can process the source. + /// Confidence score used for deterministic ranking. + /// Indicates whether extension matching is exact. + /// Human-readable decision lines for diagnostics. + public InputFilterAnalysisResult( + string filterId, + bool canHandle, + int confidenceScore, + bool isExactExtensionMatch, + IEnumerable? decisionLines = null) + { + if (string.IsNullOrWhiteSpace(filterId)) + throw new ArgumentException("A filter identifier is required.", nameof(filterId)); + + FilterId = filterId; + CanHandle = canHandle; + ConfidenceScore = confidenceScore; + IsExactExtensionMatch = isExactExtensionMatch; + DecisionLines = (decisionLines ?? Enumerable.Empty()).ToArray(); + } + + /// + /// Unique filter identifier. + /// + public string FilterId { get; } + + /// + /// Indicates whether the filter can process the source. + /// + public bool CanHandle { get; } + + /// + /// Confidence score used for deterministic ranking. + /// + public int ConfidenceScore { get; } + + /// + /// Indicates whether extension matching is exact. + /// + public bool IsExactExtensionMatch { get; } + + /// + /// Human-readable decision lines for diagnostics. + /// + public IReadOnlyList DecisionLines { get; } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/InputFilterSelectionResult.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/InputFilterSelectionResult.cs new file mode 100644 index 000000000..ece761d9e --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/InputFilterSelectionResult.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; + +namespace TraceAnalysis.Base.Filters +{ + /// + /// Contains the selected input filter and the corresponding analysis details. + /// + public sealed class InputFilterSelectionResult + { + /// + /// Initializes a new instance of . + /// + /// Selected filter or null if no filter matches. + /// All filter analyses produced during selection. + public InputFilterSelectionResult( + IAnalyzableInputFilter? selectedFilter, + IEnumerable? analyses) + { + SelectedFilter = selectedFilter; + Analyses = (analyses ?? Enumerable.Empty()).ToArray(); + } + + /// + /// Selected filter or null if no filter matches. + /// + public IAnalyzableInputFilter? SelectedFilter { get; } + + /// + /// All filter analyses produced during selection. + /// + public IReadOnlyList Analyses { get; } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/InputFilterSelector.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/InputFilterSelector.cs new file mode 100644 index 000000000..b003adb0d --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Filters/InputFilterSelector.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace TraceAnalysis.Base.Filters +{ + /// + /// Default deterministic selector for . + /// + public sealed class InputFilterSelector : IInputFilterSelector + { + /// + public InputFilterSelectionResult Select( + IEnumerable filters, + Stream stream, + FilterSourceDescriptor sourceDescriptor) + { + if (filters == null) + throw new ArgumentNullException(nameof(filters)); + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + if (sourceDescriptor == null) + throw new ArgumentNullException(nameof(sourceDescriptor)); + + var filterList = filters.ToList(); + if (filterList.Count == 0) + return new InputFilterSelectionResult(null, Array.Empty()); + + var rewindableStream = PrepareRewindableStream(stream); + var analyses = new List(filterList.Count); + + foreach (var filter in filterList) + { + ResetStream(rewindableStream); + analyses.Add(filter.Analyze(rewindableStream, sourceDescriptor)); + } + + var manualSelection = SelectManualOverride(filterList, analyses, sourceDescriptor.ManualFilterId); + if (manualSelection != null) + return new InputFilterSelectionResult(manualSelection, analyses); + + var candidates = analyses + .Where(a => a.CanHandle) + .Join(filterList, a => a.FilterId, f => f.FilterId, (analysis, filter) => new Candidate(filter, analysis)) + .ToList(); + + if (candidates.Count == 0) + return new InputFilterSelectionResult(null, analyses); + + var selectedCandidate = candidates + .OrderByDescending(c => c.Analysis.ConfidenceScore) + .ThenByDescending(c => c.Analysis.IsExactExtensionMatch) + .ThenByDescending(c => c.Filter.Priority) + .ThenBy(c => c.Filter.FilterId, StringComparer.Ordinal) + .First(); + + return new InputFilterSelectionResult(selectedCandidate.Filter, analyses); + } + + private static IAnalyzableInputFilter? SelectManualOverride( + IReadOnlyList filters, + IReadOnlyCollection analyses, + string? manualFilterId) + { + if (string.IsNullOrWhiteSpace(manualFilterId)) + return null; + + var filter = filters.FirstOrDefault(f => string.Equals(f.FilterId, manualFilterId, StringComparison.Ordinal)); + if (filter == null) + return null; + + var analysis = analyses.FirstOrDefault(a => string.Equals(a.FilterId, filter.FilterId, StringComparison.Ordinal)); + return analysis != null && analysis.CanHandle ? filter : null; + } + + private static Stream PrepareRewindableStream(Stream sourceStream) + { + if (sourceStream.CanSeek) + return sourceStream; + + var buffered = new MemoryStream(); + sourceStream.CopyTo(buffered); + buffered.Position = 0; + return buffered; + } + + private static void ResetStream(Stream stream) + { + if (!stream.CanSeek) + return; + + stream.Position = 0; + } + + private sealed class Candidate + { + public Candidate(IAnalyzableInputFilter filter, InputFilterAnalysisResult analysis) + { + Filter = filter; + Analysis = analysis; + } + + public IAnalyzableInputFilter Filter { get; } + + public InputFilterAnalysisResult Analysis { get; } + } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/Interfaces/ITraceDataSet.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/Interfaces/ITraceDataSet.cs new file mode 100644 index 000000000..a04c68ba1 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/Interfaces/ITraceDataSet.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace TraceAnalysis.Base.Models.Interfaces +{ + /// + /// The canonical intermediate structure that carries imported trace data + /// between input and output filters. + /// + public interface ITraceDataSet + { + /// Source-level metadata describing the origin and field layout of the data set. + ITraceMetadata Metadata { get; } + + /// Ordered list of canonical trace records. + IReadOnlyList Records { get; } + + /// + /// Errors collected during parsing. + /// Processing continues with partial results when errors occur. + /// + IReadOnlyList ParseErrors { get; } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/Interfaces/ITraceFieldMetadata.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/Interfaces/ITraceFieldMetadata.cs new file mode 100644 index 000000000..89308c3c7 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/Interfaces/ITraceFieldMetadata.cs @@ -0,0 +1,25 @@ +using System; + +namespace TraceAnalysis.Base.Models.Interfaces +{ + /// + /// Describes the metadata of a single field within a trace data set. + /// + public interface ITraceFieldMetadata + { + /// Field name as it appears in the source. + string sName { get; } + + /// + /// Inferred field group derived from a shared name prefix and separator, + /// e.g. "AGV1" from "AGV1.X", or null if no group is applicable. + /// + string? sGroup { get; } + + /// Optional format string for display or export purposes. + string? sFormat { get; } + + /// CLR type of the field values, or null if unknown. + Type? FieldType { get; } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/Interfaces/ITraceMetadata.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/Interfaces/ITraceMetadata.cs new file mode 100644 index 000000000..feea6fa81 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/Interfaces/ITraceMetadata.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace TraceAnalysis.Base.Models.Interfaces +{ + /// + /// Describes source-level metadata for a complete trace data set. + /// + public interface ITraceMetadata + { + /// Identifies the source of the trace data, e.g. a file path or stream label. + string sSourceId { get; } + + /// Ordered list of field metadata definitions for the data set. + IReadOnlyList Fields { get; } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/Interfaces/ITraceRecord.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/Interfaces/ITraceRecord.cs new file mode 100644 index 000000000..f2d1551f1 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/Interfaces/ITraceRecord.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace TraceAnalysis.Base.Models.Interfaces +{ + /// + /// Represents a single canonical trace row. + /// The timestamp is the only mandatory canonical field; + /// all other field values are optional and are passed through unchanged. + /// + public interface ITraceRecord + { + /// + /// Mandatory canonical timestamp or row key. + /// All other values are optional. + /// + object Timestamp { get; } + + /// + /// Field values keyed by field name; null when a value is absent + /// for that field in this record. + /// + IReadOnlyDictionary Values { get; } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/TraceDataSet.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/TraceDataSet.cs new file mode 100644 index 000000000..1f55d44b8 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/TraceDataSet.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using TraceAnalysis.Base.Models.Interfaces; + +namespace TraceAnalysis.Base.Models +{ + /// + /// Default implementation of . + /// + public class TraceDataSet : ITraceDataSet + { + private readonly List _parseErrors = new(); + + /// + public ITraceMetadata Metadata { get; } + + /// + public IReadOnlyList Records { get; } + + /// + public IReadOnlyList ParseErrors => _parseErrors; + + /// + /// Initializes a new instance of . + /// + /// Source-level metadata for this data set. + /// Ordered list of canonical trace records. + /// Errors collected during parsing, if any. + public TraceDataSet( + ITraceMetadata _metadata, + IReadOnlyList _records, + IEnumerable? _errors = null) + { + Metadata = _metadata; + Records = _records; + if (_errors != null) + _parseErrors.AddRange(_errors); + } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/TraceFieldMetadata.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/TraceFieldMetadata.cs new file mode 100644 index 000000000..667c8e6f4 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/TraceFieldMetadata.cs @@ -0,0 +1,60 @@ +using System; +using TraceAnalysis.Base.Models.Interfaces; + +namespace TraceAnalysis.Base.Models +{ + /// + /// Default implementation of . + /// + public class TraceFieldMetadata : ITraceFieldMetadata + { + /// + public string sName { get; } + + /// + public string? sGroup { get; } + + /// + public string? sFormat { get; } + + /// + public Type? FieldType { get; } + + /// + /// Initializes a new instance of . + /// The field group is inferred from the field name when not provided explicitly. + /// + /// Field name as it appears in the source. + /// CLR type of the field values, or null if unknown. + /// + /// Optional field group; when null the group is inferred from the field name + /// using '.' or '_' as structural separators. + /// + /// Optional format string for display or export. + public TraceFieldMetadata(string _name, Type? _fieldType = null, string? _group = null, string? _format = null) + { + sName = _name; + FieldType = _fieldType; + sGroup = _group ?? InferGroup(_name); + sFormat = _format; + } + + /// + /// Infers a field group from the field name using '.' or '_' as structural + /// separators (e.g. "AGV1" from "AGV1.X"). + /// Returns null if no separator with a non-empty prefix is found. + /// + private static string? InferGroup(string _name) + { + var dotIndex = _name.IndexOf('.'); + if (dotIndex > 0) + return _name.Substring(0, dotIndex); + + var underscoreIndex = _name.IndexOf('_'); + if (underscoreIndex > 0) + return _name.Substring(0, underscoreIndex); + + return null; + } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/TraceMetadata.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/TraceMetadata.cs new file mode 100644 index 000000000..47545e8bf --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/TraceMetadata.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using TraceAnalysis.Base.Models.Interfaces; + +namespace TraceAnalysis.Base.Models +{ + /// + /// Default implementation of . + /// + public class TraceMetadata : ITraceMetadata + { + /// + public string sSourceId { get; } + + /// + public IReadOnlyList Fields { get; } + + /// + /// Initializes a new instance of . + /// + /// Identifier for the trace source, e.g. a file path or stream label. + /// Ordered field metadata definitions. + public TraceMetadata(string _sourceId, IReadOnlyList _fields) + { + sSourceId = _sourceId; + Fields = _fields; + } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/TraceRecord.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/TraceRecord.cs new file mode 100644 index 000000000..66fff6b66 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/Models/TraceRecord.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using TraceAnalysis.Base.Models.Interfaces; + +namespace TraceAnalysis.Base.Models +{ + /// + /// Default implementation of . + /// + public class TraceRecord : ITraceRecord + { + /// + public object Timestamp { get; } + + /// + public IReadOnlyDictionary Values { get; } + + /// + /// Initializes a new instance of . + /// + /// Mandatory timestamp or row key for this record. + /// Field values keyed by field name. + public TraceRecord(object _timestamp, IReadOnlyDictionary _values) + { + Timestamp = _timestamp; + Values = _values; + } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/TraceAnalysis.Base.csproj b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/TraceAnalysis.Base.csproj new file mode 100644 index 000000000..a49b2325e --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Base/TraceAnalysis.Base.csproj @@ -0,0 +1,16 @@ + + + + Library + net462;net472;net48;net481;net6.0;net7.0;net8.0 + + + + + $(TargetFrameworks);net9.0 + + + $(TargetFrameworks);net10.0 + + + diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Filters/CsvOutputFilter.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Filters/CsvOutputFilter.cs new file mode 100644 index 000000000..8164e2fb4 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Filters/CsvOutputFilter.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.IO; +using TraceAnalysis.Base.Filters; +using TraceAnalysis.Base.Models.Interfaces; +using TraceAnalysis.Filter.CSV.Model; + +namespace TraceAnalysis.Filter.CSV.Filters +{ + /// + /// Output filter that serializes the canonical + /// to a tab-separated CSV stream. + /// + public class CsvOutputFilter : IOutputFilter + { + private readonly char _separator; + + /// + /// Initializes a new instance of . + /// + /// + /// Column separator character. Defaults to '\t' (tab). + /// + public CsvOutputFilter(char _separator = '\t') + { + this._separator = _separator; + } + + /// + /// + /// The first written column is the timestamp / row key. + /// Subsequent columns follow the field order defined in + /// . + /// Missing optional field values are written as empty cells. + /// + public void Write(ITraceDataSet _dataSet, Stream _stream) + { + var model = new CsvModel(); + + // Build header: timestamp key column first, then one column per field. + var header = new List<(string name, System.Type? type)> + { + ("TimeBase", typeof(object)) + }; + foreach (var field in _dataSet.Metadata.Fields) + header.Add((field.sName, field.FieldType)); + + model.SetHeader(header); + + // Append one row per canonical record. + foreach (var record in _dataSet.Records) + { + var rowValues = new List { record.Timestamp }; + foreach (var field in _dataSet.Metadata.Fields) + rowValues.Add(record.Values.TryGetValue(field.sName, out var v) ? v : null); + + model.AppendData(rowValues); + } + + model.WriteCSV(_stream, _separator); + } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Filters/FlatCsvInputFilter.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Filters/FlatCsvInputFilter.cs new file mode 100644 index 000000000..fc58810c2 --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Filters/FlatCsvInputFilter.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using TraceAnalysis.Base.Filters; +using TraceAnalysis.Base.Models; +using TraceAnalysis.Base.Models.Interfaces; +using TraceAnalysis.Filter.CSV.Model; + +namespace TraceAnalysis.Filter.CSV.Filters +{ + /// + /// Input filter for flat CSV files with a header row. + /// Supports common separators (;, \t, ,). + /// + public class FlatCsvInputFilter : IInputFilter + { + /// Expected first line of a TraceCsv stream (used for format exclusion). + private const string TraceCsvHeader = "[key]; [value]"; + + /// + /// + /// Detects the format by file extension (.csv) first, + /// then confirms the stream header is a flat CSV header row + /// (i.e. not a TraceCsv stream). + /// The stream position is restored after inspection. + /// Returns false when the stream does not support seeking. + /// + public bool CanHandle(Stream _stream, string _sourceId) + { + if (!_stream.CanSeek) + return false; + + var ext = System.IO.Path.GetExtension(_sourceId); + if (!string.IsNullOrEmpty(ext) && !ext.Equals(".csv", StringComparison.OrdinalIgnoreCase)) + return false; + + var startPos = _stream.Position; + try + { + using var reader = new StreamReader(_stream, Encoding.UTF8, true, 1024, leaveOpen: true); + var firstLine = reader.ReadLine(); + return !string.IsNullOrEmpty(firstLine) && firstLine != TraceCsvHeader; + } + finally + { + _stream.Position = startPos; + } + } + + /// + /// + /// Reads the flat CSV stream into a and converts it + /// to the canonical structure. + /// The first column is used as the mandatory timestamp / row key. + /// All remaining columns are treated as optional value fields. + /// Parse errors are collected in . + /// + public ITraceDataSet Read(Stream _stream, string _sourceId) + { + var errors = new List(); + var model = new CsvModel(); + + try + { + model.ReadCsv(_stream); + } + catch (Exception ex) + { + errors.Add($"Failed to read flat CSV from '{_sourceId}': {ex.Message}"); + return new TraceDataSet( + new TraceMetadata(_sourceId, Array.Empty()), + Array.Empty(), + errors); + } + + // Build field metadata from header, skipping the first column (= timestamp key). + var fields = new List(); + for (var i = 1; i < model.Header.Count; i++) + fields.Add(new TraceFieldMetadata(model.Header[i].name, model.Header[i].type)); + + // Convert each row to a canonical record. + var records = new List(); + for (var i = 0; i < model.Rows.Count; i++) + { + var row = model.Rows[i]; + var timestampKey = model.Header.Count > 0 ? model.Header[0].name : string.Empty; + var timestamp = row.TryGetValue(timestampKey, out var ts) ? ts : (object)i; + var values = new Dictionary(); + foreach (var kvp in row) + if (kvp.Key != timestampKey) + values[kvp.Key] = kvp.Value; + + records.Add(new TraceRecord(timestamp, values)); + } + + return new TraceDataSet(new TraceMetadata(_sourceId, fields), records, errors); + } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Filters/TraceCsvInputFilter.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Filters/TraceCsvInputFilter.cs new file mode 100644 index 000000000..2ebc529ce --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Filters/TraceCsvInputFilter.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using TraceAnalysis.Base.Filters; +using TraceAnalysis.Base.Models; +using TraceAnalysis.Base.Models.Interfaces; +using TraceAnalysis.Filter.CSV.Model; + +namespace TraceAnalysis.Filter.CSV.Filters +{ + /// + /// Input filter for the TraceCsv format produced by trace-export tools. + /// The format is identified by the header line [key]; [value]. + /// + public class TraceCsvInputFilter : IInputFilter + { + /// Expected first line of a TraceCsv stream. + private const string TraceCsvHeader = "[key]; [value]"; + + /// + /// + /// Detects the format by file extension (.csv) first, + /// then verifies the stream header line. + /// The stream position is restored after inspection. + /// Returns false when the stream does not support seeking. + /// + public bool CanHandle(Stream _stream, string _sourceId) + { + if (!_stream.CanSeek) + return false; + + var ext = System.IO.Path.GetExtension(_sourceId); + if (!string.IsNullOrEmpty(ext) && !ext.Equals(".csv", StringComparison.OrdinalIgnoreCase)) + return false; + + var startPos = _stream.Position; + try + { + using var reader = new StreamReader(_stream, Encoding.UTF8, true, 1024, leaveOpen: true); + var firstLine = reader.ReadLine(); + return firstLine == TraceCsvHeader; + } + finally + { + _stream.Position = startPos; + } + } + + /// + /// + /// Reads the TraceCsv stream into a and converts it + /// to the canonical structure. + /// The first column (TimeBase) is used as the mandatory timestamp. + /// All other columns are treated as optional double fields. + /// Parse errors are collected in . + /// + public ITraceDataSet Read(Stream _stream, string _sourceId) + { + var errors = new List(); + var model = new CsvModel(); + + try + { + model.ReadTraceCSV(_stream); + } + catch (Exception ex) + { + errors.Add($"Failed to read TraceCsv from '{_sourceId}': {ex.Message}"); + return new TraceDataSet( + new TraceMetadata(_sourceId, Array.Empty()), + Array.Empty(), + errors); + } + + // Build field metadata from header, skipping the first column (TimeBase = timestamp key). + var fields = new List(); + for (var i = 1; i < model.Header.Count; i++) + fields.Add(new TraceFieldMetadata(model.Header[i].name, model.Header[i].type)); + + // Convert each row to a canonical record. + var records = new List(); + for (var i = 0; i < model.Rows.Count; i++) + { + var row = model.Rows[i]; + var timestamp = row.TryGetValue("TimeBase", out var ts) ? ts : (object)i; + var values = new Dictionary(); + foreach (var kvp in row) + if (kvp.Key != "TimeBase") + values[kvp.Key] = kvp.Value; + + records.Add(new TraceRecord(timestamp, values)); + } + + return new TraceDataSet(new TraceMetadata(_sourceId, fields), records, errors); + } + } +} diff --git a/CSharpBible/Data/TraceCsv2realCsv/Model/CsvModel.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Model/CsvModel.cs similarity index 96% rename from CSharpBible/Data/TraceCsv2realCsv/Model/CsvModel.cs rename to CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Model/CsvModel.cs index d306e6bbf..a742112d5 100644 --- a/CSharpBible/Data/TraceCsv2realCsv/Model/CsvModel.cs +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Model/CsvModel.cs @@ -1,232 +1,235 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using BaseLib.Helper; - -namespace TraceCsv2realCsv.Model -{ - public class CsvModel - { - #region internal Class - public class _DataRows - { - CsvModel _parent; - public int Count => _parent._data.Count; - - public int Fields => _parent._header.Count; - - public Dictionary this[int ix] - { - get - { - var val = new Dictionary(); - var row = _parent._data.ElementAt(ix); - var i = 0; - val[_parent._header[i++].name] = row.Key; - foreach (var fieldval in _parent._data.ElementAt(ix).Value) - val[_parent._header[i++].name] = fieldval; - return val; - } - } - - public _DataRows(CsvModel parent) - => _parent = parent; - } - #endregion - #region Properties - // Header - private List<(string name, Type? type)> _header = new(); - // Data - private Dictionary> _data = new(); - private List _separators = new() { ";", "\t", "," }; - const char cQuotation = '\"'; - public _DataRows Rows { get; } - #endregion - - #region Methods - public CsvModel() - { - Rows = new _DataRows(this); - } - public bool ReadCsv(Stream st) - => ReadCsv(st, _separators); - - public bool ReadCsv(Stream st, List separators) - { - using var tr = new StreamReader(st, true); - // Header - var line = tr.ReadLine(); - var seperator = _separators.FirstOrDefault(s => line.Contains(s)) ?? ","; - _header.Clear(); - var header = line.Split(new string[] { seperator }, StringSplitOptions.None).ToList(); - - // Lesen Sie die ersten fünf Zeilen der CSV-Datei und trennen Sie die Werte. - var lines = new List>(); - for (int i = 0; i < 5 && !tr.EndOfStream; i++) - { - lines.Add(SplitCSVLine(tr.ReadLine(), seperator, cQuotation)); - } - - // Bestimmen Sie den Datentyp jeder Spalte. - for (int i = 0; i < header.Count; i++) - { - _header.Add((header[i], GetColumnType(lines.Select(l => l[i]).ToList()))); - } - - foreach (var lnVals in lines) - AppendData(CastList(lnVals)); - - while (!tr.EndOfStream) - { - line = tr.ReadLine(); - var values = SplitCSVLine(line, seperator, cQuotation); - - AppendData(CastList(values)); - } - return true; - } - - public void AppendData(List values) - // _data - { - // Hier können Sie die Werte in Ihre Klasse einfügen. - var Index = values[0]; - values.RemoveAt(0); - _data.Add(Index, values); - } - - public void AppendData(object[][] values) - // _data - { - // Hier können Sie die Werte in Ihre Klasse einfügen. - foreach (object?[] o in values) - AppendData(o.ToList()); - } - public List CastList(List? values) - // Benutzt _header - { - List _List = new(); - for (int i = 0;values != null && i < Math.Min(values.Count, _header.Count); i++) - _List.Add(_header[i].type?.Get(values[i])); - return _List; - } - - public static List SplitCSVLine(string line, string seperator, char quotation = cQuotation) - { - var values = new List(); - int i = 0; - int pn; - if (string.IsNullOrEmpty(seperator)) - return new() { line }; - string value = ""; - - while (i < line.Length) - { - if (line.Substring(i).StartsWith(seperator)) - { - values.Add(value); - i += seperator.Length; - value = ""; - if (i == line.Length) - values.Add(value); - } - else if ((line[i] == quotation) - && (((pn = line.IndexOf($"{quotation}" + seperator, i)) > -1) - || (line[pn = line.Length - 1] == quotation))) - { - value = line.Substring(i, pn - i + 1); - i = pn + 1; // length of quotation - if (i == line.Length) - values.Add(value); - } - else if ((line[i] != quotation) - && (pn = line.IndexOf(seperator, i)) > -1) - { - value = line.Substring(i, pn - i); - i = pn; - } - else - { - values.Add(line.Substring(i)); - i = line.Length; - } - } - return values; - } - - public static Type GetColumnType(List values, char quotation = cQuotation) - { - // Versuchen Sie zuerst, Mit den Qutations die Strings herauszufiltern. -#if NET5_0_OR_GREATER - if (values.All(v => v.StartsWith(quotation) && v.EndsWith(quotation))) -#else - if (values.All(v => v.StartsWith(quotation.ToString()) && v.EndsWith(quotation.ToString()))) -#endif - return typeof(string); - else - // Dann versuchen Sie, den Datentyp als Ganzzahl zu bestimmen. - if (values.All(v => int.TryParse(v, out _))) - { - return typeof(int); - } - - // Versuchen Sie dann, den Datentyp als Gleitkommazahl zu bestimmen. - else if (values.All(v => double.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out _))) - { - return typeof(double); - } - - // Wenn alle Werte Zeichenfolgen sind oder nicht in einen Ganzzahl- oder Gleitkommazahl-Datentyp konvertiert werden können, - // geben Sie den Datentyp als Zeichenfolge zurück. - else - return typeof(string); - } - - public void SetHeader(List lsHNames) - { - _data.Clear(); - _header.Clear(); - foreach (string s in lsHNames) - _header.Add((s, typeof(object))); - } - public void SetHeader(List<(string , Type?)> lsHeader) - { - _data.Clear(); - _header.Clear(); - _header = lsHeader; - } - - public void WriteCSV(Stream stream, char cSeparator = '\t') - { - using var tw = new StreamWriter(stream, Encoding.UTF8); - // Write Header - tw.WriteLine(string.Join($"{cSeparator}", _header.ConvertAll((l) => $"\"{l.name}\""))); - // Write Data - foreach (var l in _data) - tw.WriteLine($"{(l.Key is string sl ? $"\"{sl.Quote()}\"" : l.Key)}{cSeparator}" + string.Join($"{cSeparator}", l.Value.ConvertAll((l) => l is string sl ? $"\"{sl.Quote()}\"" : l is double d ? $"{d.ToString(CultureInfo.InvariantCulture)}" : $"{l}"))); - - } - - public void AddColumnData(string header, Dictionary? data) - { - var icolidx = _header.ConvertAll(s => s.name).IndexOf(header); - if (icolidx > 0 && data !=null) - foreach (var r in data) - if (_data.TryGetValue(r.Key, out var ls)) - ls[icolidx - 1] = r.Value; - else - { - ls = new(); - foreach (var h in _header) - if (h.name != _header[0].name) - ls.Add(0d); - ls[icolidx - 1] = r.Value; - _data.Add(r.Key, ls); - } - } - #endregion - } -} +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using BaseLib.Helper; + +namespace TraceAnalysis.Filter.CSV.Model +{ + public class CsvModel + { + #region internal Class + public class _DataRows + { + CsvModel _parent; + public int Count => _parent._data.Count; + + public int Fields => _parent._header.Count; + + public Dictionary this[int ix] + { + get + { + var val = new Dictionary(); + var row = _parent._data.ElementAt(ix); + var i = 0; + val[_parent._header[i++].name] = row.Key; + foreach (var fieldval in _parent._data.ElementAt(ix).Value) + val[_parent._header[i++].name] = fieldval; + return val; + } + } + + public _DataRows(CsvModel parent) + => _parent = parent; + } + #endregion + #region Properties + // Header + private List<(string name, Type? type)> _header = new(); + // Data + private Dictionary> _data = new(); + private List _separators = new() { ";", "\t", "," }; + const char cQuotation = '\"'; + public _DataRows Rows { get; } + + /// Returns the ordered list of (name, type) header entries for all columns. + public IReadOnlyList<(string name, Type? type)> Header => _header; + #endregion + + #region Methods + public CsvModel() + { + Rows = new _DataRows(this); + } + public bool ReadCsv(Stream st) + => ReadCsv(st, _separators); + + public bool ReadCsv(Stream st, List separators) + { + using var tr = new StreamReader(st, true); + // Header + var line = tr.ReadLine(); + var seperator = _separators.FirstOrDefault(s => line.Contains(s)) ?? ","; + _header.Clear(); + var header = line.Split(new string[] { seperator }, StringSplitOptions.None).ToList(); + + // Lesen Sie die ersten fünf Zeilen der CSV-Datei und trennen Sie die Werte. + var lines = new List>(); + for (int i = 0; i < 5 && !tr.EndOfStream; i++) + { + lines.Add(SplitCSVLine(tr.ReadLine(), seperator, cQuotation)); + } + + // Bestimmen Sie den Datentyp jeder Spalte. + for (int i = 0; i < header.Count; i++) + { + _header.Add((header[i], GetColumnType(lines.Select(l => l[i]).ToList()))); + } + + foreach (var lnVals in lines) + AppendData(CastList(lnVals)); + + while (!tr.EndOfStream) + { + line = tr.ReadLine(); + var values = SplitCSVLine(line, seperator, cQuotation); + + AppendData(CastList(values)); + } + return true; + } + + public void AppendData(List values) + // _data + { + // Hier können Sie die Werte in Ihre Klasse einfügen. + var Index = values[0]; + values.RemoveAt(0); + _data.Add(Index, values); + } + + public void AppendData(object[][] values) + // _data + { + // Hier können Sie die Werte in Ihre Klasse einfügen. + foreach (object?[] o in values) + AppendData(o.ToList()); + } + public List CastList(List? values) + // Benutzt _header + { + List _List = new(); + for (int i = 0;values != null && i < Math.Min(values.Count, _header.Count); i++) + _List.Add(_header[i].type?.Get(values[i])); + return _List; + } + + public static List SplitCSVLine(string line, string seperator, char quotation = cQuotation) + { + var values = new List(); + int i = 0; + int pn; + if (string.IsNullOrEmpty(seperator)) + return new() { line }; + string value = ""; + + while (i < line.Length) + { + if (line.Substring(i).StartsWith(seperator)) + { + values.Add(value); + i += seperator.Length; + value = ""; + if (i == line.Length) + values.Add(value); + } + else if ((line[i] == quotation) + && (((pn = line.IndexOf($"{quotation}" + seperator, i)) > -1) + || (line[pn = line.Length - 1] == quotation))) + { + value = line.Substring(i, pn - i + 1); + i = pn + 1; // length of quotation + if (i == line.Length) + values.Add(value); + } + else if ((line[i] != quotation) + && (pn = line.IndexOf(seperator, i)) > -1) + { + value = line.Substring(i, pn - i); + i = pn; + } + else + { + values.Add(line.Substring(i)); + i = line.Length; + } + } + return values; + } + + public static Type GetColumnType(List values, char quotation = cQuotation) + { + // Versuchen Sie zuerst, Mit den Qutations die Strings herauszufiltern. +#if NET5_0_OR_GREATER + if (values.All(v => v.StartsWith(quotation) && v.EndsWith(quotation))) +#else + if (values.All(v => v.StartsWith(quotation.ToString()) && v.EndsWith(quotation.ToString()))) +#endif + return typeof(string); + else + // Dann versuchen Sie, den Datentyp als Ganzzahl zu bestimmen. + if (values.All(v => int.TryParse(v, out _))) + { + return typeof(int); + } + + // Versuchen Sie dann, den Datentyp als Gleitkommazahl zu bestimmen. + else if (values.All(v => double.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out _))) + { + return typeof(double); + } + + // Wenn alle Werte Zeichenfolgen sind oder nicht in einen Ganzzahl- oder Gleitkommazahl-Datentyp konvertiert werden können, + // geben Sie den Datentyp als Zeichenfolge zurück. + else + return typeof(string); + } + + public void SetHeader(List lsHNames) + { + _data.Clear(); + _header.Clear(); + foreach (string s in lsHNames) + _header.Add((s, typeof(object))); + } + public void SetHeader(List<(string , Type?)> lsHeader) + { + _data.Clear(); + _header.Clear(); + _header = lsHeader; + } + + public void WriteCSV(Stream stream, char cSeparator = '\t') + { + using var tw = new StreamWriter(stream, Encoding.UTF8); + // Write Header + tw.WriteLine(string.Join($"{cSeparator}", _header.ConvertAll((l) => $"\"{l.name}\""))); + // Write Data + foreach (var l in _data) + tw.WriteLine($"{(l.Key is string sl ? $"\"{sl.Quote()}\"" : l.Key)}{cSeparator}" + string.Join($"{cSeparator}", l.Value.ConvertAll((l) => l is string sl ? $"\"{sl.Quote()}\"" : l is double d ? $"{d.ToString(CultureInfo.InvariantCulture)}" : $"{l}"))); + + } + + public void AddColumnData(string header, Dictionary? data) + { + var icolidx = _header.ConvertAll(s => s.name).IndexOf(header); + if (icolidx > 0 && data !=null) + foreach (var r in data) + if (_data.TryGetValue(r.Key, out var ls)) + ls[icolidx - 1] = r.Value; + else + { + ls = new(); + foreach (var h in _header) + if (h.name != _header[0].name) + ls.Add(0d); + ls[icolidx - 1] = r.Value; + _data.Add(r.Key, ls); + } + } + #endregion + } +} diff --git a/CSharpBible/Data/TraceCsv2realCsv/Model/TraceCSVReader.cs b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Model/TraceCSVReader.cs similarity index 95% rename from CSharpBible/Data/TraceCsv2realCsv/Model/TraceCSVReader.cs rename to CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Model/TraceCSVReader.cs index 21d7ca11f..8bad9157d 100644 --- a/CSharpBible/Data/TraceCsv2realCsv/Model/TraceCSVReader.cs +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/Model/TraceCSVReader.cs @@ -1,66 +1,66 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; - -namespace TraceCsv2realCsv.Model -{ - public static class TraceCSVReader - { - public static void ReadTraceCSV(this CsvModel model, Stream stream) - { - using (var reader = new StreamReader(stream)) - { - // Check Header - var line = reader.ReadLine(); - - if (line != "[key]; [value]") - throw new ArgumentException("Stream does not contain a Trace-csv"); - - List<(string header, Dictionary? data)> columns = new() {("TimeBase",null) }; - while (!reader.EndOfStream) - { - if (ReadColumn(reader, out var header,out var data)) - columns.Add((header, data)); - } - - model.SetHeader(columns.ConvertAll((s) => (s.header, (Type?)typeof(double)))); - - foreach (var s in columns) - model.AddColumnData(s.header, s.data); - } - } - - private static bool ReadColumn(StreamReader reader, out string header,out Dictionary data) - { - var xColumnEnd = false; - header = ""; - bool xDataMode = false; - data = new(); - while (!reader.EndOfStream && !xColumnEnd ) - { - int pp = reader.Peek(); - if (xDataMode && (pp != 59)) - return !string.IsNullOrEmpty(header); - var line = reader.ReadLine(); - - if (string.IsNullOrEmpty(header) && line.Contains(".Variable;")) - { - header = line.Split(';')[1].Trim(); - } - - if (!xDataMode) - xDataMode = line.TrimEnd().EndsWith(".Data;"); - else - { - var ls = line.Split(';'); - if ((ls.Length > 2) - && int.TryParse(ls[1].Trim(), out var ix) - && double.TryParse(ls[2].Trim(), System.Globalization.NumberStyles.Float, CultureInfo.InvariantCulture, out var dVal)) - data[ix] = dVal; - } - } - return !string.IsNullOrEmpty(header) && xDataMode; - } - } -} +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; + +namespace TraceAnalysis.Filter.CSV.Model +{ + public static class TraceCSVReader + { + public static void ReadTraceCSV(this CsvModel model, Stream stream) + { + using (var reader = new StreamReader(stream)) + { + // Check Header + var line = reader.ReadLine(); + + if (line != "[key]; [value]") + throw new ArgumentException("Stream does not contain a Trace-csv"); + + List<(string header, Dictionary? data)> columns = new() {("TimeBase",null) }; + while (!reader.EndOfStream) + { + if (ReadColumn(reader, out var header,out var data)) + columns.Add((header, data)); + } + + model.SetHeader(columns.ConvertAll((s) => (s.header, (Type?)typeof(double)))); + + foreach (var s in columns) + model.AddColumnData(s.header, s.data); + } + } + + private static bool ReadColumn(StreamReader reader, out string header,out Dictionary data) + { + var xColumnEnd = false; + header = ""; + bool xDataMode = false; + data = new(); + while (!reader.EndOfStream && !xColumnEnd ) + { + int pp = reader.Peek(); + if (xDataMode && (pp != 59)) + return !string.IsNullOrEmpty(header); + var line = reader.ReadLine(); + + if (string.IsNullOrEmpty(header) && line.Contains(".Variable;")) + { + header = line.Split(';')[1].Trim(); + } + + if (!xDataMode) + xDataMode = line.TrimEnd().EndsWith(".Data;"); + else + { + var ls = line.Split(';'); + if ((ls.Length > 2) + && int.TryParse(ls[1].Trim(), out var ix) + && double.TryParse(ls[2].Trim(), System.Globalization.NumberStyles.Float, CultureInfo.InvariantCulture, out var dVal)) + data[ix] = dVal; + } + } + return !string.IsNullOrEmpty(header) && xDataMode; + } + } +} diff --git a/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/TraceAnalysis.Filter.CSV.csproj b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/TraceAnalysis.Filter.CSV.csproj new file mode 100644 index 000000000..63cbe2bbf --- /dev/null +++ b/CSharpBible/Data/TraceAnalysis/TraceAnalysis.Filter.CSV/TraceAnalysis.Filter.CSV.csproj @@ -0,0 +1,22 @@ + + + + Library + net462;net472;net48;net481;net6.0;net7.0;net8.0 + + + + + $(TargetFrameworks);net9.0 + + + $(TargetFrameworks);net10.0 + + + + + + + + + diff --git a/CSharpBible/Data/TraceCsv2realCsv/Program.cs b/CSharpBible/Data/TraceCsv2realCsv/Program.cs index 3dbbc9c23..bd066a082 100644 --- a/CSharpBible/Data/TraceCsv2realCsv/Program.cs +++ b/CSharpBible/Data/TraceCsv2realCsv/Program.cs @@ -1,5 +1,5 @@ using System.IO; -using TraceCsv2realCsv.Model; +using TraceAnalysis.Filter.CSV.Model; namespace TraceCsv2realCsv { diff --git a/CSharpBible/Data/TraceCsv2realCsv/TraceCsv2realCsv.csproj b/CSharpBible/Data/TraceCsv2realCsv/TraceCsv2realCsv.csproj index 89a828058..5576e5da0 100644 --- a/CSharpBible/Data/TraceCsv2realCsv/TraceCsv2realCsv.csproj +++ b/CSharpBible/Data/TraceCsv2realCsv/TraceCsv2realCsv.csproj @@ -16,7 +16,11 @@ - + + + + + diff --git a/CSharpBible/Data/TraceCsv2realCsvTests/Model/CsvModelTests.cs b/CSharpBible/Data/TraceCsv2realCsvTests/Model/CsvModelTests.cs index c5cf975aa..de24921d5 100644 --- a/CSharpBible/Data/TraceCsv2realCsvTests/Model/CsvModelTests.cs +++ b/CSharpBible/Data/TraceCsv2realCsvTests/Model/CsvModelTests.cs @@ -5,6 +5,7 @@ using System.Text; using System.IO; using BaseLib.Helper; +using TraceAnalysis.Filter.CSV.Model; namespace TraceCsv2realCsv.Model.Tests { diff --git a/CSharpBible/Data/TraceCsv2realCsvTests/TraceCsv2realCsvTests.csproj b/CSharpBible/Data/TraceCsv2realCsvTests/TraceCsv2realCsvTests.csproj index 5e7c8b5d6..ccce73ce3 100644 --- a/CSharpBible/Data/TraceCsv2realCsvTests/TraceCsv2realCsvTests.csproj +++ b/CSharpBible/Data/TraceCsv2realCsvTests/TraceCsv2realCsvTests.csproj @@ -1,14 +1,14 @@  - net462;net472;net48;net481;net6.0;net7.0;net8.0;net9.0 + net462;net472;net48;net481;net6.0;net7.0;net8.0 true - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -17,6 +17,8 @@ + + diff --git a/CSharpBible/DependencyInjection/CustomerRepositoryTests/CustomerRepositoryTests.csproj b/CSharpBible/DependencyInjection/CustomerRepositoryTests/CustomerRepositoryTests.csproj index b3a111ddf..0405a7a47 100644 --- a/CSharpBible/DependencyInjection/CustomerRepositoryTests/CustomerRepositoryTests.csproj +++ b/CSharpBible/DependencyInjection/CustomerRepositoryTests/CustomerRepositoryTests.csproj @@ -15,7 +15,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Games/AsteroidsModernEngine.Tests/AsteroidsModernEngine.Tests.csproj b/CSharpBible/Games/AsteroidsModernEngine.Tests/AsteroidsModernEngine.Tests.csproj index 4cd9f96aa..c28dac82c 100644 --- a/CSharpBible/Games/AsteroidsModernEngine.Tests/AsteroidsModernEngine.Tests.csproj +++ b/CSharpBible/Games/AsteroidsModernEngine.Tests/AsteroidsModernEngine.Tests.csproj @@ -32,7 +32,7 @@ - - + + \ No newline at end of file diff --git a/CSharpBible/Games/DetectiveGame.Tests/DetectiveGame.Tests.csproj b/CSharpBible/Games/DetectiveGame.Tests/DetectiveGame.Tests.csproj index 17c22cb15..1b35aea7e 100644 --- a/CSharpBible/Games/DetectiveGame.Tests/DetectiveGame.Tests.csproj +++ b/CSharpBible/Games/DetectiveGame.Tests/DetectiveGame.Tests.csproj @@ -23,7 +23,7 @@ - - + + \ No newline at end of file diff --git a/CSharpBible/Games/Directory.Packages.props b/CSharpBible/Games/Directory.Packages.props index 07fa7bc83..b2f483ad4 100644 --- a/CSharpBible/Games/Directory.Packages.props +++ b/CSharpBible/Games/Directory.Packages.props @@ -5,7 +5,7 @@ - + \ No newline at end of file diff --git a/CSharpBible/Games/Galaxia_BaseTests/Galaxia_BaseTests.csproj b/CSharpBible/Games/Galaxia_BaseTests/Galaxia_BaseTests.csproj index a994bb297..bca5abdf8 100644 --- a/CSharpBible/Games/Galaxia_BaseTests/Galaxia_BaseTests.csproj +++ b/CSharpBible/Games/Galaxia_BaseTests/Galaxia_BaseTests.csproj @@ -23,8 +23,8 @@ - - + + diff --git a/CSharpBible/Games/Galaxia_UI.Tests/Galaxia_UI.Tests.csproj b/CSharpBible/Games/Galaxia_UI.Tests/Galaxia_UI.Tests.csproj index f021ee2da..6df1cf0d0 100644 --- a/CSharpBible/Games/Galaxia_UI.Tests/Galaxia_UI.Tests.csproj +++ b/CSharpBible/Games/Galaxia_UI.Tests/Galaxia_UI.Tests.csproj @@ -14,6 +14,6 @@ - + \ No newline at end of file diff --git a/CSharpBible/Games/Game_BaseTests/Game_BaseTests.csproj b/CSharpBible/Games/Game_BaseTests/Game_BaseTests.csproj index d4819430a..de9e95043 100644 --- a/CSharpBible/Games/Game_BaseTests/Game_BaseTests.csproj +++ b/CSharpBible/Games/Game_BaseTests/Game_BaseTests.csproj @@ -22,8 +22,8 @@ - - + + diff --git a/CSharpBible/Games/Packages.props b/CSharpBible/Games/Packages.props index 83fe7d6ee..3c8d6d990 100644 --- a/CSharpBible/Games/Packages.props +++ b/CSharpBible/Games/Packages.props @@ -36,7 +36,7 @@ - + diff --git a/CSharpBible/Games/RemoteTerminal.Tests/RemoteTerminal.Tests.csproj b/CSharpBible/Games/RemoteTerminal.Tests/RemoteTerminal.Tests.csproj index 73433b0d9..a5ccb602d 100644 --- a/CSharpBible/Games/RemoteTerminal.Tests/RemoteTerminal.Tests.csproj +++ b/CSharpBible/Games/RemoteTerminal.Tests/RemoteTerminal.Tests.csproj @@ -25,7 +25,7 @@ - + diff --git a/CSharpBible/Games/Snake_BaseTests/Snake_BaseTests.csproj b/CSharpBible/Games/Snake_BaseTests/Snake_BaseTests.csproj index cc23ba46b..6d7ed3bfa 100644 --- a/CSharpBible/Games/Snake_BaseTests/Snake_BaseTests.csproj +++ b/CSharpBible/Games/Snake_BaseTests/Snake_BaseTests.csproj @@ -27,8 +27,8 @@ - - + + diff --git a/CSharpBible/Games/Sokoban_BaseTests/Sokoban_BaseTests.csproj b/CSharpBible/Games/Sokoban_BaseTests/Sokoban_BaseTests.csproj index 717294772..710e1d405 100644 --- a/CSharpBible/Games/Sokoban_BaseTests/Sokoban_BaseTests.csproj +++ b/CSharpBible/Games/Sokoban_BaseTests/Sokoban_BaseTests.csproj @@ -30,8 +30,8 @@ - - + + diff --git a/CSharpBible/Games/Sudoku_BaseTests/Sudoku_BaseTests.csproj b/CSharpBible/Games/Sudoku_BaseTests/Sudoku_BaseTests.csproj index 8023173b8..62d66edd3 100644 --- a/CSharpBible/Games/Sudoku_BaseTests/Sudoku_BaseTests.csproj +++ b/CSharpBible/Games/Sudoku_BaseTests/Sudoku_BaseTests.csproj @@ -43,8 +43,8 @@ - - + + diff --git a/CSharpBible/Games/Tetris_BaseTests/Tetris_BaseTests.csproj b/CSharpBible/Games/Tetris_BaseTests/Tetris_BaseTests.csproj index 62e6b4eb9..641f3a81f 100644 --- a/CSharpBible/Games/Tetris_BaseTests/Tetris_BaseTests.csproj +++ b/CSharpBible/Games/Tetris_BaseTests/Tetris_BaseTests.csproj @@ -30,8 +30,8 @@ - - + + diff --git a/CSharpBible/Games/TileSetAnimator.Tests/TileSetAnimator.Tests.csproj b/CSharpBible/Games/TileSetAnimator.Tests/TileSetAnimator.Tests.csproj index 91f4a927a..c454764b8 100644 --- a/CSharpBible/Games/TileSetAnimator.Tests/TileSetAnimator.Tests.csproj +++ b/CSharpBible/Games/TileSetAnimator.Tests/TileSetAnimator.Tests.csproj @@ -28,7 +28,7 @@ - + diff --git a/CSharpBible/Games/Treppen.BaseTests/Treppen.BaseTests.csproj b/CSharpBible/Games/Treppen.BaseTests/Treppen.BaseTests.csproj index ad5423255..cf92e88d0 100644 --- a/CSharpBible/Games/Treppen.BaseTests/Treppen.BaseTests.csproj +++ b/CSharpBible/Games/Treppen.BaseTests/Treppen.BaseTests.csproj @@ -17,7 +17,7 @@ - - + + diff --git a/CSharpBible/Games/VTileEdit.WPFTests/VTileEdit.WPFTests.csproj b/CSharpBible/Games/VTileEdit.WPFTests/VTileEdit.WPFTests.csproj index 76aa2d48c..d004c73ee 100644 --- a/CSharpBible/Games/VTileEdit.WPFTests/VTileEdit.WPFTests.csproj +++ b/CSharpBible/Games/VTileEdit.WPFTests/VTileEdit.WPFTests.csproj @@ -33,8 +33,8 @@ - - + + diff --git a/CSharpBible/Games/VTileEditTests/VTileEditTests.csproj b/CSharpBible/Games/VTileEditTests/VTileEditTests.csproj index 774bd5815..0663b7699 100644 --- a/CSharpBible/Games/VTileEditTests/VTileEditTests.csproj +++ b/CSharpBible/Games/VTileEditTests/VTileEditTests.csproj @@ -51,7 +51,7 @@ - - + + diff --git a/CSharpBible/Games/Werner_Flaschbier_BaseTests/Werner_Flaschbier_BaseTests.csproj b/CSharpBible/Games/Werner_Flaschbier_BaseTests/Werner_Flaschbier_BaseTests.csproj index 2c24aee13..659059e0a 100644 --- a/CSharpBible/Games/Werner_Flaschbier_BaseTests/Werner_Flaschbier_BaseTests.csproj +++ b/CSharpBible/Games/Werner_Flaschbier_BaseTests/Werner_Flaschbier_BaseTests.csproj @@ -29,8 +29,8 @@ - - + + diff --git a/CSharpBible/Games/Werner_Flaschbier_BaseTests/Werner_Flaschbier_ConsoleTests.csproj b/CSharpBible/Games/Werner_Flaschbier_BaseTests/Werner_Flaschbier_ConsoleTests.csproj index 7709cd3b3..60aceffcf 100644 --- a/CSharpBible/Games/Werner_Flaschbier_BaseTests/Werner_Flaschbier_ConsoleTests.csproj +++ b/CSharpBible/Games/Werner_Flaschbier_BaseTests/Werner_Flaschbier_ConsoleTests.csproj @@ -25,8 +25,8 @@ - - + + diff --git a/CSharpBible/Graphics/MVVM_ImageHandlingTests/MVVM_ImageHandlingTests.csproj b/CSharpBible/Graphics/MVVM_ImageHandlingTests/MVVM_ImageHandlingTests.csproj index f7639ad8a..f8d606615 100644 --- a/CSharpBible/Graphics/MVVM_ImageHandlingTests/MVVM_ImageHandlingTests.csproj +++ b/CSharpBible/Graphics/MVVM_ImageHandlingTests/MVVM_ImageHandlingTests.csproj @@ -10,8 +10,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Graphics/MVVM_ImageHandlingTests/MVVM_ImageHandling_netTests.csproj b/CSharpBible/Graphics/MVVM_ImageHandlingTests/MVVM_ImageHandling_netTests.csproj index 1f04824af..90d3a5a18 100644 --- a/CSharpBible/Graphics/MVVM_ImageHandlingTests/MVVM_ImageHandling_netTests.csproj +++ b/CSharpBible/Graphics/MVVM_ImageHandlingTests/MVVM_ImageHandling_netTests.csproj @@ -16,8 +16,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Graphics/MarbleBoard.Engine.Tests/MarbleBoard.Engine.Tests.csproj b/CSharpBible/Graphics/MarbleBoard.Engine.Tests/MarbleBoard.Engine.Tests.csproj index 3a463af98..66d010c57 100644 --- a/CSharpBible/Graphics/MarbleBoard.Engine.Tests/MarbleBoard.Engine.Tests.csproj +++ b/CSharpBible/Graphics/MarbleBoard.Engine.Tests/MarbleBoard.Engine.Tests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Graphics/PermutationTests/PermutationTests.csproj b/CSharpBible/Graphics/PermutationTests/PermutationTests.csproj index 30fdbd46e..dad68a013 100644 --- a/CSharpBible/Graphics/PermutationTests/PermutationTests.csproj +++ b/CSharpBible/Graphics/PermutationTests/PermutationTests.csproj @@ -13,8 +13,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Graphics/ScreenX.BaseTests/ScreenX.BaseTests.csproj b/CSharpBible/Graphics/ScreenX.BaseTests/ScreenX.BaseTests.csproj index 56ac400c6..d2495d5d0 100644 --- a/CSharpBible/Graphics/ScreenX.BaseTests/ScreenX.BaseTests.csproj +++ b/CSharpBible/Graphics/ScreenX.BaseTests/ScreenX.BaseTests.csproj @@ -23,7 +23,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/CSharpBible/Libraries/BaseLib/Models/FileProxy.cs b/CSharpBible/Libraries/BaseLib/Models/FileProxy.cs new file mode 100644 index 000000000..09c1ab499 --- /dev/null +++ b/CSharpBible/Libraries/BaseLib/Models/FileProxy.cs @@ -0,0 +1,39 @@ +using BaseLib.Models.Interfaces; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BaseLib.Models; + +public class FileProxy : IFile +{ + public bool Exists(string sPath) + => File.Exists(sPath); + public Stream OpenRead(string sPath) + => File.OpenRead(sPath); + public Stream OpenWrite(string sPath) + => File.OpenWrite(sPath); + public Stream Create(string sPath) + => File.Create(sPath); + public string ReadAllText(string sPath) + => File.ReadAllText(sPath); + public string ReadAllText(string sPath, Encoding encoding) + => File.ReadAllText(sPath, encoding); + public void WriteAllText(string sPath, string sContents) + => File.WriteAllText(sPath, sContents); + public void WriteAllText(string sPath, string sContents, Encoding encoding) + => File.WriteAllText(sPath, sContents, encoding); + public byte[] ReadAllBytes(string sPath) + => File.ReadAllBytes(sPath); + public void WriteAllBytes(string sPath, byte[] rgBytes) + => File.WriteAllBytes(sPath, rgBytes); + public void Delete(string sPath) + => File.Delete(sPath); + public void Copy(string sSourceFileName, string sDestFileName, bool xOverwrite) + => File.Copy(sSourceFileName, sDestFileName, xOverwrite); + public void Move(string sSourceFileName, string sDestFileName) + => File.Move(sSourceFileName, sDestFileName); +} diff --git a/CSharpBible/Libraries/BaseLib/Models/Interfaces/IFile.cs b/CSharpBible/Libraries/BaseLib/Models/Interfaces/IFile.cs new file mode 100644 index 000000000..eadf02be8 --- /dev/null +++ b/CSharpBible/Libraries/BaseLib/Models/Interfaces/IFile.cs @@ -0,0 +1,103 @@ +using System.IO; +using System.Text; + +namespace BaseLib.Models.Interfaces; + +/// +/// Provides an abstraction over for testable file system access. +/// +public interface IFile +{ + /// + /// Determines whether the specified file exists. + /// + /// The file path. + /// if the file exists; otherwise . + bool Exists(string sPath); + + /// + /// Opens a file for reading. + /// + /// The file path. + /// A readable stream. + Stream OpenRead(string sPath); + + /// + /// Opens an existing file for writing. + /// + /// The file path. + /// A writable stream. + Stream OpenWrite(string sPath); + + /// + /// Creates or overwrites a file. + /// + /// The file path. + /// A writable stream. + Stream Create(string sPath); + + /// + /// Reads all text from a file using UTF-8 encoding. + /// + /// The file path. + /// The file content. + string ReadAllText(string sPath); + + /// + /// Reads all text from a file using the specified encoding. + /// + /// The file path. + /// The text encoding. + /// The file content. + string ReadAllText(string sPath, Encoding encoding); + + /// + /// Writes text to a file using UTF-8 encoding. + /// + /// The file path. + /// The text content. + void WriteAllText(string sPath, string sContents); + + /// + /// Writes text to a file using the specified encoding. + /// + /// The file path. + /// The text content. + /// The text encoding. + void WriteAllText(string sPath, string sContents, Encoding encoding); + + /// + /// Reads all bytes from a file. + /// + /// The file path. + /// The file content as bytes. + byte[] ReadAllBytes(string sPath); + + /// + /// Writes all bytes to a file. + /// + /// The file path. + /// The byte content. + void WriteAllBytes(string sPath, byte[] rgBytes); + + /// + /// Deletes the specified file. + /// + /// The file path. + void Delete(string sPath); + + /// + /// Copies a file to a new location. + /// + /// The source file path. + /// The destination file path. + /// to overwrite an existing destination file. + void Copy(string sSourceFileName, string sDestFileName, bool xOverwrite); + + /// + /// Moves a file to a new location. + /// + /// The source file path. + /// The destination file path. + void Move(string sSourceFileName, string sDestFileName); +} diff --git a/CSharpBible/Libraries/BaseLibTests/BaseLibTests.csproj b/CSharpBible/Libraries/BaseLibTests/BaseLibTests.csproj index 1f48d0795..a0d0961c3 100644 --- a/CSharpBible/Libraries/BaseLibTests/BaseLibTests.csproj +++ b/CSharpBible/Libraries/BaseLibTests/BaseLibTests.csproj @@ -15,7 +15,7 @@ $(TargetFrameworks);net10.0 - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Libraries/BaseLibTests/Model/BaseTest.cs b/CSharpBible/Libraries/BaseLibTests/Models/BaseTest.cs similarity index 95% rename from CSharpBible/Libraries/BaseLibTests/Model/BaseTest.cs rename to CSharpBible/Libraries/BaseLibTests/Models/BaseTest.cs index cdfaa704d..6ba8e4ef3 100644 --- a/CSharpBible/Libraries/BaseLibTests/Model/BaseTest.cs +++ b/CSharpBible/Libraries/BaseLibTests/Models/BaseTest.cs @@ -1,19 +1,19 @@ -using System; - -namespace BaseLib.Model.Tests -{ - public class BaseTest - { - private string _debugLog = ""; - - protected virtual void ClearLog() - { - _debugLog = string.Empty; - } - - protected string DebugLog => _debugLog; - - protected virtual void DoLog(string st) - => _debugLog += $"{st}{Environment.NewLine}"; - } -} +using System; + +namespace BaseLib.Model.Tests +{ + public class BaseTest + { + private string _debugLog = ""; + + protected virtual void ClearLog() + { + _debugLog = string.Empty; + } + + protected string DebugLog => _debugLog; + + protected virtual void DoLog(string st) + => _debugLog += $"{st}{Environment.NewLine}"; + } +} diff --git a/CSharpBible/Libraries/BaseLibTests/Model/CRandomTests.cs b/CSharpBible/Libraries/BaseLibTests/Models/CRandomTests.cs similarity index 100% rename from CSharpBible/Libraries/BaseLibTests/Model/CRandomTests.cs rename to CSharpBible/Libraries/BaseLibTests/Models/CRandomTests.cs diff --git a/CSharpBible/Libraries/BaseLibTests/Model/ConsoleProxyTests.cs b/CSharpBible/Libraries/BaseLibTests/Models/ConsoleProxyTests.cs similarity index 100% rename from CSharpBible/Libraries/BaseLibTests/Model/ConsoleProxyTests.cs rename to CSharpBible/Libraries/BaseLibTests/Models/ConsoleProxyTests.cs diff --git a/CSharpBible/Libraries/BaseLibTests/Models/FileProxyTests.cs b/CSharpBible/Libraries/BaseLibTests/Models/FileProxyTests.cs new file mode 100644 index 000000000..d01734aff --- /dev/null +++ b/CSharpBible/Libraries/BaseLibTests/Models/FileProxyTests.cs @@ -0,0 +1,199 @@ +using BaseLib.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Text; + +namespace BaseLib.Tests.Models; + +[TestClass] +public sealed class FileProxyTests +{ + private readonly FileProxy _fileProxy = new(); + private string _sTestDirectory = null!; + + [TestInitialize] + public void TestInitialize() + { + _sTestDirectory = Path.Combine(Path.GetTempPath(), "BaseLib.Tests", nameof(FileProxyTests), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_sTestDirectory); + } + + [TestCleanup] + public void TestCleanup() + { + if (Directory.Exists(_sTestDirectory)) + Directory.Delete(_sTestDirectory, recursive: true); + } + + [DataTestMethod] + [DataRow(false)] + [DataRow(true)] + public void Exists_ForFilePresence_ReturnsExpectedResult(bool xCreateFile) + { + var sPath = Path.Combine(_sTestDirectory, "exists.txt"); + if (xCreateFile) + File.WriteAllText(sPath, "content"); + + var xResult = _fileProxy.Exists(sPath); + + Assert.AreEqual(xCreateFile, xResult); + } + + [TestMethod] + public void Create_CreatesWritableFile() + { + var sPath = Path.Combine(_sTestDirectory, "create.bin"); + var rgBytes = new byte[] { 1, 2, 3, 4 }; + + using (var stm = _fileProxy.Create(sPath)) + { + stm.Write(rgBytes, 0, rgBytes.Length); + } + + CollectionAssert.AreEqual(rgBytes, File.ReadAllBytes(sPath)); + } + + [TestMethod] + public void OpenWrite_ForExistingFile_OverwritesFromStart() + { + var sPath = Path.Combine(_sTestDirectory, "write.bin"); + File.WriteAllBytes(sPath, new byte[] { 9, 9, 9, 9 }); + var rgBytes = new byte[] { 1, 2 }; + + using (var stm = _fileProxy.OpenWrite(sPath)) + { + stm.Write(rgBytes, 0, rgBytes.Length); + stm.SetLength(rgBytes.Length); + } + + CollectionAssert.AreEqual(rgBytes, File.ReadAllBytes(sPath)); + } + + [TestMethod] + public void OpenRead_ForExistingFile_ReturnsReadableStream() + { + var sPath = Path.Combine(_sTestDirectory, "read.bin"); + var rgBytes = new byte[] { 5, 6, 7 }; + File.WriteAllBytes(sPath, rgBytes); + + using var stm = _fileProxy.OpenRead(sPath); + using var stmMemory = new MemoryStream(); + stm.CopyTo(stmMemory); + + CollectionAssert.AreEqual(rgBytes, stmMemory.ToArray()); + } + + [DataTestMethod] + [DataRow("Plain UTF8 text")] + [DataRow("äöü ß Ελληνικά")] + public void WriteAllText_WithoutEncoding_WritesReadableUtf8Content(string sContents) + { + var sPath = Path.Combine(_sTestDirectory, "utf8.txt"); + + _fileProxy.WriteAllText(sPath, sContents); + + Assert.AreEqual(sContents, File.ReadAllText(sPath)); + } + + [DataTestMethod] + [DataRow("Plain UTF8 text")] + [DataRow("äöü ß Ελληνικά")] + public void ReadAllText_WithoutEncoding_ReturnsFileContent(string sContents) + { + var sPath = Path.Combine(_sTestDirectory, "read-text.txt"); + File.WriteAllText(sPath, sContents); + + var sResult = _fileProxy.ReadAllText(sPath); + + Assert.AreEqual(sContents, sResult); + } + + [TestMethod] + public void WriteAllText_WithEncoding_WritesEncodedContent() + { + var sPath = Path.Combine(_sTestDirectory, "encoded-write.txt"); + var sContents = "Grüße aus Köln"; + var encEncoding = Encoding.Unicode; + + _fileProxy.WriteAllText(sPath, sContents, encEncoding); + + Assert.AreEqual(sContents, File.ReadAllText(sPath, encEncoding)); + } + + [TestMethod] + public void ReadAllText_WithEncoding_ReturnsEncodedContent() + { + var sPath = Path.Combine(_sTestDirectory, "encoded-read.txt"); + var sContents = "Grüße aus Köln"; + var encEncoding = Encoding.Unicode; + File.WriteAllText(sPath, sContents, encEncoding); + + var sResult = _fileProxy.ReadAllText(sPath, encEncoding); + + Assert.AreEqual(sContents, sResult); + } + + [TestMethod] + public void WriteAllBytes_WritesBinaryContent() + { + var sPath = Path.Combine(_sTestDirectory, "bytes-write.bin"); + var rgBytes = new byte[] { 10, 20, 30, 40 }; + + _fileProxy.WriteAllBytes(sPath, rgBytes); + + CollectionAssert.AreEqual(rgBytes, File.ReadAllBytes(sPath)); + } + + [TestMethod] + public void ReadAllBytes_ReturnsBinaryContent() + { + var sPath = Path.Combine(_sTestDirectory, "bytes-read.bin"); + var rgBytes = new byte[] { 10, 20, 30, 40 }; + File.WriteAllBytes(sPath, rgBytes); + + var rgResult = _fileProxy.ReadAllBytes(sPath); + + CollectionAssert.AreEqual(rgBytes, rgResult); + } + + [TestMethod] + public void Delete_RemovesFile() + { + var sPath = Path.Combine(_sTestDirectory, "delete.txt"); + File.WriteAllText(sPath, "content"); + + _fileProxy.Delete(sPath); + + Assert.IsFalse(File.Exists(sPath)); + } + + [DataTestMethod] + [DataRow(false, "source")] + [DataRow(true, "new")] + public void Copy_CopiesFileToDestination(bool xOverwrite, string sExpectedDestinationContent) + { + var sSourcePath = Path.Combine(_sTestDirectory, "source.txt"); + var sDestinationPath = Path.Combine(_sTestDirectory, "destination.txt"); + File.WriteAllText(sSourcePath, sExpectedDestinationContent); + if (xOverwrite) + File.WriteAllText(sDestinationPath, "old"); + + _fileProxy.Copy(sSourcePath, sDestinationPath, xOverwrite); + + Assert.AreEqual(sExpectedDestinationContent, File.ReadAllText(sDestinationPath)); + } + + [TestMethod] + public void Move_MovesFileToDestination() + { + var sSourcePath = Path.Combine(_sTestDirectory, "source-move.txt"); + var sDestinationPath = Path.Combine(_sTestDirectory, "destination-move.txt"); + File.WriteAllText(sSourcePath, "moved"); + + _fileProxy.Move(sSourcePath, sDestinationPath); + + Assert.IsFalse(File.Exists(sSourcePath)); + Assert.AreEqual("moved", File.ReadAllText(sDestinationPath)); + } +} diff --git a/CSharpBible/Libraries/BaseLibTests/Model/NotificationObjectAdvTests.cs b/CSharpBible/Libraries/BaseLibTests/Models/NotificationObjectAdvTests.cs similarity index 98% rename from CSharpBible/Libraries/BaseLibTests/Model/NotificationObjectAdvTests.cs rename to CSharpBible/Libraries/BaseLibTests/Models/NotificationObjectAdvTests.cs index 88353ec55..d8bdc554c 100644 --- a/CSharpBible/Libraries/BaseLibTests/Model/NotificationObjectAdvTests.cs +++ b/CSharpBible/Libraries/BaseLibTests/Models/NotificationObjectAdvTests.cs @@ -1,263 +1,263 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; -using BaseLib.Models; -using BaseLib.Interfaces; - -namespace BaseLib.ViewModel.Tests -{ - /// - /// Defines test class PropertyTests. - /// - [TestClass()] - public class NotificationObjectAdvTests : NotificationObjectAdv - { - public enum eValidReact - { - OK=0, - NIO, - GeneralException, - ArgumetException, - } - - private string _testString = ""; - private int _testInt; - private float _testFloat; - private double _testDouble; - - private eValidReact valReact=eValidReact.OK; - - private string DebugResult =""; - private TypeCode _testEnum; - - public string TestString { get => _testString;set=>SetProperty(ref _testString,value); } - public string TestString1 { get => _testString; set => SetProperty(ref _testString, value,ValidateString); } - public string TestString2 { get => _testString; set => SetProperty(ref _testString, value, StringAct); } - public string TestString3 { get => _testString; set => SetProperty(ref _testString, value, ValidateString, StringAct); } - public string TestString4 { get => _testString; set => SetProperty(ref _testString, value, new string[] {nameof(TestString),nameof(TestString2) }); } - public string TestString5 { get => _testString; set - => SetProperty(ref _testString, value, new string[] { nameof(TestString), nameof(TestString1) },ValidateString); } - public string TestString6 { get => _testString; set - => SetProperty(ref _testString, value, new string[] { nameof(TestString), nameof(TestString1) },StringAct); } - public string TestString7 { get => _testString; set - => SetProperty(ref _testString, value, new string[] { nameof(TestString), nameof(TestString1) }, ValidateString, StringAct); } - - public int TestInt { get => _testInt; set => SetProperty(ref _testInt, value); } - public float TestFloat { get => _testFloat; set => SetProperty(ref _testFloat, value); } - public double TestDouble { get => _testDouble; set => SetProperty(ref _testDouble, value); } - - public TypeCode TestEnum { get => _testEnum; set => SetProperty(ref _testEnum, value); } - - private void StringAct(string arg1, string arg2) - { - DebugResult += $"StrAct: {arg1}; {arg2}{Environment.NewLine}"; - switch (valReact) - { - case eValidReact.OK: - return ; - case eValidReact.NIO: - return ; - case eValidReact.GeneralException: - throw new Exception("A general exception occured"); - case eValidReact.ArgumetException: - throw new ArgumentException($"Argument ({arg2}) not valid!"); - default: return; - } - } - - private bool ValidateString(string arg1) - { - DebugResult += $"Validate: {arg1}, React:{valReact}{Environment.NewLine}"; - switch (valReact) - { - case eValidReact.OK: - return true; - case eValidReact.NIO: - return false; - case eValidReact.GeneralException: - throw new Exception("A general exception occured"); - case eValidReact.ArgumetException: - throw new ArgumentException($"Argument ({arg1}) not valid!"); - default: return false; - } - } - - public NotificationObjectAdvTests() - { - PropertyChangedAdv += OnPropertyChanged; - } - - private void Clear() - { - _testString = String.Empty; - _testInt = 0; - _testFloat = 0f; - _testDouble = 0d; - DebugResult = ""; - } - - private void OnPropertyChanged(object? sender, PropertyChangedAdvEventArgs e) - => DebugResult += $"OnPropChanged: o:{sender}, p:{e.PropertyName}:{sender?.GetType().GetProperty(e.PropertyName)?.GetValue(sender)}, o:{e.OldVal}, n:{e.NewVal}{Environment.NewLine}"; - - [TestInitialize] - public void Init() - { - Clear(); - } - - [TestMethod] - [TestProperty("Author","J.C.")] - [TestCategory("SetData")] - [DataRow("00 Empty",0,"",eValidReact.OK,"","")] - [DataRow("01-Test", 0, "Test", eValidReact.OK, "Test", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString:Test, o:, n:Test\r\n")] - [DataRow("10-1 Empty", 1, "", eValidReact.OK, "", "")] - [DataRow("10-2 Test" , 1, "", eValidReact.NIO, "", "")] - [DataRow("10-2 Test" , 1, "", eValidReact.GeneralException, "", "")] - [DataRow("11-1 Empty", 1, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString1:Test, o:, n:Test\r\n")] - [DataRow("11-2 Test" , 1, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] - [DataRow("11-3 GEx" , 1, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] - [DataRow("11-4 AEx" , 1, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] - [DataRow("12-2 Test2", 1, "Test2", eValidReact.OK, "Test2", "Validate: Test2, React:OK\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString1:Test2, o:, n:Test2\r\n")] - [DataRow("20-1 Empty", 2, "", eValidReact.OK, "", "")] - [DataRow("20-2 Test", 2, "", eValidReact.NIO, "", "")] - [DataRow("21-1 Test", 2, "Test", eValidReact.OK, "Test", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString2:Test, o:, n:Test\r\nStrAct: ; Test\r\n")] - [DataRow("21-2 Test2", 2, "Test2", eValidReact.NIO, "Test2", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString2:Test2, o:, n:Test2\r\nStrAct: ; Test2\r\n")] - [DataRow("21-3 GEx", 2, "Test", eValidReact.GeneralException, "Test", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString2:Test, o:, n:Test\r\nStrAct: ; Test\r\n")] - [DataRow("21-4 AEx", 2, "Test", eValidReact.ArgumetException, "Test", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString2:Test, o:, n:Test\r\nStrAct: ; Test\r\n")] - [DataRow("30-1 Empty", 3, "", eValidReact.OK, "", "")] - [DataRow("30-2 Test", 3, "", eValidReact.NIO, "", "")] - [DataRow("30-2 Test", 3, "", eValidReact.GeneralException, "", "")] - [DataRow("31-1 Empty", 3, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString3:Test, o:, n:Test\r\nStrAct: ; Test\r\n")] - [DataRow("31-2 Test", 3, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] - [DataRow("31-3 GEx", 3, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] - [DataRow("31-4 AEx", 3, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] - [DataRow("40-1 Empty", 4, "", eValidReact.OK, "", "")] - [DataRow("40-2 Test " , 4, "", eValidReact.NIO, "", "")] - [DataRow("41-1 Test " , 4, "Test", eValidReact.OK, "Test", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString4:Test, o:, n:Test\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString:Test, o:, n:\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString2:Test, o:, n:\r\n")] - [DataRow("41-2 Test2", 4, "Test2", eValidReact.NIO, "Test2", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString4:Test2, o:, n:Test2\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString:Test2, o:, n:\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString2:Test2, o:, n:\r\n")] - [DataRow("50-1 Empty", 5, "", eValidReact.OK, "", "")] - [DataRow("50-2 Test ", 5, "", eValidReact.NIO, "", "")] - [DataRow("50-2 Test ", 5, "", eValidReact.GeneralException, "", "")] - [DataRow("51-1 Empty", 5, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString5:Test, o:, n:Test\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString:Test, o:, n:\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString1:Test, o:, n:\r\n")] - [DataRow("51-2 Test ", 5, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] - [DataRow("51-3 GEx ", 5, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] - [DataRow("51-4 AEx ", 5, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] - [DataRow("60-1 Empty", 6, "", eValidReact.OK, "", "")] - [DataRow("60-2 Test ", 6, "", eValidReact.NIO, "", "")] - [DataRow("61-1 Test ", 6, "Test", eValidReact.OK, "Test", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString6:Test, o:, n:Test\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString:Test, o:, n:\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString1:Test, o:, n:\r\nStrAct: ; Test\r\n")] - [DataRow("61-2 Test2", 6, "Test2", eValidReact.NIO, "Test2", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString6:Test2, o:, n:Test2\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString:Test2, o:, n:\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString1:Test2, o:, n:\r\nStrAct: ; Test2\r\n")] - [DataRow("70-1 Empty", 7, "", eValidReact.OK, "", "")] - [DataRow("70-2 Test ", 7, "", eValidReact.NIO, "", "")] - [DataRow("70-2 Test ", 7, "", eValidReact.GeneralException, "", "")] - [DataRow("71-1 Empty", 7, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString7:Test, o:, n:Test\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString:Test, o:, n:\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString1:Test, o:, n:\r\nStrAct: ; Test\r\n")] - [DataRow("71-2 Test ", 7, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] - [DataRow("71-3 GEx ", 7, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] - [DataRow("71-4 AEx ", 7, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] - public void TestStringProp(string name,int iTs,string sVal, eValidReact eReact, string sExp,string sDebExp) - { - valReact = eReact; - bool xCh = sVal != _testString; - bool eRIsEx = eReact == eValidReact.GeneralException || eReact == eValidReact.ArgumetException; - switch (iTs) - { - //case 1: TestString1 = sVal; break; - case 1 when xCh && eReact == eValidReact.GeneralException: Assert.ThrowsExactly(()=>TestString1 = sVal,$"{name}.T1"); break; - case 1 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString1 = sVal, $"{name}.T1"); break; - case 1: TestString1 = sVal; break; - case 2: TestString2 = sVal; break; - case 3 when xCh && eReact == eValidReact.GeneralException: Assert.ThrowsExactly(() => TestString3 = sVal, $"{name}.T3"); break; - case 3 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString3 = sVal, $"{name}.T3"); break; - case 3: TestString3 = sVal; break; - case 4: TestString4 = sVal; break; - case 5 when xCh && eReact == eValidReact.GeneralException: Assert.ThrowsExactly(() => TestString5 = sVal, $"{name}.T5"); break; - case 5 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString5 = sVal, $"{name}.T5"); break; - case 5: TestString5 = sVal; break; - case 6: TestString6 = sVal; break; - case 7 when xCh && eReact == eValidReact.GeneralException: Assert.ThrowsExactly(() => TestString7 = sVal, $"{name}.T7"); break; - case 7 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString7 = sVal, $"{name}.T7"); break; - case 7: TestString7 = sVal; break; - default: TestString = sVal; break; - } - Assert.AreEqual(sExp, _testString, $"{name}.Result"); - Assert.AreEqual(sDebExp, DebugResult, $"{name}.DebRes"); - } - - [TestMethod] - [TestProperty("Author", "J.C.")] - [TestCategory("SetData")] - [DataRow("00 Empty", 0, "", eValidReact.OK, "", "")] - [DataRow("01-Test", 0, "Test", eValidReact.OK, "Test", "")] - [DataRow("10-1 Empty", 1, "", eValidReact.OK, "", "")] - [DataRow("10-2 Test", 1, "", eValidReact.NIO, "", "")] - [DataRow("10-2 Test", 1, "", eValidReact.GeneralException, "", "")] - [DataRow("11-1 Empty", 1, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\n")] - [DataRow("11-2 Test", 1, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] - [DataRow("11-3 GEx", 1, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] - [DataRow("11-4 AEx", 1, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] - [DataRow("12-2 Test2", 1, "Test2", eValidReact.OK, "Test2", "Validate: Test2, React:OK\r\n")] - [DataRow("20-1 Empty", 2, "", eValidReact.OK, "", "")] - [DataRow("20-2 Test", 2, "", eValidReact.NIO, "", "")] - [DataRow("21-1 Test", 2, "Test", eValidReact.OK, "Test", "StrAct: ; Test\r\n")] - [DataRow("21-2 Test2", 2, "Test2", eValidReact.NIO, "Test2", "StrAct: ; Test2\r\n")] - [DataRow("21-3 GEx", 2, "Test", eValidReact.GeneralException, "Test", "StrAct: ; Test\r\n")] - [DataRow("21-4 AEx", 2, "Test", eValidReact.ArgumetException, "Test", "StrAct: ; Test\r\n")] - [DataRow("30-1 Empty", 3, "", eValidReact.OK, "", "")] - [DataRow("30-2 Test", 3, "", eValidReact.NIO, "", "")] - [DataRow("30-2 Test", 3, "", eValidReact.GeneralException, "", "")] - [DataRow("31-1 Empty", 3, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\nStrAct: ; Test\r\n")] - [DataRow("31-2 Test", 3, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] - [DataRow("31-3 GEx", 3, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] - [DataRow("31-4 AEx", 3, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] - [DataRow("40-1 Empty", 4, "", eValidReact.OK, "", "")] - [DataRow("40-2 Test ", 4, "", eValidReact.NIO, "", "")] - [DataRow("41-1 Test ", 4, "Test", eValidReact.OK, "Test", "")] - [DataRow("41-2 Test2", 4, "Test2", eValidReact.NIO, "Test2", "")] - [DataRow("50-1 Empty", 5, "", eValidReact.OK, "", "")] - [DataRow("50-2 Test ", 5, "", eValidReact.NIO, "", "")] - [DataRow("50-2 Test ", 5, "", eValidReact.GeneralException, "", "")] - [DataRow("51-1 Empty", 5, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\n")] - [DataRow("51-2 Test ", 5, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] - [DataRow("51-3 GEx ", 5, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] - [DataRow("51-4 AEx ", 5, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] - [DataRow("60-1 Empty", 6, "", eValidReact.OK, "", "")] - [DataRow("60-2 Test ", 6, "", eValidReact.NIO, "", "")] - [DataRow("61-1 Test ", 6, "Test", eValidReact.OK, "Test", "StrAct: ; Test\r\n")] - [DataRow("61-2 Test2", 6, "Test2", eValidReact.NIO, "Test2", "StrAct: ; Test2\r\n")] - [DataRow("70-1 Empty", 7, "", eValidReact.OK, "", "")] - [DataRow("70-2 Test ", 7, "", eValidReact.NIO, "", "")] - [DataRow("70-2 Test ", 7, "", eValidReact.GeneralException, "", "")] - [DataRow("71-1 Empty", 7, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\nStrAct: ; Test\r\n")] - [DataRow("71-2 Test ", 7, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] - [DataRow("71-3 GEx ", 7, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] - [DataRow("71-4 AEx ", 7, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] - public void TestStringProp2(string name, int iTs, string sVal, eValidReact eReact, string sExp, string sDebExp) - { - PropertyChangedAdv -= OnPropertyChanged; - valReact = eReact; - bool xCh = sVal != _testString; - bool eRIsEx = eReact == eValidReact.GeneralException || eReact == eValidReact.ArgumetException; - switch (iTs) - { - //case 1: TestString1 = sVal; break; - case 1 when xCh && eReact == eValidReact.GeneralException: Assert.Throws(() => TestString1 = sVal, $"{name}.T1"); break; - case 1 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString1 = sVal, $"{name}.T1"); break; - case 1: TestString1 = sVal; break; - case 2: TestString2 = sVal; break; - case 3 when xCh && eReact == eValidReact.GeneralException: Assert.ThrowsExactly(() => TestString3 = sVal, $"{name}.T3"); break; - case 3 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString3 = sVal, $"{name}.T3"); break; - case 3: TestString3 = sVal; break; - case 4: TestString4 = sVal; break; - case 5 when xCh && eReact == eValidReact.GeneralException: Assert.ThrowsExactly(() => TestString5 = sVal, $"{name}.T5"); break; - case 5 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString5 = sVal, $"{name}.T5"); break; - case 5: TestString5 = sVal; break; - case 6: TestString6 = sVal; break; - case 7 when xCh && eReact == eValidReact.GeneralException: Assert.ThrowsExactly(() => TestString7 = sVal, $"{name}.T7"); break; - case 7 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString7 = sVal, $"{name}.T7"); break; - case 7: TestString7 = sVal; break; - default: TestString = sVal; break; - } - Assert.AreEqual(sExp, _testString, $"{name}.Result"); - Assert.AreEqual(sDebExp, DebugResult, $"{name}.DebRes"); - } - - } -} +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using BaseLib.Models; +using BaseLib.Interfaces; + +namespace BaseLib.ViewModel.Tests +{ + /// + /// Defines test class PropertyTests. + /// + [TestClass()] + public class NotificationObjectAdvTests : NotificationObjectAdv + { + public enum eValidReact + { + OK=0, + NIO, + GeneralException, + ArgumetException, + } + + private string _testString = ""; + private int _testInt; + private float _testFloat; + private double _testDouble; + + private eValidReact valReact=eValidReact.OK; + + private string DebugResult =""; + private TypeCode _testEnum; + + public string TestString { get => _testString;set=>SetProperty(ref _testString,value); } + public string TestString1 { get => _testString; set => SetProperty(ref _testString, value,ValidateString); } + public string TestString2 { get => _testString; set => SetProperty(ref _testString, value, StringAct); } + public string TestString3 { get => _testString; set => SetProperty(ref _testString, value, ValidateString, StringAct); } + public string TestString4 { get => _testString; set => SetProperty(ref _testString, value, new string[] {nameof(TestString),nameof(TestString2) }); } + public string TestString5 { get => _testString; set + => SetProperty(ref _testString, value, new string[] { nameof(TestString), nameof(TestString1) },ValidateString); } + public string TestString6 { get => _testString; set + => SetProperty(ref _testString, value, new string[] { nameof(TestString), nameof(TestString1) },StringAct); } + public string TestString7 { get => _testString; set + => SetProperty(ref _testString, value, new string[] { nameof(TestString), nameof(TestString1) }, ValidateString, StringAct); } + + public int TestInt { get => _testInt; set => SetProperty(ref _testInt, value); } + public float TestFloat { get => _testFloat; set => SetProperty(ref _testFloat, value); } + public double TestDouble { get => _testDouble; set => SetProperty(ref _testDouble, value); } + + public TypeCode TestEnum { get => _testEnum; set => SetProperty(ref _testEnum, value); } + + private void StringAct(string arg1, string arg2) + { + DebugResult += $"StrAct: {arg1}; {arg2}{Environment.NewLine}"; + switch (valReact) + { + case eValidReact.OK: + return ; + case eValidReact.NIO: + return ; + case eValidReact.GeneralException: + throw new Exception("A general exception occured"); + case eValidReact.ArgumetException: + throw new ArgumentException($"Argument ({arg2}) not valid!"); + default: return; + } + } + + private bool ValidateString(string arg1) + { + DebugResult += $"Validate: {arg1}, React:{valReact}{Environment.NewLine}"; + switch (valReact) + { + case eValidReact.OK: + return true; + case eValidReact.NIO: + return false; + case eValidReact.GeneralException: + throw new Exception("A general exception occured"); + case eValidReact.ArgumetException: + throw new ArgumentException($"Argument ({arg1}) not valid!"); + default: return false; + } + } + + public NotificationObjectAdvTests() + { + PropertyChangedAdv += OnPropertyChanged; + } + + private void Clear() + { + _testString = String.Empty; + _testInt = 0; + _testFloat = 0f; + _testDouble = 0d; + DebugResult = ""; + } + + private void OnPropertyChanged(object? sender, PropertyChangedAdvEventArgs e) + => DebugResult += $"OnPropChanged: o:{sender}, p:{e.PropertyName}:{sender?.GetType().GetProperty(e.PropertyName)?.GetValue(sender)}, o:{e.OldVal}, n:{e.NewVal}{Environment.NewLine}"; + + [TestInitialize] + public void Init() + { + Clear(); + } + + [TestMethod] + [TestProperty("Author","J.C.")] + [TestCategory("SetData")] + [DataRow("00 Empty",0,"",eValidReact.OK,"","")] + [DataRow("01-Test", 0, "Test", eValidReact.OK, "Test", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString:Test, o:, n:Test\r\n")] + [DataRow("10-1 Empty", 1, "", eValidReact.OK, "", "")] + [DataRow("10-2 Test" , 1, "", eValidReact.NIO, "", "")] + [DataRow("10-2 Test" , 1, "", eValidReact.GeneralException, "", "")] + [DataRow("11-1 Empty", 1, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString1:Test, o:, n:Test\r\n")] + [DataRow("11-2 Test" , 1, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] + [DataRow("11-3 GEx" , 1, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] + [DataRow("11-4 AEx" , 1, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] + [DataRow("12-2 Test2", 1, "Test2", eValidReact.OK, "Test2", "Validate: Test2, React:OK\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString1:Test2, o:, n:Test2\r\n")] + [DataRow("20-1 Empty", 2, "", eValidReact.OK, "", "")] + [DataRow("20-2 Test", 2, "", eValidReact.NIO, "", "")] + [DataRow("21-1 Test", 2, "Test", eValidReact.OK, "Test", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString2:Test, o:, n:Test\r\nStrAct: ; Test\r\n")] + [DataRow("21-2 Test2", 2, "Test2", eValidReact.NIO, "Test2", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString2:Test2, o:, n:Test2\r\nStrAct: ; Test2\r\n")] + [DataRow("21-3 GEx", 2, "Test", eValidReact.GeneralException, "Test", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString2:Test, o:, n:Test\r\nStrAct: ; Test\r\n")] + [DataRow("21-4 AEx", 2, "Test", eValidReact.ArgumetException, "Test", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString2:Test, o:, n:Test\r\nStrAct: ; Test\r\n")] + [DataRow("30-1 Empty", 3, "", eValidReact.OK, "", "")] + [DataRow("30-2 Test", 3, "", eValidReact.NIO, "", "")] + [DataRow("30-2 Test", 3, "", eValidReact.GeneralException, "", "")] + [DataRow("31-1 Empty", 3, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString3:Test, o:, n:Test\r\nStrAct: ; Test\r\n")] + [DataRow("31-2 Test", 3, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] + [DataRow("31-3 GEx", 3, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] + [DataRow("31-4 AEx", 3, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] + [DataRow("40-1 Empty", 4, "", eValidReact.OK, "", "")] + [DataRow("40-2 Test " , 4, "", eValidReact.NIO, "", "")] + [DataRow("41-1 Test " , 4, "Test", eValidReact.OK, "Test", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString4:Test, o:, n:Test\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString:Test, o:, n:\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString2:Test, o:, n:\r\n")] + [DataRow("41-2 Test2", 4, "Test2", eValidReact.NIO, "Test2", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString4:Test2, o:, n:Test2\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString:Test2, o:, n:\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString2:Test2, o:, n:\r\n")] + [DataRow("50-1 Empty", 5, "", eValidReact.OK, "", "")] + [DataRow("50-2 Test ", 5, "", eValidReact.NIO, "", "")] + [DataRow("50-2 Test ", 5, "", eValidReact.GeneralException, "", "")] + [DataRow("51-1 Empty", 5, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString5:Test, o:, n:Test\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString:Test, o:, n:\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString1:Test, o:, n:\r\n")] + [DataRow("51-2 Test ", 5, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] + [DataRow("51-3 GEx ", 5, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] + [DataRow("51-4 AEx ", 5, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] + [DataRow("60-1 Empty", 6, "", eValidReact.OK, "", "")] + [DataRow("60-2 Test ", 6, "", eValidReact.NIO, "", "")] + [DataRow("61-1 Test ", 6, "Test", eValidReact.OK, "Test", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString6:Test, o:, n:Test\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString:Test, o:, n:\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString1:Test, o:, n:\r\nStrAct: ; Test\r\n")] + [DataRow("61-2 Test2", 6, "Test2", eValidReact.NIO, "Test2", "OnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString6:Test2, o:, n:Test2\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString:Test2, o:, n:\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString1:Test2, o:, n:\r\nStrAct: ; Test2\r\n")] + [DataRow("70-1 Empty", 7, "", eValidReact.OK, "", "")] + [DataRow("70-2 Test ", 7, "", eValidReact.NIO, "", "")] + [DataRow("70-2 Test ", 7, "", eValidReact.GeneralException, "", "")] + [DataRow("71-1 Empty", 7, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString7:Test, o:, n:Test\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString:Test, o:, n:\r\nOnPropChanged: o:BaseLib.ViewModel.Tests.NotificationObjectAdvTests, p:TestString1:Test, o:, n:\r\nStrAct: ; Test\r\n")] + [DataRow("71-2 Test ", 7, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] + [DataRow("71-3 GEx ", 7, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] + [DataRow("71-4 AEx ", 7, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] + public void TestStringProp(string name,int iTs,string sVal, eValidReact eReact, string sExp,string sDebExp) + { + valReact = eReact; + bool xCh = sVal != _testString; + bool eRIsEx = eReact == eValidReact.GeneralException || eReact == eValidReact.ArgumetException; + switch (iTs) + { + //case 1: TestString1 = sVal; break; + case 1 when xCh && eReact == eValidReact.GeneralException: Assert.ThrowsExactly(()=>TestString1 = sVal,$"{name}.T1"); break; + case 1 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString1 = sVal, $"{name}.T1"); break; + case 1: TestString1 = sVal; break; + case 2: TestString2 = sVal; break; + case 3 when xCh && eReact == eValidReact.GeneralException: Assert.ThrowsExactly(() => TestString3 = sVal, $"{name}.T3"); break; + case 3 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString3 = sVal, $"{name}.T3"); break; + case 3: TestString3 = sVal; break; + case 4: TestString4 = sVal; break; + case 5 when xCh && eReact == eValidReact.GeneralException: Assert.ThrowsExactly(() => TestString5 = sVal, $"{name}.T5"); break; + case 5 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString5 = sVal, $"{name}.T5"); break; + case 5: TestString5 = sVal; break; + case 6: TestString6 = sVal; break; + case 7 when xCh && eReact == eValidReact.GeneralException: Assert.ThrowsExactly(() => TestString7 = sVal, $"{name}.T7"); break; + case 7 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString7 = sVal, $"{name}.T7"); break; + case 7: TestString7 = sVal; break; + default: TestString = sVal; break; + } + Assert.AreEqual(sExp, _testString, $"{name}.Result"); + Assert.AreEqual(sDebExp, DebugResult, $"{name}.DebRes"); + } + + [TestMethod] + [TestProperty("Author", "J.C.")] + [TestCategory("SetData")] + [DataRow("00 Empty", 0, "", eValidReact.OK, "", "")] + [DataRow("01-Test", 0, "Test", eValidReact.OK, "Test", "")] + [DataRow("10-1 Empty", 1, "", eValidReact.OK, "", "")] + [DataRow("10-2 Test", 1, "", eValidReact.NIO, "", "")] + [DataRow("10-2 Test", 1, "", eValidReact.GeneralException, "", "")] + [DataRow("11-1 Empty", 1, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\n")] + [DataRow("11-2 Test", 1, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] + [DataRow("11-3 GEx", 1, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] + [DataRow("11-4 AEx", 1, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] + [DataRow("12-2 Test2", 1, "Test2", eValidReact.OK, "Test2", "Validate: Test2, React:OK\r\n")] + [DataRow("20-1 Empty", 2, "", eValidReact.OK, "", "")] + [DataRow("20-2 Test", 2, "", eValidReact.NIO, "", "")] + [DataRow("21-1 Test", 2, "Test", eValidReact.OK, "Test", "StrAct: ; Test\r\n")] + [DataRow("21-2 Test2", 2, "Test2", eValidReact.NIO, "Test2", "StrAct: ; Test2\r\n")] + [DataRow("21-3 GEx", 2, "Test", eValidReact.GeneralException, "Test", "StrAct: ; Test\r\n")] + [DataRow("21-4 AEx", 2, "Test", eValidReact.ArgumetException, "Test", "StrAct: ; Test\r\n")] + [DataRow("30-1 Empty", 3, "", eValidReact.OK, "", "")] + [DataRow("30-2 Test", 3, "", eValidReact.NIO, "", "")] + [DataRow("30-2 Test", 3, "", eValidReact.GeneralException, "", "")] + [DataRow("31-1 Empty", 3, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\nStrAct: ; Test\r\n")] + [DataRow("31-2 Test", 3, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] + [DataRow("31-3 GEx", 3, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] + [DataRow("31-4 AEx", 3, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] + [DataRow("40-1 Empty", 4, "", eValidReact.OK, "", "")] + [DataRow("40-2 Test ", 4, "", eValidReact.NIO, "", "")] + [DataRow("41-1 Test ", 4, "Test", eValidReact.OK, "Test", "")] + [DataRow("41-2 Test2", 4, "Test2", eValidReact.NIO, "Test2", "")] + [DataRow("50-1 Empty", 5, "", eValidReact.OK, "", "")] + [DataRow("50-2 Test ", 5, "", eValidReact.NIO, "", "")] + [DataRow("50-2 Test ", 5, "", eValidReact.GeneralException, "", "")] + [DataRow("51-1 Empty", 5, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\n")] + [DataRow("51-2 Test ", 5, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] + [DataRow("51-3 GEx ", 5, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] + [DataRow("51-4 AEx ", 5, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] + [DataRow("60-1 Empty", 6, "", eValidReact.OK, "", "")] + [DataRow("60-2 Test ", 6, "", eValidReact.NIO, "", "")] + [DataRow("61-1 Test ", 6, "Test", eValidReact.OK, "Test", "StrAct: ; Test\r\n")] + [DataRow("61-2 Test2", 6, "Test2", eValidReact.NIO, "Test2", "StrAct: ; Test2\r\n")] + [DataRow("70-1 Empty", 7, "", eValidReact.OK, "", "")] + [DataRow("70-2 Test ", 7, "", eValidReact.NIO, "", "")] + [DataRow("70-2 Test ", 7, "", eValidReact.GeneralException, "", "")] + [DataRow("71-1 Empty", 7, "Test", eValidReact.OK, "Test", "Validate: Test, React:OK\r\nStrAct: ; Test\r\n")] + [DataRow("71-2 Test ", 7, "Test", eValidReact.NIO, "", "Validate: Test, React:NIO\r\n")] + [DataRow("71-3 GEx ", 7, "Test", eValidReact.GeneralException, "", "Validate: Test, React:GeneralException\r\n")] + [DataRow("71-4 AEx ", 7, "Test", eValidReact.ArgumetException, "", "Validate: Test, React:ArgumetException\r\n")] + public void TestStringProp2(string name, int iTs, string sVal, eValidReact eReact, string sExp, string sDebExp) + { + PropertyChangedAdv -= OnPropertyChanged; + valReact = eReact; + bool xCh = sVal != _testString; + bool eRIsEx = eReact == eValidReact.GeneralException || eReact == eValidReact.ArgumetException; + switch (iTs) + { + //case 1: TestString1 = sVal; break; + case 1 when xCh && eReact == eValidReact.GeneralException: Assert.Throws(() => TestString1 = sVal, $"{name}.T1"); break; + case 1 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString1 = sVal, $"{name}.T1"); break; + case 1: TestString1 = sVal; break; + case 2: TestString2 = sVal; break; + case 3 when xCh && eReact == eValidReact.GeneralException: Assert.ThrowsExactly(() => TestString3 = sVal, $"{name}.T3"); break; + case 3 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString3 = sVal, $"{name}.T3"); break; + case 3: TestString3 = sVal; break; + case 4: TestString4 = sVal; break; + case 5 when xCh && eReact == eValidReact.GeneralException: Assert.ThrowsExactly(() => TestString5 = sVal, $"{name}.T5"); break; + case 5 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString5 = sVal, $"{name}.T5"); break; + case 5: TestString5 = sVal; break; + case 6: TestString6 = sVal; break; + case 7 when xCh && eReact == eValidReact.GeneralException: Assert.ThrowsExactly(() => TestString7 = sVal, $"{name}.T7"); break; + case 7 when xCh && eReact == eValidReact.ArgumetException: Assert.ThrowsExactly(() => TestString7 = sVal, $"{name}.T7"); break; + case 7: TestString7 = sVal; break; + default: TestString = sVal; break; + } + Assert.AreEqual(sExp, _testString, $"{name}.Result"); + Assert.AreEqual(sDebExp, DebugResult, $"{name}.DebRes"); + } + + } +} diff --git a/CSharpBible/Libraries/BaseLibTests/Model/SysTimeTests.cs b/CSharpBible/Libraries/BaseLibTests/Models/SysTimeTests.cs similarity index 100% rename from CSharpBible/Libraries/BaseLibTests/Model/SysTimeTests.cs rename to CSharpBible/Libraries/BaseLibTests/Models/SysTimeTests.cs diff --git a/CSharpBible/Libraries/ConsoleDisplayTests/ConsoleDisplayTests.csproj b/CSharpBible/Libraries/ConsoleDisplayTests/ConsoleDisplayTests.csproj index 5653aafcd..bdb18edaa 100644 --- a/CSharpBible/Libraries/ConsoleDisplayTests/ConsoleDisplayTests.csproj +++ b/CSharpBible/Libraries/ConsoleDisplayTests/ConsoleDisplayTests.csproj @@ -17,7 +17,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Libraries/ConsoleLibTests/ConsoleLibTests.csproj b/CSharpBible/Libraries/ConsoleLibTests/ConsoleLibTests.csproj index 27bb48a54..1161cfe77 100644 --- a/CSharpBible/Libraries/ConsoleLibTests/ConsoleLibTests.csproj +++ b/CSharpBible/Libraries/ConsoleLibTests/ConsoleLibTests.csproj @@ -14,8 +14,8 @@ - - + + \ No newline at end of file diff --git a/CSharpBible/Libraries/MVVM_BaseLibTests/MVVM_BaseLibTests.csproj b/CSharpBible/Libraries/MVVM_BaseLibTests/MVVM_BaseLibTests.csproj index 45dd12f50..c19b5744c 100644 --- a/CSharpBible/Libraries/MVVM_BaseLibTests/MVVM_BaseLibTests.csproj +++ b/CSharpBible/Libraries/MVVM_BaseLibTests/MVVM_BaseLibTests.csproj @@ -18,7 +18,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Libraries/MathLibraryTests/MathLibraryTests.csproj b/CSharpBible/Libraries/MathLibraryTests/MathLibraryTests.csproj index 069fc5b3d..0295358ea 100644 --- a/CSharpBible/Libraries/MathLibraryTests/MathLibraryTests.csproj +++ b/CSharpBible/Libraries/MathLibraryTests/MathLibraryTests.csproj @@ -18,7 +18,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/CSharpBible/Patterns_Tutorial/Pattern_00_TemplateTests/Pattern_00_TemplateTests.csproj b/CSharpBible/Patterns_Tutorial/Pattern_00_TemplateTests/Pattern_00_TemplateTests.csproj index a2cb71ff5..58dc70649 100644 --- a/CSharpBible/Patterns_Tutorial/Pattern_00_TemplateTests/Pattern_00_TemplateTests.csproj +++ b/CSharpBible/Patterns_Tutorial/Pattern_00_TemplateTests/Pattern_00_TemplateTests.csproj @@ -13,8 +13,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Patterns_Tutorial/Pattern_01_SingletonTests/Pattern_01_SingletonTests.csproj b/CSharpBible/Patterns_Tutorial/Pattern_01_SingletonTests/Pattern_01_SingletonTests.csproj index 75b1c4af6..678b92f20 100644 --- a/CSharpBible/Patterns_Tutorial/Pattern_01_SingletonTests/Pattern_01_SingletonTests.csproj +++ b/CSharpBible/Patterns_Tutorial/Pattern_01_SingletonTests/Pattern_01_SingletonTests.csproj @@ -13,8 +13,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Patterns_Tutorial/Pattern_02_ObserverTests/Pattern_02_ObserverTests.csproj b/CSharpBible/Patterns_Tutorial/Pattern_02_ObserverTests/Pattern_02_ObserverTests.csproj index ba6b83e4e..19752b3d9 100644 --- a/CSharpBible/Patterns_Tutorial/Pattern_02_ObserverTests/Pattern_02_ObserverTests.csproj +++ b/CSharpBible/Patterns_Tutorial/Pattern_02_ObserverTests/Pattern_02_ObserverTests.csproj @@ -13,8 +13,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/SomeThing/QuineTest/QuineTest.csproj b/CSharpBible/SomeThing/QuineTest/QuineTest.csproj index 3048d1f55..18e31fe7a 100644 --- a/CSharpBible/SomeThing/QuineTest/QuineTest.csproj +++ b/CSharpBible/SomeThing/QuineTest/QuineTest.csproj @@ -8,7 +8,7 @@ - + diff --git a/CSharpBible/SomeThing/SomeThing2Tests/SomeThing2Tests.csproj b/CSharpBible/SomeThing/SomeThing2Tests/SomeThing2Tests.csproj index 5320e54d1..f96afffe2 100644 --- a/CSharpBible/SomeThing/SomeThing2Tests/SomeThing2Tests.csproj +++ b/CSharpBible/SomeThing/SomeThing2Tests/SomeThing2Tests.csproj @@ -10,8 +10,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/SomeThing/SomeThing2aTests/SomeThing2aTests.csproj b/CSharpBible/SomeThing/SomeThing2aTests/SomeThing2aTests.csproj index de42129fe..ae3f2c64f 100644 --- a/CSharpBible/SomeThing/SomeThing2aTests/SomeThing2aTests.csproj +++ b/CSharpBible/SomeThing/SomeThing2aTests/SomeThing2aTests.csproj @@ -10,8 +10,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/Tests/Test.csproj b/CSharpBible/Tests/Test.csproj index c6338e43d..f8a2afec3 100644 --- a/CSharpBible/Tests/Test.csproj +++ b/CSharpBible/Tests/Test.csproj @@ -11,8 +11,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_AnimationTimingTests/WPF_AnimationTimingTests.csproj b/CSharpBible/WPFSamples_2/WPF_AnimationTimingTests/WPF_AnimationTimingTests.csproj index f542ebbe4..c5045d999 100644 --- a/CSharpBible/WPFSamples_2/WPF_AnimationTimingTests/WPF_AnimationTimingTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_AnimationTimingTests/WPF_AnimationTimingTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_AnimationTimingTests/WPF_AnimationTiming_netTests.csproj b/CSharpBible/WPFSamples_2/WPF_AnimationTimingTests/WPF_AnimationTiming_netTests.csproj index ffdcffbfb..d5513a215 100644 --- a/CSharpBible/WPFSamples_2/WPF_AnimationTimingTests/WPF_AnimationTiming_netTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_AnimationTimingTests/WPF_AnimationTiming_netTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_Complex_LayoutTests/WPF_Complex_LayoutTests.csproj b/CSharpBible/WPFSamples_2/WPF_Complex_LayoutTests/WPF_Complex_LayoutTests.csproj index 2ea2e28fb..0030f0ab7 100644 --- a/CSharpBible/WPFSamples_2/WPF_Complex_LayoutTests/WPF_Complex_LayoutTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_Complex_LayoutTests/WPF_Complex_LayoutTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_Complex_LayoutTests/WPF_Complex_Layout_netTests.csproj b/CSharpBible/WPFSamples_2/WPF_Complex_LayoutTests/WPF_Complex_Layout_netTests.csproj index 9ce443bbd..c882ae619 100644 --- a/CSharpBible/WPFSamples_2/WPF_Complex_LayoutTests/WPF_Complex_Layout_netTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_Complex_LayoutTests/WPF_Complex_Layout_netTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_ControlsAndLayoutTests/WPF_ControlsAndLayoutTests.csproj b/CSharpBible/WPFSamples_2/WPF_ControlsAndLayoutTests/WPF_ControlsAndLayoutTests.csproj index a6b311436..2eaf01269 100644 --- a/CSharpBible/WPFSamples_2/WPF_ControlsAndLayoutTests/WPF_ControlsAndLayoutTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_ControlsAndLayoutTests/WPF_ControlsAndLayoutTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_ControlsAndLayoutTests/WPF_ControlsAndLayout_netTests.csproj b/CSharpBible/WPFSamples_2/WPF_ControlsAndLayoutTests/WPF_ControlsAndLayout_netTests.csproj index 8ed1f72d9..dd87d0522 100644 --- a/CSharpBible/WPFSamples_2/WPF_ControlsAndLayoutTests/WPF_ControlsAndLayout_netTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_ControlsAndLayoutTests/WPF_ControlsAndLayout_netTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_CustomAnimationTests/WPF_CustomAnimationTests.csproj b/CSharpBible/WPFSamples_2/WPF_CustomAnimationTests/WPF_CustomAnimationTests.csproj index 252e6ec08..23051d4ab 100644 --- a/CSharpBible/WPFSamples_2/WPF_CustomAnimationTests/WPF_CustomAnimationTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_CustomAnimationTests/WPF_CustomAnimationTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_CustomAnimationTests/WPF_CustomAnimation_netTests.csproj b/CSharpBible/WPFSamples_2/WPF_CustomAnimationTests/WPF_CustomAnimation_netTests.csproj index 7a39994e5..2439beb73 100644 --- a/CSharpBible/WPFSamples_2/WPF_CustomAnimationTests/WPF_CustomAnimation_netTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_CustomAnimationTests/WPF_CustomAnimation_netTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_Hello_WorldTests/WPF_Hello_WorldTests.csproj b/CSharpBible/WPFSamples_2/WPF_Hello_WorldTests/WPF_Hello_WorldTests.csproj index ef619de4b..dc2aed35c 100644 --- a/CSharpBible/WPFSamples_2/WPF_Hello_WorldTests/WPF_Hello_WorldTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_Hello_WorldTests/WPF_Hello_WorldTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_Hello_WorldTests/WPF_Hello_World_netTests.csproj b/CSharpBible/WPFSamples_2/WPF_Hello_WorldTests/WPF_Hello_World_netTests.csproj index e0961695d..ae26dbf20 100644 --- a/CSharpBible/WPFSamples_2/WPF_Hello_WorldTests/WPF_Hello_World_netTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_Hello_WorldTests/WPF_Hello_World_netTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_MasterDetailTests/WPF_MasterDetailTests.csproj b/CSharpBible/WPFSamples_2/WPF_MasterDetailTests/WPF_MasterDetailTests.csproj index dfc87bc07..9adfe3d0d 100644 --- a/CSharpBible/WPFSamples_2/WPF_MasterDetailTests/WPF_MasterDetailTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_MasterDetailTests/WPF_MasterDetailTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_MasterDetailTests/WPF_MasterDetail_netTests.csproj b/CSharpBible/WPFSamples_2/WPF_MasterDetailTests/WPF_MasterDetail_netTests.csproj index 21efe77a9..d7a9b467c 100644 --- a/CSharpBible/WPFSamples_2/WPF_MasterDetailTests/WPF_MasterDetail_netTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_MasterDetailTests/WPF_MasterDetail_netTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_MoveWindowTests/WPF_MoveWindowTests.csproj b/CSharpBible/WPFSamples_2/WPF_MoveWindowTests/WPF_MoveWindowTests.csproj index 6752e265a..1eca3d569 100644 --- a/CSharpBible/WPFSamples_2/WPF_MoveWindowTests/WPF_MoveWindowTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_MoveWindowTests/WPF_MoveWindowTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_MoveWindowTests/WPF_MoveWindow_netTests.csproj b/CSharpBible/WPFSamples_2/WPF_MoveWindowTests/WPF_MoveWindow_netTests.csproj index b790229f5..d5ff05d65 100644 --- a/CSharpBible/WPFSamples_2/WPF_MoveWindowTests/WPF_MoveWindow_netTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_MoveWindowTests/WPF_MoveWindow_netTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_Sample_TemplateTests/WPF_Sample_TemplateTests.csproj b/CSharpBible/WPFSamples_2/WPF_Sample_TemplateTests/WPF_Sample_TemplateTests.csproj index 2e0cbb39a..a66617c1c 100644 --- a/CSharpBible/WPFSamples_2/WPF_Sample_TemplateTests/WPF_Sample_TemplateTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_Sample_TemplateTests/WPF_Sample_TemplateTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_Sample_TemplateTests/WPF_Sample_Template_netTests.csproj b/CSharpBible/WPFSamples_2/WPF_Sample_TemplateTests/WPF_Sample_Template_netTests.csproj index dc70a1ed5..e9e4063ba 100644 --- a/CSharpBible/WPFSamples_2/WPF_Sample_TemplateTests/WPF_Sample_Template_netTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_Sample_TemplateTests/WPF_Sample_Template_netTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_StickyNotesDemoTests/WPF_StickyNotesDemoTests.csproj b/CSharpBible/WPFSamples_2/WPF_StickyNotesDemoTests/WPF_StickyNotesDemoTests.csproj index 20567ab6e..637c74270 100644 --- a/CSharpBible/WPFSamples_2/WPF_StickyNotesDemoTests/WPF_StickyNotesDemoTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_StickyNotesDemoTests/WPF_StickyNotesDemoTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CSharpBible/WPFSamples_2/WPF_StickyNotesDemoTests/WPF_StickyNotesDemo_netTests.csproj b/CSharpBible/WPFSamples_2/WPF_StickyNotesDemoTests/WPF_StickyNotesDemo_netTests.csproj index 78786028c..2a6e96d39 100644 --- a/CSharpBible/WPFSamples_2/WPF_StickyNotesDemoTests/WPF_StickyNotesDemo_netTests.csproj +++ b/CSharpBible/WPFSamples_2/WPF_StickyNotesDemoTests/WPF_StickyNotesDemo_netTests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/GenFreeWin/.github/copilot-instructions.md b/GenFreeWin/.github/copilot-instructions.md index 4f86cf3d7..77988b00d 100644 --- a/GenFreeWin/.github/copilot-instructions.md +++ b/GenFreeWin/.github/copilot-instructions.md @@ -5,8 +5,11 @@ Apply these defaults when working in this repository unless the user explicitly ## General Guidelines - Document code thoroughly in English. - Validate changes with relevant builds and tests before finishing. -- If requirements are unclear, ask clarifying questions before starting implementation. -- Respect strict nullability to indicate when a variable can be null, and handle nullability appropriately in code. +- If requirements are unclear, ask clarifying questions before starting implementation or planning refinement. +- Avoid UI text strings in core services. Use Enumerations instead, and keep UI-facing strings in the ViewModel/UI layer. +- Prefer one class/interface/struct per file. +- document changes in an DevOps-manner markdown prefered, extrapolate bugs, tasks, baglogs and features +- Use `DevOps` as the planning directory in this workspace, and treat `.Info.md` as the general planning description file. Team terminology around Azure DevOps backlog items may differ from generic 'story' naming. ## Testing - Use `MSTest` in the latest practical version for new or updated tests. @@ -18,17 +21,28 @@ Apply these defaults when working in this repository unless the user explicitly ## Architecture - Use MVVM architecture for UI components to separate concerns and improve testability, using CommunityToolkit.Mvvm for MVVM implementation. +- Prefer `NotifyPropertyChangedFor` over manual `OnPropertyChanged(nameof(...))` in CommunityToolkit.Mvvm observable properties where applicable. - Use Dependency Injection to manage dependencies and improve testability, using Microsoft.Extensions.DependencyInjection. -- CommunityToolkit.Mvvm source-generator-based view models are valid; editor errors can disappear after a build, so do not replace them prematurely with manual implementations just because generated members are not yet available in design-time analysis. +- UI-facing strings and summary formatting should stay in the ViewModel/UI layer, not in extracted application logic services. ## Naming Conventions +- Distinguish between UI control naming and variable/field naming. - Use PascalCase for class names, method names, and properties. -- Use _camelCase for local/private variables and parameters. -- Use 1-3 letter prefixes for type of variable, e.g. - - `s` for string, - - `i` for all int (8-128bit), - - `u` for Unsigned Int (8-128 bit), - - `x` for bool, - - `f` for float/double, - - `lst` for list, - - `btn` for button, etc. +- Use `_camelCase` for private fields. +- Use `camelCase` for local variables and parameters. +- Use short 1-character prefixes for simple types only when they meaningfully disambiguate, e.g. + - `s` for `string` + - `i` for signed integer types + - `u` for unsigned integer types + - `x` for `bool` + - `f` for `float`, `double`, or `decimal` +- Prefer meaningful domain names over type prefixes when the intent is already clear. +- In UI code, use short 3-character prefixes for actual controls in views and code-behind, e.g. + - `lst` for list controls + - `btn` for all kind of buttons, + - `edt` for any keyboard input control + - `lbl` for any text output control +- Do not use UI control prefixes for ViewModel properties or other non-UI members. + +## Nullability +- Use strict nullable reference types to indicate when a variable can be null, and handle nullability appropriately in code. diff --git a/GenFreeWin/GenDBImplOLEDBTests/GenDBImplOLEDBTests.csproj b/GenFreeWin/GenDBImplOLEDBTests/GenDBImplOLEDBTests.csproj index 6e234a376..4c46c4cca 100644 --- a/GenFreeWin/GenDBImplOLEDBTests/GenDBImplOLEDBTests.csproj +++ b/GenFreeWin/GenDBImplOLEDBTests/GenDBImplOLEDBTests.csproj @@ -14,8 +14,8 @@ - - + + diff --git a/GenFreeWin/GenFreeBase/Interfaces/Model/IPrintDat.cs b/GenFreeWin/GenFreeBase/Interfaces/Model/IPrintDat.cs new file mode 100644 index 000000000..fb015528c --- /dev/null +++ b/GenFreeWin/GenFreeBase/Interfaces/Model/IPrintDat.cs @@ -0,0 +1,69 @@ +using GenFree.Data; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace GenFree.Interfaces.Model; + +/// +/// Interface for print-specific data and methods used only in Druck project. +/// Accessed via IModul1.PrintDat property. +/// +public interface IPrintDat +{ + /// Gets or sets the superscript offset for citations. + int Hoch { get; set; } + + /// Gets or sets the count of remarks/citations. + int BemZahl { get; set; } + + /// Gets the remarks/citations container. + IList KontBem { get; } + + /// Gets the temporary storage for Kont values. + IList KontSP { get; } + + /// Gets the temporary storage for Kont1 values. + IList KontSP1 { get; } + + /// Gets or sets the family save variable. + int FamSp { get; set; } + + /// Gets or sets the godparent flag. + bool Pat { get; set; } + + /// Gets or sets the flag switch for printing. + int Flagsch { get; set; } + + /// Gets or sets the ancestor number length. + byte KonLen { get; set; } + + /// Gets or sets general yes/no flag. + int Ja { get; set; } + + /// Gets or sets the sequence number. + byte LfNR { get; set; } + + /// Removes trailing newlines and spaces. + string Retweg(string sText); + + /// Adds person to name index. + void Namenindex(long lAhne); + + /// Reads person source/citation data. + void PerQu(ref int iFamPer); + + /// Reads source data for date. + void QuellenDatum(ref int iNr, EEventArt eArt, ref short iLfNR); + + /// Reads source data with dot notation. + void QuelledotnDatum(ref int iNr, EEventArt eArt, ref short iLfNR); + + /// Reads family residence data. + void FWohn(EEventArt eArt, ref short iListart); + + /// Reads witness data for person. + void Zeuge_Bei(int iPersInArb, ref short iListart); +} diff --git a/GenFreeWin/GenFreeBase/Interfaces/Sys/IModul1.cs b/GenFreeWin/GenFreeBase/Interfaces/Sys/IModul1.cs index 5997735f0..3f778972c 100644 --- a/GenFreeWin/GenFreeBase/Interfaces/Sys/IModul1.cs +++ b/GenFreeWin/GenFreeBase/Interfaces/Sys/IModul1.cs @@ -392,7 +392,6 @@ public struct Letzter int PersSp { get; set; } string Datschuname { get; set; } string Eltq { get; set; } - string Ubg1T { get; set; } void Datles2(); string Person_FullSurname(IPersonData person, bool xFamToUpper); diff --git a/GenFreeWin/GenFreeBaseClassesTests/GenFreeBaseClassesTests.csproj b/GenFreeWin/GenFreeBaseClassesTests/GenFreeBaseClassesTests.csproj index 9f2cb504c..efed7a5b8 100644 --- a/GenFreeWin/GenFreeBaseClassesTests/GenFreeBaseClassesTests.csproj +++ b/GenFreeWin/GenFreeBaseClassesTests/GenFreeBaseClassesTests.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/GenFreeWin/GenFreeBaseTests/GenFreeBaseTests.csproj b/GenFreeWin/GenFreeBaseTests/GenFreeBaseTests.csproj index bbf473860..c06f5d81c 100644 --- a/GenFreeWin/GenFreeBaseTests/GenFreeBaseTests.csproj +++ b/GenFreeWin/GenFreeBaseTests/GenFreeBaseTests.csproj @@ -15,8 +15,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/GenFreeWin/GenFreeBrowser.Tests/GenFreeBrowser.Tests.csproj b/GenFreeWin/GenFreeBrowser.Tests/GenFreeBrowser.Tests.csproj index 900d43f97..d0c730c8e 100644 --- a/GenFreeWin/GenFreeBrowser.Tests/GenFreeBrowser.Tests.csproj +++ b/GenFreeWin/GenFreeBrowser.Tests/GenFreeBrowser.Tests.csproj @@ -12,8 +12,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/GenFreeWin/GenFreeDataTests/GenFreeDataTests.csproj b/GenFreeWin/GenFreeDataTests/GenFreeDataTests.csproj index e00aac8a2..758bc3c8f 100644 --- a/GenFreeWin/GenFreeDataTests/GenFreeDataTests.csproj +++ b/GenFreeWin/GenFreeDataTests/GenFreeDataTests.csproj @@ -18,8 +18,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/GenFreeWin/GenFreeHelperTests/GenFreeHelperTests.csproj b/GenFreeWin/GenFreeHelperTests/GenFreeHelperTests.csproj index 198807ab0..03e6f312c 100644 --- a/GenFreeWin/GenFreeHelperTests/GenFreeHelperTests.csproj +++ b/GenFreeWin/GenFreeHelperTests/GenFreeHelperTests.csproj @@ -19,8 +19,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/GenFreeWin/GenFreeWinFormsTests/GenFreeWinFormsTests.csproj b/GenFreeWin/GenFreeWinFormsTests/GenFreeWinFormsTests.csproj index 9191926af..e0e9e797e 100644 --- a/GenFreeWin/GenFreeWinFormsTests/GenFreeWinFormsTests.csproj +++ b/GenFreeWin/GenFreeWinFormsTests/GenFreeWinFormsTests.csproj @@ -11,8 +11,8 @@ - - + + diff --git a/GenFreeWin/GenFreeWinTests/genFreeWinTests.csproj b/GenFreeWin/GenFreeWinTests/genFreeWinTests.csproj index 487496f70..02f332b42 100644 --- a/GenFreeWin/GenFreeWinTests/genFreeWinTests.csproj +++ b/GenFreeWin/GenFreeWinTests/genFreeWinTests.csproj @@ -19,8 +19,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/GenFreeWin/GenSecure.Core.Tests/GenSecure.Core.Tests.csproj b/GenFreeWin/GenSecure.Core.Tests/GenSecure.Core.Tests.csproj index 2b2f77066..0c49fd2ab 100644 --- a/GenFreeWin/GenSecure.Core.Tests/GenSecure.Core.Tests.csproj +++ b/GenFreeWin/GenSecure.Core.Tests/GenSecure.Core.Tests.csproj @@ -1,4 +1,4 @@ - + net9.0-windows @@ -14,8 +14,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/GenFreeWin/Gen_FreeWin.sln b/GenFreeWin/Gen_FreeWin.sln index a7ab67025..9ae226669 100644 --- a/GenFreeWin/Gen_FreeWin.sln +++ b/GenFreeWin/Gen_FreeWin.sln @@ -296,6 +296,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinAhnenNew.Model.Tests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinAhnenNew.UI.Tests", "..\WinAhnenNew\WinAhnenNew.UI.Tests\WinAhnenNew.UI.Tests.csproj", "{CFBAA8EF-C46B-9F97-97FF-9D08F68ECE58}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "RnzTrauer", "RnzTrauer", "{9257CE06-4B6A-44D1-869C-445CBFCDFF1D}" + ProjectSection(SolutionItems) = preProject + ..\WinAhnenNew\RnzTrauer\Directory.Build.props = ..\WinAhnenNew\RnzTrauer\Directory.Build.props + ..\WinAhnenNew\RnzTrauer\Directory.Packages.props = ..\WinAhnenNew\RnzTrauer\Directory.Packages.props + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RnzTrauer.Core", "..\WinAhnenNew\RnzTrauer\RnzTrauer.Core\RnzTrauer.Core.csproj", "{C550598C-93A6-237F-E1A5-37106948D110}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RnzTrauer.Tests", "..\WinAhnenNew\RnzTrauer\RnzTrauer.Tests\RnzTrauer.Tests.csproj", "{A176D9D1-803C-21F9-9CC0-0A4E9E3067A0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RnzTrauer.Console", "..\WinAhnenNew\RnzTrauer\RnzTrauer.Console\RnzTrauer.Console.csproj", "{E46C404F-B6FE-7940-6A5F-C8CE98B13CFC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AmtsblattLoader.Console", "..\WinAhnenNew\RnzTrauer\AmtsblattLoader.Console\AmtsblattLoader.Console.csproj", "{E0654505-59DC-B459-B483-7EA9602FB041}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1192,6 +1206,54 @@ Global {CFBAA8EF-C46B-9F97-97FF-9D08F68ECE58}.Release|x64.Build.0 = Release|Any CPU {CFBAA8EF-C46B-9F97-97FF-9D08F68ECE58}.Release|x86.ActiveCfg = Release|Any CPU {CFBAA8EF-C46B-9F97-97FF-9D08F68ECE58}.Release|x86.Build.0 = Release|Any CPU + {C550598C-93A6-237F-E1A5-37106948D110}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C550598C-93A6-237F-E1A5-37106948D110}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C550598C-93A6-237F-E1A5-37106948D110}.Debug|x64.ActiveCfg = Debug|Any CPU + {C550598C-93A6-237F-E1A5-37106948D110}.Debug|x64.Build.0 = Debug|Any CPU + {C550598C-93A6-237F-E1A5-37106948D110}.Debug|x86.ActiveCfg = Debug|Any CPU + {C550598C-93A6-237F-E1A5-37106948D110}.Debug|x86.Build.0 = Debug|Any CPU + {C550598C-93A6-237F-E1A5-37106948D110}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C550598C-93A6-237F-E1A5-37106948D110}.Release|Any CPU.Build.0 = Release|Any CPU + {C550598C-93A6-237F-E1A5-37106948D110}.Release|x64.ActiveCfg = Release|Any CPU + {C550598C-93A6-237F-E1A5-37106948D110}.Release|x64.Build.0 = Release|Any CPU + {C550598C-93A6-237F-E1A5-37106948D110}.Release|x86.ActiveCfg = Release|Any CPU + {C550598C-93A6-237F-E1A5-37106948D110}.Release|x86.Build.0 = Release|Any CPU + {A176D9D1-803C-21F9-9CC0-0A4E9E3067A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A176D9D1-803C-21F9-9CC0-0A4E9E3067A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A176D9D1-803C-21F9-9CC0-0A4E9E3067A0}.Debug|x64.ActiveCfg = Debug|Any CPU + {A176D9D1-803C-21F9-9CC0-0A4E9E3067A0}.Debug|x64.Build.0 = Debug|Any CPU + {A176D9D1-803C-21F9-9CC0-0A4E9E3067A0}.Debug|x86.ActiveCfg = Debug|Any CPU + {A176D9D1-803C-21F9-9CC0-0A4E9E3067A0}.Debug|x86.Build.0 = Debug|Any CPU + {A176D9D1-803C-21F9-9CC0-0A4E9E3067A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A176D9D1-803C-21F9-9CC0-0A4E9E3067A0}.Release|Any CPU.Build.0 = Release|Any CPU + {A176D9D1-803C-21F9-9CC0-0A4E9E3067A0}.Release|x64.ActiveCfg = Release|Any CPU + {A176D9D1-803C-21F9-9CC0-0A4E9E3067A0}.Release|x64.Build.0 = Release|Any CPU + {A176D9D1-803C-21F9-9CC0-0A4E9E3067A0}.Release|x86.ActiveCfg = Release|Any CPU + {A176D9D1-803C-21F9-9CC0-0A4E9E3067A0}.Release|x86.Build.0 = Release|Any CPU + {E46C404F-B6FE-7940-6A5F-C8CE98B13CFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E46C404F-B6FE-7940-6A5F-C8CE98B13CFC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E46C404F-B6FE-7940-6A5F-C8CE98B13CFC}.Debug|x64.ActiveCfg = Debug|Any CPU + {E46C404F-B6FE-7940-6A5F-C8CE98B13CFC}.Debug|x64.Build.0 = Debug|Any CPU + {E46C404F-B6FE-7940-6A5F-C8CE98B13CFC}.Debug|x86.ActiveCfg = Debug|Any CPU + {E46C404F-B6FE-7940-6A5F-C8CE98B13CFC}.Debug|x86.Build.0 = Debug|Any CPU + {E46C404F-B6FE-7940-6A5F-C8CE98B13CFC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E46C404F-B6FE-7940-6A5F-C8CE98B13CFC}.Release|Any CPU.Build.0 = Release|Any CPU + {E46C404F-B6FE-7940-6A5F-C8CE98B13CFC}.Release|x64.ActiveCfg = Release|Any CPU + {E46C404F-B6FE-7940-6A5F-C8CE98B13CFC}.Release|x64.Build.0 = Release|Any CPU + {E46C404F-B6FE-7940-6A5F-C8CE98B13CFC}.Release|x86.ActiveCfg = Release|Any CPU + {E46C404F-B6FE-7940-6A5F-C8CE98B13CFC}.Release|x86.Build.0 = Release|Any CPU + {E0654505-59DC-B459-B483-7EA9602FB041}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0654505-59DC-B459-B483-7EA9602FB041}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0654505-59DC-B459-B483-7EA9602FB041}.Debug|x64.ActiveCfg = Debug|Any CPU + {E0654505-59DC-B459-B483-7EA9602FB041}.Debug|x64.Build.0 = Debug|Any CPU + {E0654505-59DC-B459-B483-7EA9602FB041}.Debug|x86.ActiveCfg = Debug|Any CPU + {E0654505-59DC-B459-B483-7EA9602FB041}.Debug|x86.Build.0 = Debug|Any CPU + {E0654505-59DC-B459-B483-7EA9602FB041}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0654505-59DC-B459-B483-7EA9602FB041}.Release|Any CPU.Build.0 = Release|Any CPU + {E0654505-59DC-B459-B483-7EA9602FB041}.Release|x64.ActiveCfg = Release|Any CPU + {E0654505-59DC-B459-B483-7EA9602FB041}.Release|x64.Build.0 = Release|Any CPU + {E0654505-59DC-B459-B483-7EA9602FB041}.Release|x86.ActiveCfg = Release|Any CPU + {E0654505-59DC-B459-B483-7EA9602FB041}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1274,6 +1336,10 @@ Global {4CBA4707-37E9-F26E-1A6D-7FE5DEC40EE7} = {2A2E129C-95B6-4192-BA94-D9C5A249AB43} {020CBE92-89AC-FBA2-7546-79AAF62859F0} = {2A2E129C-95B6-4192-BA94-D9C5A249AB43} {CFBAA8EF-C46B-9F97-97FF-9D08F68ECE58} = {2A2E129C-95B6-4192-BA94-D9C5A249AB43} + {C550598C-93A6-237F-E1A5-37106948D110} = {9257CE06-4B6A-44D1-869C-445CBFCDFF1D} + {A176D9D1-803C-21F9-9CC0-0A4E9E3067A0} = {9257CE06-4B6A-44D1-869C-445CBFCDFF1D} + {E46C404F-B6FE-7940-6A5F-C8CE98B13CFC} = {9257CE06-4B6A-44D1-869C-445CBFCDFF1D} + {E0654505-59DC-B459-B483-7EA9602FB041} = {9257CE06-4B6A-44D1-869C-445CBFCDFF1D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3B766B89-B32C-4777-9290-8BF7CEC401B5} diff --git a/GenFreeWin/MdbBrowserTests/MdbBrowserTests.csproj b/GenFreeWin/MdbBrowserTests/MdbBrowserTests.csproj index 8b2e8dca0..15b827150 100644 --- a/GenFreeWin/MdbBrowserTests/MdbBrowserTests.csproj +++ b/GenFreeWin/MdbBrowserTests/MdbBrowserTests.csproj @@ -18,8 +18,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/GenFreeWin/VBUnObfusicatorTests/VBUnObfusicatorTests.csproj b/GenFreeWin/VBUnObfusicatorTests/VBUnObfusicatorTests.csproj index 99034f930..3461297cd 100644 --- a/GenFreeWin/VBUnObfusicatorTests/VBUnObfusicatorTests.csproj +++ b/GenFreeWin/VBUnObfusicatorTests/VBUnObfusicatorTests.csproj @@ -23,8 +23,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/TestStatements/.github/upgrades/dotnet-upgrade-plan.md b/TestStatements/.github/upgrades/dotnet-upgrade-plan.md new file mode 100644 index 000000000..c4a8a1fd8 --- /dev/null +++ b/TestStatements/.github/upgrades/dotnet-upgrade-plan.md @@ -0,0 +1,33 @@ +# .NET 8.0 Upgrade Plan + +## Execution Steps + +Execute steps below sequentially one by one in the order they are listed. + +1. Validate that an .NET 8.0 SDK required for this upgrade is installed on the machine and if not, help to get it installed. +2. Ensure that the SDK version specified in global.json files is compatible with the .NET 8.0 upgrade. +3. Upgrade UWP_00_Test\UWP_00_Test.csproj + +## Settings + +This section contains settings and data used by execution steps. + +### Excluded projects + +Table below contains projects that do belong to the dependency graph for selected projects and should not be included in the upgrade. + +| Project name | Description | +|:-------------|:-----------:| + +### Project upgrade details +This section contains details about each project upgrade and modifications that need to be done in the project. + +#### UWP_00_Test\UWP_00_Test.csproj modifications + +Project properties changes: + - Include shared props: `..\MVVM_Tutorial.props` + - Enable nullable reference types: `nullable` set to `enable` + - Migrate project to SDK style targeting .NET 8 (Windows): adopt `Microsoft.NET.Sdk` with appropriate target framework if required by the app model + +Other changes: + - Review and adjust Windows application model dependencies as needed for .NET 8 (e.g., Windows App SDK / WinUI migration if applicable) diff --git a/TestStatements/AppWithPluginTest/AppWithPluginTest.csproj b/TestStatements/AppWithPluginTest/AppWithPluginTest.csproj index 3cddb82ae..86c563215 100644 --- a/TestStatements/AppWithPluginTest/AppWithPluginTest.csproj +++ b/TestStatements/AppWithPluginTest/AppWithPluginTest.csproj @@ -14,8 +14,8 @@ - - + + diff --git a/TestStatements/HelloPluginTest/HelloPluginTest.csproj b/TestStatements/HelloPluginTest/HelloPluginTest.csproj index 5d37750ef..955149e92 100644 --- a/TestStatements/HelloPluginTest/HelloPluginTest.csproj +++ b/TestStatements/HelloPluginTest/HelloPluginTest.csproj @@ -13,8 +13,8 @@ - - + + diff --git a/TestStatements/TestGJKAlgTest/TestGJKAlgTest.csproj b/TestStatements/TestGJKAlgTest/TestGJKAlgTest.csproj index 0e08213c1..f70e52a30 100644 --- a/TestStatements/TestGJKAlgTest/TestGJKAlgTest.csproj +++ b/TestStatements/TestGJKAlgTest/TestGJKAlgTest.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/TestStatements/TestStatementsTest/TestStatementsTest.csproj b/TestStatements/TestStatementsTest/TestStatementsTest.csproj index a7c4458c0..32f2346bc 100644 --- a/TestStatements/TestStatementsTest/TestStatementsTest.csproj +++ b/TestStatements/TestStatementsTest/TestStatementsTest.csproj @@ -31,8 +31,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/TestStatements/TestStatementsTest/TestStatements_netTest.csproj b/TestStatements/TestStatementsTest/TestStatements_netTest.csproj index e489d2227..fca4f1508 100644 --- a/TestStatements/TestStatementsTest/TestStatements_netTest.csproj +++ b/TestStatements/TestStatementsTest/TestStatements_netTest.csproj @@ -32,8 +32,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Transpiler_pp/.github/upgrades/dotnet-upgrade-plan.md b/Transpiler_pp/.github/upgrades/dotnet-upgrade-plan.md new file mode 100644 index 000000000..c4a8a1fd8 --- /dev/null +++ b/Transpiler_pp/.github/upgrades/dotnet-upgrade-plan.md @@ -0,0 +1,33 @@ +# .NET 8.0 Upgrade Plan + +## Execution Steps + +Execute steps below sequentially one by one in the order they are listed. + +1. Validate that an .NET 8.0 SDK required for this upgrade is installed on the machine and if not, help to get it installed. +2. Ensure that the SDK version specified in global.json files is compatible with the .NET 8.0 upgrade. +3. Upgrade UWP_00_Test\UWP_00_Test.csproj + +## Settings + +This section contains settings and data used by execution steps. + +### Excluded projects + +Table below contains projects that do belong to the dependency graph for selected projects and should not be included in the upgrade. + +| Project name | Description | +|:-------------|:-----------:| + +### Project upgrade details +This section contains details about each project upgrade and modifications that need to be done in the project. + +#### UWP_00_Test\UWP_00_Test.csproj modifications + +Project properties changes: + - Include shared props: `..\MVVM_Tutorial.props` + - Enable nullable reference types: `nullable` set to `enable` + - Migrate project to SDK style targeting .NET 8 (Windows): adopt `Microsoft.NET.Sdk` with appropriate target framework if required by the app model + +Other changes: + - Review and adjust Windows application model dependencies as needed for .NET 8 (e.g., Windows App SDK / WinUI migration if applicable) diff --git a/Transpiler_pp/Analyzer1/Analyzer1.Test/Analyzer1.Test.csproj b/Transpiler_pp/Analyzer1/Analyzer1.Test/Analyzer1.Test.csproj index 4a3382906..c9aa8d937 100644 --- a/Transpiler_pp/Analyzer1/Analyzer1.Test/Analyzer1.Test.csproj +++ b/Transpiler_pp/Analyzer1/Analyzer1.Test/Analyzer1.Test.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/Transpiler_pp/Analyzer1/Analyzer1.Vsix/Analyzer1.Vsix.csproj b/Transpiler_pp/Analyzer1/Analyzer1.Vsix/Analyzer1.Vsix.csproj index 94f25de42..fe9a470d5 100644 --- a/Transpiler_pp/Analyzer1/Analyzer1.Vsix/Analyzer1.Vsix.csproj +++ b/Transpiler_pp/Analyzer1/Analyzer1.Vsix/Analyzer1.Vsix.csproj @@ -19,7 +19,7 @@ - + diff --git a/Transpiler_pp/TranspilerLib.CSharp.Tests/TranspilerLib.CSharp.Tests.csproj b/Transpiler_pp/TranspilerLib.CSharp.Tests/TranspilerLib.CSharp.Tests.csproj index 5718b8ebc..2b930376a 100644 --- a/Transpiler_pp/TranspilerLib.CSharp.Tests/TranspilerLib.CSharp.Tests.csproj +++ b/Transpiler_pp/TranspilerLib.CSharp.Tests/TranspilerLib.CSharp.Tests.csproj @@ -24,8 +24,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Transpiler_pp/TranspilerLib.DriveBASIC.Tests/TranspilerLib.DriveBASIC.Tests.csproj b/Transpiler_pp/TranspilerLib.DriveBASIC.Tests/TranspilerLib.DriveBASIC.Tests.csproj index 58b56ead6..1dc0b4a94 100644 --- a/Transpiler_pp/TranspilerLib.DriveBASIC.Tests/TranspilerLib.DriveBASIC.Tests.csproj +++ b/Transpiler_pp/TranspilerLib.DriveBASIC.Tests/TranspilerLib.DriveBASIC.Tests.csproj @@ -24,8 +24,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Transpiler_pp/TranspilerLib.IEC.Tests/TranspilerLib.IEC.Tests.csproj b/Transpiler_pp/TranspilerLib.IEC.Tests/TranspilerLib.IEC.Tests.csproj index c2f9b939b..d78ccb65c 100644 --- a/Transpiler_pp/TranspilerLib.IEC.Tests/TranspilerLib.IEC.Tests.csproj +++ b/Transpiler_pp/TranspilerLib.IEC.Tests/TranspilerLib.IEC.Tests.csproj @@ -24,8 +24,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Transpiler_pp/TranspilerLib.Pascal.Tests/TranspilerLib.Pascal.Tests.csproj b/Transpiler_pp/TranspilerLib.Pascal.Tests/TranspilerLib.Pascal.Tests.csproj index 3af65ca10..f9907da3b 100644 --- a/Transpiler_pp/TranspilerLib.Pascal.Tests/TranspilerLib.Pascal.Tests.csproj +++ b/Transpiler_pp/TranspilerLib.Pascal.Tests/TranspilerLib.Pascal.Tests.csproj @@ -24,8 +24,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Transpiler_pp/TranspilerLibTests/TranspilerLibTests.csproj b/Transpiler_pp/TranspilerLibTests/TranspilerLibTests.csproj index ec02c718a..3b9703b06 100644 --- a/Transpiler_pp/TranspilerLibTests/TranspilerLibTests.csproj +++ b/Transpiler_pp/TranspilerLibTests/TranspilerLibTests.csproj @@ -37,8 +37,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Transpiler_pp/Trnsp.Show.Lfm.Tests/Trnsp.Show.Lfm.Tests.csproj b/Transpiler_pp/Trnsp.Show.Lfm.Tests/Trnsp.Show.Lfm.Tests.csproj index 471bc8d3f..58875cbcf 100644 --- a/Transpiler_pp/Trnsp.Show.Lfm.Tests/Trnsp.Show.Lfm.Tests.csproj +++ b/Transpiler_pp/Trnsp.Show.Lfm.Tests/Trnsp.Show.Lfm.Tests.csproj @@ -15,8 +15,8 @@ $(TargetFrameworks);net10.0-windows - - + + diff --git a/Transpiler_pp/Trnsp.Show.Pas.Tests/Trnsp.Show.Pas.Tests.csproj b/Transpiler_pp/Trnsp.Show.Pas.Tests/Trnsp.Show.Pas.Tests.csproj index ab421e875..a1277832b 100644 --- a/Transpiler_pp/Trnsp.Show.Pas.Tests/Trnsp.Show.Pas.Tests.csproj +++ b/Transpiler_pp/Trnsp.Show.Pas.Tests/Trnsp.Show.Pas.Tests.csproj @@ -15,8 +15,8 @@ $(TargetFrameworks);net10.0-windows - - + + diff --git a/WinAhnenNew/BaseGenClasses/Model/GenTransaction.cs b/WinAhnenNew/BaseGenClasses/Model/GenTransaction.cs new file mode 100644 index 000000000..d71211545 --- /dev/null +++ b/WinAhnenNew/BaseGenClasses/Model/GenTransaction.cs @@ -0,0 +1,90 @@ +using BaseGenClasses.Helper; +using GenInterfaces.Data; +using GenInterfaces.Interfaces; +using GenInterfaces.Interfaces.Genealogic; +using System; +using System.Text.Json.Serialization; + +namespace BaseGenClasses.Model; + +/// +/// Represents a genealogy journal transaction. +/// +[JsonDerivedType(typeof(GenTransaction), typeDiscriminator: nameof(GenTransaction))] +public sealed class GenTransaction : GenObject, IGenTransaction, IHasOwner +{ + private object? _owner; + private IGenTransaction? _next; + + /// + /// Initializes a new instance of the class. + /// + public GenTransaction() + { + Items = new IndexedList(static objItem => objItem.GetHashCode().ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + /// + public override EGenType eGenType => EGenType.Genealogy; + + /// + public IGenBase Class { get; init; } = null!; + + /// + public IGenBase Entry { get; init; } = null!; + + /// + public object? Data { get; init; } + + /// + public object? OldData { get; init; } + + /// + public DateTime Timestamp { get; init; } = DateTime.UtcNow; + + /// + public IGenTransaction? Prev { get; init; } + + /// + [JsonIgnore] + public IIndexedList Items { get; } + + /// + [JsonIgnore] + public IGenTransaction Next => _next ?? this; + + /// + [JsonIgnore] + public IGenTransaction First => Prev?.First ?? this; + + /// + [JsonIgnore] + public IGenTransaction Last => _next?.Last ?? this; + + /// + [JsonIgnore] + public bool IsFirst => Prev is null; + + /// + [JsonIgnore] + public bool IsLast => _next is null; + + /// + [JsonIgnore] + public object? Owner => _owner; + + /// + /// Links the current transaction to a subsequent one. + /// + /// The next transaction. + public void SetNext(IGenTransaction? genNext) + { + _next = genNext; + } + + /// + public void SetOwner(object tOwner) + { + _owner = tOwner; + } +} diff --git a/WinAhnenNew/BaseGenClasses/Model/Genealogy.cs b/WinAhnenNew/BaseGenClasses/Model/Genealogy.cs index 81fd98eda..2f36d3f25 100644 --- a/WinAhnenNew/BaseGenClasses/Model/Genealogy.cs +++ b/WinAhnenNew/BaseGenClasses/Model/Genealogy.cs @@ -15,7 +15,7 @@ namespace BaseGenClasses.Model; -public class Genealogy : IGenealogy, IGenealogyPersistenceContext, IRecipient, IDisposable +public class Genealogy : IGenealogy, IGenealogyPersistenceContext, IGenealogyJournalContext, IRecipient, IDisposable { private readonly IMessenger _messanger; private IGenealogyPersistenceProvider? _persistenceProvider; @@ -51,6 +51,10 @@ public class Genealogy : IGenealogy, IGenealogyPersistenceContext, IRecipient? FlushFailed; + public event EventHandler? JournalEntryRecorded; + + public IReadOnlyList JournalEntries => Transactions.ToArray(); + #endregion #region Methods @@ -195,16 +199,54 @@ private IGenTransaction _GetTransaction(IList o) public void Receive(IGenTransaction message) { - var _lastTA = Transactions.Where(ta => ta.Class == message.Class && ta.Entry == message.Entry).LastOrDefault(); - Transactions.Add(message); + RecordIncomingTransaction(message); MarkDirty(null, "A genealogy transaction was recorded."); } + public IGenTransaction RecordJournalEntry(IGenBase genClass, IGenBase genEntry, object? objData, object? objOldData) + { + if (genClass is null) + { + throw new ArgumentNullException(nameof(genClass)); + } + + if (genEntry is null) + { + throw new ArgumentNullException(nameof(genEntry)); + } + + var genTransaction = new GenTransaction + { + UId = Guid.NewGuid(), + Class = genClass, + Entry = genEntry, + Data = objData, + OldData = objOldData, + Timestamp = DateTime.UtcNow, + Prev = Transactions.LastOrDefault() + }; + + ((IHasOwner)genTransaction).SetOwner(this); + return RecordIncomingTransaction(genTransaction); + } + public void AttachPersistenceProvider(IGenealogyPersistenceProvider persistenceProvider) { _persistenceProvider = persistenceProvider ?? throw new ArgumentNullException(nameof(persistenceProvider)); } + private IGenTransaction RecordIncomingTransaction(IGenTransaction genTransaction) + { + if (Transactions.LastOrDefault() is GenTransaction genPreviousTransaction) + { + genPreviousTransaction.SetNext(genTransaction); + } + + Transactions.Add(genTransaction); + JournalEntryRecorded?.Invoke(this, new JournalEntryRecordedEventArgs(genTransaction)); + return genTransaction; + } + public void MarkDirty(IGenEntity? genChangedEntity = null, string? sReason = null) { _genLastChangedEntity = genChangedEntity ?? _genLastChangedEntity; diff --git a/WinAhnenNew/BaseGenClasses/Persistence/FactJournalValue.cs b/WinAhnenNew/BaseGenClasses/Persistence/FactJournalValue.cs new file mode 100644 index 000000000..4ab150b2f --- /dev/null +++ b/WinAhnenNew/BaseGenClasses/Persistence/FactJournalValue.cs @@ -0,0 +1,64 @@ +using System; +using GenInterfaces.Data; +using GenInterfaces.Interfaces.Genealogic; + +namespace BaseGenClasses.Persistence; + +/// +/// Represents a serializable fact snapshot for genealogy journal entries. +/// +public sealed class FactJournalValue +{ + /// + /// Gets or sets the fact type. + /// + public EFactType eFactType { get; init; } + + /// + /// Gets or sets the fact data text. + /// + public string? Data { get; init; } + + /// + /// Gets or sets the primary event date. + /// + public DateTime? Date1 { get; init; } + + /// + /// Gets or sets the date modifier. + /// + public EDateModifier? eDateModifier { get; init; } + + /// + /// Gets or sets the display date text. + /// + public string? DateText { get; init; } + + /// + /// Gets or sets the place name. + /// + public string? PlaceName { get; init; } + + /// + /// Creates a journal snapshot from a genealogy fact. + /// + /// The fact to snapshot. + /// The snapshot value, or when no fact is available. + public static FactJournalValue? FromFact(IGenFact? genFact) + { + if (genFact is null) + { + return null; + } + + return new FactJournalValue + { + eFactType = genFact.eFactType, + Data = genFact.Data, + Date1 = genFact.Date?.Date1 == DateTime.MinValue ? null : genFact.Date?.Date1, + eDateModifier = genFact.Date?.eDateModifier, + DateText = genFact.Date?.DateText, + PlaceName = genFact.Place?.Name + }; + } +} diff --git a/WinAhnenNew/BaseGenClasses/Persistence/GenealogyJournalExtensions.cs b/WinAhnenNew/BaseGenClasses/Persistence/GenealogyJournalExtensions.cs new file mode 100644 index 000000000..3c21b5904 --- /dev/null +++ b/WinAhnenNew/BaseGenClasses/Persistence/GenealogyJournalExtensions.cs @@ -0,0 +1,57 @@ +using GenInterfaces.Interfaces.Genealogic; + +namespace BaseGenClasses.Persistence; + +/// +/// Provides helper methods for recording typed genealogy journal entries. +/// +public static class GenealogyJournalExtensions +{ + /// + /// Records a journal entry for a source change. + /// + /// The genealogy journal context. + /// The changed source. + /// The new source snapshot. + /// The old source snapshot. + public static void RecordSourceChange( + this IGenealogyJournalContext journalContext, + IGenSource genSource, + SourceJournalValue? srcNewValue, + SourceJournalValue? srcOldValue) + { + journalContext.RecordJournalEntry(genSource, genSource, srcNewValue, srcOldValue); + } + + /// + /// Records a journal entry for a media change. + /// + /// The genealogy journal context. + /// The changed media item. + /// The new media snapshot. + /// The old media snapshot. + public static void RecordMediaChange( + this IGenealogyJournalContext journalContext, + IGenMedia genMedia, + MediaJournalValue? medNewValue, + MediaJournalValue? medOldValue) + { + journalContext.RecordJournalEntry(genMedia, genMedia, medNewValue, medOldValue); + } + + /// + /// Records a journal entry for a repository change. + /// + /// The genealogy journal context. + /// The changed repository. + /// The new repository snapshot. + /// The old repository snapshot. + public static void RecordRepositoryChange( + this IGenealogyJournalContext journalContext, + IGenRepository genRepository, + RepositoryJournalValue? repNewValue, + RepositoryJournalValue? repOldValue) + { + journalContext.RecordJournalEntry(genRepository, genRepository, repNewValue, repOldValue); + } +} diff --git a/WinAhnenNew/BaseGenClasses/Persistence/IGenealogyJournalContext.cs b/WinAhnenNew/BaseGenClasses/Persistence/IGenealogyJournalContext.cs new file mode 100644 index 000000000..313cda680 --- /dev/null +++ b/WinAhnenNew/BaseGenClasses/Persistence/IGenealogyJournalContext.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using GenInterfaces.Interfaces; +using GenInterfaces.Interfaces.Genealogic; + +namespace BaseGenClasses.Persistence; + +/// +/// Exposes recorded journal entries for a genealogy root context. +/// +public interface IGenealogyJournalContext +{ + /// + /// Occurs when a new journal entry was recorded. + /// + event EventHandler? JournalEntryRecorded; + + /// + /// Gets the recorded journal entries. + /// + IReadOnlyList JournalEntries { get; } + + /// + /// Records a journal entry for a genealogy change. + /// + /// The changed aggregate or class. + /// The changed entry. + /// The new value snapshot. + /// The old value snapshot. + /// The recorded transaction. + IGenTransaction RecordJournalEntry(IGenBase genClass, IGenBase genEntry, object? objData, object? objOldData); +} diff --git a/WinAhnenNew/BaseGenClasses/Persistence/JournalEntryRecordedEventArgs.cs b/WinAhnenNew/BaseGenClasses/Persistence/JournalEntryRecordedEventArgs.cs new file mode 100644 index 000000000..f3a1c42e0 --- /dev/null +++ b/WinAhnenNew/BaseGenClasses/Persistence/JournalEntryRecordedEventArgs.cs @@ -0,0 +1,24 @@ +using System; +using GenInterfaces.Interfaces; + +namespace BaseGenClasses.Persistence; + +/// +/// Provides details about a recorded genealogy journal entry. +/// +public sealed class JournalEntryRecordedEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The recorded journal transaction. + public JournalEntryRecordedEventArgs(IGenTransaction genTransaction) + { + GenTransaction = genTransaction ?? throw new ArgumentNullException(nameof(genTransaction)); + } + + /// + /// Gets the recorded journal transaction. + /// + public IGenTransaction GenTransaction { get; } +} diff --git a/WinAhnenNew/BaseGenClasses/Persistence/MediaJournalValue.cs b/WinAhnenNew/BaseGenClasses/Persistence/MediaJournalValue.cs new file mode 100644 index 000000000..549e8eb16 --- /dev/null +++ b/WinAhnenNew/BaseGenClasses/Persistence/MediaJournalValue.cs @@ -0,0 +1,52 @@ +using System; +using GenInterfaces.Data; +using GenInterfaces.Interfaces.Genealogic; + +namespace BaseGenClasses.Persistence; + +/// +/// Represents a serializable media snapshot for genealogy journal entries. +/// +public sealed class MediaJournalValue +{ + /// + /// Gets or sets the media type. + /// + public EMediaType eMediaType { get; init; } + + /// + /// Gets or sets the media URI. + /// + public string? MediaUri { get; init; } + + /// + /// Gets or sets the display name of the media. + /// + public string? MediaName { get; init; } + + /// + /// Gets or sets the description of the media. + /// + public string? MediaDescription { get; init; } + + /// + /// Creates a journal snapshot from a genealogy media item. + /// + /// The media item to snapshot. + /// The snapshot value, or when no media item is available. + public static MediaJournalValue? FromMedia(IGenMedia? genMedia) + { + if (genMedia is null) + { + return null; + } + + return new MediaJournalValue + { + eMediaType = genMedia.eMediaType, + MediaUri = genMedia.MediaUri?.ToString(), + MediaName = genMedia.MediaName, + MediaDescription = genMedia.MediaDescription + }; + } +} diff --git a/WinAhnenNew/BaseGenClasses/Persistence/RepositoryJournalValue.cs b/WinAhnenNew/BaseGenClasses/Persistence/RepositoryJournalValue.cs new file mode 100644 index 000000000..a7eb2a9ac --- /dev/null +++ b/WinAhnenNew/BaseGenClasses/Persistence/RepositoryJournalValue.cs @@ -0,0 +1,51 @@ +using System; +using GenInterfaces.Interfaces.Genealogic; + +namespace BaseGenClasses.Persistence; + +/// +/// Represents a serializable repository snapshot for genealogy journal entries. +/// +public sealed class RepositoryJournalValue +{ + /// + /// Gets or sets the repository name. + /// + public string? Name { get; init; } + + /// + /// Gets or sets the repository information text. + /// + public string? Info { get; init; } + + /// + /// Gets or sets the repository URI. + /// + public string? Uri { get; init; } + + /// + /// Gets or sets the number of linked sources. + /// + public int SourceCount { get; init; } + + /// + /// Creates a journal snapshot from a genealogy repository. + /// + /// The repository to snapshot. + /// The snapshot value, or when no repository is available. + public static RepositoryJournalValue? FromRepository(IGenRepository? genRepository) + { + if (genRepository is null) + { + return null; + } + + return new RepositoryJournalValue + { + Name = genRepository.Name, + Info = genRepository.Info, + Uri = genRepository.Uri?.ToString(), + SourceCount = genRepository.GenSources.Count + }; + } +} diff --git a/WinAhnenNew/BaseGenClasses/Persistence/SourceJournalValue.cs b/WinAhnenNew/BaseGenClasses/Persistence/SourceJournalValue.cs new file mode 100644 index 000000000..1b94dfbdc --- /dev/null +++ b/WinAhnenNew/BaseGenClasses/Persistence/SourceJournalValue.cs @@ -0,0 +1,51 @@ +using System; +using GenInterfaces.Interfaces.Genealogic; + +namespace BaseGenClasses.Persistence; + +/// +/// Represents a serializable source snapshot for genealogy journal entries. +/// +public sealed class SourceJournalValue +{ + /// + /// Gets or sets the short description of the source. + /// + public string? Description { get; init; } + + /// + /// Gets or sets the source URI. + /// + public string? Url { get; init; } + + /// + /// Gets or sets the source payload text. + /// + public string? Data { get; init; } + + /// + /// Gets or sets the number of linked media items. + /// + public int MediaCount { get; init; } + + /// + /// Creates a journal snapshot from a genealogy source. + /// + /// The source to snapshot. + /// The snapshot value, or when no source is available. + public static SourceJournalValue? FromSource(IGenSource? genSource) + { + if (genSource is null) + { + return null; + } + + return new SourceJournalValue + { + Description = genSource.Description, + Url = genSource.Url?.ToString(), + Data = genSource.Data, + MediaCount = genSource.Medias.Count + }; + } +} diff --git a/WinAhnenNew/BaseGenClassesTests/BaseGenClassesTests.csproj b/WinAhnenNew/BaseGenClassesTests/BaseGenClassesTests.csproj index 93dafacf2..f0e5f7b1c 100644 --- a/WinAhnenNew/BaseGenClassesTests/BaseGenClassesTests.csproj +++ b/WinAhnenNew/BaseGenClassesTests/BaseGenClassesTests.csproj @@ -15,7 +15,7 @@ $(TargetFrameworks);net10.0 - + all diff --git a/WinAhnenNew/BaseGenClassesTests/GenealogyPersistenceContextTests.cs b/WinAhnenNew/BaseGenClassesTests/GenealogyPersistenceContextTests.cs index 6e06fe73e..802174be5 100644 --- a/WinAhnenNew/BaseGenClassesTests/GenealogyPersistenceContextTests.cs +++ b/WinAhnenNew/BaseGenClassesTests/GenealogyPersistenceContextTests.cs @@ -1,9 +1,13 @@ using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using BaseGenClasses.Model; using BaseGenClasses.Persistence; using CommunityToolkit.Mvvm.Messaging; +using GenInterfaces.Data; +using GenInterfaces.Interfaces; +using GenInterfaces.Interfaces.Genealogic; using NSubstitute; namespace BaseGenClassesTests @@ -30,5 +34,92 @@ public async Task FlushAsync_WhenGenealogyIsDirty_DelegatesToPersistenceProvider Assert.IsFalse(genGenealogy.xDirty); await prvPersistence.Received(1).FlushAsync(genGenealogy, null, GenealogyFlushScope.Auto, Arg.Any()); } + + [TestMethod] + public void Receive_WhenTransactionIsRecorded_RaisesJournalEntryRecorded() + { + var msgMessenger = new WeakReferenceMessenger(); + var genGenealogy = new Genealogy(msgMessenger) + { + UId = Guid.NewGuid() + }; + var genTransaction = Substitute.For(); + JournalEntryRecordedEventArgs? eRecordedArgs = null; + + genGenealogy.JournalEntryRecorded += (_, eArgs) => eRecordedArgs = eArgs; + + genGenealogy.Receive(genTransaction); + + Assert.IsNotNull(eRecordedArgs); + Assert.AreSame(genTransaction, eRecordedArgs.GenTransaction); + CollectionAssert.Contains(genGenealogy.JournalEntries.ToArray(), genTransaction); + } + + [TestMethod] + public void RecordSourceChange_StoresTypedSourceJournalSnapshot() + { + var msgMessenger = new WeakReferenceMessenger(); + var genGenealogy = new Genealogy(msgMessenger) + { + UId = Guid.NewGuid() + }; + var genSource = Substitute.For(); + genSource.Description.Returns("Kirchenbuch"); + genSource.Data.Returns("Taufeintrag"); + genSource.Url.Returns(new Uri("https://example.org/source")); + genSource.Medias.Returns(new System.Collections.Generic.List()); + + genGenealogy.RecordSourceChange(genSource, SourceJournalValue.FromSource(genSource), null); + + Assert.AreEqual(1, genGenealogy.JournalEntries.Count); + Assert.IsInstanceOfType(genGenealogy.JournalEntries[0].Data); + Assert.AreEqual("Kirchenbuch", ((SourceJournalValue)genGenealogy.JournalEntries[0].Data!).Description); + } + + [TestMethod] + public void RecordMediaChange_StoresTypedMediaJournalSnapshot() + { + var msgMessenger = new WeakReferenceMessenger(); + var genGenealogy = new Genealogy(msgMessenger) + { + UId = Guid.NewGuid() + }; + var genMedia = Substitute.For(); + genMedia.eMediaType.Returns(EMediaType.Picture); + genMedia.MediaName.Returns("Portrait"); + genMedia.MediaDescription.Returns("Porträtfoto"); + genMedia.MediaUri.Returns(new Uri("file:///c:/temp/portrait.jpg")); + + genGenealogy.RecordMediaChange(genMedia, MediaJournalValue.FromMedia(genMedia), null); + + Assert.AreEqual(1, genGenealogy.JournalEntries.Count); + Assert.IsInstanceOfType(genGenealogy.JournalEntries[0].Data); + Assert.AreEqual("Portrait", ((MediaJournalValue)genGenealogy.JournalEntries[0].Data!).MediaName); + } + + [TestMethod] + public void RecordRepositoryChange_StoresTypedRepositoryJournalSnapshot() + { + var msgMessenger = new WeakReferenceMessenger(); + var genGenealogy = new Genealogy(msgMessenger) + { + UId = Guid.NewGuid() + }; + var genSources = Substitute.For>(); + genSources.Count.Returns(2); + + var genRepository = Substitute.For(); + genRepository.Name.Returns("Landeskirchliches Archiv"); + genRepository.Info.Returns("Bestand A"); + genRepository.Uri.Returns(new Uri("https://example.org/archive")); + genRepository.GenSources.Returns(genSources); + + genGenealogy.RecordRepositoryChange(genRepository, RepositoryJournalValue.FromRepository(genRepository), null); + + Assert.AreEqual(1, genGenealogy.JournalEntries.Count); + Assert.IsInstanceOfType(genGenealogy.JournalEntries[0].Data); + Assert.AreEqual("Landeskirchliches Archiv", ((RepositoryJournalValue)genGenealogy.JournalEntries[0].Data!).Name); + Assert.AreEqual(2, ((RepositoryJournalValue)genGenealogy.JournalEntries[0].Data!).SourceCount); + } } } diff --git a/WinAhnenNew/RnzTrauer/AmtsblattLoader.Console/AmtsblattLoader.Console.csproj b/WinAhnenNew/RnzTrauer/AmtsblattLoader.Console/AmtsblattLoader.Console.csproj new file mode 100644 index 000000000..a078f0b34 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/AmtsblattLoader.Console/AmtsblattLoader.Console.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/WinAhnenNew/RnzTrauer/AmtsblattLoader.Console/Program.cs b/WinAhnenNew/RnzTrauer/AmtsblattLoader.Console/Program.cs new file mode 100644 index 000000000..b533b3436 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/AmtsblattLoader.Console/Program.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; +using AmtsblattLoader.Console.ViewModels; +using AmtsblattLoader.Console.Views; +using RnzTrauer.Core; + +var xServices = new ServiceCollection() + .AddSingleton() + .AddSingleton() + .AddTransient() + .AddTransient() + .BuildServiceProvider(); + +var xView = xServices.GetRequiredService(); + +try +{ + var xConfig = new AmtsblattConfig(xServices.GetRequiredService()).Load(Path.Combine(AppContext.BaseDirectory, "Amtsblatt_Cfg.json")); + var xViewModel = xServices.GetRequiredService(); + xViewModel.Run(xConfig); +} +catch (FileNotFoundException ex) +{ + xView.WriteErrorLine(ex.Message); + xView.WriteErrorLine("Lege eine Datei `Amtsblatt_Cfg.json` neben die EXE. Eine Vorlage liegt als `Amtsblatt_Cfg.sample.json` im Projekt."); +} diff --git a/WinAhnenNew/RnzTrauer/AmtsblattLoader.Console/ViewModels/AmtsblattLoaderConsoleViewModel.cs b/WinAhnenNew/RnzTrauer/AmtsblattLoader.Console/ViewModels/AmtsblattLoaderConsoleViewModel.cs new file mode 100644 index 000000000..4f5ab16d4 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/AmtsblattLoader.Console/ViewModels/AmtsblattLoaderConsoleViewModel.cs @@ -0,0 +1,40 @@ +using AmtsblattLoader.Console.Views; +using RnzTrauer.Core; + +namespace AmtsblattLoader.Console.ViewModels; + +/// +/// Coordinates the Amtsblatt console workflow. +/// +public sealed class AmtsblattLoaderConsoleViewModel +{ + private readonly ConsoleOutputView _view; + + /// + /// Initializes a new instance of the class. + /// + public AmtsblattLoaderConsoleViewModel(ConsoleOutputView xView) + { + _view = xView; + } + + /// + /// Runs the Amtsblatt loader workflow. + /// + public void Run(AmtsblattConfig xConfig) + { + using var xWebHandler = new AmtsblattWebHandler(xConfig); + xWebHandler.InitPage(); + var iOffset = 1; + var iDayDelta = 0; + while (iDayDelta <= 400) + { + var dtCurrent = DateOnly.FromDateTime(DateTime.Today).AddDays(-(iDayDelta + iOffset)); + iDayDelta += 7; + var iWeek = (dtCurrent.DayOfYear - 1) / 7; + var sStart = $"{xConfig.Url}-{iWeek:00}-{dtCurrent.Year:0000}"; + _view.WriteLine($"Load: {sStart}"); + xWebHandler.GetData1(sStart); + } + } +} diff --git a/WinAhnenNew/RnzTrauer/AmtsblattLoader.Console/Views/ConsoleOutputView.cs b/WinAhnenNew/RnzTrauer/AmtsblattLoader.Console/Views/ConsoleOutputView.cs new file mode 100644 index 000000000..0dae32466 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/AmtsblattLoader.Console/Views/ConsoleOutputView.cs @@ -0,0 +1,23 @@ +namespace AmtsblattLoader.Console.Views; + +/// +/// Provides console-based output for the Amtsblatt console application. +/// +public sealed class ConsoleOutputView +{ + /// + /// Writes text followed by a newline. + /// + public void WriteLine(string sText = "") + { + System.Console.WriteLine(sText); + } + + /// + /// Writes an error line. + /// + public void WriteErrorLine(string sText) + { + System.Console.Error.WriteLine(sText); + } +} diff --git a/WinAhnenNew/RnzTrauer/Directory.Build.props b/WinAhnenNew/RnzTrauer/Directory.Build.props new file mode 100644 index 000000000..e7cd6b498 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/Directory.Build.props @@ -0,0 +1,7 @@ + + + $(MSBuildThisFileDirectory)..\..\bin\$(MSBuildProjectName)\ + $(MSBuildThisFileDirectory)..\..\obj\$(MSBuildProjectName)\ + $(DefaultItemExcludes);$(MSBuildProjectDirectory)\bin\**;$(MSBuildProjectDirectory)\obj\** + + diff --git a/WinAhnenNew/RnzTrauer/Directory.Packages.props b/WinAhnenNew/RnzTrauer/Directory.Packages.props new file mode 100644 index 000000000..f8c24d522 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/Directory.Packages.props @@ -0,0 +1,16 @@ + + + true + + + + + + + + + + + + + \ No newline at end of file diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Console/Program.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Console/Program.cs new file mode 100644 index 000000000..a50930c43 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Console/Program.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; +using RnzTrauer.Console.ViewModels; +using RnzTrauer.Console.Views; +using RnzTrauer.Core; + +var xServices = new ServiceCollection() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddTransient() + .AddTransient() + .BuildServiceProvider(); + +var xView = xServices.GetRequiredService(); + +try +{ + var xConfig = new RnzConfig(xServices.GetRequiredService()).Load(Path.Combine(AppContext.BaseDirectory, "RNZ_Config.json")); + var xViewModel = xServices.GetRequiredService(); + xViewModel.Run(xConfig, args.FirstOrDefault() ?? ""); +} +catch (FileNotFoundException ex) +{ + xView.WriteErrorLine(ex.Message); + xView.WriteErrorLine("Lege eine Datei `RNZ_Config.json` neben die EXE. Eine Vorlage liegt als `RNZ_Config.sample.json` im Projekt."); +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Console/RnzTrauer.Console.csproj b/WinAhnenNew/RnzTrauer/RnzTrauer.Console/RnzTrauer.Console.csproj new file mode 100644 index 000000000..63abeaa39 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Console/RnzTrauer.Console.csproj @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + PreserveNewest + + + + + Exe + net10.0 + enable + enable + + + diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Console/ViewModels/RnzTrauerConsoleViewModel.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Console/ViewModels/RnzTrauerConsoleViewModel.cs new file mode 100644 index 000000000..71906b78d --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Console/ViewModels/RnzTrauerConsoleViewModel.cs @@ -0,0 +1,383 @@ +using System.Text; +using System.Text.Json.Nodes; +using RnzTrauer.Console.Views; +using RnzTrauer.Core; + +namespace RnzTrauer.Console.ViewModels; + +/// +/// Coordinates the RNZ console workflow while keeping user-facing output outside the core services. +/// +public sealed class RnzTrauerConsoleViewModel +{ + private readonly ConsoleOutputView _view; + private readonly IFile _xFile; + private readonly IHttpClientProxy _xHttpClient; + private readonly IWebDriverFactory _xWebDriverFactory; + + // Dictionary keys + private const string KeyContent = "content"; + private const string KeyHref = "href"; + private const string KeyLocalPath = "localpath"; + private const string KeyParent = "parent"; + private const string KeyPdfText = "pdfText"; + private const string KeyUrl = "url"; + + // File extensions + private const string ExtHtml = ".html"; + private const string ExtJpeg = ".jpeg"; + private const string ExtJson = ".json"; + private const string ExtPdf = ".pdf"; + private const string ExtPng = ".png"; + + // Binary file signatures + private const string SignatureHtmlDoctype = " + /// Initializes a new instance of the class. + /// + public RnzTrauerConsoleViewModel(ConsoleOutputView xView, IFile xFile, IHttpClientProxy xHttpClient, IWebDriverFactory xWebDriverFactory) + { + _view = xView; + _xFile = xFile ?? throw new ArgumentNullException(nameof(xFile)); + _xHttpClient = xHttpClient ?? throw new ArgumentNullException(nameof(xHttpClient)); + _xWebDriverFactory = xWebDriverFactory ?? throw new ArgumentNullException(nameof(xWebDriverFactory)); + } + + /// + /// Runs the RNZ scraping and import workflow. + /// + public void Run(RnzConfig xConfig, string sParam1 = "") + { + _view.WriteLine(UiMsgStart); + var xProgress = new Progress(xUpdate => + { + if (xUpdate.WriteLine) + { + _view.WriteLine(xUpdate.Text); + } + else + { + _view.Write(xUpdate.Text); + } + }); + using var xWebHandler = new WebHandler(xConfig, _xHttpClient, _xWebDriverFactory, xProgress); + xWebHandler.InitPage(); + + _view.WriteLine(UiMsgInit); + var sBaseHost = Uri.TryCreate(xConfig.Url, UriKind.Absolute, out var xBaseUri) + ? xBaseUri.GetLeftPart(UriPartial.Authority) + : string.Empty; + var sSearchBaseUrl = $"{sBaseHost}{RnzSearchPath}"; + DateTime today = DateTime.Today; + var iOffset = DateTime.TryParse(sParam1,out var dtStart)?(today- dtStart).Days : 0; + var iDayDelta = 0; + while (iDayDelta <= 14) + { + var dtCurrent = DateOnly.FromDateTime(today).AddDays(-(iDayDelta + iOffset)); + iDayDelta += 1; + var sStart = $"{sSearchBaseUrl}{dtCurrent.Day:00}-{dtCurrent.Month:00}-{dtCurrent.Year:0000}"; + var (dPages, arrItems) = xWebHandler.GetData1(sStart); + + _view.Write(UiMsgCompute); + for (var iIndex = 0; iIndex < arrItems.Count; iIndex++) + { + var dEntry = new Dictionary(arrItems[iIndex], StringComparer.Ordinal); + try + { + if (dEntry.TryGetValue(WebHandler.CsData, out var xDataObject) && xDataObject is byte[] arrData && arrData.Length >= 10) + { + var sPrefix = Encoding.ASCII.GetString(arrData.Take(10).ToArray()); + if (sPrefix.Contains(SignaturePdf, StringComparison.Ordinal)) + { + dEntry[KeyPdfText] = PortedHelpers.PdfText(arrData); + arrItems[iIndex] = dEntry; + if (dEntry.TryGetValue(KeyParent, out var xParentObject)) + { + var sParent = Convert.ToString(xParentObject) ?? string.Empty; + if (dPages.TryGetValue(sParent, out var dParentPage) && dEntry.TryGetValue(WebHandler.CsSrc, out var xSourceObject)) + { + dParentPage[Convert.ToString(xSourceObject) ?? string.Empty] = new Dictionary + { + [KeyPdfText] = dEntry[KeyPdfText] + }; + } + } + + _view.Write('+'); + } + else + { + _view.Write('.'); + } + } + } + catch + { + } + } + + _view.Write(UiMsgSave); + SavePages(xConfig, dPages); + _view.Write(UiMsgSaveMedia); + SaveMedia(xConfig, arrItems, dtCurrent); + _view.WriteLine(); + } + + xWebHandler.Close(); + + using var xDataHandler = new DataHandler(xConfig, _xFile); + iDayDelta = -7; + while (iDayDelta <= 30) + { + var dtCurrent = DateOnly.FromDateTime(DateTime.Today).AddDays(-(iDayDelta + iOffset)); + _view.WriteLine($"Handle: {dtCurrent}"); + iDayDelta += 1; + foreach (var sAnnouncementType in AnnouncementTypes) + { + _view.WriteLine($"Type: {sAnnouncementType}"); + var iPage = 0; + while (iPage < 20) + { + iPage += 1; + _view.WriteLine($"Page: {iPage}"); + var sStart = iPage == 1 + ? $"{sSearchBaseUrl}{dtCurrent.Day:00}-{dtCurrent.Month:00}-{dtCurrent.Year:0000}{PathAnzeigenArt}{sAnnouncementType}" + : $"{sSearchBaseUrl}{dtCurrent.Day:00}-{dtCurrent.Month:00}-{dtCurrent.Year:0000}{PathAnzeigenArt}{sAnnouncementType}{PathSeite}{iPage}"; + var sPath = PortedHelpers.GetLocalPath(sStart, xConfig.LocalPath, dtCurrent); + var sJsonFile = Path.HasExtension(sPath) + ? Path.ChangeExtension(sPath, ExtJson) + : sPath + ExtJson; + if (_xFile.Exists(sJsonFile)) + { + var xData = JsonNode.Parse(_xFile.ReadAllText(sJsonFile)); + var arrTrauerfaelle = xDataHandler.ExtractTrauerData(xData, xConfig.LocalPath); + xDataHandler.TrauerDataToDb(arrTrauerfaelle, xConfig.LocalPath); + } + else + { + if (iPage == 1) + { + _view.Write('-'); + } + + iPage = 99; + } + } + } + } + } + + private void SavePages(RnzConfig xConfig, Dictionary> dPages) + { + foreach (var dPage in dPages.Values) + { + var dParentData = new Dictionary(StringComparer.Ordinal); + var dParentPaths = new Dictionary(StringComparer.Ordinal); + var xParentChanged = false; + + if (dPage.TryGetValue(KeyParent, out var xParentObject) && xParentObject is List arrParents) + { + foreach (var xParentValue in arrParents) + { + var sParent = Convert.ToString(xParentValue) ?? string.Empty; + var sParentPath = PortedHelpers.GetLocalPath(sParent, xConfig.LocalPath); + sParentPath = Path.HasExtension(sParentPath) ? Path.ChangeExtension(sParentPath, ExtJson) : sParentPath + ExtJson; + if (_xFile.Exists(sParentPath)) + { + try + { + dParentData[sParent] = JsonNode.Parse(_xFile.ReadAllText(sParentPath)) as JsonObject ?? new JsonObject(); + } + catch + { + dParentData[sParent] = new JsonObject(); + xParentChanged = true; + } + } + else + { + dParentData[sParent] = new JsonObject(); + xParentChanged = true; + } + + dParentPaths[sParent] = sParentPath; + } + } + + var sLocalPath = PortedHelpers.GetLocalPath(Convert.ToString(dPage[KeyUrl]) ?? string.Empty, xConfig.LocalPath); + var sHtmlPath = Path.HasExtension(sLocalPath) ? sLocalPath : sLocalPath + ExtHtml; + Directory.CreateDirectory(Path.GetDirectoryName(sHtmlPath)!); + _xFile.WriteAllText(sHtmlPath, Convert.ToString(dPage[KeyContent]) ?? string.Empty); + + var sJsonPath = Path.ChangeExtension(sHtmlPath, ExtJson); + var xPageNode = PortedHelpers.ToJsonObject(dPage); + if (_xFile.Exists(sJsonPath)) + { + try + { + var xOldNode = JsonNode.Parse(_xFile.ReadAllText(sJsonPath)) as JsonObject; + if (xOldNode is not null) + { + foreach (var kvValue in xOldNode) + { + if (kvValue.Key.StartsWith(HttpSchemePrefix, StringComparison.Ordinal) && !xPageNode.ContainsKey(kvValue.Key)) + { + xPageNode[kvValue.Key] = kvValue.Value?.DeepClone(); + } + } + } + } + catch + { + _view.Write('-'); + } + } + + _xFile.WriteAllText(sJsonPath, xPageNode.ToJsonString(PortedHelpers.JsonOptions)); + + if (dPage.TryGetValue(KeyParent, out xParentObject) && xParentObject is List arrParentList) + { + foreach (var xParentValue in arrParentList) + { + var sParent = Convert.ToString(xParentValue) ?? string.Empty; + var dEntryCopy = new Dictionary(dPage, StringComparer.Ordinal); + dEntryCopy.Remove(KeyContent); + dEntryCopy[KeyLocalPath] = sJsonPath; + var sUrl = Convert.ToString(dPage[KeyUrl]) ?? string.Empty; + if (!dParentData[sParent].ContainsKey(sUrl)) + { + dParentData[sParent][sUrl] = PortedHelpers.ToJsonObject(dEntryCopy); + xParentChanged = true; + } + else if (dParentData[sParent][sUrl] is JsonObject xTarget) + { + foreach (var kvValue in dEntryCopy) + { + xTarget[kvValue.Key] = kvValue.Value.ToJsonNode(); + } + + xParentChanged = true; + } + + if (xParentChanged) + { + Directory.CreateDirectory(Path.GetDirectoryName(dParentPaths[sParent])!); + _xFile.WriteAllText(dParentPaths[sParent], dParentData[sParent].ToJsonString(PortedHelpers.JsonOptions)); + } + } + } + + _view.Write('.'); + } + } + + private void SaveMedia(RnzConfig xConfig, List> arrItems, DateOnly dtCurrent) + { + foreach (var dEntry in arrItems) + { + try + { + var dEntryCopy = new Dictionary(dEntry, StringComparer.Ordinal); + dEntryCopy.Remove(WebHandler.CsData); + var sParent = Convert.ToString(dEntry.GetValueOrDefault(KeyParent)) ?? string.Empty; + var sParentPath = PortedHelpers.GetLocalPath(sParent, xConfig.LocalPath); + sParentPath = Path.HasExtension(sParentPath) ? Path.ChangeExtension(sParentPath, ExtJson) : sParentPath + ExtJson; + JsonObject xParentData; + var xParentChanged = false; + if (_xFile.Exists(sParentPath)) + { + try + { + xParentData = JsonNode.Parse(_xFile.ReadAllText(sParentPath)) as JsonObject ?? new JsonObject(); + } + catch + { + xParentData = new JsonObject(); + xParentChanged = true; + } + } + else + { + xParentData = new JsonObject(); + xParentChanged = true; + } + + if (dEntryCopy.TryGetValue(WebHandler.CsHeader, out var xHeaderObject) && xHeaderObject is Dictionary dHeaders) + { + dEntryCopy[WebHandler.CsHeader] = dHeaders.ToDictionary(k => k.Key, v => (object?)v.Value, StringComparer.OrdinalIgnoreCase); + } + + if (dEntry.TryGetValue(KeyHref, out var xHrefObject)) + { + var sHref = Convert.ToString(xHrefObject) ?? string.Empty; + var sLocalPath = PortedHelpers.GetLocalPath(sHref, xConfig.LocalPath, dtCurrent); + var sDataPath = Path.Combine(sLocalPath, $"{DataFilePrefix}{dtCurrent.ToString(DateFormatDaily)}{ExtJson}"); + Directory.CreateDirectory(Path.GetDirectoryName(sDataPath)!); + _xFile.WriteAllText(sDataPath, PortedHelpers.ToJsonObject(dEntryCopy).ToJsonString(PortedHelpers.JsonOptions)); + } + + var sSource = Convert.ToString(dEntry.GetValueOrDefault(WebHandler.CsSrc)) ?? string.Empty; + if (!string.IsNullOrEmpty(sSource) && dEntry.TryGetValue(WebHandler.CsData, out var xDataObject) && xDataObject is byte[] arrData) + { + var sLocalPath = PortedHelpers.GetLocalPath(sSource, xConfig.LocalPath); + var sFilePath = sLocalPath; + var sPrefix = Encoding.ASCII.GetString(arrData.Take(10).ToArray()); + if (sPrefix.Contains(SignaturePng, StringComparison.Ordinal)) + { + sFilePath = Path.ChangeExtension(sFilePath, ExtPng); + } + else if (sPrefix.Contains(SignaturePdf, StringComparison.Ordinal)) + { + sFilePath = Path.ChangeExtension(sFilePath, ExtPdf); + } + else if (sPrefix.Contains(SignatureJfif, StringComparison.Ordinal)) + { + sFilePath = Path.ChangeExtension(sFilePath, ExtJpeg); + } + else if (sPrefix.Contains(SignatureHtmlDoctype, StringComparison.Ordinal)) + { + sFilePath = Path.ChangeExtension(sFilePath, ExtHtml); + } + + Directory.CreateDirectory(Path.GetDirectoryName(sFilePath)!); + _xFile.WriteAllBytes(sFilePath, arrData); + dEntryCopy[KeyLocalPath] = sFilePath; + xParentData[sSource] = PortedHelpers.ToJsonObject(dEntryCopy); + xParentChanged = true; + } + + if (xParentChanged) + { + Directory.CreateDirectory(Path.GetDirectoryName(sParentPath)!); + _xFile.WriteAllText(sParentPath, xParentData.ToJsonString(PortedHelpers.JsonOptions)); + } + } + catch + { + } + + _view.Write('.'); + } + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Console/Views/ConsoleOutputView.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Console/Views/ConsoleOutputView.cs new file mode 100644 index 000000000..3e01df22b --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Console/Views/ConsoleOutputView.cs @@ -0,0 +1,39 @@ +namespace RnzTrauer.Console.Views; + +/// +/// Provides console-based output for the RNZ console application. +/// +public sealed class ConsoleOutputView +{ + /// + /// Writes text without a trailing newline. + /// + public void Write(string sText) + { + System.Console.Write(sText); + } + + /// + /// Writes a single character without a trailing newline. + /// + public void Write(char cValue) + { + System.Console.Write(cValue); + } + + /// + /// Writes text followed by a newline. + /// + public void WriteLine(string sText = "") + { + System.Console.WriteLine(sText); + } + + /// + /// Writes an error line. + /// + public void WriteErrorLine(string sText) + { + System.Console.Error.WriteLine(sText); + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Models/AmtsblattConfig.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Models/AmtsblattConfig.cs new file mode 100644 index 000000000..c250114f8 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Models/AmtsblattConfig.cs @@ -0,0 +1,47 @@ +namespace RnzTrauer.Core; + +/// +/// Describes the Amtsblatt loader configuration loaded from JSON. +/// +public sealed class AmtsblattConfig : DatabaseSettings +{ + private readonly IConfigLoader? _xConfigLoader; + + /// + /// Initializes a new instance of the class. + /// + public AmtsblattConfig() + { + } + + /// + /// Initializes a new instance of the class with a configuration loader dependency. + /// + public AmtsblattConfig(IConfigLoader xConfigLoader) + { + _xConfigLoader = xConfigLoader ?? throw new ArgumentNullException(nameof(xConfigLoader)); + } + + /// + /// Gets or sets the page URL prefix. + /// + public string Url { get; set; } = string.Empty; + + /// + /// Gets or sets the expected page title. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the local storage root. + /// + public string LocalPath { get; set; } = string.Empty; + + /// + /// Loads the configuration from a JSON file. + /// + public AmtsblattConfig Load(string sFilePath) + { + return (_xConfigLoader ?? throw new InvalidOperationException("No configuration loader has been provided.")).Load(sFilePath); + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Models/DatabaseSettings.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Models/DatabaseSettings.cs new file mode 100644 index 000000000..76d5364f5 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Models/DatabaseSettings.cs @@ -0,0 +1,27 @@ +namespace RnzTrauer.Core; + +/// +/// Provides database connection settings shared by the ported applications. +/// +public class DatabaseSettings +{ + /// + /// Gets or sets the database user name. + /// + public string DBuser { get; set; } = string.Empty; + + /// + /// Gets or sets the database password. + /// + public string DBpass { get; set; } = string.Empty; + + /// + /// Gets or sets the database host name. + /// + public string DBhost { get; set; } = string.Empty; + + /// + /// Gets or sets the database name. + /// + public string DB { get; set; } = string.Empty; +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Models/RnzConfig.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Models/RnzConfig.cs new file mode 100644 index 000000000..ed4d0709e --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Models/RnzConfig.cs @@ -0,0 +1,57 @@ +namespace RnzTrauer.Core; + +/// +/// Describes the RNZ scraper configuration loaded from JSON. +/// +public sealed class RnzConfig : DatabaseSettings +{ + private readonly IConfigLoader? _xConfigLoader; + + /// + /// Initializes a new instance of the class. + /// + public RnzConfig() + { + } + + /// + /// Initializes a new instance of the class with a configuration loader dependency. + /// + public RnzConfig(IConfigLoader xConfigLoader) + { + _xConfigLoader = xConfigLoader ?? throw new ArgumentNullException(nameof(xConfigLoader)); + } + + /// + /// Gets or sets the login URL. + /// + public string Url { get; set; } = string.Empty; + + /// + /// Gets or sets the expected page title after navigation. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the login user name. + /// + public string User { get; set; } = string.Empty; + + /// + /// Gets or sets the login password. + /// + public string Password { get; set; } = string.Empty; + + /// + /// Gets or sets the local storage root. + /// + public string LocalPath { get; set; } = string.Empty; + + /// + /// Loads the configuration from a JSON file. + /// + public RnzConfig Load(string sFilePath) + { + return (_xConfigLoader ?? throw new InvalidOperationException("No configuration loader has been provided.")).Load(sFilePath); + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Models/WebQuery.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Models/WebQuery.cs new file mode 100644 index 000000000..6d3e97412 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Models/WebQuery.cs @@ -0,0 +1,34 @@ +using OpenQA.Selenium; + +namespace RnzTrauer.Core; + +/// +/// Describes a recursive Selenium query used to capture HTML fragments into dictionaries. +/// +public sealed class WebQuery +{ + /// + /// Initializes a new instance of the class. + /// + public WebQuery(string sName, By bySelector, params WebQuery[] arrChildren) + { + Name = sName; + By = bySelector; + Children = arrChildren; + } + + /// + /// Gets the target property name. + /// + public string Name { get; } + + /// + /// Gets the Selenium selector. + /// + public By By { get; } + + /// + /// Gets the nested child queries. + /// + public IReadOnlyList Children { get; } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/RnzTrauer.Core.csproj b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/RnzTrauer.Core.csproj new file mode 100644 index 000000000..545f1d64c --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/RnzTrauer.Core.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + + + + + + + + + diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/AmtsblattWebHandler.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/AmtsblattWebHandler.cs new file mode 100644 index 000000000..284b9f224 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/AmtsblattWebHandler.cs @@ -0,0 +1,115 @@ +using OpenQA.Selenium; +using OpenQA.Selenium.Firefox; + +namespace RnzTrauer.Core; + +/// +/// Provides Selenium-based scraping for the Amtsblatt loader. +/// +public sealed class AmtsblattWebHandler : IDisposable +{ + private readonly AmtsblattConfig _config; + + /// + /// Initializes a new instance of the class. + /// + public AmtsblattWebHandler(AmtsblattConfig xConfig) + { + _config = xConfig; + } + + /// + /// Gets the active Firefox driver instance. + /// + public FirefoxDriver? Driver { get; private set; } + + /// + /// Opens the configured start page. + /// + public void InitPage() + { + var xOptions = new FirefoxOptions(); + Driver = new FirefoxDriver(xOptions); + Driver.Navigate().GoToUrl(_config.Url); + while (Driver.Title != _config.Title) + { + Thread.Sleep(500); + } + + Thread.Sleep(500); + } + + /// + /// Loads the target issue page and triggers the PDF download action. + /// + public (Dictionary> Pages, List> Items) GetData1(string sStart) + { + var xDriver = Driver ?? throw new InvalidOperationException("The web driver has not been initialized."); + xDriver.Navigate().GoToUrl(sStart); + while (xDriver.Title == "RNZ" || string.IsNullOrEmpty(xDriver.Title)) + { + Thread.Sleep(500); + } + + var dPages = new Dictionary>(StringComparer.Ordinal); + var arrItems = new List>(); + var sNextUrl = "."; + var iCounter = 0; + while (!string.IsNullOrEmpty(sNextUrl) && iCounter < 20) + { + Console.WriteLine(xDriver.Url); + (_, _, sNextUrl) = WorkMainPage(dPages, arrItems); + iCounter++; + } + + return (dPages, arrItems); + } + + /// + /// Closes the browser session. + /// + public void Close() + { + Driver?.Quit(); + Driver = null; + } + + /// + public void Dispose() + { + Close(); + } + + private (List SubPages, string Url, string NextUrl) WorkMainPage(Dictionary> dPages, List> arrItems) + { + var xDriver = Driver ?? throw new InvalidOperationException("The web driver has not been initialized."); + var sUrl = xDriver.Url; + var dPage = new Dictionary + { + ["Title"] = xDriver.Title, + ["url"] = sUrl, + ["sections"] = new List>(), + ["content"] = xDriver.PageSource + }; + dPages[sUrl] = dPage; + + Thread.Sleep(4000); + foreach (var xButton in xDriver.FindElements(By.TagName("button"))) + { + if ((xButton.Text ?? string.Empty).StartsWith("Alle akz", StringComparison.Ordinal)) + { + xButton.Click(); + } + } + + foreach (var xLink in xDriver.FindElements(By.TagName("a"))) + { + if ((xLink.Text ?? string.Empty).StartsWith("PDF her", StringComparison.Ordinal)) + { + xLink.Click(); + } + } + + return ([], sUrl, string.Empty); + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/ConfigLoader.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/ConfigLoader.cs new file mode 100644 index 000000000..d18080b42 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/ConfigLoader.cs @@ -0,0 +1,39 @@ +using System.Text.Json; + +namespace RnzTrauer.Core; + +/// +/// Provides JSON-based configuration loading for the ported tools. +/// +public sealed class ConfigLoader : IConfigLoader +{ + private static readonly JsonSerializerOptions _options = new() + { + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + + private readonly IFile _xFile; + + /// + /// Initializes a new instance of the class. + /// + public ConfigLoader(IFile xFile) + { + _xFile = xFile ?? throw new ArgumentNullException(nameof(xFile)); + } + + /// + /// Loads a configuration instance from the specified JSON file. + /// + public T Load(string sFilePath) where T : new() + { + if (!_xFile.Exists(sFilePath)) + { + throw new FileNotFoundException($"Configuration file was not found: {sFilePath}"); + } + + var xConfiguration = JsonSerializer.Deserialize(_xFile.ReadAllText(sFilePath), _options); + return xConfiguration ?? new T(); + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/DataHandler.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/DataHandler.cs new file mode 100644 index 000000000..9a2829b56 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/DataHandler.cs @@ -0,0 +1,585 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using MySqlConnector; + +namespace RnzTrauer.Core; + +/// +/// Provides database access and extraction helpers for the RNZ obituary data. +/// +public sealed class DataHandler : IDisposable +{ + private readonly MySqlConnection _dbConn; + private readonly IFile _xFile; + + /// + /// Initializes a new instance of the class. + /// + public DataHandler(DatabaseSettings xSettings, IFile xFile) + { + _xFile = xFile ?? throw new ArgumentNullException(nameof(xFile)); + + var sConnectionString = new MySqlConnectionStringBuilder + { + Server = xSettings.DBhost, + Port = 3306, + UserID = xSettings.DBuser, + Password = xSettings.DBpass, + Database = xSettings.DB, + AllowUserVariables = true, + ConvertZeroDateTime = true + }.ConnectionString; + + _dbConn = new MySqlConnection(sConnectionString); + _dbConn.Open(); + } + + /// + /// Gets the in-memory obituary case index. + /// + public Dictionary TfIdx { get; private set; } = new(StringComparer.Ordinal); + + /// + /// Extracts obituary dictionaries from the stored page JSON structure. + /// + public List> ExtractTrauerData(JsonNode? xData, string sLocalPathRoot) + { + var arrResult = new List>(); + if (xData is not JsonObject xRoot || xRoot["sections"] is not JsonArray arrSections) + { + return arrResult; + } + + foreach (var xSectionNode in arrSections.OfType()) + { + var arrTexts = xSectionNode["text"] as JsonArray; + if (arrTexts is null || arrTexts.Count == 0 || !(arrTexts[0]?.ToString() ?? string.Empty).StartsWith("ANZ", StringComparison.Ordinal)) + { + continue; + } + + JsonObject xTrauerfallData = new(); + if (xSectionNode["links"] is JsonArray arrLinks && arrLinks.Count > 0 && arrLinks[0] is JsonObject xLink0) + { + var sLinkHref = xLink0["href"]?.ToString() ?? string.Empty; + if (xRoot[$"{sLinkHref}/anzeigen"] is JsonObject xInline) + { + xTrauerfallData = xInline; + Console.Write('.'); + } + else + { + var sLocalPath = PortedHelpers.GetLocalPath(sLinkHref, sLocalPathRoot); + var sFullName = Path.HasExtension(sLocalPath) ? sLocalPath : sLocalPath + ".json"; + if (_xFile.Exists(sFullName)) + { + xTrauerfallData = JsonNode.Parse(_xFile.ReadAllText(sFullName)) as JsonObject ?? new JsonObject(); + Console.Write(','); + } + else + { + Console.Write('-'); + } + } + } + + if (xTrauerfallData["sections"] is not JsonArray arrTrauerfallSections) + { + continue; + } + + var sProfileImagePath = string.Empty; + foreach (var xAnnouncementNode in arrTrauerfallSections.OfType()) + { + var sCssClass = xAnnouncementNode["class"]?.ToString() ?? string.Empty; + if (sCssClass.StartsWith("col-12", StringComparison.Ordinal)) + { + try + { + if (xAnnouncementNode["imgs"] is JsonArray arrImages && arrImages.Count > 0 && arrImages[0] is JsonObject xImage0) + { + var sSource = xImage0["src"]?.ToString() ?? string.Empty; + if (sSource.Contains("MEDIA", StringComparison.Ordinal)) + { + sProfileImagePath = PortedHelpers.GetLocalPath(sSource.LCropStr("?"), sLocalPathRoot); + } + } + } + catch + { + } + } + + if (!sCssClass.StartsWith("container", StringComparison.Ordinal)) + { + continue; + } + + var dTrauerfall = new Dictionary(StringComparer.Ordinal) + { + ["profImg"] = sProfileImagePath, + ["filter"] = xAnnouncementNode["filter"]?.ToString() ?? string.Empty + }; + + var sId = xAnnouncementNode["id"]?.ToString() ?? string.Empty; + var arrSplit = sId.Split('_', 2); + dTrauerfall["id"] = arrSplit.Length > 1 ? arrSplit[1] : string.Empty; + + if (xAnnouncementNode["text"] is JsonArray arrAnnouncementText && arrAnnouncementText.Count > 1) + { + var sPublish = arrAnnouncementText[1]?.ToString() ?? string.Empty; + if (sPublish.StartsWith("vom", StringComparison.Ordinal)) + { + dTrauerfall["publish"] = sPublish.Length > 4 ? sPublish[4..] : string.Empty; + } + } + + if (xTrauerfallData["name"] is not null) + { + dTrauerfall["name"] = xTrauerfallData["name"]!.ToString(); + } + + if (arrTrauerfallSections.Count > 0 && arrTrauerfallSections[0] is JsonObject xFirstSection && xFirstSection["text"] is JsonArray arrFirstText && arrFirstText.Count > 1) + { + var sBirthName = arrFirstText[1]?.ToString() ?? string.Empty; + if (sBirthName.StartsWith("geb.", StringComparison.Ordinal)) + { + dTrauerfall["Birthname"] = sBirthName.Length > 5 ? sBirthName[5..] : string.Empty; + } + } + + foreach (var sKey in new[] { "url", "Birth", "Death", "Place", "created_by", "created_on", "visits" }) + { + if (xTrauerfallData[sKey] is not null) + { + dTrauerfall[sKey] = xTrauerfallData[sKey]!.ToString(); + } + } + + if (xAnnouncementNode["links"] is JsonArray arrAnnouncementLinks && arrAnnouncementLinks.Count >= 3) + { + var sImage = arrAnnouncementLinks[1]?["href"]?.ToString() ?? string.Empty; + dTrauerfall["img"] = PortedHelpers.GetLocalPath(sImage, sLocalPathRoot); + var sPdf = arrAnnouncementLinks[2]?["href"]?.ToString() ?? string.Empty; + dTrauerfall["pdf"] = PortedHelpers.GetLocalPath(sPdf, sLocalPathRoot); + if (xTrauerfallData[sPdf] is JsonObject xPdfObject && xPdfObject["pdfText"] is not null) + { + dTrauerfall["pdfText"] = xPdfObject["pdfText"]!.ToString(); + } + else + { + var sPdfFile = dTrauerfall["pdf"]?.ToString() ?? string.Empty; + if (_xFile.Exists(sPdfFile)) + { + dTrauerfall["pdfText"] = PortedHelpers.PdfText(_xFile.ReadAllBytes(sPdfFile)); + } + } + } + + arrResult.Add(dTrauerfall); + } + } + + return arrResult; + } + + /// + /// Reads obituary rows by internal id. + /// + public List> TrauerAnzId(int iId) + { + return Query("SELECT * FROM Anzeigen WHERE idAnzeige=@id", xCommand => xCommand.Parameters.AddWithValue("@id", iId)); + } + + /// + /// Reads obituary rows by announcement id. + /// + public List> TrauerAnz(int iAnnouncement) + { + return Query("SELECT * FROM Anzeigen WHERE Announcement=@announcement", xCommand => xCommand.Parameters.AddWithValue("@announcement", iAnnouncement)); + } + + /// + /// Reads legacy obituary rows by legacy order id. + /// + public List> LegacyTrauerAnz(string sAuftrag) + { + return Query("SELECT * FROM `RNZ-Traueranzeigen`.`Anzeigen` WHERE Auftrag=@auftrag", xCommand => xCommand.Parameters.AddWithValue("@auftrag", sAuftrag)); + } + + /// + /// Reads obituary rows where the specified field is null. + /// + public List> TrauerAnzIsNull(string sField, int iLimit = 1) + { + return Query($"SELECT * FROM `Anzeigen` WHERE `{sField}` is null limit @limit", xCommand => xCommand.Parameters.AddWithValue("@limit", iLimit)); + } + + /// + /// Reads obituary case rows where the specified field is null. + /// + public List> TrauerFallIsNull(string sField, int iLimit = 1) + { + return Query($"SELECT * FROM `Trauerfall` WHERE `{sField}` is null limit @limit", xCommand => xCommand.Parameters.AddWithValue("@limit", iLimit)); + } + + /// + /// Reads obituary case rows where the specified field matches the provided value. + /// + public List> TrauerFallEquals(string sField, string sValue, int iLimit = 1) + { + return Query($"SELECT * FROM `Trauerfall` WHERE `{sField}`=@value limit @limit", xCommand => + { + xCommand.Parameters.AddWithValue("@value", sValue); + xCommand.Parameters.AddWithValue("@limit", iLimit); + }); + } + + /// + /// Updates obituary case rows when values have changed. + /// + public void UpdateTrauerFall(List> arrNewValues, List> arrOldValues) + { + UpdateRows("Trauerfall", arrNewValues, arrOldValues); + } + + /// + /// Updates obituary announcement rows when values have changed. + /// + public bool UpdateTrauerAnz(List> arrNewValues, List> arrOldValues) + { + return UpdateRows("Anzeigen", arrNewValues, arrOldValues); + } + + /// + /// Reads obituary case rows by internal id. + /// + public List> TrauerFallById(int iId) + { + return Query("SELECT * FROM Trauerfall WHERE idTrauerfall=@id", xCommand => xCommand.Parameters.AddWithValue("@id", iId)); + } + + /// + /// Reads obituary case rows by URL. + /// + public List> TrauerFallByUrl(string sUrl) + { + return Query("SELECT idTrauerfall, url FROM Trauerfall WHERE url=@url", xCommand => xCommand.Parameters.AddWithValue("@url", sUrl)); + } + + /// + /// Builds an in-memory index of obituary case URLs. + /// + public void BuildTrauerFallIndex() + { + TfIdx = new Dictionary(StringComparer.Ordinal); + using var xCommand = new MySqlCommand("SELECT idTrauerfall,url FROM Trauerfall", _dbConn); + using var xReader = xCommand.ExecuteReader(); + while (xReader.Read()) + { + TfIdx[xReader.GetString(1)] = xReader.GetInt64(0); + } + } + + /// + /// Inserts a new obituary case row. + /// + public long AppendTrauerFall(Dictionary dTrauerfall) + { + var (sLastName, sFirstName) = dTrauerfall.Cond("name").SplitName(); + using var xCommand = new MySqlCommand( + "INSERT INTO `Trauerfall` (`URL`, `Created`, `Preread_Birth`, `Preread_Death`, `Fullname`, `Firstname`, `Lastname`, `Birthname`, `Place`, `Created_by`) VALUES (@url, @created, @birth, @death, @fullName, @firstName, @lastName, @birthName, @place, @createdBy);", + _dbConn); + xCommand.Parameters.AddWithValue("@url", dTrauerfall.Cond("url")); + xCommand.Parameters.AddWithValue("@created", ToDbValue(PortedHelpers.Str2Date(dTrauerfall.Cond("created_on")))); + xCommand.Parameters.AddWithValue("@birth", ToDbValue(PortedHelpers.Str2Date(TrimLeadingTwo(dTrauerfall.Cond("Birth"))))); + xCommand.Parameters.AddWithValue("@death", ToDbValue(PortedHelpers.Str2Date(TrimLeadingTwo(dTrauerfall.Cond("Death"))))); + xCommand.Parameters.AddWithValue("@fullName", $"{sLastName}, {sFirstName}"); + xCommand.Parameters.AddWithValue("@firstName", sFirstName); + xCommand.Parameters.AddWithValue("@lastName", sLastName); + xCommand.Parameters.AddWithValue("@birthName", dTrauerfall.Cond("Birthname")); + xCommand.Parameters.AddWithValue("@place", dTrauerfall.Cond("Place")); + xCommand.Parameters.AddWithValue("@createdBy", dTrauerfall.Cond("created_by")); + xCommand.ExecuteNonQuery(); + return xCommand.LastInsertedId; + } + + /// + /// Inserts a new obituary announcement row. + /// + public long AppendTrauerAnz(long iTrauerfallId, Dictionary dTrauerfall, string sLocalPath) + { + string sImgPath = dTrauerfall.Cond("img"); + if (string.IsNullOrEmpty(sImgPath)) + { + sImgPath = dTrauerfall.Cond("pdf"); + } + if (string.IsNullOrEmpty(sImgPath)) + { + sImgPath = sLocalPath; + } + var sPath = Directory.GetParent(sImgPath)?.FullName ?? string.Empty; + var sNormalizedLocalPath = sPath.Replace(sLocalPath, string.Empty, StringComparison.Ordinal); + var sProfileBase = Directory.GetParent(Directory.GetParent(sPath)?.FullName ?? string.Empty)?.FullName ?? string.Empty; + var sProfileImage = dTrauerfall.Cond("profImg").Replace(sProfileBase, "..\\..", StringComparison.Ordinal); + var iRubrik = GetRubrik(dTrauerfall); + var (sLastName, sFirstName) = dTrauerfall.Cond("name").SplitName(); + + using var xCommand = new MySqlCommand( + "INSERT INTO `Anzeigen` (`idTrauerfall`, `url`, `Announcement`, `release`,`localpath`, `pngFile`, `pdfFile`, `Additional`, `Firstname`,`Lastname`, `Birthname`, `Birth`, `Death`, `Place`, `Info`, `ProfileImg`, `Rubrik`) VALUES (@idtf, @url, @announcement, @release, @localpath, @pngFile, @pdfFile, @additional, @firstName, @lastName, @birthName, @birth, @death, @place, @info, @profileImg, @rubrik);", + _dbConn); + xCommand.Parameters.AddWithValue("@idtf", iTrauerfallId); + xCommand.Parameters.AddWithValue("@url", dTrauerfall.Cond("url")); + xCommand.Parameters.AddWithValue("@announcement", int.TryParse(dTrauerfall.Cond("id"), out var iId) ? iId : 0); + xCommand.Parameters.AddWithValue("@release", ToDbValue(PortedHelpers.Str2Date(dTrauerfall.Cond("publish")))); + xCommand.Parameters.AddWithValue("@localpath", sNormalizedLocalPath); + xCommand.Parameters.AddWithValue("@pngFile", Path.GetFileName(dTrauerfall.Cond("img"))); + xCommand.Parameters.AddWithValue("@pdfFile", Path.GetFileName(dTrauerfall.Cond("pdf"))); + xCommand.Parameters.AddWithValue("@additional", JsonSerializer.Serialize(dTrauerfall, PortedHelpers.JsonOptions)); + xCommand.Parameters.AddWithValue("@firstName", sFirstName); + xCommand.Parameters.AddWithValue("@lastName", sLastName); + xCommand.Parameters.AddWithValue("@birthName", dTrauerfall.Cond("Birthname")); + xCommand.Parameters.AddWithValue("@birth", ToDbValue(PortedHelpers.Str2Date(TrimLeadingTwo(dTrauerfall.Cond("Birth"))))); + xCommand.Parameters.AddWithValue("@death", ToDbValue(PortedHelpers.Str2Date(TrimLeadingTwo(dTrauerfall.Cond("Death"))))); + xCommand.Parameters.AddWithValue("@place", dTrauerfall.Cond("Place")); + xCommand.Parameters.AddWithValue("@info", dTrauerfall.Cond("pdfText")); + xCommand.Parameters.AddWithValue("@profileImg", sProfileImage); + xCommand.Parameters.AddWithValue("@rubrik", iRubrik); + xCommand.ExecuteNonQuery(); + return xCommand.LastInsertedId; + } + + /// + /// Applies announcement values to the current row dictionary. + /// + public void SetTrauerAnz(Dictionary dCurrent, Dictionary dTrauerfall, string sLocalPath) + { + string sImgPath = dTrauerfall.Cond("img"); + if (string.IsNullOrEmpty(sImgPath)) + { + sImgPath = dTrauerfall.Cond("pdf"); + } + if (string.IsNullOrEmpty(sImgPath)) + { + sImgPath = sLocalPath; + } + var sPath = Directory.GetParent(sImgPath)?.FullName ?? string.Empty; + var sNormalizedLocalPath = sPath.Replace(sLocalPath, string.Empty, StringComparison.Ordinal); + var sProfileBase = Directory.GetParent(Directory.GetParent(sPath)?.FullName ?? string.Empty)?.FullName ?? string.Empty; + var sProfileImage = dTrauerfall.Cond( "profImg").Replace(sProfileBase, "..\\..", StringComparison.Ordinal); + var iRubrik = dCurrent.TryGetValue("Rubrik", out var xCurrentRubrik) && int.TryParse(Convert.ToString(xCurrentRubrik, CultureInfo.InvariantCulture), out var iParsed) ? iParsed : 8050; + var sFilter = dTrauerfall.Cond( "filter"); + if (sFilter == "danksagungen") + { + iRubrik = 8060; + } + else if (sFilter == "nachrufe") + { + iRubrik = 8070; + } + else if (sFilter == "todesanzeigen") + { + iRubrik = 8050; + } + else + { + try + { + var xJson = JsonNode.Parse(Convert.ToString(dCurrent.GetValueOrDefault("Additional"), CultureInfo.InvariantCulture) ?? string.Empty) as JsonObject; + if (xJson?["filter"] is not null) + { + dTrauerfall["filter"] = xJson["filter"]!.ToString(); + } + } + catch + { + } + } + + var (sLastName, sFirstName) = dTrauerfall.Cond( "name").SplitName(); + foreach (var kvPair in new Dictionary + { + ["url"] = dTrauerfall.Cond( "url"), + ["Announcement"] = int.TryParse(dTrauerfall.Cond( "id"), out var iId) ? iId : 0, + ["release"] = ToDbValue(PortedHelpers.Str2Date(dTrauerfall.Cond( "publish"))), + ["localpath"] = sNormalizedLocalPath, + ["pngFile"] = Path.GetFileName(dTrauerfall.Cond( "img")), + ["pdfFile"] = Path.GetFileName(dTrauerfall.Cond( "pdf")), + ["Additional"] = JsonSerializer.Serialize(dTrauerfall, PortedHelpers.JsonOptions), + ["Firstname"] = sFirstName, + ["Lastname"] = sLastName, + ["Birthname"] = dTrauerfall.Cond( "Birthname"), + ["Birth"] = ToDbValue(PortedHelpers.Str2Date(TrimLeadingTwo(dTrauerfall.Cond( "Birth")))), + ["Death"] = ToDbValue(PortedHelpers.Str2Date(TrimLeadingTwo(dTrauerfall.Cond( "Death")))), + ["Place"] = dTrauerfall.Cond( "Place"), + ["Info"] = dTrauerfall.Cond( "pdfText"), + ["ProfileImg"] = sProfileImage, + ["Rubrik"] = iRubrik + }) + { + dCurrent[kvPair.Key] = kvPair.Value; + } + } + + /// + /// Inserts a legacy obituary announcement row. + /// + public long AppendLegacyTAnz(string sAuftrag, Dictionary dTrauerfall, string sLocalPath) + { + var sNormalizedLocalPath = Directory.GetParent(PortedHelpers.Cond(dTrauerfall, "pdf"))?.FullName?.Replace(sLocalPath, string.Empty, StringComparison.Ordinal) ?? string.Empty; + using var xCommand = new MySqlCommand( + "INSERT INTO `RNZ-Traueranzeigen`.`Anzeigen` (`Auftrag`, `url`, `Announcement`, `release`,`localpath`, `pngFile`, `pdfFile`, `Additional`) VALUES (@auftrag, @url, @announcement, @release, @localpath, @pngFile, @pdfFile, @additional);", + _dbConn); + xCommand.Parameters.AddWithValue("@auftrag", sAuftrag); + xCommand.Parameters.AddWithValue("@url", dTrauerfall.Cond( "url")); + xCommand.Parameters.AddWithValue("@announcement", int.TryParse(dTrauerfall.Cond( "id"), out var iId) ? iId : 0); + xCommand.Parameters.AddWithValue("@release", ToDbValue(PortedHelpers.Str2Date(dTrauerfall.Cond( "publish")))); + xCommand.Parameters.AddWithValue("@localpath", sNormalizedLocalPath); + xCommand.Parameters.AddWithValue("@pngFile", Path.GetFileName(dTrauerfall.Cond( "img"))); + xCommand.Parameters.AddWithValue("@pdfFile", Path.GetFileName(dTrauerfall.Cond( "pdf"))); + xCommand.Parameters.AddWithValue("@additional", JsonSerializer.Serialize(dTrauerfall, PortedHelpers.JsonOptions)); + xCommand.ExecuteNonQuery(); + return xCommand.LastInsertedId; + } + + /// + /// Imports extracted obituary data into the database. + /// + public void TrauerDataToDb(IEnumerable> arrData, string sLocalPath) + { + foreach (var dAnnouncement in arrData) + { + var dtCreated = PortedHelpers.Str2Date(dAnnouncement.Cond("created_on")); + var dtPublished = PortedHelpers.Str2Date(dAnnouncement.Cond("publish")); + if (!dtCreated.HasValue || !dtPublished.HasValue) + { + Console.WriteLine($"Skip incomplete announcement (missing created/publish): {dAnnouncement.Cond("url")}"); + continue; + } + + var arrCurrentCases = TrauerFallByUrl(dAnnouncement.Cond( "url")); + var iTrauerfallId = arrCurrentCases.Count == 0 + ? AppendTrauerFall(dAnnouncement) + : Convert.ToInt64(arrCurrentCases[0]["idTrauerfall"], CultureInfo.InvariantCulture); + + var arrCurrentAnnouncements = TrauerAnz(int.TryParse(dAnnouncement.Cond( "id"), out var iId) ? iId : 0); + if (arrCurrentAnnouncements.Count == 0) + { + Console.Write('+'); + AppendTrauerAnz(iTrauerfallId, dAnnouncement, sLocalPath); + } + else + { + Console.Write('-'); + var arrCopy = arrCurrentAnnouncements.Select(d => new Dictionary(d, StringComparer.Ordinal)).ToList(); + SetTrauerAnz(arrCopy[0], dAnnouncement, sLocalPath); + if (UpdateTrauerAnz(arrCopy, arrCurrentAnnouncements)) + { + Console.Write("\bx"); + } + } + } + } + + /// + public void Dispose() + { + _dbConn.Dispose(); + } + + private bool UpdateRows(string sTable, List> arrNewValues, List> arrOldValues) + { + var xChanged = false; + if (arrNewValues.Count == 0) + { + return false; + } + + var sKeyField = arrNewValues[0].Keys.FirstOrDefault(k => k.StartsWith("id", StringComparison.OrdinalIgnoreCase)); + if (string.IsNullOrEmpty(sKeyField)) + { + return false; + } + + var dOldById = arrOldValues.ToDictionary(x => Convert.ToString(x[sKeyField], CultureInfo.InvariantCulture) ?? string.Empty, StringComparer.Ordinal); + foreach (var dNewRow in arrNewValues) + { + var sKey = Convert.ToString(dNewRow[sKeyField], CultureInfo.InvariantCulture) ?? string.Empty; + if (!dOldById.TryGetValue(sKey, out var dOldRow)) + { + continue; + } + + foreach (var (sColumn, xValue) in dNewRow) + { + dOldRow.TryGetValue(sColumn, out var xOldValue); + if (Equals(xOldValue, xValue)) + { + continue; + } + + using var xCommand = new MySqlCommand($"UPDATE `{sTable}` SET `{sColumn}`=@value WHERE `{sKeyField}`=@key", _dbConn); + xCommand.Parameters.AddWithValue("@value", xValue ?? DBNull.Value); + xCommand.Parameters.AddWithValue("@key", dNewRow[sKeyField]); + xCommand.ExecuteNonQuery(); + xChanged = true; + } + } + + return xChanged; + } + + private List> Query(string sSql, Action xBind) + { + var arrData = new List>(); + using var xCommand = new MySqlCommand(sSql, _dbConn); + xBind(xCommand); + using var xReader = xCommand.ExecuteReader(); + while (xReader.Read()) + { + var dRow = new Dictionary(StringComparer.Ordinal); + for (var iIndex = 0; iIndex < xReader.FieldCount; iIndex++) + { + if (xReader.IsDBNull(iIndex)) + { + dRow[xReader.GetName(iIndex)] = null; + continue; + } + + var xValue = xReader.GetValue(iIndex); + dRow[xReader.GetName(iIndex)] = xValue switch + { + DateTime dtValue => dtValue.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + _ => xValue + }; + } + + arrData.Add(dRow); + } + + return arrData; + } + + private static object ToDbValue(DateOnly? dtValue) + { + return dtValue.HasValue + ? dtValue.Value.ToDateTime(TimeOnly.MinValue) + : DBNull.Value; + } + + private static string TrimLeadingTwo(string sValue) + { + return sValue.Length > 2 ? sValue[2..] : string.Empty; + } + + private static int GetRubrik(IReadOnlyDictionary dTrauerfall) + { + return PortedHelpers.Cond(dTrauerfall, "filter") switch + { + "danksagungen" => 8060, + "nachrufe" => 8070, + _ => 8050 + }; + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/FileProxy.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/FileProxy.cs new file mode 100644 index 000000000..4a2e119a4 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/FileProxy.cs @@ -0,0 +1,37 @@ +namespace RnzTrauer.Core; + +/// +/// Delegates file-system operations to . +/// +public sealed class FileProxy : IFile +{ + /// + public bool Exists(string sPath) + { + return File.Exists(sPath); + } + + /// + public string ReadAllText(string sPath) + { + return File.ReadAllText(sPath); + } + + /// + public byte[] ReadAllBytes(string sPath) + { + return File.ReadAllBytes(sPath); + } + + /// + public void WriteAllText(string sPath, string sContents) + { + File.WriteAllText(sPath, sContents); + } + + /// + public void WriteAllBytes(string sPath, byte[] arrBytes) + { + File.WriteAllBytes(sPath, arrBytes); + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/FirefoxWebDriverFactory.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/FirefoxWebDriverFactory.cs new file mode 100644 index 000000000..7a2aaa093 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/FirefoxWebDriverFactory.cs @@ -0,0 +1,17 @@ +using OpenQA.Selenium; +using OpenQA.Selenium.Firefox; + +namespace RnzTrauer.Core; + +/// +/// Creates Firefox-based Selenium web drivers. +/// +public sealed class FirefoxWebDriverFactory : IWebDriverFactory +{ + /// + public IWebDriver Create() + { + var xOptions = new FirefoxOptions(); + return new FirefoxDriver(xOptions); + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/HttpClientProxy.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/HttpClientProxy.cs new file mode 100644 index 000000000..d157b7f8f --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/HttpClientProxy.cs @@ -0,0 +1,21 @@ +namespace RnzTrauer.Core; + +/// +/// Delegates HTTP operations to . +/// +public sealed class HttpClientProxy : IHttpClientProxy +{ + private readonly HttpClient _xHttpClient = new(); + + /// + public Task GetAsync(string sRequestUri) + { + return _xHttpClient.GetAsync(sRequestUri); + } + + /// + public void Dispose() + { + _xHttpClient.Dispose(); + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/IConfigLoader.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/IConfigLoader.cs new file mode 100644 index 000000000..1dc632865 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/IConfigLoader.cs @@ -0,0 +1,12 @@ +namespace RnzTrauer.Core; + +/// +/// Provides JSON-based configuration loading for the ported tools. +/// +public interface IConfigLoader +{ + /// + /// Loads a configuration instance from the specified JSON file. + /// + T Load(string sFilePath) where T : new(); +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/IFile.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/IFile.cs new file mode 100644 index 000000000..ba44a6fd1 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/IFile.cs @@ -0,0 +1,32 @@ +namespace RnzTrauer.Core; + +/// +/// Provides an abstraction over for testable file-system access. +/// +public interface IFile +{ + /// + /// Determines whether the specified file exists. + /// + bool Exists(string sPath); + + /// + /// Reads all text from the specified file. + /// + string ReadAllText(string sPath); + + /// + /// Reads all bytes from the specified file. + /// + byte[] ReadAllBytes(string sPath); + + /// + /// Writes all text to the specified file. + /// + void WriteAllText(string sPath, string sContents); + + /// + /// Writes all bytes to the specified file. + /// + void WriteAllBytes(string sPath, byte[] arrBytes); +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/IHttpClientProxy.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/IHttpClientProxy.cs new file mode 100644 index 000000000..28ce45d2b --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/IHttpClientProxy.cs @@ -0,0 +1,12 @@ +namespace RnzTrauer.Core; + +/// +/// Provides an abstraction over for testable HTTP access. +/// +public interface IHttpClientProxy : IDisposable +{ + /// + /// Sends a GET request to the specified URI. + /// + Task GetAsync(string sRequestUri); +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/IWebDriverFactory.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/IWebDriverFactory.cs new file mode 100644 index 000000000..d4c8f49be --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/IWebDriverFactory.cs @@ -0,0 +1,14 @@ +using OpenQA.Selenium; + +namespace RnzTrauer.Core; + +/// +/// Creates Selenium web driver instances. +/// +public interface IWebDriverFactory +{ + /// + /// Creates a new web driver instance. + /// + IWebDriver Create(); +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/PortedHelpers.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/PortedHelpers.cs new file mode 100644 index 000000000..49d2449ba --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/PortedHelpers.cs @@ -0,0 +1,282 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using UglyToad.PdfPig; + +namespace RnzTrauer.Core; + +/// +/// Contains helper methods ported from the original Python implementation. +/// +public static class PortedHelpers +{ + /// + /// Gets the shared JSON serializer options used by the ported tools. + /// + public static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true + }; + + /// + /// Converts a RNZ date string in dd.MM.yyyy format into a value. + /// + public static DateOnly? Str2Date(string? sValue) + { + if (string.IsNullOrWhiteSpace(sValue)) + { + return null; + } + + var arrParts = sValue.Split('.'); + if (arrParts.Length != 3) + { + return null; + } + + if (int.TryParse(arrParts[2], out var iYear) && + int.TryParse(arrParts[1], out var iMonth) && + int.TryParse(arrParts[0], out var iDay)) + { + try + { + return new DateOnly(iYear, iMonth, iDay); + } + catch + { + return null; + } + } + + return null; + } + + /// + /// Returns a trimmed string value from a loosely typed dictionary. + /// + public static string Cond(this IReadOnlyDictionary dValues, string sKey) => dValues.TryGetValue(sKey, out var xValue) + ? Convert.ToString(xValue, CultureInfo.InvariantCulture)?.Trim(' ') ?? string.Empty + : string.Empty; + + /// + /// Crops the input string at the first occurrence of the specified separator. + /// + public static string LCropStr(this string sOriginal, string sSeparator) + { + var iFound = sOriginal.IndexOf(sSeparator, StringComparison.Ordinal); + return iFound >= 0 ? sOriginal[..iFound] : sOriginal; + } + + /// + /// Splits a full name into last name and first name using the original Python rules. + /// + public static (string LastName, string FirstName) SplitName(this string sName) + { + var arrNames = sName.Trim(' ').Split(' ', StringSplitOptions.RemoveEmptyEntries).ToArray(); + if (arrNames.Length == 0) + { + return (string.Empty, string.Empty); + } + + for (var iIndex = arrNames.Length - 1; iIndex >= 0; iIndex--) + { + if (arrNames[iIndex].Equals("von", StringComparison.OrdinalIgnoreCase) || arrNames[iIndex].Equals("van", StringComparison.OrdinalIgnoreCase)) + { + arrNames[iIndex] = arrNames[iIndex].ToLowerInvariant(); + if (iIndex < arrNames.Length - 1) + { + arrNames[iIndex + 1] = $"{arrNames[iIndex]} {arrNames[iIndex + 1]}"; + arrNames[iIndex] = string.Empty; + } + } + else if (arrNames[iIndex].Contains('-', StringComparison.Ordinal)) + { + var arrParts = arrNames[iIndex].Split('-', 2); + arrNames[iIndex] = $"{arrParts[0].Capitalize()}-{(arrParts.Length > 1 ? arrParts[1].Capitalize() : string.Empty)}"; + } + else + { + arrNames[iIndex] = arrNames[iIndex].Capitalize(); + } + } + + var sLastName = arrNames[^1]; + var sFirstName = string.Join(" ", arrNames[..^1]).Trim(' '); + return (sLastName, sFirstName); + } + + /// + /// Rewrites absolute page references into relative local paths. + /// + public static string MakeLocal(string sSource, string sReference) + { + var iSlashCount = sReference.Count(c => c == '/'); + const string sPrefix = "../../../../../"; + var sRelative = sPrefix[..Math.Max(0, Math.Min(sPrefix.Length, (iSlashCount - 3) * 3))]; + sSource = sSource.Replace("src=\"/", $"src=\"{sRelative}", StringComparison.Ordinal); + sSource = sSource.Replace("href=\"/", $"href=\"{sRelative}", StringComparison.Ordinal); + var sRest = sReference.Length > 9 ? sReference[9..] : string.Empty; + var iIndex = sRest.IndexOf('/', StringComparison.Ordinal); + var sRoot = iIndex >= 0 && sReference.Length >= iIndex + 10 ? sReference[..(iIndex + 10)] : sReference; + sSource = sSource.Replace(sRoot, sRelative, StringComparison.Ordinal); + sSource = sSource.Replace(".jpg", ".png", StringComparison.OrdinalIgnoreCase); + return sSource; + } + + /// + /// Maps a remote RNZ URL to the local file system path used by the Python scripts. + /// + public static string GetLocalPath(string sUrl, string sLocalPathRoot, DateOnly? dtReference = null) + { + var dtCurrent = dtReference ?? DateOnly.FromDateTime(DateTime.Today); + var sLocalPath = sUrl.Replace("https://trauer.rnz.de", string.Empty, StringComparison.Ordinal) + .Replace("/", "\\", StringComparison.Ordinal); + + var iQueryIndex = sLocalPath.IndexOf('?', StringComparison.Ordinal); + if (iQueryIndex >= 0) + { + sLocalPath = sLocalPath[..iQueryIndex]; + } + + if (sLocalPath.Contains("aktuelle-ausgabe", StringComparison.Ordinal)) + { + sLocalPath = sLocalPath.Replace( + "suche\\aktuelle-ausgabe", + $"suche\\erscheinungstag-{dtCurrent.Day}-{dtCurrent.Month}-{dtCurrent.Year}", + StringComparison.Ordinal); + } + + var iFound = sLocalPath.IndexOf("erscheinungstag", StringComparison.Ordinal); + if (iFound >= 0) + { + sLocalPath = sLocalPath.Replace("\\seite", "-pg", StringComparison.Ordinal); + foreach (var sAnnouncementType in new[] { "nachrufe", "danksagungen", "todesanzeigen", "_" }) + { + sLocalPath = sLocalPath.Replace($"\\anzeigenart-{sAnnouncementType}", $"-{sAnnouncementType[..1]}", StringComparison.Ordinal); + } + + if (sLocalPath.Contains("tag-heute", StringComparison.Ordinal)) + { + sLocalPath = sLocalPath.Replace( + "traueranzeigen-suche\\erscheinungstag", + $"{dtCurrent.Year}\\{dtCurrent:yyyy-MM-dd}\\liste", + StringComparison.Ordinal); + } + else + { + var iStartIndex = Math.Min(sLocalPath.Length, iFound + 16); + var sDateFragment = sLocalPath.Substring(iStartIndex, Math.Min(10, Math.Max(0, sLocalPath.Length - iStartIndex))).LCropStr("\\"); + var arrSplit = sDateFragment.Split('-'); + if (arrSplit.Length >= 3) + { + sLocalPath = sLocalPath.Replace( + "traueranzeigen-suche\\erscheinungstag", + $"{arrSplit[2]}\\{arrSplit[2]}-{arrSplit[1].PadLeft(2, '0')}-{arrSplit[0].PadLeft(2, '0')}\\liste", + StringComparison.Ordinal); + } + } + } + + const string sMarker = "traueranzeige\\"; + iFound = sLocalPath.IndexOf(sMarker, StringComparison.Ordinal); + if (iFound >= 0) + { + var sPathPart = sLocalPath[(iFound + sMarker.Length)..].LCropStr("\\"); + using var xMd5 = MD5.Create(); + var arrDigest = xMd5.ComputeHash(Encoding.UTF8.GetBytes(sPathPart)); + var iCrc = (((int)arrDigest[^2] << 8) | arrDigest[^1]) & 1023; + sLocalPath = sLocalPath.Replace(sMarker, $"{sMarker}{iCrc:X3}\\", StringComparison.Ordinal); + } + + return sLocalPathRoot + sLocalPath; + } + + /// + /// Extracts text from a PDF byte array. + /// + public static string PdfText(byte[] arrBytes) + { + try + { + using var xStream = new MemoryStream(arrBytes); + using var xDocument = PdfDocument.Open(xStream); + var sBuilder = new StringBuilder(); + foreach (var xPage in xDocument.GetPages()) + { + sBuilder.AppendLine(xPage.Text); + } + + return sBuilder.ToString(); + } + catch + { + return string.Empty; + } + } + + /// + /// Converts a supported CLR value into a . + /// + public static JsonNode? ToJsonNode(this object? xValue) => xValue switch + { + null => null, + JsonNode xNode => xNode.DeepClone(), + string sValue => JsonValue.Create(sValue), + bool xValue1 => JsonValue.Create(xValue1), + int iValue => JsonValue.Create(iValue), + long iValue => JsonValue.Create(iValue), + double fValue => JsonValue.Create(fValue), + decimal fValue => JsonValue.Create(fValue), + DateOnly dtValue => JsonValue.Create(dtValue.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + DateTime dtValue => JsonValue.Create(dtValue.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)), + byte[] arrBytes => JsonValue.Create(Convert.ToBase64String(arrBytes)), + IDictionary dValues => ToJsonObject(new Dictionary(dValues, StringComparer.Ordinal)), + IDictionary dValues => ToJsonObject(dValues.ToDictionary(k => k.Key, v => (object?)v.Value)), + IEnumerable> arrItems => ToJsonArray(arrItems.Cast()), + IEnumerable arrItems when xValue is not string => ToJsonArray(arrItems), + _ => JsonSerializer.SerializeToNode(xValue, JsonOptions) + }; + + /// + /// Converts a dictionary into a JSON object. + /// + public static JsonObject ToJsonObject(this IReadOnlyDictionary dValues) + { + var xObject = new JsonObject(); + foreach (var kvValue in dValues) + { + xObject[kvValue.Key] = kvValue.Value.ToJsonNode(); + } + + return xObject; + } + + private static JsonArray ToJsonArray(IEnumerable arrItems) + { + var xArray = new JsonArray(); + foreach (var xItem in arrItems) + { + xArray.Add(xItem.ToJsonNode()); + } + + return xArray; + } + + public static string Capitalize(this string sValue) + { + if (string.IsNullOrEmpty(sValue)) + { + return sValue; + } + + if (sValue.Length == 1) + { + return sValue.ToUpperInvariant(); + } + + return char.ToUpperInvariant(sValue[0]) + sValue[1..].ToLowerInvariant(); + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/WebHandler.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/WebHandler.cs new file mode 100644 index 000000000..78e4ce0be --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/WebHandler.cs @@ -0,0 +1,809 @@ +using System.Globalization; +using System.Text; +using OpenQA.Selenium; + +namespace RnzTrauer.Core; + +/// +/// Provides Selenium-based scraping for the RNZ obituary portal. +/// +public sealed class WebHandler : IDisposable +{ + /// + /// Gets the data payload key used by the ported dictionaries. + /// + public const string CsData = "Data"; + + /// + /// Gets the header payload key used by the ported dictionaries. + /// + public const string CsHeader = "Header"; + + /// + /// Gets the media source key used by the ported dictionaries. + /// + public const string CsSrc = "Src"; + + private const string AnnouncementPrefix = "ANZ"; + private const string DataAttributeName = "data-original"; + private const string HtmlAttrAlt = "alt"; + private const string HtmlAttrClass = "class"; + private const string HtmlAttrHref = "href"; + private const string HtmlAttrId = "id"; + private const string HtmlAttrStyle = "style"; + private const string HtmlAttrTarget = "target"; + private const string HtmlAttrTitle = "title"; + private const string HtmlAttrSrc = "src"; + private const string HtmlClassDetailColumn = "col-sm-6"; + private const string HtmlClassContentColumn = "col-12"; + private const string HtmlClassWideColumn = "col-xl-8"; + private const string HtmlTagA = "a"; + private const string HtmlTagDiv = "div"; + private const string HtmlTagHeading1 = "h1"; + private const string HtmlTagImage = "img"; + private const string HtmlTagSection = "section"; + private const string HtmlTagBody = "body"; + private const string ImageFileJpg = ".jpg"; + private const string ImageFilePng = ".png"; + private const string ImageSignaturePng = "PNG"; + private const string KeyBirth = "Birth"; + private const string KeyContent = "content"; + private const string KeyCreatedBy = "created_by"; + private const string KeyCreatedOn = "created_on"; + private const string KeyDeath = "Death"; + private const string KeyFilter = "filter"; + private const string KeyHref = "href"; + private const string KeyId = "id"; + private const string KeyIdAnz = "id-anz"; + private const string KeyImages = "imgs"; + private const string KeyInfo = "Info"; + private const string KeyLinks = "links"; + private const string KeyMedia = "media"; + private const string KeyName = "name"; + private const string KeyParent = "parent"; + private const string KeyPlace = "Place"; + private const string KeySections = "sections"; + private const string KeyTag = "tag"; + private const string KeyText = "text"; + private const string KeyTitle = "Title"; + private const string KeyUrl = "url"; + private const string KeyVisits = "visits"; + private const string LoginEmailAddressId = "emailAddress"; + private const string LoginFormId = "form"; + private const string LoginPasswordId = "password"; + private const string MediaServerToken = "MEDIASERVER"; + private const string MissingDriverMessage = "The web driver has not been initialized."; + private const string NextLinkMarker = ">"; + private const string PageBlockItemClass = "c-blockitem"; + private const string PagePathAnzeigen = "/anzeigen"; + private const string PagePathAnzeigenArt = "/anzeigenart-"; + private const string PagePathSeparator = "/"; + private const string PageTitleRnz = "RNZ"; + private const string BirthMarker = "*"; + private const string CommaSeparator = ","; + private const string PlaceSeparator = "in"; + private const string SpaceSeparator = " "; + private const string ErrorMarker404 = "404"; + private const string ErrorMarkerNotFound = "not found"; + private const string ErrorMarkerServiceTimeout = "service timeout"; + private const string ErrorMarkerTimeout = "timeout"; + private const string ErrorMarkerServiceUnavailable = "service unavailable"; + private const string ErrorMarkerTemporarilyUnavailable = "temporarily unavailable"; + private const string ErrorMarkerBadGateway = "bad gateway"; + private const string ErrorMarkerGatewayTimeout = "gateway timeout"; + private const string ErrorMarker504GatewayTimeOut = "504 gateway time-out"; + private const string ErrorMarkerServerDidntRespondInTime = "the server didn't respond in time"; + private const string ErrorMarkerServerDidNotRespondInTime = "the server did not respond in time"; + private const string ErrorMarkerRequestTimeout = "request timeout"; + private const string ErrorMarkerConnectionTimeout = "connection timeout"; + private const string ErrorMarkerOperationTimedOut = "operation timed out"; + private const int MaxReloadAttempts = 6; + private const int NavigationWaitCycles = 20; + private const int NavigationWaitMilliseconds = 500; + + // UI strings from the RNZ portal. + private const string UiActionLargeView = "Großansicht"; + private const string UiActionSave = "Speichern"; + private const string UiLabelCreated = "Erstellt"; + private const string UiLabelCreatedOn = "Angelegt"; + private const string UiLabelVisits = "Besuche"; + private const string UiProgressDot = "."; + private const string UiProgressGetSubPages = "\nGet Subpages:"; + + private static readonly string[] AnnouncementTypes = ["nachrufe", "danksagungen", "todesanzeigen", "_"]; + + private static readonly Dictionary _tagAttributes = new(StringComparer.OrdinalIgnoreCase) + { + [HtmlTagSection] = [HtmlAttrClass, HtmlAttrId], + [HtmlTagDiv] = [HtmlAttrClass, HtmlAttrId], + [HtmlTagImage] = [HtmlAttrClass, HtmlAttrTitle, HtmlAttrAlt, CsSrc, HtmlAttrSrc, HtmlAttrStyle, DataAttributeName], + [HtmlTagA] = [HtmlAttrTitle, HtmlAttrTarget, HtmlAttrHref] + }; + + private readonly RnzConfig _config; + private readonly string _sBaseHost; + private readonly IHttpClientProxy _xHttpClient; + private readonly IWebDriverFactory _xWebDriverFactory; + private readonly IProgress? _xProgress; + private readonly HashSet _hsIndexedSubPages = new(StringComparer.Ordinal); + private readonly Dictionary _dIndexedBinaryData = new(StringComparer.Ordinal); + private readonly Dictionary> _dIndexedBinaryHeaders = new(StringComparer.Ordinal); + + /// + /// Initializes a new instance of the class. + /// + public WebHandler(RnzConfig xConfig, IHttpClientProxy xHttpClient, IWebDriverFactory xWebDriverFactory, IProgress? xProgress = null) + { + _config = xConfig; + _sBaseHost = Uri.TryCreate(_config.Url, UriKind.Absolute, out var xUri) + ? xUri.GetLeftPart(UriPartial.Authority) + : string.Empty; + _xHttpClient = xHttpClient ?? throw new ArgumentNullException(nameof(xHttpClient)); + _xWebDriverFactory = xWebDriverFactory ?? throw new ArgumentNullException(nameof(xWebDriverFactory)); + _xProgress = xProgress; + } + + /// + /// Gets the active Firefox driver instance. + /// + public IWebDriver? Driver { get; private set; } + + /// + /// Initializes the RNZ login session. + /// + public void InitPage() + { + try + { + Driver?.Quit(); + } + catch + { + } + + Driver = _xWebDriverFactory.Create(); + Driver.Navigate().GoToUrl(_config.Url); + Driver.FindElement(By.Id(LoginEmailAddressId)).SendKeys(_config.User); + Driver.FindElement(By.Id(LoginPasswordId)).SendKeys(_config.Password); + Driver.FindElement(By.Id(LoginFormId)).Submit(); + while (Driver.Title == _config.Title || string.IsNullOrEmpty(Driver.Title)) + { + Thread.Sleep(500); + } + + Thread.Sleep(500); + } + + /// + /// Recursively captures matching elements into dictionary-based structures. + /// + public List> Wdr2List(ISearchContext xWebElement, WebQuery xQuery) + { + var arrResult = new List>(); + IReadOnlyCollection arrElements; + try + { + arrElements = xWebElement.FindElements(xQuery.By); + } + catch (StaleElementReferenceException) + { + return arrResult; + } + + foreach (var xElement in arrElements) + { + string sTagName; + try + { + sTagName = xElement.TagName; + } + catch (StaleElementReferenceException) + { + continue; + } + + var dItem = new Dictionary + { + [KeyTag] = sTagName + }; + arrResult.Add(dItem); + + if (_tagAttributes.TryGetValue(sTagName, out var arrAttributes)) + { + foreach (var sAttribute in arrAttributes) + { + try + { + dItem[sAttribute] = xElement.GetAttribute(sAttribute) ?? string.Empty; + } + catch + { + dItem[sAttribute] = string.Empty; + } + } + } + + try + { + dItem[KeyText] = (xElement.Text ?? string.Empty).Split(Environment.NewLine, StringSplitOptions.None).ToList(); + } + catch + { + } + try + { + foreach (var xChildQuery in xQuery.Children) + { + dItem[xChildQuery.Name] = Wdr2List(xElement, xChildQuery); + } + } + catch + { + } + } + return arrResult; + } + + /// + /// Loads the configured RNZ pages and associated media. + /// + public (Dictionary> Pages, List> Items) GetData1(string sStart, int iMaxPage = 30) + { + var xDriver = Driver ?? throw new InvalidOperationException(MissingDriverMessage); + var dPages = new Dictionary>(StringComparer.Ordinal); + var arrItems = new List>(); + + foreach (var sAnnouncementType in AnnouncementTypes) + { + if (!NavigateWithReloadRetry($"{sStart}{PagePathAnzeigenArt}{sAnnouncementType}")) + { + continue; + } + + var sNextUrl = UiProgressDot; + var iCounter = 0; + while (!string.IsNullOrEmpty(sNextUrl) && iCounter < iMaxPage) + { + _xProgress?.Report(new WebHandlerProgress(xDriver.Url, true)); + List arrSubPages; + string sUrl; + string sNext; + try + { + (arrSubPages, sUrl, sNext) = WorkMainPage(dPages, arrItems, sAnnouncementType); + } + catch + { + (arrSubPages, sUrl, sNext) = WorkMainPage(dPages, arrItems, sAnnouncementType); + } + sNextUrl = sNext; + + _xProgress?.Report(new WebHandlerProgress(UiProgressGetSubPages)); + foreach (var sSubPage in arrSubPages) + { + var sSubPageUrl = sSubPage + PagePathAnzeigen; + if (_hsIndexedSubPages.Contains(sSubPageUrl)) + { + continue; + } + + if (!NavigateWithReloadRetry(sSubPageUrl)) + { + continue; + } + + WorkSubPage(sUrl, dPages, arrItems); + _hsIndexedSubPages.Add(sSubPageUrl); + } + + _xProgress?.Report(new WebHandlerProgress(string.Empty, true)); + if (!string.IsNullOrEmpty(sNextUrl)) + { + if (!NavigateWithReloadRetry(sNextUrl)) + { + sNextUrl = string.Empty; + } + else + { + while (xDriver.Url == sUrl) + { + Thread.Sleep(NavigationWaitMilliseconds); + _xProgress?.Report(new WebHandlerProgress(UiProgressDot)); + } + } + } + + iCounter++; + } + } + + return (dPages, arrItems); + } + + /// + /// Closes the browser session. + /// + public void Close() + { + Driver?.Quit(); + Driver = null; + } + + /// + public void Dispose() + { + Close(); + _xHttpClient.Dispose(); + } + + private (List SubPages, string Url, string NextUrl) WorkMainPage(Dictionary> dPages, List> arrItems, string sAnnouncementType) + { + var xDriver = Driver ?? throw new InvalidOperationException(MissingDriverMessage); + var sUrl = xDriver.Url; + var dPage = new Dictionary + { + [KeyTitle] = xDriver.Title, + [KeyUrl] = sUrl, + [KeyFilter] = sAnnouncementType, + [KeySections] = new List>(), + [KeyContent] = PortedHelpers.MakeLocal(xDriver.PageSource, sUrl.Length > 50 ? sUrl[..50] + PagePathSeparator : sUrl + PagePathSeparator) + }; + var arrSubPages = new List(); + dPages[sUrl] = dPage; + + var arrElements = Wdr2List(xDriver, new WebQuery(string.Empty, By.ClassName(PageBlockItemClass), new WebQuery(KeyLinks, By.TagName(HtmlTagA)), new WebQuery(KeyImages, By.TagName(HtmlTagImage)))); + dPage[KeySections] = arrElements; + var sNextUrl = string.Empty; + + foreach (var dElement in arrElements) + { + var arrText = GetStringList(dElement, KeyText); + if (arrText.Count > 1 && arrText[0].StartsWith(AnnouncementPrefix, StringComparison.Ordinal)) + { + var dAnnouncement = new Dictionary + { + [KeyTitle] = arrText[0].Length >= 8 ? arrText[0][8..] : arrText[0], + [KeyParent] = sUrl, + [KeyText] = arrText.Cast().ToList() + }; + + if (arrText.Count > 1) + { + dAnnouncement[KeyInfo] = arrText[1]; + } + + var arrLinks = GetDictionaryList(dElement, KeyLinks); + if (arrLinks.Count > 0) + { + var sHref = Convert.ToString(arrLinks[0].GetValueOrDefault(KeyHref), CultureInfo.InvariantCulture) ?? string.Empty; + dAnnouncement[KeyHref] = sHref; + arrSubPages.Add(sHref); + } + + var arrImages = GetDictionaryList(dElement, KeyImages); + if (arrImages.Count > 0) + { + foreach (var dImage in arrImages) + { + var dCopy = new Dictionary(dAnnouncement, StringComparer.Ordinal); + dCopy[CsSrc] = string.Empty; + var sSource = Convert.ToString(dImage.GetValueOrDefault(CsSrc), CultureInfo.InvariantCulture) ?? string.Empty; + if (string.IsNullOrEmpty(sSource)) + { + sSource = Convert.ToString(dImage.GetValueOrDefault(HtmlAttrSrc), CultureInfo.InvariantCulture) ?? string.Empty; + } + + var sDataOriginal = Convert.ToString(dImage.GetValueOrDefault(DataAttributeName), CultureInfo.InvariantCulture) ?? string.Empty; + if (sSource.Contains(MediaServerToken, StringComparison.Ordinal)) + { + dCopy[CsSrc] = sSource; + } + else if (sDataOriginal.Contains(MediaServerToken, StringComparison.Ordinal)) + { + dCopy[CsSrc] = sDataOriginal; + } + + _xProgress?.Report(new WebHandlerProgress(UiProgressDot)); + try + { + var sMediaSource = Convert.ToString(dCopy[CsSrc], CultureInfo.InvariantCulture) ?? string.Empty; + if (!string.IsNullOrEmpty(sMediaSource)) + { + TryLoadBinary(sMediaSource, dCopy); + if (sMediaSource.Contains(MediaServerToken, StringComparison.Ordinal)) + { + dPage[sMediaSource] = new Dictionary + { + [CsHeader] = (Dictionary)dCopy[CsHeader]! + }; + } + } + } + catch + { + dCopy[CsData] = Array.Empty(); + } + + arrItems.Add(dCopy); + } + } + else + { + arrItems.Add(dAnnouncement); + } + } + else if (arrText.Count > 1 && string.Join(SpaceSeparator, arrText).Contains(NextLinkMarker, StringComparison.Ordinal) && string.IsNullOrEmpty(sNextUrl)) + { + var arrLinks = GetDictionaryList(dElement, KeyLinks); + foreach (var dLink in arrLinks) + { + var arrLinkText = GetStringList(dLink, KeyText); + if (arrLinkText.Count == 1 && arrLinkText[0] == NextLinkMarker) + { + sNextUrl = Convert.ToString(dLink.GetValueOrDefault(KeyHref), CultureInfo.InvariantCulture) ?? string.Empty; + } + } + + _xProgress?.Report(new WebHandlerProgress(sNextUrl, true)); + } + } + + return (arrSubPages, sUrl, sNextUrl); + } + + private void WorkSubPage(string sUrl, Dictionary> dPages, List> arrItems) + { + var xDriver = Driver ?? throw new InvalidOperationException(MissingDriverMessage); + var dPage = dPages[sUrl]; + var sSubPageUrl = xDriver.Url; + if (!dPages.TryGetValue(sSubPageUrl, out var dPage2)) + { + dPage2 = new Dictionary + { + [KeyParent] = new List() + }; + dPages[sSubPageUrl] = dPage2; + } + + Dictionary dParentSection = new(StringComparer.Ordinal); + var arrPageSections = GetDictionaryList(dPage, KeySections); + for (var iIndex = 0; iIndex < arrPageSections.Count; iIndex++) + { + var dSection = arrPageSections[iIndex]; + var arrLinks = GetDictionaryList(dSection, KeyLinks); + var sHref = arrLinks.Count > 0 ? Convert.ToString(arrLinks[0].GetValueOrDefault(KeyHref), CultureInfo.InvariantCulture) ?? string.Empty : string.Empty; + if (!string.IsNullOrEmpty(sHref) && sHref + PagePathAnzeigen == sSubPageUrl) + { + dParentSection = dSection; + arrPageSections[iIndex] = dSection; + } + } + + dPage2[KeyTitle] = xDriver.Title; + dPage2[KeyUrl] = sSubPageUrl; + if (dPage2[KeyParent] is not List arrParents) + { + arrParents = new List(); + dPage2[KeyParent] = arrParents; + } + + arrParents.Add(sUrl); + dPage2[KeyContent] = PortedHelpers.MakeLocal(xDriver.PageSource, sSubPageUrl + PagePathSeparator); + var arrMedia = new List(); + dPage2[KeyMedia] = arrMedia; + var arrSections = Wdr2List(xDriver, new WebQuery(string.Empty, By.TagName(HtmlTagSection), new WebQuery(KeyLinks, By.TagName(HtmlTagA)), new WebQuery(KeyImages, By.TagName(HtmlTagImage)))); + dPage2[KeySections] = arrSections; + + foreach (var xElement in xDriver.FindElements(By.TagName(HtmlTagHeading1))) + { + dPage2[KeyName] = xElement.Text; + } + + foreach (var xElement in xDriver.FindElements(By.ClassName(HtmlClassDetailColumn))) + { + if (xElement.Text.StartsWith(BirthMarker, StringComparison.Ordinal)) + { + dPage2[KeyBirth] = xElement.Text; + } + else if (xElement.Text.Contains('†')) + { + var arrParts = xElement.Text.Split(PlaceSeparator, 2, StringSplitOptions.None); + dPage2[KeyDeath] = arrParts[0]; + dPage2[KeyPlace] = arrParts.Length > 1 ? arrParts[1] : string.Empty; + } + } + + _xProgress?.Report(new WebHandlerProgress(UiProgressDot)); + foreach (var dSection in arrSections) + { + var sSectionClass = Convert.ToString(dSection.GetValueOrDefault(HtmlAttrClass), CultureInfo.InvariantCulture) ?? string.Empty; + if (sSectionClass.StartsWith(HtmlClassContentColumn, StringComparison.Ordinal)) + { + foreach (var sLine in GetStringList(dSection, KeyText)) + { + if (sLine.StartsWith(UiLabelCreated, StringComparison.Ordinal)) + { + dPage2[KeyCreatedBy] = sLine.Length > 12 ? sLine[12..] : string.Empty; + } + else if (sLine.StartsWith(UiLabelCreatedOn, StringComparison.Ordinal)) + { + dPage2[KeyCreatedOn] = sLine.Length > 11 ? sLine[11..] : string.Empty; + } + else if (sLine.EndsWith(UiLabelVisits, StringComparison.Ordinal)) + { + dPage2[KeyVisits] = sLine.Length > 7 ? sLine[..^7] : string.Empty; + } + } + + foreach (var dImage in GetDictionaryList(dSection, KeyImages)) + { + var sHref = Convert.ToString(dImage.GetValueOrDefault(CsSrc), CultureInfo.InvariantCulture) ?? string.Empty; + if (string.IsNullOrEmpty(sHref)) + { + sHref = Convert.ToString(dImage.GetValueOrDefault(HtmlAttrSrc), CultureInfo.InvariantCulture) ?? string.Empty; + } + + var sText = Convert.ToString(dImage.GetValueOrDefault(HtmlAttrAlt), CultureInfo.InvariantCulture) ?? string.Empty; + if (sHref.Contains(MediaServerToken, StringComparison.Ordinal) && !arrMedia.Contains(sHref)) + { + arrMedia.Add(sHref); + var dAnnouncement = new Dictionary + { + [KeyParent] = sSubPageUrl, + [CsSrc] = sHref, + [KeyInfo] = sText, + [KeyId] = string.Empty + }; + + TryLoadBinary(sHref, dAnnouncement); + arrItems.Add(dAnnouncement); + } + } + } + else if (!sSectionClass.StartsWith(HtmlClassWideColumn, StringComparison.Ordinal)) + { + foreach (var dImage in GetDictionaryList(dSection, KeyImages)) + { + var sSource = Convert.ToString(dImage.GetValueOrDefault(CsSrc), CultureInfo.InvariantCulture) ?? string.Empty; + if (string.IsNullOrEmpty(sSource)) + { + sSource = Convert.ToString(dImage.GetValueOrDefault(HtmlAttrSrc), CultureInfo.InvariantCulture) ?? string.Empty; + } + + var sDataOriginal = Convert.ToString(dImage.GetValueOrDefault(DataAttributeName), CultureInfo.InvariantCulture) ?? string.Empty; + if (!string.IsNullOrEmpty(sSource) && dPage.ContainsKey(sSource)) + { + dParentSection[KeyIdAnz] = Convert.ToString(dSection.GetValueOrDefault(KeyId), CultureInfo.InvariantCulture) ?? string.Empty; + if (dPage[sSource] is Dictionary dPageMedia) + { + dPageMedia[KeyIdAnz] = dParentSection[KeyIdAnz]; + } + + dSection[KeyFilter] = dPage[KeyFilter]; + } + else if (!string.IsNullOrEmpty(sDataOriginal) && dPage.ContainsKey($"{_sBaseHost}{sDataOriginal}")) + { + dParentSection[KeyIdAnz] = Convert.ToString(dSection.GetValueOrDefault(KeyId), CultureInfo.InvariantCulture) ?? string.Empty; + if (dPage[$"{_sBaseHost}{sDataOriginal}"] is Dictionary dPageMedia) + { + dPageMedia[KeyIdAnz] = dParentSection[KeyIdAnz]; + } + + dSection[KeyFilter] = dPage[KeyFilter]; + } + } + + foreach (var dLink in GetDictionaryList(dSection, KeyLinks)) + { + try + { + var sHref = Convert.ToString(dLink.GetValueOrDefault(KeyHref), CultureInfo.InvariantCulture) ?? string.Empty; + var arrText = GetStringList(dLink, KeyText); + if (sHref.Contains(MediaServerToken, StringComparison.Ordinal) && !arrMedia.Contains(sHref)) + { + arrMedia.Add(sHref); + } + + if ((arrText.Count == 1 && arrText[0] == UiActionSave) || (arrText.Count == 1 && arrText[0] == UiActionLargeView)) + { + var dAnnouncement = new Dictionary + { + [KeyParent] = sSubPageUrl, + [CsSrc] = sHref, + [KeyId] = Convert.ToString(dSection.GetValueOrDefault(KeyId), CultureInfo.InvariantCulture) ?? string.Empty + }; + + if (Equals(dParentSection.GetValueOrDefault(KeyIdAnz), dAnnouncement[KeyId])) + { + var arrImages = GetDictionaryList(dParentSection, KeyImages); + arrImages.Add(new Dictionary(dAnnouncement)); + dParentSection[KeyImages] = arrImages; + } + + TryLoadBinary(sHref, dAnnouncement); + if (sHref.EndsWith(ImageFileJpg, StringComparison.OrdinalIgnoreCase) && dAnnouncement.TryGetValue(CsData, out var xDataObject) && xDataObject is byte[] arrBinary) + { + var sPrefix = Encoding.ASCII.GetString(arrBinary.Take(10).ToArray()); + if (sPrefix.Contains(ImageSignaturePng, StringComparison.Ordinal)) + { + dLink[KeyHref] = sHref.Replace(ImageFileJpg, ImageFilePng, StringComparison.OrdinalIgnoreCase); + } + } + + arrItems.Add(dAnnouncement); + } + } + catch + { + } + } + } + } + } + + private void TryLoadBinary(string sHref, Dictionary dTarget) + { + if (_dIndexedBinaryData.TryGetValue(sHref, out var arrIndexedData)) + { + dTarget[CsData] = arrIndexedData; + dTarget[CsHeader] = _dIndexedBinaryHeaders.TryGetValue(sHref, out var dIndexedHeaders) + ? new Dictionary(dIndexedHeaders, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + return; + } + + try + { + var xResponse = _xHttpClient.GetAsync(sHref).GetAwaiter().GetResult(); + var arrData = xResponse.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + var dHeaders = xResponse.Headers.Concat(xResponse.Content.Headers) + .ToDictionary(h => h.Key, h => string.Join(CommaSeparator, h.Value), StringComparer.OrdinalIgnoreCase); + + dTarget[CsData] = arrData; + dTarget[CsHeader] = dHeaders; + _dIndexedBinaryData[sHref] = arrData; + _dIndexedBinaryHeaders[sHref] = new Dictionary(dHeaders, StringComparer.OrdinalIgnoreCase); + } + catch + { + dTarget[CsData] = Array.Empty(); + dTarget[CsHeader] = new Dictionary(StringComparer.OrdinalIgnoreCase); + _dIndexedBinaryData[sHref] = Array.Empty(); + _dIndexedBinaryHeaders[sHref] = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } + + private static List> GetDictionaryList(IReadOnlyDictionary dValues, string sKey) + { + if (!dValues.TryGetValue(sKey, out var xValue) || xValue is null) + { + return []; + } + + return xValue switch + { + List> arrReady => arrReady, + IEnumerable> arrEnumerable => arrEnumerable.ToList(), + _ => [] + }; + } + + private static List GetStringList(IReadOnlyDictionary dValues, string sKey) + { + if (!dValues.TryGetValue(sKey, out var xValue) || xValue is null) + { + return []; + } + + return xValue switch + { + List arrList => arrList.Select(v => Convert.ToString(v, CultureInfo.InvariantCulture) ?? string.Empty).ToList(), + IEnumerable arrStringList => arrStringList.ToList(), + _ => [] + }; + } + + private bool NavigateWithReloadRetry(string sUrl) + { + var xDriver = Driver ?? throw new InvalidOperationException(MissingDriverMessage); + var iAttempt = 0; + while (iAttempt < MaxReloadAttempts) + { + iAttempt++; + try + { + xDriver.Navigate().GoToUrl(sUrl); + } + catch (WebDriverException) + { + _xProgress.Report(new WebHandlerProgress($"Navigation attempt {iAttempt} to {sUrl} failed with WebDriverException.")); + Thread.Sleep(3000); + continue; + } + WaitForPageLoadCompletion(xDriver); + if (HasMeaningfulPageContent(xDriver)) + { + return true; + } + _xProgress.Report(new WebHandlerProgress("O")); + Thread.Sleep(30000); + } + + return false; + } + + private static void WaitForPageLoadCompletion(IWebDriver xDriver) + { + var iCycle = 0; + while ((xDriver.Title == PageTitleRnz || string.IsNullOrEmpty(xDriver.Title)) && iCycle < NavigationWaitCycles) + { + Thread.Sleep(NavigationWaitMilliseconds); + iCycle++; + } + + Thread.Sleep(NavigationWaitMilliseconds); + } + + private static bool HasMeaningfulPageContent(IWebDriver xDriver) + { + var sTitle = xDriver.Title ?? string.Empty; + var sBodyText = TryGetBodyText(xDriver); + + if (string.IsNullOrWhiteSpace(sTitle) && string.IsNullOrWhiteSpace(sBodyText)) + { + return false; + } + + return !ContainsErrorMarker(sTitle) && !ContainsErrorMarker(sBodyText); + } + + private static string TryGetBodyText(ISearchContext xSearchContext) + { + try + { + var arrBodies = xSearchContext.FindElements(By.TagName(HtmlTagBody)); + foreach (var xBody in arrBodies) + { + try + { + return xBody.Text ?? string.Empty; + } + catch (StaleElementReferenceException) + { + } + } + } + catch (WebDriverException) + { + } + + return string.Empty; + } + + private static bool ContainsErrorMarker(string sText) + { + if (string.IsNullOrWhiteSpace(sText)) + { + return false; + } + + var sNormalized = sText.ToLowerInvariant() + .Replace('’', '\'') + .Replace('‑', '-') + .Replace('–', '-'); + + return sNormalized.Contains(ErrorMarker404, StringComparison.Ordinal) && sNormalized.Length < 500 + || sNormalized.Contains(ErrorMarkerNotFound, StringComparison.Ordinal) + || sNormalized.Contains(ErrorMarkerServiceTimeout, StringComparison.Ordinal) + || sNormalized.Contains(ErrorMarkerServiceUnavailable, StringComparison.Ordinal) + || sNormalized.Contains(ErrorMarkerTemporarilyUnavailable, StringComparison.Ordinal) + || sNormalized.Contains(ErrorMarkerBadGateway, StringComparison.Ordinal) + || sNormalized.Contains(ErrorMarkerGatewayTimeout, StringComparison.Ordinal) + || sNormalized.Contains(ErrorMarker504GatewayTimeOut, StringComparison.Ordinal) + || sNormalized.Contains(ErrorMarkerServerDidntRespondInTime, StringComparison.Ordinal) + || sNormalized.Contains(ErrorMarkerServerDidNotRespondInTime, StringComparison.Ordinal) + || sNormalized.Contains(ErrorMarkerRequestTimeout, StringComparison.Ordinal) + || sNormalized.Contains(ErrorMarkerConnectionTimeout, StringComparison.Ordinal) + || sNormalized.Contains(ErrorMarkerOperationTimedOut, StringComparison.Ordinal); + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/WebHandlerProgress.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/WebHandlerProgress.cs new file mode 100644 index 000000000..1044ea18b --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Core/Services/WebHandlerProgress.cs @@ -0,0 +1,6 @@ +namespace RnzTrauer.Core; + +/// +/// Describes a progress update emitted by . +/// +public sealed record WebHandlerProgress(string Text, bool WriteLine = false); diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/ConfigLoaderTests.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/ConfigLoaderTests.cs new file mode 100644 index 000000000..1d6ef2f42 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/ConfigLoaderTests.cs @@ -0,0 +1,136 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NSubstitute; +using RnzTrauer.Core; + +namespace RnzTrauer.Tests; + +[TestClass] +public sealed class ConfigLoaderTests +{ + [TestMethod] + public void Load_Uses_File_Abstraction_And_Deserializes_Content() + { + var xFile = Substitute.For(); + xFile.Exists("config.json").Returns(true); + xFile.ReadAllText("config.json").Returns( + """ + { + "name": "RNZ", + "count": 5 + } + """); + var xLoader = new ConfigLoader(xFile); + + var xResult = xLoader.Load("config.json"); + + Assert.IsNotNull(xResult); + Assert.AreEqual("RNZ", xResult.Name); + Assert.AreEqual(5, xResult.Count); + _ = xFile.Received(1).Exists("config.json"); + _ = xFile.Received(1).ReadAllText("config.json"); + } + + [TestMethod] + public void Load_Throws_When_File_Does_Not_Exist() + { + var xFile = Substitute.For(); + xFile.Exists("missing.json").Returns(false); + var xLoader = new ConfigLoader(xFile); + + try + { + _ = xLoader.Load("missing.json"); + Assert.Fail("Expected FileNotFoundException was not thrown."); + } + catch (FileNotFoundException xException) + { + StringAssert.Contains(xException.Message, "missing.json"); + } + } + + [TestMethod] + public void FileProxy_Delegates_To_File_Class() + { + var sPath = Path.GetTempFileName(); + var sTextPath = Path.ChangeExtension(sPath, ".txt"); + var sBytesPath = Path.ChangeExtension(sPath, ".bin"); + + try + { + File.WriteAllText(sPath, "proxy-test"); + var xProxy = new FileProxy(); + + Assert.IsTrue(xProxy.Exists(sPath)); + Assert.AreEqual("proxy-test", xProxy.ReadAllText(sPath)); + CollectionAssert.AreEqual(File.ReadAllBytes(sPath), xProxy.ReadAllBytes(sPath)); + + xProxy.WriteAllText(sTextPath, "written-text"); + xProxy.WriteAllBytes(sBytesPath, [1, 2, 3]); + + Assert.AreEqual("written-text", File.ReadAllText(sTextPath)); + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, File.ReadAllBytes(sBytesPath)); + } + finally + { + if (File.Exists(sPath)) + { + File.Delete(sPath); + } + + if (File.Exists(sTextPath)) + { + File.Delete(sTextPath); + } + + if (File.Exists(sBytesPath)) + { + File.Delete(sBytesPath); + } + } + } + + [TestMethod] + public void RnzConfig_Load_Uses_Injected_ConfigLoader() + { + var xConfigLoader = Substitute.For(); + var xExpected = new RnzConfig + { + Url = "https://example.invalid", + Title = "RNZ" + }; + xConfigLoader.Load("rnz.json").Returns(xExpected); + + var xConfig = new RnzConfig(xConfigLoader); + + var xResult = xConfig.Load("rnz.json"); + + Assert.AreSame(xExpected, xResult); + _ = xConfigLoader.Received(1).Load("rnz.json"); + } + + [TestMethod] + public void AmtsblattConfig_Load_Uses_Injected_ConfigLoader() + { + var xConfigLoader = Substitute.For(); + var xExpected = new AmtsblattConfig + { + Url = "https://example.invalid", + Title = "Amtsblatt" + }; + xConfigLoader.Load("amtsblatt.json").Returns(xExpected); + + var xConfig = new AmtsblattConfig(xConfigLoader); + + var xResult = xConfig.Load("amtsblatt.json"); + + Assert.AreSame(xExpected, xResult); + _ = xConfigLoader.Received(1).Load("amtsblatt.json"); + } + + private sealed class TestConfig + { + public string Name { get; set; } = string.Empty; + + public int Count { get; set; } + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/PortedHelpersAdditionalTests.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/PortedHelpersAdditionalTests.cs new file mode 100644 index 000000000..b26e0890a --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/PortedHelpersAdditionalTests.cs @@ -0,0 +1,129 @@ +using System.Text.Json.Nodes; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using RnzTrauer.Core; + +namespace RnzTrauer.Tests; + +[TestClass] +public sealed class PortedHelpersAdditionalTests +{ + [TestMethod] + public void Str2Date_Returns_Date_For_Valid_Input() + { + var dtResult = PortedHelpers.Str2Date("24.12.2024"); + + Assert.AreEqual(new DateOnly(2024, 12, 24), dtResult); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("24-12-2024")] + [DataRow("24.12")] + [DataRow("31.02.2024")] + [DataRow("aa.bb.cccc")] + public void Str2Date_Returns_Null_For_Invalid_Input(string? sValue) + { + Assert.IsNull(PortedHelpers.Str2Date(sValue)); + } + + [TestMethod] + public void Cond_Returns_Trimmed_String_For_Existing_Key() + { + IReadOnlyDictionary dValues = new Dictionary + { + ["name"] = " RNZ " + }; + + Assert.AreEqual("RNZ", dValues.Cond("name")); + } + + [TestMethod] + public void Cond_Returns_Empty_String_For_Missing_Or_Null_Value() + { + IReadOnlyDictionary dValues = new Dictionary + { + ["name"] = null + }; + + Assert.AreEqual(string.Empty, dValues.Cond("missing")); + Assert.AreEqual(string.Empty, dValues.Cond("name")); + } + + [DataTestMethod] + [DataRow("", "")] + [DataRow("a", "A")] + [DataRow("äPFEL", "Äpfel")] + [DataRow("rnZ", "Rnz")] + public void Capitalize_Normalizes_Text(string sValue, string sExpected) + { + Assert.AreEqual(sExpected, sValue.Capitalize()); + } + + [TestMethod] + public void ToJsonNode_Creates_Deep_Clone_For_JsonNode() + { + var xOriginal = JsonNode.Parse("""{"name":"RNZ"}""")!; + + var xClone = xOriginal.ToJsonNode(); + xClone!["name"] = "Changed"; + + Assert.AreEqual("RNZ", xOriginal["name"]!.ToString()); + Assert.AreEqual("Changed", xClone["name"]!.ToString()); + } + + [TestMethod] + public void ToJsonNode_Converts_DateOnly_And_ByteArray() + { + var xDateNode = new DateOnly(2024, 12, 24).ToJsonNode(); + var xBytesNode = new byte[] { 1, 2, 3 }.ToJsonNode(); + + Assert.AreEqual("2024-12-24", xDateNode!.ToString()); + Assert.AreEqual("AQID", xBytesNode!.ToString()); + } + + [TestMethod] + public void ToJsonNode_Converts_Dictionary_And_Array() + { + var xObjectNode = new Dictionary + { + ["name"] = "RNZ", + ["count"] = 2 + }.ToJsonNode(); + var xArrayNode = new object?[] { "RNZ", 2, true }.ToJsonNode(); + + Assert.IsInstanceOfType(xObjectNode); + Assert.IsInstanceOfType(xArrayNode); + Assert.AreEqual("RNZ", xObjectNode!["name"]!.ToString()); + Assert.AreEqual("2", xObjectNode["count"]!.ToString()); + Assert.AreEqual("RNZ", xArrayNode![0]!.ToString()); + Assert.AreEqual("2", xArrayNode[1]!.ToString()); + Assert.AreEqual("true", xArrayNode[2]!.ToString()); + } + + [TestMethod] + public void ToJsonObject_Converts_Dictionary_Entries() + { + IReadOnlyDictionary dValues = new Dictionary + { + ["name"] = "RNZ", + ["items"] = new object?[] { 1, "two" } + }; + + var xObject = dValues.ToJsonObject(); + + Assert.AreEqual("RNZ", xObject["name"]!.ToString()); + Assert.IsInstanceOfType(xObject["items"]); + Assert.AreEqual("1", xObject["items"]![0]!.ToString()); + Assert.AreEqual("two", xObject["items"]![1]!.ToString()); + } + + [TestMethod] + public void PdfText_Returns_Empty_String_For_Invalid_Pdf() + { + var sResult = PortedHelpers.PdfText([1, 2, 3, 4]); + + Assert.AreEqual(string.Empty, sResult); + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/PortedHelpersTests.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/PortedHelpersTests.cs new file mode 100644 index 000000000..1f9fcb9c0 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/PortedHelpersTests.cs @@ -0,0 +1,62 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using RnzTrauer.Core; + +namespace RnzTrauer.Tests; + +[TestClass] +public sealed class PortedHelpersTests +{ + private const string LocalRoot = "\\\\Diskstation\\Daten\\dokumente\\HTML\\trauer.rnz.de"; + + [TestMethod] + public void LCropStr_Returns_Left_Part() + { + Assert.AreEqual("123", "1234567".LCropStr("4")); + } + + [DataTestMethod] + [DataRow("A B", "B", "A")] + [DataRow("aa bb", "Bb", "Aa")] + [DataRow(" a b c ", "C", "A B")] + [DataRow(" aü b-c ", "B-C", "Aü")] + [DataRow("üa bö-cä", "Bö-Cä", "Üa")] + [DataRow("üa öb-äc", "Öb-Äc", "Üa")] + [DataRow(" a von c ", "von C", "A")] + [DataRow(" aa bb-cc ", "Bb-Cc", "Aa")] + public void SplitName_Matches_Python_Behaviour(string input, string expectedLastName, string expectedFirstName) + { + var (lastName, firstName) = input.SplitName(); + Assert.AreEqual(expectedLastName, lastName); + Assert.AreEqual(expectedFirstName, firstName); + } + + [DataTestMethod] + [DataRow("https://trauer.rnz.de/MEDIASERVER/content/LH230/obi_new/2022_11/waltraud-hacker-traueranzeige-697fddce-93c0-4b07-af61-bb046a47cbba.jpg", "\\\\Diskstation\\Daten\\dokumente\\HTML\\trauer.rnz.de\\MEDIASERVER\\content\\LH230\\obi_new\\2022_11\\waltraud-hacker-traueranzeige-697fddce-93c0-4b07-af61-bb046a47cbba.jpg")] + [DataRow("https://trauer.rnz.de/traueranzeigen-suche/erscheinungstag-01-10-2021", "\\\\Diskstation\\Daten\\dokumente\\HTML\\trauer.rnz.de\\2021\\2021-10-01\\liste-01-10-2021")] + [DataRow("https://trauer.rnz.de/traueranzeigen-suche/erscheinungstag-01-10-2021/seite-3", "\\\\Diskstation\\Daten\\dokumente\\HTML\\trauer.rnz.de\\2021\\2021-10-01\\liste-01-10-2021-pg-3")] + [DataRow("https://trauer.rnz.de/traueranzeigen-suche/erscheinungstag-3-8-2021/anzeigenart-danksagungen/seite-3", "\\\\Diskstation\\Daten\\dokumente\\HTML\\trauer.rnz.de\\2021\\2021-08-03\\liste-3-8-2021-d-pg-3")] + [DataRow("https://trauer.rnz.de/traueranzeige/aktuelle-ausgabe?code=934598745894569734506987456304567", "\\\\Diskstation\\Daten\\dokumente\\HTML\\trauer.rnz.de\\traueranzeige\\22D\\aktuelle-ausgabe")] + public void GetLocalPath_Matches_Known_Paths(string url, string expected) + { + var actual = PortedHelpers.GetLocalPath(url, LocalRoot, new DateOnly(2024, 12, 24)); + Assert.AreEqual(expected, actual); + } + + [TestMethod] + public void GetLocalPath_Handles_AktuelleAusgabe_And_Heute() + { + var day = new DateOnly(2024, 12, 24); + Assert.AreEqual($"{LocalRoot}\\2024\\2024-12-24\\liste-24-12-2024", PortedHelpers.GetLocalPath("https://trauer.rnz.de/traueranzeigen-suche/aktuelle-ausgabe", LocalRoot, day)); + Assert.AreEqual($"{LocalRoot}\\2024\\2024-12-24\\liste-heute-pg-2", PortedHelpers.GetLocalPath("https://trauer.rnz.de/traueranzeigen-suche/erscheinungstag-heute/seite-2", LocalRoot, day)); + } + + [DataTestMethod] + [DataRow("", "https://www.jc99.de/test/text2", "")] + [DataRow("", "https://www.jc99.de/test/test/text2", "")] + [DataRow("", "https://www.jc99.de/test/text2", "")] + [DataRow("", "https://www.jc99.de/test/test/text2", "")] + public void MakeLocal_Matches_Python_Behaviour(string source, string reference, string expected) + { + Assert.AreEqual(expected, PortedHelpers.MakeLocal(source, reference)); + } +} diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/RnzTrauer.Tests.csproj b/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/RnzTrauer.Tests.csproj new file mode 100644 index 000000000..b93c939a9 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/RnzTrauer.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + \ No newline at end of file diff --git a/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/WebHandlerTests.cs b/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/WebHandlerTests.cs new file mode 100644 index 000000000..2473b1ee6 --- /dev/null +++ b/WinAhnenNew/RnzTrauer/RnzTrauer.Tests/WebHandlerTests.cs @@ -0,0 +1,574 @@ +using System.Collections.ObjectModel; +using System.Net; +using System.Net.Http.Headers; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NSubstitute; +using OpenQA.Selenium; +using RnzTrauer.Core; + +namespace RnzTrauer.Tests; + +[TestClass] +public sealed class WebHandlerTests +{ + [TestMethod] + public void InitPage_Uses_Injected_DriverFactory_And_Submits_Login() + { + var xConfig = new RnzConfig + { + Url = "https://example.invalid/login", + User = "user@example.invalid", + Password = "secret", + Title = "RNZ" + }; + var xHttpClient = Substitute.For(); + var xWebDriverFactory = Substitute.For(); + var xDriver = Substitute.For(); + var xNavigation = Substitute.For(); + var xEmailElement = Substitute.For(); + var xPasswordElement = Substitute.For(); + var xFormElement = Substitute.For(); + + xDriver.Navigate().Returns(xNavigation); + xDriver.Title.Returns("Loaded"); + xDriver.FindElement(Arg.Is(xBy => xBy.ToString() == By.Id("emailAddress").ToString())).Returns(xEmailElement); + xDriver.FindElement(Arg.Is(xBy => xBy.ToString() == By.Id("password").ToString())).Returns(xPasswordElement); + xDriver.FindElement(Arg.Is(xBy => xBy.ToString() == By.Id("form").ToString())).Returns(xFormElement); + xWebDriverFactory.Create().Returns(xDriver); + + using var xHandler = new WebHandler(xConfig, xHttpClient, xWebDriverFactory); + + xHandler.InitPage(); + + _ = xWebDriverFactory.Received(1).Create(); + xNavigation.Received(1).GoToUrl(xConfig.Url); + xEmailElement.Received(1).SendKeys(xConfig.User); + xPasswordElement.Received(1).SendKeys(xConfig.Password); + xFormElement.Received(1).Submit(); + Assert.AreSame(xDriver, xHandler.Driver); + } + + [TestMethod] + public void Wdr2List_Reads_Attributes_Text_And_Children() + { + var xConfig = new RnzConfig(); + var xHttpClient = Substitute.For(); + var xWebDriverFactory = Substitute.For(); + var xSearchContext = Substitute.For(); + var xParentElement = Substitute.For(); + var xChildElement = Substitute.For(); + + xSearchContext.FindElements(Arg.Is(xBy => xBy.ToString() == By.TagName("div").ToString())) + .Returns(CreateCollection(xParentElement)); + xParentElement.TagName.Returns("div"); + xParentElement.Text.Returns($"Line1{Environment.NewLine}Line2"); + xParentElement.GetAttribute("class").Returns("c-blockitem"); + xParentElement.GetAttribute("id").Returns(_ => throw new InvalidOperationException()); + xParentElement.FindElements(Arg.Is(xBy => xBy.ToString() == By.TagName("a").ToString())) + .Returns(CreateCollection(xChildElement)); + + xChildElement.TagName.Returns("a"); + xChildElement.Text.Returns("Read more"); + xChildElement.GetAttribute("title").Returns("title"); + xChildElement.GetAttribute("target").Returns("_blank"); + xChildElement.GetAttribute("href").Returns("https://example.invalid/item"); + xChildElement.FindElements(Arg.Any()).Returns(CreateCollection()); + + using var xHandler = new WebHandler(xConfig, xHttpClient, xWebDriverFactory); + + var lstResult = xHandler.Wdr2List(xSearchContext, new WebQuery(string.Empty, By.TagName("div"), new WebQuery("links", By.TagName("a")))); + + Assert.AreEqual(1, lstResult.Count); + Assert.AreEqual("div", lstResult[0]["tag"]); + Assert.AreEqual("c-blockitem", lstResult[0]["class"]); + Assert.AreEqual(string.Empty, lstResult[0]["id"]); + CollectionAssert.AreEqual(new object?[] { "Line1", "Line2" }, (System.Collections.ICollection)lstResult[0]["text"]!); + var lstLinks = (List>)lstResult[0]["links"]!; + Assert.AreEqual(1, lstLinks.Count); + Assert.AreEqual("a", lstLinks[0]["tag"]); + Assert.AreEqual("https://example.invalid/item", lstLinks[0]["href"]); + } + + [TestMethod] + public void GetData1_Throws_When_Driver_Has_Not_Been_Initialized() + { + using var xHandler = new WebHandler(new RnzConfig(), Substitute.For(), Substitute.For()); + + try + { + _ = xHandler.GetData1("https://example.invalid/start"); + Assert.Fail("Expected InvalidOperationException was not thrown."); + } + catch (InvalidOperationException xException) + { + StringAssert.Contains(xException.Message, "initialized"); + } + } + + [TestMethod] + public void GetData1_Loads_Announcement_Media_Using_Injected_Dependencies() + { + var xConfig = new RnzConfig + { + Url = "https://example.invalid/login", + User = "user@example.invalid", + Password = "secret", + Title = "RNZ" + }; + var xHttpClient = Substitute.For(); + var xWebDriverFactory = Substitute.For(); + var xProgress = Substitute.For>(); + var xDriver = Substitute.For(); + var xNavigation = Substitute.For(); + var xEmailElement = Substitute.For(); + var xPasswordElement = Substitute.For(); + var xFormElement = Substitute.For(); + var xAnnouncementElement = Substitute.For(); + var xImageElement = Substitute.For(); + var sCurrentUrl = string.Empty; + + xNavigation.When(xNav => xNav.GoToUrl(Arg.Any())) + .Do(xCall => sCurrentUrl = xCall.Arg()); + xDriver.Navigate().Returns(xNavigation); + xDriver.Url.Returns(_ => sCurrentUrl); + xDriver.Title.Returns("Loaded"); + xDriver.FindElement(Arg.Is(xBy => xBy.ToString() == By.Id("emailAddress").ToString())).Returns(xEmailElement); + xDriver.FindElement(Arg.Is(xBy => xBy.ToString() == By.Id("password").ToString())).Returns(xPasswordElement); + xDriver.FindElement(Arg.Is(xBy => xBy.ToString() == By.Id("form").ToString())).Returns(xFormElement); + xDriver.FindElements(Arg.Any()).Returns(xCall => + { + var xBy = xCall.Arg(); + if (xBy.ToString() == By.ClassName("c-blockitem").ToString()) + { + return CreateCollection(xAnnouncementElement); + } + + return CreateCollection(); + }); + xWebDriverFactory.Create().Returns(xDriver); + + xAnnouncementElement.TagName.Returns("div"); + xAnnouncementElement.Text.Returns($"ANZ 12345678{Environment.NewLine}Info"); + xAnnouncementElement.GetAttribute("class").Returns("c-blockitem"); + xAnnouncementElement.GetAttribute("id").Returns("item-1"); + xAnnouncementElement.FindElements(Arg.Is(xBy => xBy.ToString() == By.TagName("a").ToString())).Returns(CreateCollection()); + xAnnouncementElement.FindElements(Arg.Is(xBy => xBy.ToString() == By.TagName("img").ToString())).Returns(CreateCollection(xImageElement)); + + xImageElement.TagName.Returns("img"); + xImageElement.Text.Returns(string.Empty); + xImageElement.GetAttribute("class").Returns(string.Empty); + xImageElement.GetAttribute("title").Returns(string.Empty); + xImageElement.GetAttribute("alt").Returns("image alt"); + xImageElement.GetAttribute("src").Returns("https://trauer.rnz.de/MEDIASERVER/image.jpg"); + xImageElement.GetAttribute("style").Returns(string.Empty); + xImageElement.GetAttribute("data-original").Returns(string.Empty); + xImageElement.FindElements(Arg.Any()).Returns(CreateCollection()); + + var xResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(Encoding.ASCII.GetBytes("PNG1234567")) + }; + xResponse.Headers.Add("X-Test", "1"); + xResponse.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + xHttpClient.GetAsync("https://trauer.rnz.de/MEDIASERVER/image.jpg").Returns(Task.FromResult(xResponse)); + + using var xHandler = new WebHandler(xConfig, xHttpClient, xWebDriverFactory, xProgress); + xHandler.InitPage(); + + var (dPages, lstItems) = xHandler.GetData1("https://example.invalid/start", 1); + + Assert.AreEqual(4, dPages.Count); + Assert.AreEqual(4, lstItems.Count); + Assert.IsTrue(lstItems.All(dItem => dItem.ContainsKey(WebHandler.CsData))); + Assert.IsTrue(lstItems.All(dItem => dItem.ContainsKey(WebHandler.CsHeader))); + Assert.IsTrue(dPages.Values.All(dPage => dPage.ContainsKey("https://trauer.rnz.de/MEDIASERVER/image.jpg"))); + _ = xHttpClient.Received(1).GetAsync("https://trauer.rnz.de/MEDIASERVER/image.jpg"); + xProgress.Received().Report(new WebHandlerProgress(".")); + } + + [TestMethod] + public void TryLoadBinary_Loads_Data_And_Headers_Via_Reflection() + { + var xHttpClient = Substitute.For(); + var xResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([1, 2, 3]) + }; + xResponse.Headers.Add("X-Test", "1"); + xResponse.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + xHttpClient.GetAsync("https://example.invalid/data.bin").Returns(Task.FromResult(xResponse)); + using var xHandler = CreateHandler(xHttpClient: xHttpClient); + var dTarget = new Dictionary(); + + InvokeNonPublicInstanceMethod(xHandler, "TryLoadBinary", "https://example.invalid/data.bin", dTarget); + + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, (byte[])dTarget[WebHandler.CsData]!); + Assert.IsInstanceOfType>(dTarget[WebHandler.CsHeader]); + var dHeaders = (Dictionary)dTarget[WebHandler.CsHeader]!; + Assert.AreEqual("1", dHeaders["X-Test"]); + } + + [TestMethod] + public void TryLoadBinary_Sets_Empty_Result_On_Failure_Via_Reflection() + { + var xHttpClient = Substitute.For(); + xHttpClient.GetAsync("https://example.invalid/data.bin").Returns>(_ => throw new HttpRequestException("failed")); + using var xHandler = CreateHandler(xHttpClient: xHttpClient); + var dTarget = new Dictionary(); + + InvokeNonPublicInstanceMethod(xHandler, "TryLoadBinary", "https://example.invalid/data.bin", dTarget); + + CollectionAssert.AreEqual(Array.Empty(), (byte[])dTarget[WebHandler.CsData]!); + Assert.IsInstanceOfType>(dTarget[WebHandler.CsHeader]); + Assert.AreEqual(0, ((Dictionary)dTarget[WebHandler.CsHeader]!).Count); + } + + [TestMethod] + public void GetDictionaryList_Returns_Private_List_Result_Via_Reflection() + { + IReadOnlyDictionary dValues = new Dictionary + { + ["links"] = new List>() + { + new(StringComparer.Ordinal) { ["href"] = "https://example.invalid" } + } + }; + + var lstResult = InvokeNonPublicStaticMethod>>(typeof(WebHandler), "GetDictionaryList", dValues, "links"); + var lstEmpty = InvokeNonPublicStaticMethod>>(typeof(WebHandler), "GetDictionaryList", dValues, "missing"); + + Assert.AreEqual(1, lstResult.Count); + Assert.AreEqual("https://example.invalid", lstResult[0]["href"]); + Assert.AreEqual(0, lstEmpty.Count); + } + + [TestMethod] + public void GetStringList_Returns_Private_List_Result_Via_Reflection() + { + IReadOnlyDictionary dValues = new Dictionary + { + ["text"] = new List { "Line1", 2, null } + }; + + var lstResult = InvokeNonPublicStaticMethod>(typeof(WebHandler), "GetStringList", dValues, "text"); + var lstEmpty = InvokeNonPublicStaticMethod>(typeof(WebHandler), "GetStringList", dValues, "missing"); + + CollectionAssert.AreEqual(new[] { "Line1", "2", string.Empty }, lstResult); + Assert.AreEqual(0, lstEmpty.Count); + } + + [TestMethod] + public void WorkMainPage_Returns_Page_SubPages_And_NextUrl_Via_Reflection() + { + var xDriver = Substitute.For(); + var xAnnouncementElement = Substitute.For(); + var xAnnouncementLink = Substitute.For(); + var xPagerElement = Substitute.For(); + var xNextLink = Substitute.For(); + using var xHandler = CreateHandler(); + SetDriver(xHandler, xDriver); + + xDriver.Url.Returns("https://example.invalid/list"); + xDriver.Title.Returns("Loaded"); + xDriver.PageSource.Returns(""); + xDriver.FindElements(Arg.Is(xBy => xBy.ToString() == By.ClassName("c-blockitem").ToString())) + .Returns(CreateCollection(xAnnouncementElement, xPagerElement)); + + xAnnouncementElement.TagName.Returns("div"); + xAnnouncementElement.Text.Returns($"ANZ 12345678{Environment.NewLine}Info"); + xAnnouncementElement.GetAttribute("class").Returns("c-blockitem"); + xAnnouncementElement.GetAttribute("id").Returns("ann-1"); + xAnnouncementElement.FindElements(Arg.Is(xBy => xBy.ToString() == By.TagName("a").ToString())).Returns(CreateCollection(xAnnouncementLink)); + xAnnouncementElement.FindElements(Arg.Is(xBy => xBy.ToString() == By.TagName("img").ToString())).Returns(CreateCollection()); + + xAnnouncementLink.TagName.Returns("a"); + xAnnouncementLink.Text.Returns(string.Empty); + xAnnouncementLink.GetAttribute("title").Returns(string.Empty); + xAnnouncementLink.GetAttribute("target").Returns(string.Empty); + xAnnouncementLink.GetAttribute("href").Returns("https://example.invalid/subpage"); + xAnnouncementLink.FindElements(Arg.Any()).Returns(CreateCollection()); + + xPagerElement.TagName.Returns("div"); + xPagerElement.Text.Returns($"Page{Environment.NewLine}>\n"); + xPagerElement.GetAttribute("class").Returns("c-blockitem"); + xPagerElement.GetAttribute("id").Returns("pager-1"); + xPagerElement.FindElements(Arg.Is(xBy => xBy.ToString() == By.TagName("a").ToString())).Returns(CreateCollection(xNextLink)); + xPagerElement.FindElements(Arg.Is(xBy => xBy.ToString() == By.TagName("img").ToString())).Returns(CreateCollection()); + + xNextLink.TagName.Returns("a"); + xNextLink.Text.Returns(">"); + xNextLink.GetAttribute("title").Returns(string.Empty); + xNextLink.GetAttribute("target").Returns(string.Empty); + xNextLink.GetAttribute("href").Returns("https://example.invalid/list?page=2"); + xNextLink.FindElements(Arg.Any()).Returns(CreateCollection()); + + var dPages = new Dictionary>(StringComparer.Ordinal); + var lstItems = new List>(); + + var tResult = InvokeNonPublicInstanceMethod<(List SubPages, string Url, string NextUrl)>(xHandler, "WorkMainPage", dPages, lstItems, "nachrufe"); + + Assert.AreEqual("https://example.invalid/list", tResult.Url); + Assert.AreEqual("https://example.invalid/list?page=2", tResult.NextUrl); + CollectionAssert.AreEqual(new[] { "https://example.invalid/subpage" }, tResult.SubPages); + Assert.AreEqual(1, lstItems.Count); + Assert.AreEqual("5678", lstItems[0]["Title"]); + Assert.AreEqual("Info", lstItems[0]["Info"]); + Assert.AreEqual("nachrufe", dPages[tResult.Url]["filter"]); + } + + [TestMethod] + public void WorkSubPage_Extracts_Metadata_And_Media_Via_Reflection() + { + var xHttpClient = Substitute.For(); + var xDriver = Substitute.For(); + var xTitleElement = Substitute.For(); + var xBirthElement = Substitute.For(); + var xDeathElement = Substitute.For(); + var xInfoSection = Substitute.For(); + var xMediaImage = Substitute.For(); + var xResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(Encoding.ASCII.GetBytes("PNG1234567")) + }; + xHttpClient.GetAsync("https://trauer.rnz.de/MEDIASERVER/profile.jpg").Returns(Task.FromResult(xResponse)); + using var xHandler = CreateHandler(xHttpClient: xHttpClient); + SetDriver(xHandler, xDriver); + + xDriver.Url.Returns("https://example.invalid/subpage/anzeigen"); + xDriver.Title.Returns("SubPage"); + xDriver.PageSource.Returns("sub"); + xDriver.FindElements(Arg.Is(xBy => xBy.ToString() == By.TagName("h1").ToString())).Returns(CreateCollection(xTitleElement)); + xDriver.FindElements(Arg.Is(xBy => xBy.ToString() == By.ClassName("col-sm-6").ToString())).Returns(CreateCollection(xBirthElement, xDeathElement)); + xDriver.FindElements(Arg.Is(xBy => xBy.ToString() == By.TagName("section").ToString())).Returns(CreateCollection(xInfoSection)); + + xTitleElement.Text.Returns("Max Mustermann"); + xBirthElement.Text.Returns("* 01.01.2000"); + xDeathElement.Text.Returns("† 02.02.2020in Heidelberg"); + + xInfoSection.TagName.Returns("section"); + xInfoSection.Text.Returns($"Erstellt von Test{Environment.NewLine}Angelegt am Heute{Environment.NewLine}42 Besuche"); + xInfoSection.GetAttribute("class").Returns("col-12"); + xInfoSection.GetAttribute("id").Returns("sec-1"); + xInfoSection.FindElements(Arg.Is(xBy => xBy.ToString() == By.TagName("a").ToString())).Returns(CreateCollection()); + xInfoSection.FindElements(Arg.Is(xBy => xBy.ToString() == By.TagName("img").ToString())).Returns(CreateCollection(xMediaImage)); + + xMediaImage.TagName.Returns("img"); + xMediaImage.Text.Returns(string.Empty); + xMediaImage.GetAttribute("class").Returns(string.Empty); + xMediaImage.GetAttribute("title").Returns(string.Empty); + xMediaImage.GetAttribute("alt").Returns("Profile image"); + xMediaImage.GetAttribute("src").Returns("https://trauer.rnz.de/MEDIASERVER/profile.jpg"); + xMediaImage.GetAttribute("style").Returns(string.Empty); + xMediaImage.GetAttribute("data-original").Returns(string.Empty); + xMediaImage.FindElements(Arg.Any()).Returns(CreateCollection()); + + var dPages = new Dictionary>(StringComparer.Ordinal) + { + ["https://example.invalid/list"] = new Dictionary(StringComparer.Ordinal) + { + ["filter"] = "nachrufe", + ["sections"] = new List>() + { + new(StringComparer.Ordinal) + { + ["links"] = new List>() + { + new(StringComparer.Ordinal) { ["href"] = "https://example.invalid/subpage" } + }, + ["imgs"] = new List>() + } + } + } + }; + var lstItems = new List>(); + + InvokeNonPublicInstanceMethod(xHandler, "WorkSubPage", "https://example.invalid/list", dPages, lstItems); + + var dSubPage = dPages["https://example.invalid/subpage/anzeigen"]; + Assert.AreEqual("SubPage", dSubPage["Title"]); + Assert.AreEqual("Max Mustermann", dSubPage["name"]); + Assert.AreEqual("* 01.01.2000", dSubPage["Birth"]); + Assert.AreEqual("† 02.02.2020", dSubPage["Death"]); + Assert.AreEqual(" Heidelberg", dSubPage["Place"]); + Assert.AreEqual(" Test", dSubPage["created_by"]); + Assert.AreEqual(" Heute", dSubPage["created_on"]); + Assert.AreEqual("42 ", dSubPage["visits"]); + Assert.AreEqual(1, lstItems.Count); + Assert.AreEqual("Profile image", lstItems[0]["Info"]); + Assert.AreEqual("https://example.invalid/subpage/anzeigen", lstItems[0]["parent"]); + } + + [DataTestMethod] + [DataRow("ok", false)] + [DataRow("504 gateway time-out", true)] + [DataRow("Service Unavailable", true)] + public void ContainsErrorMarker_Detects_Only_Real_Error_Texts(string sText, bool xExpected) + { + var xResult = InvokeNonPublicStaticMethod(typeof(WebHandler), "ContainsErrorMarker", sText); + + Assert.AreEqual(xExpected, xResult); + } + + private static ReadOnlyCollection CreateCollection(params IWebElement[] arrElements) + { + return new ReadOnlyCollection(arrElements.ToList()); + } + + private static WebHandler CreateHandler(IHttpClientProxy? xHttpClient = null, IWebDriverFactory? xWebDriverFactory = null, IProgress? xProgress = null) + { + return new WebHandler(new RnzConfig(), xHttpClient ?? Substitute.For(), xWebDriverFactory ?? Substitute.For(), xProgress); + } + + private static void SetDriver(WebHandler xHandler, IWebDriver xDriver) + { + var xSetter = typeof(WebHandler).GetProperty(nameof(WebHandler.Driver), BindingFlags.Instance | BindingFlags.Public)?.GetSetMethod(true); + Assert.IsNotNull(xSetter); + xSetter.Invoke(xHandler, [xDriver]); + } + + private static void InvokeNonPublicInstanceMethod(object xTarget, string sMethodName, params object?[] arrArguments) + { + var xMethod = xTarget.GetType().GetMethod(sMethodName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(xMethod); + + try + { + _ = xMethod.Invoke(xTarget, arrArguments); + } + catch (TargetInvocationException xException) when (xException.InnerException is not null) + { + ExceptionDispatchInfo.Capture(xException.InnerException).Throw(); + } + } + + private static T InvokeNonPublicInstanceMethod(object xTarget, string sMethodName, params object?[] arrArguments) + { + var xMethod = xTarget.GetType().GetMethod(sMethodName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(xMethod); + + try + { + return (T)xMethod.Invoke(xTarget, arrArguments)!; + } + catch (TargetInvocationException xException) when (xException.InnerException is not null) + { + ExceptionDispatchInfo.Capture(xException.InnerException).Throw(); + throw; + } + } + + private static T InvokeNonPublicStaticMethod(Type xType, string sMethodName, params object?[] arrArguments) + { + var xMethod = xType.GetMethod(sMethodName, BindingFlags.Static | BindingFlags.NonPublic); + Assert.IsNotNull(xMethod); + return (T)xMethod.Invoke(null, arrArguments)!; + } + + [TestMethod] + public void TryLoadBinary_CacheHit_Reuses_Same_ByteArray_Instance_Via_Reflection() + { + var xHttpClient = Substitute.For(); + var xResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([1, 2, 3]) + }; + xResponse.Headers.Add("X-Test", "1"); + xHttpClient.GetAsync("https://example.invalid/cached.bin").Returns(Task.FromResult(xResponse)); + using var xHandler = CreateHandler(xHttpClient: xHttpClient); + + var dTarget1 = new Dictionary(); + var dTarget2 = new Dictionary(); + + InvokeNonPublicInstanceMethod(xHandler, "TryLoadBinary", "https://example.invalid/cached.bin", dTarget1); + InvokeNonPublicInstanceMethod(xHandler, "TryLoadBinary", "https://example.invalid/cached.bin", dTarget2); + + var arrData1 = (byte[])dTarget1[WebHandler.CsData]!; + var arrData2 = (byte[])dTarget2[WebHandler.CsData]!; + Assert.IsTrue(ReferenceEquals(arrData1, arrData2)); + _ = xHttpClient.Received(1).GetAsync("https://example.invalid/cached.bin"); + } + + [TestMethod] + public void TryLoadBinary_CacheHit_Returns_Copied_Header_Dictionary_Via_Reflection() + { + var xHttpClient = Substitute.For(); + var xResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([1, 2, 3]) + }; + xResponse.Headers.Add("X-Test", "1"); + xHttpClient.GetAsync("https://example.invalid/cached-headers.bin").Returns(Task.FromResult(xResponse)); + using var xHandler = CreateHandler(xHttpClient: xHttpClient); + + var dTarget1 = new Dictionary(); + var dTarget2 = new Dictionary(); + + InvokeNonPublicInstanceMethod(xHandler, "TryLoadBinary", "https://example.invalid/cached-headers.bin", dTarget1); + var dHeaders1 = (Dictionary)dTarget1[WebHandler.CsHeader]!; + dHeaders1["X-Test"] = "modified"; + + InvokeNonPublicInstanceMethod(xHandler, "TryLoadBinary", "https://example.invalid/cached-headers.bin", dTarget2); + var dHeaders2 = (Dictionary)dTarget2[WebHandler.CsHeader]!; + + Assert.IsFalse(ReferenceEquals(dHeaders1, dHeaders2)); + Assert.AreEqual("1", dHeaders2["X-Test"]); + } + + [TestMethod] + public void HasMeaningfulPageContent_Does_Not_Read_PageSource() + { + var xDriver = Substitute.For(); + var xBody = Substitute.For(); + + xDriver.Title.Returns("Traueranzeigen von Manfred Hindemith | Trauer.rnz.de"); + xDriver.FindElements(Arg.Is(xBy => xBy.ToString() == By.TagName("body").ToString())).Returns(CreateCollection(xBody)); + xBody.Text.Returns("Seiteninhalt"); + xDriver.PageSource.Returns(_ => throw new WebDriverException("timeout")); + + var xResult = InvokeNonPublicStaticMethod(typeof(WebHandler), "HasMeaningfulPageContent", xDriver); + + Assert.IsTrue(xResult); + } + + [TestMethod] + public void HasMeaningfulPageContent_Returns_False_For_Error_Text_In_Body() + { + var xDriver = Substitute.For(); + var xBody = Substitute.For(); + + xDriver.Title.Returns("Loaded"); + xDriver.FindElements(Arg.Is(xBy => xBy.ToString() == By.TagName("body").ToString())).Returns(CreateCollection(xBody)); + xBody.Text.Returns("504 gateway time-out"); + + var xResult = InvokeNonPublicStaticMethod(typeof(WebHandler), "HasMeaningfulPageContent", xDriver); + + Assert.IsFalse(xResult); + } + + [TestMethod] + public void TryLoadBinary_Mutating_Returned_Cached_ByteArray_Affects_Followup_Reads_Via_Reflection() + { + var xHttpClient = Substitute.For(); + var xResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([1, 2, 3]) + }; + xHttpClient.GetAsync("https://example.invalid/mutable-cache.bin").Returns(Task.FromResult(xResponse)); + using var xHandler = CreateHandler(xHttpClient: xHttpClient); + + var dTarget1 = new Dictionary(); + var dTarget2 = new Dictionary(); + + InvokeNonPublicInstanceMethod(xHandler, "TryLoadBinary", "https://example.invalid/mutable-cache.bin", dTarget1); + var arrData1 = (byte[])dTarget1[WebHandler.CsData]!; + arrData1[0] = 9; + + InvokeNonPublicInstanceMethod(xHandler, "TryLoadBinary", "https://example.invalid/mutable-cache.bin", dTarget2); + var arrData2 = (byte[])dTarget2[WebHandler.CsData]!; + + Assert.AreEqual(9, arrData2[0]); + } +} diff --git a/WinAhnenNew/WinAhnenClsTests/WinAhnenClsTests.csproj b/WinAhnenNew/WinAhnenClsTests/WinAhnenClsTests.csproj index a126a6571..63ac706ce 100644 --- a/WinAhnenNew/WinAhnenClsTests/WinAhnenClsTests.csproj +++ b/WinAhnenNew/WinAhnenClsTests/WinAhnenClsTests.csproj @@ -17,8 +17,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/WinAhnenNew/WinAhnenNew.Model.Tests/EditPageViewModelTests.cs b/WinAhnenNew/WinAhnenNew.Model.Tests/EditPageViewModelTests.cs index 7afb43052..260f92ddf 100644 --- a/WinAhnenNew/WinAhnenNew.Model.Tests/EditPageViewModelTests.cs +++ b/WinAhnenNew/WinAhnenNew.Model.Tests/EditPageViewModelTests.cs @@ -4,6 +4,7 @@ using BaseGenClasses.Helper.Interfaces; using CommunityToolkit.Mvvm.Messaging; using BaseGenClasses.Model; +using BaseGenClasses.Persistence; using BaseLib.Helper; using GenInterfaces.Data; using GenInterfaces.Interfaces; @@ -44,6 +45,12 @@ public void LastNameChange_UpdatesUnderlyingSelectedPersonWithoutPersistingImmed vmEdit.LastName = "Schulze"; Assert.AreEqual("Schulze", genPerson.Surname); + Assert.IsTrue(genGenealogy.xDirty); + Assert.AreEqual(1, genGenealogy.JournalEntries.Count); + Assert.AreEqual(genPerson, genGenealogy.JournalEntries[0].Class); + Assert.AreEqual(EFactType.Surname, ((FactJournalValue)genGenealogy.JournalEntries[0].OldData!).eFactType); + Assert.AreEqual("Meyer", ((FactJournalValue)genGenealogy.JournalEntries[0].OldData!).Data); + Assert.AreEqual("Schulze", ((FactJournalValue)genGenealogy.JournalEntries[0].Data!).Data); svcPersonSelection.DidNotReceive().SaveChanges(); } diff --git a/WinAhnenNew/WinAhnenNew.Model.Tests/WinAhnenNew.Model.Tests.csproj b/WinAhnenNew/WinAhnenNew.Model.Tests/WinAhnenNew.Model.Tests.csproj index 67d73afcc..ff6cea9c1 100644 --- a/WinAhnenNew/WinAhnenNew.Model.Tests/WinAhnenNew.Model.Tests.csproj +++ b/WinAhnenNew/WinAhnenNew.Model.Tests/WinAhnenNew.Model.Tests.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/WinAhnenNew/WinAhnenNew.Model/ViewModels/EditPageViewModel.cs b/WinAhnenNew/WinAhnenNew.Model/ViewModels/EditPageViewModel.cs index 463eb3823..37671a20f 100644 --- a/WinAhnenNew/WinAhnenNew.Model/ViewModels/EditPageViewModel.cs +++ b/WinAhnenNew/WinAhnenNew.Model/ViewModels/EditPageViewModel.cs @@ -6,6 +6,7 @@ using System.Windows.Media; using BaseGenClasses.Helper; using BaseGenClasses.Model; +using BaseGenClasses.Persistence; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; using GenInterfaces.Data; @@ -670,6 +671,7 @@ private static bool SaveSimpleFact(IGenPerson genPerson, EFactType eFactType, st { var sNormalizedValue = NormalizeValue(sValue); var genFact = genPerson.Facts.FirstOrDefault(genFact => genFact?.eFactType == eFactType); + var fctOldValue = FactJournalValue.FromFact(genFact); if (genFact is null) { @@ -678,7 +680,8 @@ private static bool SaveSimpleFact(IGenPerson genPerson, EFactType eFactType, st return false; } - genPerson.AddFact(eFactType, sNormalizedValue); + var genNewFact = genPerson.AddFact(eFactType, sNormalizedValue); + RecordFactJournalEntry(genPerson, genNewFact, null, FactJournalValue.FromFact(genNewFact)); return true; } @@ -688,6 +691,7 @@ private static bool SaveSimpleFact(IGenPerson genPerson, EFactType eFactType, st } genFact.Data = sNormalizedValue; + RecordFactJournalEntry(genPerson, genFact, fctOldValue, FactJournalValue.FromFact(genFact)); return true; } @@ -703,12 +707,14 @@ private static bool SaveEventFact( var genFact = genPerson.Facts.FirstOrDefault(genFact => genFact?.eFactType == eFactType); var dtDate = TryBuildDate(sDay, sMonth, sYear); var sNormalizedPlaceName = NormalizePlaceValue(sPlaceName); + var fctOldValue = FactJournalValue.FromFact(genFact); if (dtDate is null && string.IsNullOrWhiteSpace(sNormalizedPlaceName)) { if (genFact is not null) { genPerson.Facts.Remove(genFact); + RecordFactJournalEntry(genPerson, genFact, fctOldValue, null); return true; } @@ -737,6 +743,11 @@ private static bool SaveEventFact( xChanged = true; } + if (xChanged) + { + RecordFactJournalEntry(genPerson, genFact, fctOldValue, FactJournalValue.FromFact(genFact)); + } + return xChanged; } @@ -745,7 +756,9 @@ private static bool RemoveFact(IGenPerson genPerson, EFactType eFactType) var genFact = genPerson.Facts.FirstOrDefault(genFact => genFact?.eFactType == eFactType); if (genFact is not null) { + var fctOldValue = FactJournalValue.FromFact(genFact); genPerson.Facts.Remove(genFact); + RecordFactJournalEntry(genPerson, genFact, fctOldValue, null); return true; } @@ -784,12 +797,20 @@ private static bool RemoveFact(IGenPerson genPerson, EFactType eFactType) private static void MarkGenealogyDirty(IGenPerson genPerson, string sReason) { - if (((IHasOwner)genPerson).Owner is BaseGenClasses.Persistence.IGenealogyPersistenceContext persistenceContext) + if (((IHasOwner)genPerson).Owner is IGenealogyPersistenceContext persistenceContext) { persistenceContext.MarkDirty(genPerson, sReason); } } + private static void RecordFactJournalEntry(IGenPerson genPerson, IGenFact genFact, FactJournalValue? fctOldValue, FactJournalValue? fctNewValue) + { + if (((IHasOwner)genPerson).Owner is IGenealogyJournalContext journalContext) + { + journalContext.RecordJournalEntry(genPerson, genFact, fctNewValue, fctOldValue); + } + } + private static bool AreEquivalentDates(IGenDate? genLeftDate, IGenDate? genRightDate) { if (ReferenceEquals(genLeftDate, genRightDate)) diff --git a/WinAhnenNew/WinAhnenNew.UI.Tests/WinAhnenNew.UI.Tests.csproj b/WinAhnenNew/WinAhnenNew.UI.Tests/WinAhnenNew.UI.Tests.csproj index 6fdd62cfa..75b205c95 100644 --- a/WinAhnenNew/WinAhnenNew.UI.Tests/WinAhnenNew.UI.Tests.csproj +++ b/WinAhnenNew/WinAhnenNew.UI.Tests/WinAhnenNew.UI.Tests.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/WinAhnenNew/WinAhnenNew/Views/EditPageView.xaml b/WinAhnenNew/WinAhnenNew/Views/EditPageView.xaml index 121e67d83..e7e754a6e 100644 --- a/WinAhnenNew/WinAhnenNew/Views/EditPageView.xaml +++ b/WinAhnenNew/WinAhnenNew/Views/EditPageView.xaml @@ -6,6 +6,7 @@ xmlns:local="clr-namespace:WinAhnenNew.Views" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" FontFamily="Arial" + PreviewKeyDown="Page_PreviewKeyDown" Title="EditPageView" d:DesignHeight="860" d:DesignWidth="800" diff --git a/WinAhnenNew/WinAhnenNew/Views/EditPageView.xaml.cs b/WinAhnenNew/WinAhnenNew/Views/EditPageView.xaml.cs index 8a3534468..55f177b5b 100644 --- a/WinAhnenNew/WinAhnenNew/Views/EditPageView.xaml.cs +++ b/WinAhnenNew/WinAhnenNew/Views/EditPageView.xaml.cs @@ -2,6 +2,7 @@ using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Data; +using System.Windows.Input; using System.Windows.Media; using Microsoft.Extensions.DependencyInjection; using WinAhnenNew.ViewModels; @@ -29,6 +30,44 @@ public void CommitPendingEdits() } } + private void Page_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key != Key.Enter || Keyboard.Modifiers != ModifierKeys.None) + { + return; + } + + if (!TryCommitSingleLineInput(e.OriginalSource as DependencyObject)) + { + return; + } + + e.Handled = true; + } + + private static bool TryCommitSingleLineInput(DependencyObject? objSource) + { + if (objSource is null) + { + return false; + } + + var cboComboBox = FindAncestor(objSource); + if (cboComboBox is not null && cboComboBox.IsEditable) + { + UpdateBinding(cboComboBox, ComboBox.TextProperty); + return true; + } + + if (FindAncestor(objSource) is not TextBox txtTextBox || txtTextBox.AcceptsReturn) + { + return false; + } + + UpdateBinding(txtTextBox, TextBox.TextProperty); + return true; + } + private static void UpdateBindingSources(DependencyObject objRoot) { UpdateBinding(objRoot, TextBox.TextProperty); @@ -47,5 +86,21 @@ private static void UpdateBinding(DependencyObject objTarget, DependencyProperty { BindingOperations.GetBindingExpression(objTarget, dpProperty)?.UpdateSource(); } + + private static T? FindAncestor(DependencyObject? objChild) + where T : DependencyObject + { + while (objChild is not null) + { + if (objChild is T objTarget) + { + return objTarget; + } + + objChild = VisualTreeHelper.GetParent(objChild); + } + + return null; + } } }