diff --git a/PriceChecker.Core/Services/PriceSeeker.cs b/PriceChecker.Core/Services/PriceSeeker.cs index 4471828..87ea2ea 100644 --- a/PriceChecker.Core/Services/PriceSeeker.cs +++ b/PriceChecker.Core/Services/PriceSeeker.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Text.RegularExpressions; using System.Threading; @@ -37,14 +38,23 @@ public async Task SeekAsync(Product product, CancellationToke }); return await Task.WhenAll(result) - .ContinueWith(x => x.Result?.Where(x => x != null).ToArray() ?? new PriceSeekResult[0]); + .ContinueWith(x => x.Result?.Where(x => x != null).ToArray() ?? new PriceSeekResult[0], TaskContinuationOptions.OnlyOnRanToCompletion); } private async Task Seek(ProductSource productSource, CancellationToken cancel) { var agent = productSource.Agent; var url = string.Format(agent.Url, productSource.AgentArgument); - var content = await _trickyHttpClient.DownloadContent(url, cancel); + string content; + try + { + content = await _trickyHttpClient.DownloadContent(url, cancel); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed loading content for source `{productSource.AgentId}`, url = `{url}`"); + throw; + } if (content == null) return null; diff --git a/PriceChecker.Infrastructure/Events/EventBus.cs b/PriceChecker.Infrastructure/Events/EventBus.cs index 74d0ee9..ff3b5aa 100644 --- a/PriceChecker.Infrastructure/Events/EventBus.cs +++ b/PriceChecker.Infrastructure/Events/EventBus.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Reactive; using System.Reactive.Linq; diff --git a/PriceChecker.UI.Forms/AutoGrid/AttachingBehavior.cs b/PriceChecker.UI.Forms/AutoGrid/AttachingBehavior.cs index c5ccf5f..7d81e86 100644 --- a/PriceChecker.UI.Forms/AutoGrid/AttachingBehavior.cs +++ b/PriceChecker.UI.Forms/AutoGrid/AttachingBehavior.cs @@ -57,7 +57,7 @@ private void OnItemsSourceChanged(object sender, EventArgs e) } if (AssociatedObject.SelectionMode == DataGridSelectionMode.Extended && - typeof(ISelectable).IsAssignableFrom(GetItemType())) + typeof(ISelectable).IsAssignableFrom(Helpers.GetListItemType(AssociatedObject.ItemsSource))) { BindIsSelected(); } @@ -118,15 +118,5 @@ private void BindIsSelected() rowStyle.Setters.Add(new Setter(DataGrid.IsSelectedProperty, binding)); AssociatedObject.RowStyle = rowStyle; } - - private Type GetItemType() - { - var sourceCollection = AssociatedObject.ItemsSource; - if (sourceCollection is ListCollectionView listCollectionView) - { - sourceCollection = listCollectionView.SourceCollection; - } - return sourceCollection.GetType().GetGenericArguments().Single(); - } } } diff --git a/PriceChecker.UI.Forms/AutoGrid/Properties.cs b/PriceChecker.UI.Forms/AutoGrid/Properties.cs index f1e4014..03c9254 100644 --- a/PriceChecker.UI.Forms/AutoGrid/Properties.cs +++ b/PriceChecker.UI.Forms/AutoGrid/Properties.cs @@ -30,9 +30,9 @@ public static IEnumerable GetItemsSource(DependencyObject element) return (IEnumerable) element.GetValue(ItemsSourceProperty); } - public static void ItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + private static void ItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - var itemType = e.NewValue.GetType().GetGenericArguments().Single(); + var itemType = Helpers.GetListItemType(e.NewValue); var properties = itemType.GetProperties(); var groupByProps = properties .Where(x => x.GetCustomAttributes(false).OfType().Any()) diff --git a/PriceChecker.UI.Forms/Helpers.cs b/PriceChecker.UI.Forms/Helpers.cs new file mode 100644 index 0000000..28ab6c2 --- /dev/null +++ b/PriceChecker.UI.Forms/Helpers.cs @@ -0,0 +1,26 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Windows.Data; + +namespace Genius.PriceChecker.UI.Forms +{ + internal class Helpers + { + public static Type GetListItemType(object value) + { + if (value is ListCollectionView listCollectionView) + value = listCollectionView.SourceCollection; + + if (value is ITypedObservableList typedObservableList) + return typedObservableList.ItemType; + + return value.GetType().GetGenericArguments().Single(); + } + + public static string MakeCaptionFromPropertyName(string propertyName) + { + return Regex.Replace(propertyName, @"(?<=[^$])([A-Z])", " $1"); + } + } +} diff --git a/PriceChecker.UI.Forms/TypedObservableList.cs b/PriceChecker.UI.Forms/TypedObservableList.cs new file mode 100644 index 0000000..bae92d7 --- /dev/null +++ b/PriceChecker.UI.Forms/TypedObservableList.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; + +namespace Genius.PriceChecker.UI.Forms +{ + public interface ITypedObservableList + { + Type ItemType { get; } + } + + public class TypedObservableList : ObservableCollection, ITypedObservableList, ITypedList + { + public PropertyDescriptorCollection GetItemProperties(PropertyDescriptor[] listAccessors) + { + return TypeDescriptor.GetProperties(typeof(TType)); + } + + public string GetListName(PropertyDescriptor[] listAccessors) + { + return null; + } + + public Type ItemType => typeof(TType); + } +} diff --git a/PriceChecker.UI.Forms/ViewModels/TabViewModelBase.cs b/PriceChecker.UI.Forms/ViewModels/TabViewModelBase.cs index 54fb122..02a4c01 100644 --- a/PriceChecker.UI.Forms/ViewModels/TabViewModelBase.cs +++ b/PriceChecker.UI.Forms/ViewModels/TabViewModelBase.cs @@ -2,7 +2,7 @@ namespace Genius.PriceChecker.UI.Forms.ViewModels { - public interface ITabViewModel + public interface ITabViewModel : IViewModel { IActionCommand Activated { get; } IActionCommand Deactivated { get; } diff --git a/PriceChecker.UI.Forms/ViewModels/ViewModelBase.cs b/PriceChecker.UI.Forms/ViewModels/ViewModelBase.cs index 866c988..6d57d12 100644 --- a/PriceChecker.UI.Forms/ViewModels/ViewModelBase.cs +++ b/PriceChecker.UI.Forms/ViewModels/ViewModelBase.cs @@ -12,13 +12,17 @@ namespace Genius.PriceChecker.UI.Forms.ViewModels { - public abstract class ViewModelBase : INotifyPropertyChanged, INotifyDataErrorInfo + public interface IViewModel : INotifyPropertyChanged, INotifyDataErrorInfo + { + bool TryGetPropertyValue(string propertyName, out object value); + } + + public abstract class ViewModelBase : IViewModel { protected readonly ConcurrentDictionary _propertyBag = new(); private readonly Dictionary> _validationRules = new(); private readonly Dictionary> _errors = new(); - - protected bool PropertiesAreInitialized = false; + private bool _suspendDirtySet = false; public ViewModelBase() { @@ -34,11 +38,29 @@ public IEnumerable GetErrors(string propertyName) new List(); } - internal bool TryGetPropertyValue(string propertyName, out object value) + public bool TryGetPropertyValue(string propertyName, out object value) { return _propertyBag.TryGetValue(propertyName, out value); } + protected void InitializeProperties(Action action) + { + _suspendDirtySet = true; + try + { + action(); + + if (this is IHasDirtyFlag hasDirtyFlag) + { + hasDirtyFlag.IsDirty = false; + } + } + finally + { + _suspendDirtySet = false; + } + } + protected T GetOrDefault(T defaultValue = default(T), [CallerMemberName] string name = null) { if (name == null) @@ -74,9 +96,9 @@ protected void RaiseAndSetIfChanged(T value, Action valueChangedHandler _propertyBag.AddOrUpdate(name, _ => value, (_, __) => value); OnPropertyChanged(name); - if (this is IHasDirtyFlag hasDirtyFlag && + if (!_suspendDirtySet && + this is IHasDirtyFlag hasDirtyFlag && name != nameof(IHasDirtyFlag.IsDirty) && - PropertiesAreInitialized && (this is not ISelectable || name != nameof(ISelectable.IsSelected))) { hasDirtyFlag.IsDirty = true; diff --git a/PriceChecker.UI.Forms/ViewModels/ViewModelExtensions.cs b/PriceChecker.UI.Forms/ViewModels/ViewModelExtensions.cs index a7bcbe7..72d4833 100644 --- a/PriceChecker.UI.Forms/ViewModels/ViewModelExtensions.cs +++ b/PriceChecker.UI.Forms/ViewModels/ViewModelExtensions.cs @@ -8,14 +8,14 @@ namespace Genius.PriceChecker.UI.Forms.ViewModels public static class ViewModelExtensions { public static IDisposable WhenChanged(this TViewModel viewModel, Expression> propertyAccessor, Action handler) - where TViewModel : ViewModelBase + where TViewModel : IViewModel { var propName = ExpressionHelpers.GetPropertyName(propertyAccessor); return WhenChanged(viewModel, propName, handler); } - public static IDisposable WhenChanged(this ViewModelBase viewModel, string propertyName, Action handler) + public static IDisposable WhenChanged(this IViewModel viewModel, string propertyName, Action handler) { PropertyChangedEventHandler fn = (_, args) => { diff --git a/PriceChecker.UI.Forms/WpfHelpers.cs b/PriceChecker.UI.Forms/WpfHelpers.cs index 01674d8..5a56cea 100644 --- a/PriceChecker.UI.Forms/WpfHelpers.cs +++ b/PriceChecker.UI.Forms/WpfHelpers.cs @@ -34,7 +34,7 @@ public static void AddFlyout(FrameworkElement owner, string isOpenBindingPath public static DataGridTemplateColumn CreateButtonColumn(string commandPath, string iconName) { - var caption = commandPath.Replace("Command", ""); + var caption = Helpers.MakeCaptionFromPropertyName(commandPath.Replace("Command", "")); var buttonFactory = new FrameworkElementFactory(typeof(Button)); buttonFactory.SetBinding(Button.CommandProperty, new Binding(commandPath)); diff --git a/PriceChecker.UI.Tests/Helpers/TrackerScanContextTests.cs b/PriceChecker.UI.Tests/Helpers/TrackerScanContextTests.cs new file mode 100644 index 0000000..2c9ac76 --- /dev/null +++ b/PriceChecker.UI.Tests/Helpers/TrackerScanContextTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Reactive.Subjects; +using AutoFixture; +using Genius.PriceChecker.Core.Messages; +using Genius.PriceChecker.Core.Models; +using Genius.PriceChecker.UI.Helpers; +using Xunit; + +namespace Genius.PriceChecker.UI.Tests.Helpers +{ + public class TrackerScanContextTests : TestBase + { + private readonly Fixture _fixture = new(); + private readonly TrackerScanContext _sut; + + // Session values: + private readonly Subject _productAutoScanStartedEventSubject; + private TrackerScanStatus? _lastStatus = null; + private double? _lastProgress = null; + + public TrackerScanContextTests() + { + _productAutoScanStartedEventSubject = CreateEventSubject(); + + _sut = new TrackerScanContext(EventBusMock.Object); + + _sut.ScanProgress.Subscribe(x => { + _lastStatus = x.Status; + _lastProgress = x.Progress; + }); + } + + [Fact] + public void NotifyStarted__Resets_state_and_calculates_initial_progress() + { + // Arrange + var count = 10; + + // Act + _sut.NotifyStarted(count); + + // Verify + var expectedProgress = 1d / (count * 2); + Assert.True(_sut.IsStarted); + Assert.False(_sut.HasErrors); + Assert.False(_sut.HasNewLowestPrice); + Assert.Equal(0, _sut.FinishedJobs); + Assert.Equal(TrackerScanStatus.InProgress, _lastStatus); + Assert.Equal(expectedProgress, _lastProgress); + } + + [Fact] + public void NotifyProgressChange__When_ScannedOk__Increases_progress() + { + // Arrange + var count = 2; + _sut.NotifyStarted(count); + + // Act + _sut.NotifyProgressChange(ProductScanStatus.ScannedOk); + + // Verify + var expectedProgress = 0.5d; // 50% of 2 jobs + Assert.True(_sut.IsStarted); + Assert.False(_sut.HasErrors); + Assert.False(_sut.HasNewLowestPrice); + Assert.Equal(1, _sut.FinishedJobs); + Assert.Equal(TrackerScanStatus.InProgress, _lastStatus); + Assert.Equal(expectedProgress, _lastProgress); + } + + [Fact] + public void NotifyProgressChange__When_scanned_last_job__Finishes_progress() + { + // Arrange + _sut.NotifyStarted(2); + + // Act + _sut.NotifyProgressChange(ProductScanStatus.ScannedOk); + _sut.NotifyProgressChange(ProductScanStatus.ScannedOk); + + // Verify + Assert.False(_sut.IsStarted); + Assert.False(_sut.HasErrors); + Assert.False(_sut.HasNewLowestPrice); + Assert.Equal(2, _sut.FinishedJobs); + Assert.Equal(TrackerScanStatus.Finished, _lastStatus); + Assert.Equal(1, _lastProgress); + } + + [Fact] + public void NotifyProgressChange__When_ScannedWithErrors__Reports_about_errors() + { + // Arrange + _sut.NotifyStarted(_fixture.Create()); + + // Act + _sut.NotifyProgressChange(ProductScanStatus.ScannedWithErrors); + + // Verify + Assert.True(_sut.HasErrors); + Assert.Equal(TrackerScanStatus.InProgressWithErrors, _lastStatus); + } + + [Fact] + public void NotifyProgressChange__When_ScannedNewLowest__Reports_about_new_lowest() + { + // Arrange + _sut.NotifyStarted(_fixture.Create()); + + // Act + _sut.NotifyProgressChange(ProductScanStatus.ScannedNewLowest); + + // Verify + Assert.True(_sut.HasNewLowestPrice); + } + + [Fact] + public void ProductAutoScanStartedEvent_fired__Calls_NotifyStarted() + { + // Arrange + var count = 10; + + // Act + _productAutoScanStartedEventSubject.OnNext(new ProductAutoScanStartedEvent(count)); + + // Verify + var expectedProgress = 1d / (count * 2); + Assert.True(_sut.IsStarted); + Assert.False(_sut.HasErrors); + Assert.False(_sut.HasNewLowestPrice); + Assert.Equal(0, _sut.FinishedJobs); + Assert.Equal(TrackerScanStatus.InProgress, _lastStatus); + Assert.Equal(expectedProgress, _lastProgress); + } + } +} diff --git a/PriceChecker.UI.Tests/TestBase.cs b/PriceChecker.UI.Tests/TestBase.cs new file mode 100644 index 0000000..98325c6 --- /dev/null +++ b/PriceChecker.UI.Tests/TestBase.cs @@ -0,0 +1,58 @@ +using System; +using System.ComponentModel; +using System.Linq.Expressions; +using System.Reactive.Subjects; +using System.Windows.Threading; +using AutoFixture; +using Genius.PriceChecker.Infrastructure; +using Genius.PriceChecker.Infrastructure.Events; +using Genius.PriceChecker.UI.Forms.ViewModels; +using Moq; + +namespace Genius.PriceChecker.UI.Tests +{ + public abstract class TestBase + { + public TestBase() + { + Fixture.Behaviors.Add(new OmitOnRecursionBehavior(recursionDepth: 2)); + + SetupDispatcher(); + } + + protected Subject CreateEventSubject() + where T : IEventMessage + { + Subject subject = new(); + EventBusMock.Setup(x => x.WhenFired()) + .Returns(subject); + return subject; + } + + protected void RaisePropertyChanged(Mock container, Expression> propertyNameExpr, object value) + where T : class, IViewModel + { + var propertyName = ExpressionHelpers.GetPropertyName(propertyNameExpr); + + container.Setup(x => x.TryGetPropertyValue(propertyName, out value)) + .Returns(true); + + container.Raise(x => x.PropertyChanged += null, + new PropertyChangedEventArgs(propertyName)); + } + + private static void SetupDispatcher() + { + var frame = new DispatcherFrame(); + Dispatcher.CurrentDispatcher.BeginInvoke( + DispatcherPriority.Background, + new Action(() => frame.Continue = false)); + Dispatcher.PushFrame(frame); + } + + protected Fixture Fixture { get; } = new(); + + private Lazy> _eventBusMock = new Lazy>(() => new Mock()); + protected Mock EventBusMock => _eventBusMock.Value; + } +} \ No newline at end of file diff --git a/PriceChecker.UI.Tests/Validation/MustBeUniqueValidationRuleTests.cs b/PriceChecker.UI.Tests/Validation/MustBeUniqueValidationRuleTests.cs index 0ccd93d..4ce18e6 100644 --- a/PriceChecker.UI.Tests/Validation/MustBeUniqueValidationRuleTests.cs +++ b/PriceChecker.UI.Tests/Validation/MustBeUniqueValidationRuleTests.cs @@ -20,7 +20,7 @@ public MustBeUniqueValidationRuleTests() } [Fact] - public void Value_not_string__Returns_valid() + public void Value__Not_string__Returns_valid() { // Act var result = _sut.Validate(new object(), _fixture.Create()); diff --git a/PriceChecker.UI.Tests/Validation/ValueCannotBeEmptyValidationRuleTests.cs b/PriceChecker.UI.Tests/Validation/ValueCannotBeEmptyValidationRuleTests.cs index 8742812..51e171e 100644 --- a/PriceChecker.UI.Tests/Validation/ValueCannotBeEmptyValidationRuleTests.cs +++ b/PriceChecker.UI.Tests/Validation/ValueCannotBeEmptyValidationRuleTests.cs @@ -11,7 +11,7 @@ public class ValueCannotBeEmptyValidationRuleTests private readonly ValueCannotBeEmptyValidationRule _sut = new(); [Fact] - public void Value_is_null__Returns_not_valid() + public void Value__Null__Returns_not_valid() { // Act var result = _sut.Validate(null, _fixture.Create()); @@ -21,7 +21,7 @@ public void Value_is_null__Returns_not_valid() } [Fact] - public void Value_is_not_string__Returns_not_valid() + public void Value__Not_string__Returns_not_valid() { // Act var result = _sut.Validate(new object(), _fixture.Create()); @@ -31,7 +31,7 @@ public void Value_is_not_string__Returns_not_valid() } [Fact] - public void Value_is_null_string__Returns_not_valid() + public void Value__Null_string__Returns_not_valid() { // Arrange string value = null; @@ -44,7 +44,7 @@ public void Value_is_null_string__Returns_not_valid() } [Fact] - public void Value_is_empty_string__Returns_not_valid() + public void Value__Empty_string__Returns_not_valid() { // Arrange string value = string.Empty; @@ -57,7 +57,7 @@ public void Value_is_empty_string__Returns_not_valid() } [Fact] - public void Value_is_whitespaced_string__Returns_not_valid() + public void Value__Whitespaced_string__Returns_not_valid() { // Arrange string value = " "; @@ -70,7 +70,7 @@ public void Value_is_whitespaced_string__Returns_not_valid() } [Fact] - public void Value_is_string__Returns_valid() + public void Value__String__Returns_valid() { // Arrange string value = _fixture.Create(); diff --git a/PriceChecker.UI.Tests/ViewModels/MainViewModelTests.cs b/PriceChecker.UI.Tests/ViewModels/MainViewModelTests.cs new file mode 100644 index 0000000..a281abf --- /dev/null +++ b/PriceChecker.UI.Tests/ViewModels/MainViewModelTests.cs @@ -0,0 +1,139 @@ +using System.Reactive.Subjects; +using System.Windows.Shell; +using AutoFixture; +using Genius.PriceChecker.UI.Forms; +using Genius.PriceChecker.UI.Forms.ViewModels; +using Genius.PriceChecker.UI.Helpers; +using Genius.PriceChecker.UI.ViewModels; +using Moq; +using Xunit; + +namespace Genius.PriceChecker.UI.Tests.ViewModels +{ + public class MainViewModelTests : TestBase + { + private readonly TabMock _trackerMock = new(); + private readonly TabMock _agentsMock = new(); + private readonly TabMock _settingsMock = new(); + private readonly TabMock _logsMock = new(); + private readonly Mock _scanContextMock = new(); + private readonly Mock _notifyViewModelMock = new(); + + private readonly MainViewModel _sut; + + // Session values: + private readonly Subject<(TrackerScanStatus Status, double Progress)> _scanProgressSubject = new(); + + public MainViewModelTests() + { + _scanContextMock.SetupGet(x => x.ScanProgress).Returns(_scanProgressSubject); + + _sut = new(_trackerMock.Object, _agentsMock.Object, _settingsMock.Object, + _logsMock.Object, _scanContextMock.Object, _notifyViewModelMock.Object); + } + + [Fact] + public void Constructor__Tabs_are_populated() + { + // Verify + Assert.Equal(4, _sut.Tabs.Count); + Assert.Equal(_trackerMock.Object, _sut.Tabs[0]); + Assert.Equal(_agentsMock.Object, _sut.Tabs[1]); + Assert.Equal(_settingsMock.Object, _sut.Tabs[2]); + Assert.Equal(_logsMock.Object, _sut.Tabs[3]); + } + + [Fact] + public void SelectedTabIndex_changed__Tab_is_Activated_and_old_deactivated() + { + // Arrange + _sut.SelectedTabIndex = 0; + _trackerMock.DropHistory(); + _agentsMock.DropHistory(); + _settingsMock.DropHistory(); + _logsMock.DropHistory(); + + // Act + var settingsTabIndex = 2; + _sut.SelectedTabIndex = settingsTabIndex; + + // Verify + Assert.True(_trackerMock.OnlyOneDeactivated); + Assert.Equal(0, _agentsMock.ActivatedCalls + _agentsMock.DeactivatedCalls); + Assert.True(_settingsMock.OnlyOneActivated); + Assert.Equal(0, _logsMock.ActivatedCalls + _logsMock.DeactivatedCalls); + } + + [Fact] + public void ScanProgress_changed__InProgress__Progress_state_highlighted_green() + { + // Arrange + _sut.ProgressState = TaskbarItemProgressState.None; + var progress = Fixture.Create(); + + // Act + _scanProgressSubject.OnNext((TrackerScanStatus.InProgress, progress)); + + // Verify + Assert.Equal(TaskbarItemProgressState.Normal, _sut.ProgressState); + Assert.Equal(progress, _sut.ProgressValue); + } + + [Fact] + public void ScanProgress_changed__InProgressWithErrors__Progress_state_highlighted_yellow() + { + // Arrange + _sut.ProgressState = TaskbarItemProgressState.None; + var progress = Fixture.Create(); + + // Act + _scanProgressSubject.OnNext((TrackerScanStatus.InProgressWithErrors, progress)); + + // Verify + Assert.Equal(TaskbarItemProgressState.Paused, _sut.ProgressState); + Assert.Equal(progress, _sut.ProgressValue); + } + + [Fact] + public void ScanProgress_changed__Finished__Progress_state_dropped_and_message_shown() + { + // Arrange + _sut.ProgressState = TaskbarItemProgressState.Normal; + _sut.ProgressValue = Fixture.Create(); + + // Act + _scanProgressSubject.OnNext((TrackerScanStatus.Finished, Fixture.Create())); + + // Verify + Assert.Equal(TaskbarItemProgressState.None, _sut.ProgressState); + Assert.Equal(0, _sut.ProgressValue); + } + } + + class TabMock : Mock + where T: class, ITabViewModel + { + public int ActivatedCalls = 0; + public int DeactivatedCalls = 0; + + public TabMock() + { + var activatedCommandMock = new Mock(); + activatedCommandMock.Setup(x => x.Execute(null)).Callback((object _) => ActivatedCalls++); + SetupGet(x => x.Activated).Returns(activatedCommandMock.Object); + + var deactivatedCommandMock = new Mock(); + deactivatedCommandMock.Setup(x => x.Execute(null)).Callback((object _) => DeactivatedCalls++); + SetupGet(x => x.Deactivated).Returns(deactivatedCommandMock.Object); + } + + public void DropHistory() + { + ActivatedCalls = 0; + DeactivatedCalls = 0; + } + + public bool OnlyOneActivated => ActivatedCalls == 1 && DeactivatedCalls == 0; + public bool OnlyOneDeactivated => ActivatedCalls == 0 && DeactivatedCalls == 1; + } +} diff --git a/PriceChecker.UI.Tests/ViewModels/TrackerViewModelTests.cs b/PriceChecker.UI.Tests/ViewModels/TrackerViewModelTests.cs new file mode 100644 index 0000000..7d59db3 --- /dev/null +++ b/PriceChecker.UI.Tests/ViewModels/TrackerViewModelTests.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive; +using System.Reactive.Subjects; +using AutoFixture; +using Genius.PriceChecker.Core.Messages; +using Genius.PriceChecker.Core.Models; +using Genius.PriceChecker.Core.Repositories; +using Genius.PriceChecker.UI.Forms; +using Genius.PriceChecker.UI.Helpers; +using Genius.PriceChecker.UI.ViewModels; +using Moq; +using Xunit; + +namespace Genius.PriceChecker.UI.Tests.ViewModels +{ + public class TrackerViewModelTests : TestBase + { + private readonly Mock _productRepoMock = new(); + private readonly Mock _vmFactoryMock = new(); + private readonly Mock _uiMock = new(); + private readonly Mock _scanContextMock = new(); + + // Session values: + private readonly Subject _productScanStartedEventSubject; + private readonly Subject _productScannedEventSubject; + private readonly Subject _productScanFailedEventSubject; + + public TrackerViewModelTests() + { + _productScanStartedEventSubject = CreateEventSubject(); + _productScannedEventSubject = CreateEventSubject(); + _productScanFailedEventSubject = CreateEventSubject(); + + _vmFactoryMock.Setup(x => x.CreateTrackerProduct(It.IsAny())) + .Returns((Product p) => Mock.Of(x => + x.Id == (p == null ? Guid.Empty : p.Id) && + x.RefreshPriceCommand == Mock.Of() && + x.CommitProductCommand == Mock.Of(c => c.Executed == new Subject()))); + } + + [Fact] + public void Constructor__RefreshOptions_are_defined_and_list_reloaded() + { + // Arrange + var products = SampleProducts(); + + // Act + var sut = CreateSystemUnderTest(); + + // Verify + Assert.NotEmpty(sut.RefreshOptions); + Assert.Equal(products.Select(x => x.Id), sut.Products.Select(x => x.Id)); + } + + [Fact] + public void RefreshAllCommand__Enqueues_all_products_for_scan() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + sut.IsAddEditProductVisible = true; + + // Act + sut.RefreshAllCommand.Execute(null); + + // Verify + Assert.False(sut.IsAddEditProductVisible); + _scanContextMock.Verify(x => x.NotifyStarted(products.Count)); + foreach (var product in sut.Products) + { + Mock.Get(product.RefreshPriceCommand).Verify(x => x.Execute(null), Times.Once); + } + } + + [Fact] + public void RefreshAllCommand__Enqueues_selected_products_for_scan() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + sut.Products[0].IsSelected = true; + sut.Products[2].IsSelected = true; + + // Act + sut.RefreshSelectedCommand.Execute(null); + + // Verify + _scanContextMock.Verify(x => x.NotifyStarted(2)); + foreach (var product in sut.Products) + { + var times = product == sut.Products[0] || product == sut.Products[2] ? Times.Once() : Times.Never(); + Mock.Get(product.RefreshPriceCommand).Verify(x => x.Execute(null), times); + } + } + + [Fact] + public void OpenAddProductFlyoutCommand__When_flyout_is_closed__Flyout_shows_up() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + sut.IsAddEditProductVisible = false; + sut.EditingProduct = null; + + // Act + sut.OpenAddProductFlyoutCommand.Execute(null); + + // Verify + Assert.True(sut.IsAddEditProductVisible); + Assert.NotNull(sut.EditingProduct); + Assert.Equal(Guid.Empty, sut.EditingProduct.Id); + } + + [Fact] + public void OpenAddProductFlyoutCommand__When_flyout_is_opened__Flyout_closed() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + sut.IsAddEditProductVisible = true; + + // Act + sut.OpenAddProductFlyoutCommand.Execute(null); + + // Verify + Assert.False(sut.IsAddEditProductVisible); + } + + [Fact] + public void OpenAddProductFlyoutCommand__Product_committed__List_reloaded_and_flyout_closed() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + sut.OpenAddProductFlyoutCommand.Execute(null); // trigger to open flyout + + // Act + ((Subject)sut.EditingProduct.CommitProductCommand.Executed).OnNext(Unit.Default); + + // Verify + Assert.False(sut.IsAddEditProductVisible); + _productRepoMock.Verify(x => x.GetAll(), Times.Exactly(2)); + } + + [Fact] + public void OpenEditProductFlyoutCommand__When_flyout_is_closed__Flyout_shows_up() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + sut.IsAddEditProductVisible = false; + sut.Products[0].IsSelected = true; + sut.EditingProduct = null; + + // Act + sut.OpenEditProductFlyoutCommand.Execute(null); + + // Verify + Assert.True(sut.IsAddEditProductVisible); + Assert.NotNull(sut.EditingProduct); + Assert.Equal(products.First().Id, sut.EditingProduct.Id); + } + + [Fact] + public void OpenEditProductFlyoutCommand__When_flyout_is_opened__Flyout_closed() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + sut.IsAddEditProductVisible = true; + + // Act + sut.OpenEditProductFlyoutCommand.Execute(null); + + // Verify + Assert.False(sut.IsAddEditProductVisible); + } + + [Fact] + public void OpenEditProductFlyoutCommand__Product_committed__Flyout_closed() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + sut.Products[0].IsSelected = true; + sut.OpenEditProductFlyoutCommand.Execute(null); // trigger to open flyout + + // Act + ((Subject)sut.EditingProduct.CommitProductCommand.Executed).OnNext(Unit.Default); + + // Verify + Assert.False(sut.IsAddEditProductVisible); + } + + [Fact] + public void DeleteProductCommand__User_confirmed__Flyout_closed_and_product_removed() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + sut.Products[1].IsSelected = true; + sut.IsAddEditProductVisible = true; + _uiMock.Setup(x => x.AskForConfirmation(It.IsAny(), It.IsAny())).Returns(true); + + // Act + sut.DeleteProductCommand.Execute(null); + + // Verify + var deletedProductId = products.ElementAt(1).Id; + Assert.False(sut.IsAddEditProductVisible); + Assert.DoesNotContain(sut.Products, x => x.Id == deletedProductId); + Assert.Equal(products.Count - 1, sut.Products.Count); + _productRepoMock.Verify(x => x.Delete(deletedProductId)); + } + + [Fact] + public void DeleteProductCommand__User_not_confirmed__Flyout_closed_and_product_remained() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + sut.Products[1].IsSelected = true; + sut.IsAddEditProductVisible = true; + _uiMock.Setup(x => x.AskForConfirmation(It.IsAny(), It.IsAny())).Returns(false); + + // Act + sut.DeleteProductCommand.Execute(null); + + // Verify + var deletingProductId = products.ElementAt(1).Id; + Assert.False(sut.IsAddEditProductVisible); + Assert.Contains(sut.Products, x => x.Id == deletingProductId); + Assert.Equal(products.Count, sut.Products.Count); + _productRepoMock.Verify(x => x.Delete(deletingProductId), Times.Never); + } + + [Fact] + public void DeleteProductCommand__No_product_selected__Flyout_closed_and_operation_cancelled() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + sut.IsAddEditProductVisible = true; + + // Act + sut.DeleteProductCommand.Execute(null); + + // Verify + Assert.False(sut.IsAddEditProductVisible); + Assert.Equal(products.Count, sut.Products.Count); + _productRepoMock.Verify(x => x.Delete(It.IsAny()), Times.Never); + _uiMock.Verify(x => x.AskForConfirmation(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void ProductScanStartedEvent_fired__Appropriate_product_changed_status() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + var productScanningIndex = 1; + + // Act + _productScanStartedEventSubject.OnNext(new ProductScanStartedEvent(products.ElementAt(productScanningIndex))); + + // Verify + Assert.Equal(ProductScanStatus.Scanning, sut.Products[productScanningIndex].Status); + } + + [Fact] + public void ProductScannedEvent_fired__Appropriate_product_changed_status() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + var lowestUpdated = Fixture.Create(); + var productScannedIndex = 1; + + // Act + _productScannedEventSubject.OnNext(new ProductScannedEvent(products.ElementAt(productScannedIndex), lowestUpdated)); + + // Verify + Mock.Get(sut.Products[productScannedIndex]).Verify(x => x.Reconcile(lowestUpdated), Times.Once); + } + + [Fact] + public void ProductScanFailedEvent_fired__Appropriate_product_set_to_failed() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + var productFailedIndex = 1; + var errorMessage = Fixture.Create(); + + // Act + _productScanFailedEventSubject.OnNext(new ProductScanFailedEvent(products.ElementAt(productFailedIndex), errorMessage)); + + // Verify + Mock.Get(sut.Products[productFailedIndex]).Verify(x => x.SetFailed(errorMessage), Times.Once); + } + + [Fact] + public void Deactivated__Flyout_closed() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + sut.IsAddEditProductVisible = true; + + // Act + sut.Deactivated.Execute(null); + + // Verify + Assert.False(sut.IsAddEditProductVisible); + } + + [Fact] + public void Product_status_changed__Scan_context_notified() + { + // Arrange + var products = SampleProducts(); + var sut = CreateSystemUnderTest(); + var product = sut.Products[1]; + var status = Fixture.Create>() + .First(x => x != product.Status); + + // Act + RaisePropertyChanged(Mock.Get(product), x => x.Status, status); + + // Verify + _scanContextMock.Verify(x => x.NotifyProgressChange(status)); + } + + private TrackerViewModel CreateSystemUnderTest() + { + return new TrackerViewModel(EventBusMock.Object, _productRepoMock.Object, _vmFactoryMock.Object, + _uiMock.Object, _scanContextMock.Object); + } + + private ICollection SampleProducts() + { + var products = Fixture.CreateMany().ToList(); + _productRepoMock.Setup(x => x.GetAll()).Returns(products); + return products; + } + } +} diff --git a/PriceChecker.UI/App.xaml.cs b/PriceChecker.UI/App.xaml.cs index 0cf5812..4bcec13 100644 --- a/PriceChecker.UI/App.xaml.cs +++ b/PriceChecker.UI/App.xaml.cs @@ -62,13 +62,13 @@ private void ConfigureServices(IServiceCollection services) // Views, View models, and the View model factory services.AddTransient(); services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); // Services and Helpers: services.AddTransient(); diff --git a/PriceChecker.UI/Helpers/TrackerScanContext.cs b/PriceChecker.UI/Helpers/TrackerScanContext.cs index 07ef94c..08c5f43 100644 --- a/PriceChecker.UI/Helpers/TrackerScanContext.cs +++ b/PriceChecker.UI/Helpers/TrackerScanContext.cs @@ -82,6 +82,8 @@ private double CalculateProgress() public IObservable<(TrackerScanStatus Status, double Progress)> ScanProgress => _scanProgress; + public bool IsStarted => _started; + public int FinishedJobs => _finished; public bool HasErrors { get; private set; } public bool HasNewLowestPrice { get; private set; } } diff --git a/PriceChecker.UI/Helpers/UserInteraction.cs b/PriceChecker.UI/Helpers/UserInteraction.cs index 9aa7353..2a48f05 100644 --- a/PriceChecker.UI/Helpers/UserInteraction.cs +++ b/PriceChecker.UI/Helpers/UserInteraction.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Windows; using Genius.PriceChecker.Core.Models; using Genius.PriceChecker.Core.Repositories; @@ -30,6 +31,7 @@ public interface IUserInteraction void ShowProductInBrowser(ProductSource productSource); } + [ExcludeFromCodeCoverage] public class UserInteraction : IUserInteraction { private readonly IAgentRepository _agentRepo; diff --git a/PriceChecker.UI/PriceChecker.UI.csproj b/PriceChecker.UI/PriceChecker.UI.csproj index 06f8e6c..d7a46da 100644 --- a/PriceChecker.UI/PriceChecker.UI.csproj +++ b/PriceChecker.UI/PriceChecker.UI.csproj @@ -14,6 +14,11 @@ DEBUG + + + + + diff --git a/PriceChecker.UI/ViewModels/AgentViewModel.cs b/PriceChecker.UI/ViewModels/AgentViewModel.cs index 8c8a193..e2ea5c3 100644 --- a/PriceChecker.UI/ViewModels/AgentViewModel.cs +++ b/PriceChecker.UI/ViewModels/AgentViewModel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; @@ -8,19 +9,25 @@ namespace Genius.PriceChecker.UI.ViewModels { - public class AgentViewModel : ViewModelBase, IHasDirtyFlag, ISelectable + public interface IAgentViewModel : IViewModel, IHasDirtyFlag, ISelectable { - private readonly AgentsViewModel _owner; + Agent CreateEntity(); + void ResetForm(); + + string Id { get; } + } + + internal sealed class AgentViewModel : ViewModelBase, IAgentViewModel + { + private readonly IAgentsViewModel _owner; private readonly Agent _agent; - public AgentViewModel(AgentsViewModel owner, Agent agent) + public AgentViewModel(IAgentsViewModel owner, Agent agent) { _owner = owner; _agent = agent; ResetForm(true); - - PropertiesAreInitialized = true; } public Agent CreateEntity() @@ -33,18 +40,29 @@ public Agent CreateEntity() }; } - public void ResetForm(bool enforeInitialization = false) + public void ResetForm() { - if (_agent == null && !enforeInitialization) + ResetForm(false); + } + + private void ResetForm(bool firstTimeInit) + { + if (_agent == null && !firstTimeInit) { return; } - Id = _agent?.Id; - Url = _agent?.Url; - PricePattern = _agent?.PricePattern; - DecimalDelimiter = _agent?.DecimalDelimiter ?? '.'; - IsDirty = false; + Action init = () => { + Id = _agent?.Id; + Url = _agent?.Url; + PricePattern = _agent?.PricePattern; + DecimalDelimiter = _agent?.DecimalDelimiter ?? '.'; + }; + + if (firstTimeInit) + this.InitializeProperties(init); + else + init(); } [Browsable(false)] diff --git a/PriceChecker.UI/ViewModels/AgentsViewModel.cs b/PriceChecker.UI/ViewModels/AgentsViewModel.cs index e11ffe0..34ddc1f 100644 --- a/PriceChecker.UI/ViewModels/AgentsViewModel.cs +++ b/PriceChecker.UI/ViewModels/AgentsViewModel.cs @@ -8,7 +8,12 @@ namespace Genius.PriceChecker.UI.ViewModels { - public class AgentsViewModel : TabViewModelBase, IHasDirtyFlag + public interface IAgentsViewModel : ITabViewModel + { + ObservableCollection Agents { get; } + } + + internal sealed class AgentsViewModel : TabViewModelBase, IAgentsViewModel, IHasDirtyFlag { private readonly IAgentRepository _agentRepo; private readonly IViewModelFactory _vmFactory; @@ -54,11 +59,9 @@ public AgentsViewModel(IAgentRepository agentRepo, IViewModelFactory vmFactory, } IsDirty = false; }, _ => IsDirty); - - PropertiesAreInitialized = true; } - private AgentViewModel CreateAgentViewModel(Agent x) + private IAgentViewModel CreateAgentViewModel(Agent x) { var agentVm = _vmFactory.CreateAgent(this, x); agentVm.WhenChanged(x => x.IsDirty, x => @@ -81,7 +84,8 @@ private void CommitAgents() IsDirty = false; } - public ObservableCollection Agents { get; } = new ObservableCollection(); + public ObservableCollection Agents { get; } + = new TypedObservableList(); public bool IsAddEditAgentVisible { diff --git a/PriceChecker.UI/ViewModels/LogItemViewModel.cs b/PriceChecker.UI/ViewModels/LogItemViewModel.cs index bea5948..cbf74ca 100644 --- a/PriceChecker.UI/ViewModels/LogItemViewModel.cs +++ b/PriceChecker.UI/ViewModels/LogItemViewModel.cs @@ -8,7 +8,12 @@ namespace Genius.PriceChecker.UI.ViewModels { - public class LogItemViewModel : ViewModelBase + public interface ILogItemViewModel : IViewModel + { + LogLevel Severity { get; } + } + + internal sealed class LogItemViewModel : ViewModelBase, ILogItemViewModel { public LogItemViewModel() { diff --git a/PriceChecker.UI/ViewModels/LogsViewModel.cs b/PriceChecker.UI/ViewModels/LogsViewModel.cs index bfd88bc..ffb290f 100644 --- a/PriceChecker.UI/ViewModels/LogsViewModel.cs +++ b/PriceChecker.UI/ViewModels/LogsViewModel.cs @@ -10,7 +10,11 @@ namespace Genius.PriceChecker.UI.ViewModels { - public class LogsViewModel : TabViewModelBase + public interface ILogsViewModel : ITabViewModel + { + } + + internal sealed class LogsViewModel : TabViewModelBase, ILogsViewModel { public LogsViewModel(IEventBus eventBus) { @@ -28,7 +32,7 @@ public LogsViewModel(IEventBus eventBus) LogItems.CollectionChanged += (_, args) => { if (HasNewErrors) return; - HasNewErrors = args.NewItems?.Cast() + HasNewErrors = args.NewItems?.Cast() .Any(x => x.Severity >= LogLevel.Error) ?? false; }; @@ -36,7 +40,8 @@ public LogsViewModel(IEventBus eventBus) Deactivated.Executed.Subscribe(_ => HasNewErrors = false); } - public ObservableCollection LogItems { get; } = new ObservableCollection(); + public ObservableCollection LogItems { get; } + = new TypedObservableList(); public bool HasNewErrors { diff --git a/PriceChecker.UI/ViewModels/MainViewModel.cs b/PriceChecker.UI/ViewModels/MainViewModel.cs index 330f7d2..8108459 100644 --- a/PriceChecker.UI/ViewModels/MainViewModel.cs +++ b/PriceChecker.UI/ViewModels/MainViewModel.cs @@ -7,16 +7,26 @@ namespace Genius.PriceChecker.UI.ViewModels { - public class MainViewModel : ViewModelBase + public interface IMainViewModel : IViewModel { + } + + internal sealed class MainViewModel : ViewModelBase, IMainViewModel + { + private readonly ITrackerScanContext _scanContext; + private readonly INotifyIconViewModel _notifyViewModel; + public MainViewModel( - TrackerViewModel tracker, - AgentsViewModel agents, - SettingsViewModel settings, - LogsViewModel logs, + ITrackerViewModel tracker, + IAgentsViewModel agents, + ISettingsViewModel settings, + ILogsViewModel logs, ITrackerScanContext scanContext, INotifyIconViewModel notifyViewModel) { + _scanContext = scanContext; + _notifyViewModel = notifyViewModel; + Tabs = new() { tracker, agents, @@ -24,37 +34,40 @@ public MainViewModel( logs }; - scanContext.ScanProgress.Subscribe(args => { - if (args.Status == Helpers.TrackerScanStatus.InProgress) - { - ProgressState = TaskbarItemProgressState.Normal; - ProgressValue = args.Progress; - } - else if (args.Status == Helpers.TrackerScanStatus.InProgressWithErrors) - { - ProgressState = TaskbarItemProgressState.Paused; - ProgressValue = args.Progress; - } - else if (args.Status == Helpers.TrackerScanStatus.Finished) - { - var message = scanContext.HasNewLowestPrice - ? "Prices for some products have become even lower! Check it out." - : "Nothing interesting has been caught."; - if (scanContext.HasErrors) - { - message += Environment.NewLine + "NOTE: Some products could not finish scanning properly. Check the logs for details."; - } - notifyViewModel.ShowBalloonTip("Scan finished", message, - scanContext.HasErrors ? BalloonIcon.Warning : BalloonIcon.Info); - ProgressState = TaskbarItemProgressState.None; - ProgressValue = 0; - } - else + scanContext.ScanProgress.Subscribe(args => UpdateProgress(args.Status, args.Progress)); + } + + private void UpdateProgress(TrackerScanStatus status, double progress) + { + if (status == Helpers.TrackerScanStatus.InProgress) + { + ProgressState = TaskbarItemProgressState.Normal; + ProgressValue = progress; + } + else if (status == Helpers.TrackerScanStatus.InProgressWithErrors) + { + ProgressState = TaskbarItemProgressState.Paused; + ProgressValue = progress; + } + else if (status == Helpers.TrackerScanStatus.Finished) + { + var message = _scanContext.HasNewLowestPrice ? + "Prices for some products have become even lower! Check it out." : + "Nothing interesting has been caught."; + if (_scanContext.HasErrors) { - ProgressState = TaskbarItemProgressState.None; - ProgressValue = 0; + message += Environment.NewLine + "NOTE: Some products could not finish scanning properly. Check the logs for details."; } - }); + _notifyViewModel.ShowBalloonTip("Scan finished", message, + _scanContext.HasErrors ? BalloonIcon.Warning : BalloonIcon.Info); + ProgressState = TaskbarItemProgressState.None; + ProgressValue = 0; + } + else + { + ProgressState = TaskbarItemProgressState.None; + ProgressValue = 0; + } } public List Tabs { get; } diff --git a/PriceChecker.UI/ViewModels/SettingsViewModel.cs b/PriceChecker.UI/ViewModels/SettingsViewModel.cs index ee816b2..8bd6a88 100644 --- a/PriceChecker.UI/ViewModels/SettingsViewModel.cs +++ b/PriceChecker.UI/ViewModels/SettingsViewModel.cs @@ -4,7 +4,11 @@ namespace Genius.PriceChecker.UI.ViewModels { - public class SettingsViewModel : TabViewModelBase + public interface ISettingsViewModel : ITabViewModel + { + } + + internal sealed class SettingsViewModel : TabViewModelBase, ISettingsViewModel { public SettingsViewModel(ISettingsRepository repo) { diff --git a/PriceChecker.UI/ViewModels/TrackerProductSourceViewModel.cs b/PriceChecker.UI/ViewModels/TrackerProductSourceViewModel.cs index 4619f09..586bea4 100644 --- a/PriceChecker.UI/ViewModels/TrackerProductSourceViewModel.cs +++ b/PriceChecker.UI/ViewModels/TrackerProductSourceViewModel.cs @@ -10,16 +10,16 @@ namespace Genius.PriceChecker.UI.ViewModels { - public class TrackerProductSourceViewModel : ViewModelBase + internal sealed class TrackerProductSourceViewModel : ViewModelBase { public TrackerProductSourceViewModel(IUserInteraction ui, ProductSource productSource, decimal? lastPrice) { - Id = productSource?.Id ?? Guid.NewGuid(); - Agent = productSource?.AgentId; - Argument = productSource?.AgentArgument; - LastPrice = lastPrice; - - PropertiesAreInitialized = true; + InitializeProperties(() => { + Id = productSource?.Id ?? Guid.NewGuid(); + Agent = productSource?.AgentId; + Argument = productSource?.AgentArgument; + LastPrice = lastPrice; + }); ShowInBrowserCommand = new ActionCommand(_ => { ui.ShowProductInBrowser(productSource); diff --git a/PriceChecker.UI/ViewModels/TrackerProductViewModel.cs b/PriceChecker.UI/ViewModels/TrackerProductViewModel.cs index 50d5b06..bb9323a 100644 --- a/PriceChecker.UI/ViewModels/TrackerProductViewModel.cs +++ b/PriceChecker.UI/ViewModels/TrackerProductViewModel.cs @@ -17,11 +17,24 @@ using Genius.PriceChecker.UI.Forms.ViewModels; using Genius.PriceChecker.UI.Helpers; using Genius.PriceChecker.UI.ValueConverters; +using ReactiveUI; namespace Genius.PriceChecker.UI.ViewModels { + public interface ITrackerProductViewModel : IViewModel, ISelectable + { + void Reconcile(bool lowestPriceUpdated); + void SetFailed(string errorMessage); + + Guid Id { get; } + string Name { get; } + ProductScanStatus Status { get; set; } + IActionCommand CommitProductCommand { get; } + IActionCommand RefreshPriceCommand { get; } + } + [ShowOnlyBrowsable(true)] - public class TrackerProductViewModel : ViewModelBase, ISelectable + internal sealed class TrackerProductViewModel : ViewModelBase, ITrackerProductViewModel { private readonly IAgentRepository _agentRepo; private readonly IProductPriceManager _productMng; @@ -43,14 +56,16 @@ public TrackerProductViewModel(Product product, IEventBus eventBus, _product = product; _ui = ui; - RefreshAgents(); - RefreshCategories(); + InitializeProperties(() => { + RefreshAgents(); + RefreshCategories(); - if (_product != null) - { - ResetForm(); - Reconcile(false); - } + if (_product != null) + { + ResetForm(); + Reconcile(false); + } + }); CommitProductCommand = new ActionCommand(_ => CommitProduct()); @@ -78,17 +93,15 @@ public TrackerProductViewModel(Product product, IEventBus eventBus, }, _ => Status != ProductScanStatus.Scanning); eventBus.WhenFired() - .ObserveOnDispatcher() + .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => RefreshAgents() ); eventBus.WhenFired() - .ObserveOnDispatcher() + .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => RefreshCategories() ); - - PropertiesAreInitialized = true; } public void Reconcile(bool lowestPriceUpdated) @@ -272,12 +285,14 @@ public bool IsSelected } public IActionCommand CommitProductCommand { get; } + [Browsable(true)] [Icon("Web16")] public IActionCommand ShowInBrowserCommand { get; } public IActionCommand AddSourceCommand { get; } public IActionCommand ResetCommand { get; } public IActionCommand DropPricesCommand { get; } + [Browsable(true)] [Icon("Refresh16")] public IActionCommand RefreshPriceCommand { get; } diff --git a/PriceChecker.UI/ViewModels/TrackerViewModel.cs b/PriceChecker.UI/ViewModels/TrackerViewModel.cs index 3cdfbe1..3bd8b9c 100644 --- a/PriceChecker.UI/ViewModels/TrackerViewModel.cs +++ b/PriceChecker.UI/ViewModels/TrackerViewModel.cs @@ -11,10 +11,14 @@ using Genius.PriceChecker.UI.Forms.Attributes; using Genius.PriceChecker.UI.Forms.ViewModels; using Genius.PriceChecker.UI.Helpers; +using ReactiveUI; namespace Genius.PriceChecker.UI.ViewModels { - public class TrackerViewModel : TabViewModelBase + public interface ITrackerViewModel : ITabViewModel + { } + + internal sealed class TrackerViewModel : TabViewModelBase, ITrackerViewModel { private readonly IEventBus _eventBus; private readonly IProductRepository _productRepo; @@ -55,7 +59,7 @@ public TrackerViewModel(IEventBus eventBus, }); OpenEditProductFlyoutCommand = new ActionCommand(o => { EditingProduct = Products.FirstOrDefault(x => x.IsSelected); - EditingProduct.CommitProductCommand.Executed + EditingProduct?.CommitProductCommand.Executed .Take(1) .Subscribe(_ => { IsAddEditProductVisible = false; @@ -79,7 +83,7 @@ public TrackerViewModel(IEventBus eventBus, }); _eventBus.WhenFired() - .ObserveOnDispatcher() + .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(ev => Products.First(x => x.Id == ev.Product.Id).Status = Core.Models.ProductScanStatus.Scanning ); @@ -101,11 +105,9 @@ public TrackerViewModel(IEventBus eventBus, }; ReloadList(); - - PropertiesAreInitialized = true; } - private void EnqueueScan(ICollection products) + private void EnqueueScan(ICollection products) { _scanContext.NotifyStarted(products.Count); foreach (var product in products) @@ -129,9 +131,10 @@ private void ReloadList() Products.ReplaceItems(productVms); } - public static List RefreshOptions { get; private set; } + public List RefreshOptions { get; private set; } - public ObservableCollection Products { get; } = new ObservableCollection(); + public ObservableCollection Products { get; } + = new TypedObservableList(); [FilterContext] public string Filter @@ -146,9 +149,9 @@ public bool IsAddEditProductVisible set => RaiseAndSetIfChanged(value); } - public TrackerProductViewModel EditingProduct + public ITrackerProductViewModel EditingProduct { - get => GetOrDefault(); + get => GetOrDefault(); set => RaiseAndSetIfChanged(value); } diff --git a/PriceChecker.UI/ViewModels/ViewModelFactory.cs b/PriceChecker.UI/ViewModels/ViewModelFactory.cs index 23266b8..86611f0 100644 --- a/PriceChecker.UI/ViewModels/ViewModelFactory.cs +++ b/PriceChecker.UI/ViewModels/ViewModelFactory.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Genius.PriceChecker.Core.Models; using Genius.PriceChecker.Core.Repositories; using Genius.PriceChecker.Core.Services; @@ -8,11 +9,12 @@ namespace Genius.PriceChecker.UI.ViewModels { public interface IViewModelFactory { - AgentViewModel CreateAgent(AgentsViewModel owner, Agent agent); - TrackerProductViewModel CreateTrackerProduct(Product product); + IAgentViewModel CreateAgent(IAgentsViewModel owner, Agent agent); + ITrackerProductViewModel CreateTrackerProduct(Product product); } - public class ViewModelFactory : IViewModelFactory + [ExcludeFromCodeCoverage] + internal sealed class ViewModelFactory : IViewModelFactory { private readonly IAgentRepository _agentRepo; private readonly IProductRepository _productRepo; @@ -35,12 +37,12 @@ public ViewModelFactory(IEventBus eventBus, _ui = ui; } - public AgentViewModel CreateAgent(AgentsViewModel owner, Agent agent) + public IAgentViewModel CreateAgent(IAgentsViewModel owner, Agent agent) { return new AgentViewModel(owner, agent); } - public TrackerProductViewModel CreateTrackerProduct(Product product) + public ITrackerProductViewModel CreateTrackerProduct(Product product) { return new TrackerProductViewModel(product, _eventBus, _agentRepo, _productPriceMng, _productRepo, _statusProvider, _ui); diff --git a/PriceChecker.UI/Views/MainWindow.xaml.cs b/PriceChecker.UI/Views/MainWindow.xaml.cs index 588ed74..d3ea768 100644 --- a/PriceChecker.UI/Views/MainWindow.xaml.cs +++ b/PriceChecker.UI/Views/MainWindow.xaml.cs @@ -7,7 +7,7 @@ namespace Genius.PriceChecker.UI.Views [ExcludeFromCodeCoverage] public partial class MainWindow : MetroWindow { - public MainWindow(MainViewModel mainVm) + public MainWindow(IMainViewModel mainVm) { InitializeComponent(); diff --git a/PriceChecker.UI/Views/Tracker.xaml b/PriceChecker.UI/Views/Tracker.xaml index 3934352..8b477a1 100644 --- a/PriceChecker.UI/Views/Tracker.xaml +++ b/PriceChecker.UI/Views/Tracker.xaml @@ -23,7 +23,7 @@ Command="{Binding OpenEditProductFlyoutCommand}" /> + ItemsSource="{Binding RefreshOptions}">