diff --git a/.editorconfig b/.editorconfig index 32b1dc5..84c570c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,141 @@ [*.cs] -# RCS1090: Add call to 'ConfigureAwait' (or vice versa) -dotnet_diagnostic.RCS1090.severity = none #suggestion +dotnet_naming_rule.private_members_with_underscore.symbols = private_fields +dotnet_naming_rule.private_members_with_underscore.style = prefix_underscore +dotnet_naming_rule.private_members_with_underscore.severity = suggestion +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.prefix_underscore.capitalization = camel_case +dotnet_naming_style.prefix_underscore.required_prefix = _ + +dotnet_diagnostic.CA1001.severity = warning # CA1001: Types that own disposable fields should be disposable +dotnet_diagnostic.CA1003.severity = warning # CA1003: Use generic event handler instances +dotnet_diagnostic.CA1012.severity = error # CA1012: Abstract types should not have public constructors +dotnet_diagnostic.CA1018.severity = error # CA1018: Mark attributes with AttributeUsageAttribute +dotnet_diagnostic.CA1027.severity = warning # CA1027: Mark enums with FlagsAttribute +dotnet_diagnostic.CA1032.severity = warning # CA1032: Implement standard exception constructors +dotnet_diagnostic.CA1033.severity = error # CA1033: Interface methods should be callable by child types +dotnet_diagnostic.CA1036.severity = suggestion # CA1036: Override methods on comparable types +dotnet_diagnostic.CA1044.severity = error # CA1044: Properties should not be write only +dotnet_diagnostic.CA1046.severity = error # CA1046: Do not overload equality operator on reference types +dotnet_diagnostic.CA1050.severity = error # CA1050: Declare types in namespaces +dotnet_diagnostic.CA1052.severity = error # CA1052: Static holder types should be Static or NotInheritable +dotnet_diagnostic.CA1053.severity = error # CA1053: Static holder types should not have default constructors +dotnet_diagnostic.CA1058.severity = warning # CA1058: Types should not extend certain base types +dotnet_diagnostic.CA1060.severity = error # CA1060: Move pinvokes to native methods class +dotnet_diagnostic.CA1061.severity = error # CA1061: Do not hide base class methods +dotnet_diagnostic.CA1062.severity = warning # CA1062: Validate arguments of public methods +dotnet_diagnostic.CA1066.severity = warning # CA1066: Implement IEquatable when overriding Object.Equals +dotnet_diagnostic.CA1067.severity = warning # CA1067: Override Object.Equals(object) when implementing IEquatable +dotnet_diagnostic.CA1068.severity = warning # CA1068: CancellationToken parameters must come last +dotnet_diagnostic.CA1069.severity = warning # CA1069: Enums values should not be duplicated +dotnet_diagnostic.CA1070.severity = error # CA1070: Do not declare event fields as virtual +dotnet_diagnostic.CA1304.severity = error # CA1304: Specify CultureInfo +dotnet_diagnostic.CA1305.severity = error # CA1305: Specify IFormatProvider +dotnet_diagnostic.CA1307.severity = suggestion # CA1307: Specify StringComparison for clarity +dotnet_diagnostic.CA1310.severity = error # CA1310: Specify StringComparison for correctness +dotnet_diagnostic.CA1311.severity = error # CA1311: Specify Culture for ToLower and ToUpper +dotnet_diagnostic.CA1507.severity = error # CA1507: Use nameof to express symbol names +dotnet_diagnostic.CA1708.severity = error # CA1708: Identifiers should differ by more than case +dotnet_diagnostic.CA1711.severity = suggestion # CA1711: Identifiers should not have incorrect suffix +dotnet_diagnostic.CA1712.severity = error # CA1712: Do not prefix enum values with type name +dotnet_diagnostic.CA1715.severity = warning # CA1715: Identifiers should have correct prefix +dotnet_diagnostic.CA1720.severity = suggestion # CA1720: Identifier contains type name +dotnet_diagnostic.CA1725.severity = error # CA1725: Parameter names should match base declaration +dotnet_diagnostic.CA1802.severity = warning # CA1802: Use literals where appropriate +dotnet_diagnostic.CA1805.severity = warning # CA1805: Do not initialize unnecessarily +dotnet_diagnostic.CA1806.severity = warning # CA1806: Do not ignore method results +dotnet_diagnostic.CA1822.severity = suggestion # CA1822: Mark members as static +dotnet_diagnostic.CA1823.severity = error # CA1823: Avoid unused private fields +dotnet_diagnostic.CA1825.severity = warning # CA1825: Avoid zero-length array allocations +dotnet_diagnostic.CA1827.severity = warning # CA1827: Do not use Count() or LongCount() when Any() can be used +dotnet_diagnostic.CA1828.severity = warning # CA1828: Do not use CountAsync() or LongCountAsync() when AnyAsync() can be used +dotnet_diagnostic.CA1829.severity = warning # CA1829: Use Length/Count property instead of Count() when available +dotnet_diagnostic.CA1830.severity = warning # CA1830: Prefer strongly-typed Append and Insert method overloads on StringBuilder +dotnet_diagnostic.CA1831.severity = warning # CA1831: Use AsSpan instead of Range-based indexers for string when appropriate +dotnet_diagnostic.CA1832.severity = warning # CA1832: Use AsSpan or AsMemory instead of Range-based indexers for getting ReadOnlySpan or ReadOnlyMemory portion of an array +dotnet_diagnostic.CA1833.severity = warning # CA1833: Use AsSpan or AsMemory instead of Range-based indexers for getting Span or Memory portion of an array +dotnet_diagnostic.CA1834.severity = warning # CA1834: Consider using 'StringBuilder.Append(char)' when applicable +dotnet_diagnostic.CA1836.severity = error # CA1836: Prefer IsEmpty over Count +dotnet_diagnostic.CA1837.severity = error # CA1837: Use 'Environment.ProcessId' +dotnet_diagnostic.CA1838.severity = error # CA1838: Avoid 'StringBuilder' parameters for P/Invokes +dotnet_diagnostic.CA1839.severity = error # CA1839: Use 'Environment.ProcessPath' +dotnet_diagnostic.CA1840.severity = error # CA1840: Use 'Environment.CurrentManagedThreadId' +dotnet_diagnostic.CA1841.severity = error # CA1841: Prefer Dictionary.Contains +dotnet_diagnostic.CA1842.severity = error # CA1842: Do not use WhenAll with a single task +dotnet_diagnostic.CA1843.severity = error # CA1843: Do not use WaitAll with a single task +dotnet_diagnostic.CA1844.severity = error # CA1844: Provide memory-based overrides of async methods when subclassing 'Stream' +dotnet_diagnostic.CA1845.severity = error # CA1845: Use span-based 'string.Concat' +dotnet_diagnostic.CA1846.severity = error # CA1846: Prefer AsSpan over Substring +dotnet_diagnostic.CA1847.severity = warning # CA1847: Use char literal for a single character lookup +dotnet_diagnostic.CA1849.severity = error # CA1849: Call async methods when in an async method +dotnet_diagnostic.CA1850.severity = error # CA1850: Prefer static HashData method over ComputeHash +dotnet_diagnostic.CA1851.severity = error # CA1851: Possible multiple enumerations of IEnumerable collection +dotnet_diagnostic.CA1853.severity = warning # CA1853: Unnecessary call to 'Dictionary.ContainsKey()' +dotnet_diagnostic.CA1854.severity = warning # CA1854: Prefer the IDictionary.TryGetValue() method +dotnet_diagnostic.CA1855.severity = warning # CA1855: Use Span.Clear instead of Span.Fill +dotnet_diagnostic.CA2000.severity = warning # CA2000: Dispose objects before losing scope +dotnet_diagnostic.CA2002.severity = warning # CA2002: Do not lock on objects with weak identity +dotnet_diagnostic.CA2007.severity = suggestion # CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2008.severity = suggestion # CA2008: Do not create tasks without passing a TaskScheduler +dotnet_diagnostic.CA2009.severity = warning # CA2009: Do not call ToImmutableCollection on an ImmutableCollection value +dotnet_diagnostic.CA2011.severity = warning # CA2011: Avoid infinite recursion +dotnet_diagnostic.CA2012.severity = warning # CA2012: Use ValueTasks correctly +dotnet_diagnostic.CA2013.severity = warning # CA2013: Do not use ReferenceEquals with value types +dotnet_diagnostic.CA2014.severity = error # CA2014: Do not use stackalloc in loops +dotnet_diagnostic.CA2016.severity = error # CA2016: Forward the 'CancellationToken' parameter to methods that take one +dotnet_diagnostic.CA2019.severity = error # CA2019: ThreadStatic fields should not use inline initialization +dotnet_diagnostic.CA2020.severity = error # CA2020: Prevent behavioral change caused by built-in operators of IntPtr/UIntPtr +dotnet_diagnostic.CA2100.severity = warning # CA2100: Review SQL queries for security vulnerabilities +dotnet_diagnostic.CA2101.severity = warning # CA2101: Specify marshaling for P/Invoke string arguments +dotnet_diagnostic.CA2119.severity = warning # CA2119: Seal methods that satisfy private interfaces +dotnet_diagnostic.CA2153.severity = warning # CA2153: Avoid handling Corrupted State Exceptions +dotnet_diagnostic.CA2200.severity = error # CA2200: Rethrow to preserve stack details +dotnet_diagnostic.CA2201.severity = warning # CA2201: Do not raise reserved exception types +dotnet_diagnostic.CA2207.severity = warning # CA2207: Initialize value type static fields inline +dotnet_diagnostic.CA2208.severity = warning # CA2208: Instantiate argument exceptions correctly +dotnet_diagnostic.CA2211.severity = error # CA2211: Non-constant fields should not be visible +dotnet_diagnostic.CA2213.severity = warning # CA2213: Disposable fields should be disposed +dotnet_diagnostic.CA2214.severity = error # CA2214: Do not call overridable methods in constructors +dotnet_diagnostic.CA2215.severity = error # CA2215: Dispose methods should call base class dispose +dotnet_diagnostic.CA2217.severity = warning # CA2217: Do not mark enums with FlagsAttribute +dotnet_diagnostic.CA2218.severity = error # CA2218: Override GetHashCode on overriding Equals +dotnet_diagnostic.CA2219.severity = warning # CA2219: Do not raise exceptions in finally clauses +dotnet_diagnostic.CA2224.severity = error # CA2224: Override Equals on overloading operator equals +dotnet_diagnostic.CA2231.severity = warning # CA2231: Overload operator equals on overriding value type Equals +dotnet_diagnostic.CA2241.severity = warning # CA2241: Provide correct arguments to formatting methods +dotnet_diagnostic.CA2242.severity = warning # CA2242: Test for NaN correctly +dotnet_diagnostic.CA2244.severity = warning # CA2244: Do not duplicate indexed element initializations +dotnet_diagnostic.CA2245.severity = error # CA2245: Do not assign a property to itself +dotnet_diagnostic.CA2246.severity = warning # CA2246: Assigning symbol and its member in the same statement +dotnet_diagnostic.CA2248.severity = error # CA2248: Provide correct 'enum' argument to 'Enum.HasFlag' +dotnet_diagnostic.CA2249.severity = error # CA2249: Consider using 'string.Contains' instead of 'string.IndexOf' +dotnet_diagnostic.CA2250.severity = error # CA2250: Use 'ThrowIfCancellationRequested' +dotnet_diagnostic.CA2251.severity = error # CA2251: Use 'string.Equals' +dotnet_diagnostic.CA5351.severity = warning # CA5351: Do Not Use Broken Cryptographic Algorithms +dotnet_diagnostic.CA5364.severity = warning # CA5364: Do Not Use Deprecated Security Protocols +dotnet_diagnostic.CS8509.severity = warning # CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive) +dotnet_diagnostic.RCS1075.severity = suggestion # Avoid empty catch clause that catches System.Exception +dotnet_diagnostic.RCS1090.severity = suggestion # RCS1090: Add call to 'ConfigureAwait' (or vice versa) + +# CA1001: Subject to ignore +dotnet_code_quality.CA1001.excluded_symbol_names = T:System.Reactive.Subjects.Subject* + +# CA1062: Do not analyze in method extensions +dotnet_code_quality.CA1062.exclude_extension_method_this_parameter = true + +# CA2000: Satisfy when object is being created and immediately taken by someone (whether with a method or as a ctor parameter). +# Example: https://github.com/dotnet/roslyn-analyzers/blob/main/docs/Analyzer%20Configuration.md#configure-dispose-ownership-transfer-for-arguments-passed-to-constructor-invocation +#dotnet_code_quality.dispose_analysis_kind = AllPathsOnlyNotDisposed +dotnet_code_quality.dispose_ownership_transfer_at_constructor = true +dotnet_code_quality.dispose_ownership_transfer_at_method_call = true + +[{**/*Tests.cs,**/StepDefinitions/*.cs}] +dotnet_diagnostic.CA1062.severity = suggestion # CA1062: Validate arguments of public methods +dotnet_diagnostic.CA1304.severity = suggestion # CA1304: Specify CultureInfo +dotnet_diagnostic.CA1305.severity = suggestion # CA1305: Specify IFormatProvider +dotnet_diagnostic.CA1311.severity = suggestion # CA1311: Specify Culture for ToLower and ToUpper +dotnet_diagnostic.VSTHRD105.severity = none # Avoid method overloads that assume TaskScheduler.Current. Use an overload that accepts a TaskScheduler and specify TaskScheduler.Default (or any other) explicitly +dotnet_diagnostic.VSTHRD200.severity = none # Use "Async" suffix for async methods + +[*TestingUtil/*.cs] +dotnet_diagnostic.VSTHRD002.severity = none # Use await or JoinableTaskFactory.Run instead diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 5661c6d..468c722 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -8,6 +8,9 @@ jobs: release: name: Release runs-on: windows-latest + env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c609fea..e63317b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ jobs: build: runs-on: windows-latest + env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true steps: - uses: actions/checkout@v2 diff --git a/Atom.targets b/Atom.targets new file mode 100644 index 0000000..f3c2d16 --- /dev/null +++ b/Atom.targets @@ -0,0 +1,77 @@ + + + ..\atom\ + + + + + Atom.Data + $(MSBuildThisFileDirectory)$(AtomLocation)$(Name) + true + + + + + + + $(Path)\bin\Debug\net8.0\Genius.$(Name).dll + + + + + + + + Atom.Infrastructure + $(MSBuildThisFileDirectory)$(AtomLocation)$(Name) + true + + + + + $(Path)\bin\Debug\net8.0\Genius.$(Name).dll + + + + + + + $(Path).TestingUtil\bin\Debug\net8.0\Genius.$(Name).TestingUtil.dll + + + + + + + + + Atom.UI.Forms + $(MSBuildThisFileDirectory)$(AtomLocation)$(Name) + true + + + + + $(Path)\bin\Debug\$(UiTargetFramework)\Genius.$(Name).dll + + + + + <_AtomUiFormsThirdPartyDependencies Include="$(Path)\bin\Debug\$(UiTargetFramework)\*.dll" /> + + + + + + + $(Path).TestingUtil\bin\Debug\$(UiTargetFramework)\Genius.$(Name).TestingUtil.dll + + + + + + diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..8d44791 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,90 @@ + + + 1.0.1 + Price Checker + Dima Kravtsov + A simple price checker + https://github.com/hwndmaster/price-checker + + enable + enable + 12.0 + + NU1604 + + + en + + Genius.$(MSBuildProjectName) + $(RootNamespace) + + False + True + net8.0-windows10.0.19041 + + + + net8.0 + + + net8.0-windows10.0.19041 + + + + + + + + + + + + + + + + + + + + + + + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Directory.Build.targets b/Directory.Build.targets deleted file mode 100644 index 1581b76..0000000 --- a/Directory.Build.targets +++ /dev/null @@ -1,56 +0,0 @@ - - - enable - enable - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - None - false - - diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..2094ac2 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,58 @@ + + + + true + true + 8.0.0 + 0.0.41 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GlobalSuppressions.cs b/GlobalSuppressions.cs new file mode 100644 index 0000000..6885fe3 --- /dev/null +++ b/GlobalSuppressions.cs @@ -0,0 +1,20 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +// SonarLint-related analyzers cannot be disabled in .editorconfig file, therefore disabling them here. + +[assembly: SuppressMessage("", "S112:General or reserved exceptions should never be thrown", Justification = "")] +[assembly: SuppressMessage("", "S127:\"for\" loop stop conditions should be invariant", Justification = "")] +[assembly: SuppressMessage("", "S1066:Mergeable \"if\" statements should be combined", Justification = "")] +[assembly: SuppressMessage("", "S1121:Assignments should not be made from within sub-expressions", Justification = "")] +[assembly: SuppressMessage("", "S3060:\"is\" should not be used with \"this\"", Justification = "")] +[assembly: SuppressMessage("", "S3267:Loops should be simplified with \"LINQ\" expressions", Justification = "")] +[assembly: SuppressMessage("", "S3358:Ternary operators should not be nested", Justification = "")] +[assembly: SuppressMessage("", "S3963:\"static\" fields should be initialized inline", Justification = "")] +[assembly: SuppressMessage("", "S6602:\"Find\" method should be used instead of the \"FirstOrDefault\" extension", Justification = "")] +[assembly: SuppressMessage("", "S6605:Collection-specific \"Exists\" method should be used instead of the \"Any\" extension", Justification = "")] +[assembly: SuppressMessage("", "S6667:Logging in a catch clause should pass the caught exception as a parameter", Justification = "")] diff --git a/PriceChecker.Core.Tests/AgentHandlers/SimpleRegexTests.cs b/PriceChecker.Core.Tests/AgentHandlers/SimpleRegexTests.cs index 685ef5c..4961ea9 100644 --- a/PriceChecker.Core.Tests/AgentHandlers/SimpleRegexTests.cs +++ b/PriceChecker.Core.Tests/AgentHandlers/SimpleRegexTests.cs @@ -1,6 +1,6 @@ +using Genius.Atom.Infrastructure.TestingUtil; using Genius.PriceChecker.Core.AgentHandlers; using Genius.PriceChecker.Core.Models; -using Microsoft.Extensions.Logging; namespace Genius.PriceChecker.Core.Tests.AgentHandlers; @@ -12,7 +12,7 @@ public class SimpleRegexTests public SimpleRegexTests() { - _sut = new(Mock.Of>()); + _sut = new(new FakeLogger()); } [Fact] diff --git a/PriceChecker.Core.Tests/CommandHandlers/AgentsStoreWithOverwriteCommandHandlerTests.cs b/PriceChecker.Core.Tests/CommandHandlers/AgentsStoreWithOverwriteCommandHandlerTests.cs index b484e4a..f572dd6 100644 --- a/PriceChecker.Core.Tests/CommandHandlers/AgentsStoreWithOverwriteCommandHandlerTests.cs +++ b/PriceChecker.Core.Tests/CommandHandlers/AgentsStoreWithOverwriteCommandHandlerTests.cs @@ -12,16 +12,15 @@ public class AgentsStoreWithOverwriteCommandHandlerTests { private readonly AgentsStoreWithOverwriteCommandHandler _sut; private readonly Fixture _fixture = new(); - private readonly Mock _agentRepoMock = new(); - private readonly Mock _agentQueryMock = new(); - private readonly Mock _productRepoMock = new(); - private readonly Mock _productQueryMock = new(); - private readonly Mock _eventBusMock = new(); + private readonly IAgentRepository _fakeAgentRepo = A.Fake(); + private readonly IAgentQueryService _fakeAgentQuery = A.Fake(); + private readonly IProductRepository _fakeProductRepo = A.Fake(); + private readonly IProductQueryService _fakeProductQuery = A.Fake(); + private readonly IEventBus _fakeEventBus = A.Fake(); public AgentsStoreWithOverwriteCommandHandlerTests() { - _sut = new(_agentRepoMock.Object, _agentQueryMock.Object, _productRepoMock.Object, - _productQueryMock.Object, _eventBusMock.Object); + _sut = new(_fakeAgentRepo, _fakeAgentQuery, _fakeProductRepo, _fakeProductQuery, _fakeEventBus); } [Fact] @@ -34,8 +33,8 @@ public async Task Process__Agents_are_overwritten_and_event_published() await _sut.ProcessAsync(command); // Verify - _agentRepoMock.Verify(x => x.OverwriteAsync(It.IsAny()), Times.Once); - _eventBusMock.Verify(x => x.Publish(It.IsAny()), Times.Once); + A.CallTo(() => _fakeAgentRepo.OverwriteAsync(A.That.IsSameSequenceAs(command.Agents))).MustHaveHappenedOnceExactly(); + A.CallTo(() => _fakeEventBus.Publish(A._)).MustHaveHappenedOnceExactly(); } [Fact] @@ -44,7 +43,7 @@ public async Task Process__Products_agent_keys_are_refined() // Arrange var products = ModelHelpers.SampleManyProducts().ToArray(); var agents = ModelHelpers.SampleManyAgents(products).ToArray(); - _productQueryMock.Setup(x => x.GetAllAsync()).ReturnsAsync(products); + A.CallTo(() => _fakeProductQuery.GetAllAsync()).Returns(products); var agentsToUpdate = ModelHelpers.Clone(agents); // Rename agent keys for #1 and #4: @@ -59,9 +58,8 @@ public async Task Process__Products_agent_keys_are_refined() products[1].Id // renaming and removing }; Agent[] agentsUpdated = Array.Empty(); - _agentRepoMock.Setup(x => x.OverwriteAsync(It.IsAny())) - .Callback(x => agentsUpdated = x); - _agentQueryMock.Setup(x => x.GetAllAsync()).ReturnsAsync(() => agentsUpdated); + A.CallTo(() => _fakeAgentRepo.OverwriteAsync(A.Ignored)).Invokes((Agent[] x) => agentsUpdated = x); + A.CallTo(() => _fakeAgentQuery.GetAllAsync()).ReturnsLazily(() => agentsUpdated); // Pre-Assert Assert.Equal(agents[1].Key, products[0].Sources[1].AgentKey); @@ -72,12 +70,13 @@ public async Task Process__Products_agent_keys_are_refined() await _sut.ProcessAsync(command); // Verify - _productRepoMock.Verify(x => x.OverwriteAsync(It.Is(y => - y[0].Sources[1].AgentKey.Equals(agentsToUpdate[1].Key) - && y[1].Sources[1].AgentKey.Equals(agentsToUpdate[4].Key) - && y[1].Sources.Length == 2 - )), Times.Once); - _eventBusMock.Verify(x => x.Publish(It.Is(y => - y.Updated.Keys.SequenceEqual(affectedProductIds))), Times.Once); + A.CallTo(() => _fakeProductRepo.OverwriteAsync(A.That.Matches(x => + x[0].Sources[1].AgentKey == agentsToUpdate[1].Key + && x[1].Sources[1].AgentKey == agentsToUpdate[4].Key + && x[1].Sources.Length == 2 + ))).MustHaveHappenedOnceExactly(); + A.CallTo(() => _fakeEventBus.Publish(A.That.Matches(x => + x.Updated.Keys.SequenceEqual(affectedProductIds) + ))).MustHaveHappenedOnceExactly(); } } diff --git a/PriceChecker.Core.Tests/ModelHelpers.cs b/PriceChecker.Core.Tests/ModelHelpers.cs index 2af0a9a..ecb7884 100644 --- a/PriceChecker.Core.Tests/ModelHelpers.cs +++ b/PriceChecker.Core.Tests/ModelHelpers.cs @@ -32,6 +32,11 @@ public static Product SampleProduct(ICollection? agents = null) return product; } + public static IEnumerable RandomizeOrder(this IEnumerable source) + { + return source.OrderBy(_ => Guid.NewGuid()); + } + public static IEnumerable SampleManyProducts() { return Enumerable.Range(1, 3).Select(_ => SampleProduct()); diff --git a/PriceChecker.Core.Tests/PriceChecker.Core.Tests.csproj b/PriceChecker.Core.Tests/PriceChecker.Core.Tests.csproj index 3b94a06..ccb5004 100644 --- a/PriceChecker.Core.Tests/PriceChecker.Core.Tests.csproj +++ b/PriceChecker.Core.Tests/PriceChecker.Core.Tests.csproj @@ -1,26 +1,12 @@ - - net6.0 - Genius.PriceChecker.Core.Tests - false - - - - - ..\..\atom\Atom.Data\bin\Debug\net6.0\Genius.Atom.Data.dll - - - ..\..\atom\Atom.Infrastructure\bin\Debug\net6.0\Genius.Atom.Infrastructure.dll - - - - - - + + true + true + diff --git a/PriceChecker.Core.Tests/Repositories/AgentRepositoryTests.cs b/PriceChecker.Core.Tests/Repositories/AgentRepositoryTests.cs index 47c0fec..575d084 100644 --- a/PriceChecker.Core.Tests/Repositories/AgentRepositoryTests.cs +++ b/PriceChecker.Core.Tests/Repositories/AgentRepositoryTests.cs @@ -1,34 +1,38 @@ using Genius.Atom.Data.Persistence; using Genius.Atom.Infrastructure.Entities; -using Genius.Atom.Infrastructure.Events; +using Genius.Atom.Infrastructure.TestingUtil; +using Genius.Atom.Infrastructure.TestingUtil.Events; using Genius.PriceChecker.Core.Models; using Genius.PriceChecker.Core.Repositories; -using Microsoft.Extensions.Logging; namespace Genius.PriceChecker.Core.Tests.Repositories; -public class AgentRepositoryTests +public class AgentRepositoryTests : IDisposable { private readonly AgentRepository _sut; private readonly Fixture _fixture = new(); - private readonly Mock _eventBusMock = new(); - private readonly Mock _persisterMock = new(); + private readonly FakeEventBus _eventBus = new(); + private readonly IJsonPersister _persisterMock = A.Fake(); - private readonly List _agents = new(); + private readonly Agent[] _agents; public AgentRepositoryTests() { - _agents = _fixture.CreateMany().ToList(); + _agents = _fixture.CreateMany().ToArray(); - _persisterMock.Setup(x => x.LoadCollection(It.IsAny())) - .Returns(_agents.ToArray()); + A.CallTo(() => _persisterMock.LoadCollection(A._)) + .Returns(_agents); - _sut = new AgentRepository(_eventBusMock.Object, _persisterMock.Object, - Mock.Of>()); + _sut = new AgentRepository(_eventBus, _persisterMock, new FakeLogger()); _sut.GetAllAsync().GetAwaiter().GetResult(); // To trigger the initializer } + public void Dispose() + { + _sut.Dispose(); + } + [Fact] public async Task FindById__Returns_appropriate_agent() { @@ -81,9 +85,10 @@ public async Task Store__Replaces_all_existing_agents_and_updates_cache_and_fire // Verify Assert.False((await _sut.GetAllAsync()).Except(newAgents).Any()); - _persisterMock.Verify(x => x.Store(It.IsAny(), - It.Is((List p) => p.SequenceEqual(newAgents)))); - _eventBusMock.Verify(x => x.Publish(It.Is(e => e.Added.Count == newAgents.Length - && e.Deleted.Count == previousAgents.Length)), Times.Once); + + A.CallTo(() => _persisterMock.Store(A._, A>.That.IsSameSequenceAs(newAgents))) + .MustHaveHappenedOnceExactly(); + _eventBus.AssertSingleEvent(e => e.Added.Count == newAgents.Length + && e.Deleted.Count == previousAgents.Length); } } diff --git a/PriceChecker.Core.Tests/Repositories/ProductRepositoryTests.cs b/PriceChecker.Core.Tests/Repositories/ProductRepositoryTests.cs index a7d0b67..0d11e47 100644 --- a/PriceChecker.Core.Tests/Repositories/ProductRepositoryTests.cs +++ b/PriceChecker.Core.Tests/Repositories/ProductRepositoryTests.cs @@ -1,18 +1,18 @@ using Genius.Atom.Data.Persistence; using Genius.Atom.Infrastructure.Entities; -using Genius.Atom.Infrastructure.Events; +using Genius.Atom.Infrastructure.TestingUtil; +using Genius.Atom.Infrastructure.TestingUtil.Events; using Genius.PriceChecker.Core.Models; using Genius.PriceChecker.Core.Repositories; -using Microsoft.Extensions.Logging; namespace Genius.PriceChecker.Core.Tests.Repositories; public class ProductRepositoryTests { private readonly ProductRepository _sut; - private readonly Mock _eventBusMock = new(); - private readonly Mock _persisterMock = new(); - private readonly Mock _agentQueryMock = new(); + private readonly FakeEventBus _eventBus = new(); + private readonly IJsonPersister _persisterMock = A.Fake(); + private readonly IAgentQueryService _agentQueryMock = A.Fake(); private readonly Product[] _products; private readonly Agent[] _agents; @@ -23,14 +23,12 @@ public ProductRepositoryTests() _agents = ModelHelpers.SampleManyAgents(_products).ToArray(); foreach (var agent in _agents) - _agentQueryMock.Setup(x => x.FindByKeyAsync(agent.Key)).ReturnsAsync(agent); + A.CallTo(() => _agentQueryMock.FindByKeyAsync(agent.Key)).Returns(agent); - _persisterMock.Setup(x => x.LoadCollection(It.IsAny())) + A.CallTo(() => _persisterMock.LoadCollection(A._)) .Returns(_products); - _sut = new ProductRepository(_eventBusMock.Object, _persisterMock.Object, - _agentQueryMock.Object, - Mock.Of>()); + _sut = new ProductRepository(_eventBus, _persisterMock, _agentQueryMock, new FakeLogger()); _sut.GetAllAsync().GetAwaiter().GetResult(); // To trigger the initializer } @@ -94,9 +92,9 @@ public async Task Store__For_existing_product__Saves_it_and_fires_event() await _sut.StoreAsync(product); // Verify - _persisterMock.Verify(x => x.Store(It.IsAny(), - It.Is((List p) => p.SequenceEqual(_products)))); - _eventBusMock.Verify(x => x.Publish(It.Is(e => e.Updated.First().Key == product.Id)), Times.Once); + A.CallTo(() => _persisterMock.Store(A._, A>.That.IsSameSequenceAs(_products))) + .MustHaveHappenedOnceExactly(); + _eventBus.AssertSingleEvent(e => e.Updated.First().Key == product.Id); } [Fact] @@ -111,9 +109,9 @@ public async Task Store__For_nonexisting_product__Adds_it_and_fires_event() // Verify var expectedProducts = _products.Concat(new [] { product }); - _persisterMock.Verify(x => x.Store(It.IsAny(), - It.Is((List p) => p.SequenceEqual(expectedProducts)))); - _eventBusMock.Verify(x => x.Publish(It.Is(e => e.Added.First().Key == product.Id)), Times.Once); + A.CallTo(() => _persisterMock.Store(A._, A>.That.IsSameSequenceAs(expectedProducts))) + .MustHaveHappenedOnceExactly(); + _eventBus.AssertSingleEvent(e => e.Added.First().Key == product.Id); Assert.Equal(productCount + 1, (await _sut.GetAllAsync()).Count()); } @@ -122,7 +120,7 @@ public async Task Store__When_id_is_empty__Adds_product_with_autogenerated_id() { // Arrange var product = ModelHelpers.SampleProduct(_agents); - product.Id = Guid.Empty; + // TODO: product.Id = Guid.Empty; var productCount = (await _sut.GetAllAsync()).Count(); // Act diff --git a/PriceChecker.Core.Tests/Repositories/SettingsRepositoryTests.cs b/PriceChecker.Core.Tests/Repositories/SettingsRepositoryTests.cs index 9caf348..7bb5423 100644 --- a/PriceChecker.Core.Tests/Repositories/SettingsRepositoryTests.cs +++ b/PriceChecker.Core.Tests/Repositories/SettingsRepositoryTests.cs @@ -1,5 +1,6 @@ using Genius.Atom.Data.Persistence; -using Genius.Atom.Infrastructure.Events; +using Genius.Atom.Infrastructure.TestingUtil; +using Genius.Atom.Infrastructure.TestingUtil.Events; using Genius.PriceChecker.Core.Messages; using Genius.PriceChecker.Core.Models; using Genius.PriceChecker.Core.Repositories; @@ -10,8 +11,8 @@ namespace Genius.PriceChecker.Core.Tests.Repositories; public class SettingsRepositoryTests { private readonly Fixture _fixture = new(); - private readonly Mock _eventBusMock = new(); - private readonly Mock _persisterMock = new(); + private readonly FakeEventBus _eventBus = new(); + private readonly IJsonPersister _persisterMock = A.Fake(); [Fact] public void Constructor__Previous_settings_exist__Loaded() @@ -77,15 +78,14 @@ public void Store__Replaces_existing_settings_and_updates_cache_and_fires_event( // Verify Assert.Equal(newSettings, sut.Get()); - _persisterMock.Verify(x => x.Store(It.IsAny(), newSettings)); - _eventBusMock.Verify(x => x.Publish(It.Is(e => e.Settings == newSettings)), Times.Once); + A.CallTo(() => _persisterMock.Store(A._, newSettings)).MustHaveHappenedOnceExactly(); + _eventBus.AssertSingleEvent(e => e.Settings == newSettings); } private SettingsRepository CreateSystemUnderTest(Settings? settings = null) { - _persisterMock.Setup(x => x.Load(It.IsAny())) - .Returns(settings!); - return new SettingsRepository(_eventBusMock.Object, _persisterMock.Object, - Mock.Of>()); + A.CallTo(() => _persisterMock.Load(A._)).Returns(settings); + + return new SettingsRepository(_eventBus, _persisterMock, new FakeLogger()); } } diff --git a/PriceChecker.Core.Tests/Services/PriceSeekerTests.cs b/PriceChecker.Core.Tests/Services/PriceSeekerTests.cs index 5f25bdb..f3e4ee9 100644 --- a/PriceChecker.Core.Tests/Services/PriceSeekerTests.cs +++ b/PriceChecker.Core.Tests/Services/PriceSeekerTests.cs @@ -1,5 +1,6 @@ using Genius.Atom.Infrastructure.Io; using Genius.Atom.Infrastructure.Net; +using Genius.Atom.Infrastructure.TestingUtil; using Genius.PriceChecker.Core.AgentHandlers; using Genius.PriceChecker.Core.Models; using Genius.PriceChecker.Core.Services; @@ -10,11 +11,11 @@ namespace Genius.PriceChecker.Core.Tests.Services; public class PriceSeekerTests { private readonly Fixture _fixture = new(); - private readonly Mock _httpMock = new(); - private readonly Mock _fileMock = new(); - private readonly Mock> _loggerMock = new(); - private readonly Mock _agentHandlersProviderMock = new(); - private readonly Mock _agentHandlerMock = new(); + private readonly ITrickyHttpClient _httpMock = A.Fake(); + private readonly IFileService _fileMock = A.Fake(); + private readonly FakeLogger _logger = new FakeLogger(); + private readonly IAgentHandlersProvider _agentHandlersProviderMock = A.Fake(); + private readonly IAgentHandler _agentHandlerMock = A.Fake(); private readonly PriceSeeker _sut; @@ -22,10 +23,10 @@ public PriceSeekerTests() { _fixture.Behaviors.Add(new OmitOnRecursionBehavior(recursionDepth: 2)); - _agentHandlersProviderMock.Setup(x => x.FindByName(It.IsAny())) - .Returns(_agentHandlerMock.Object); + A.CallTo(() => _agentHandlersProviderMock.FindByName(A._)) + .Returns(_agentHandlerMock); - _sut = new PriceSeeker(_httpMock.Object, _fileMock.Object, _agentHandlersProviderMock.Object, _loggerMock.Object); + _sut = new PriceSeeker(_httpMock, _fileMock, _agentHandlersProviderMock, _logger); } [Fact] @@ -39,9 +40,8 @@ public async Task SeekAsync__Happy_flow_scenario() // Verify Assert.Equal(product.Sources.Length, result.Length); - _fileMock.Verify(x => x.WriteTextToFile(It.IsAny(), It.IsAny()), Times.Never); - TestHelpers.VerifyLogger(_loggerMock, LogLevel.Warning, Times.Never()); - TestHelpers.VerifyLogger(_loggerMock, LogLevel.Error, Times.Never()); + A.CallTo(() => _fileMock.WriteTextToFile(A._, A._)).MustNotHaveHappened(); + Assert.DoesNotContain(_logger.Logs, x => x.LogLevel is LogLevel.Error or LogLevel.Warning); } [Fact] @@ -49,8 +49,8 @@ public async Task SeekAsync__Content_was_not_downloaded__Returns_bad_status() { // Arrange var product = CreateSampleProduct(sourcesCount: 1); - _httpMock.Setup(x => x.DownloadContent(It.IsAny(), It.IsAny())) - .ReturnsAsync((string?)null); + A.CallTo(() => _httpMock.DownloadContentAsync(A._, A._)) + .Returns(Task.FromResult((string?)null)); // Act var result = await _sut.SeekAsync(product, new CancellationToken()); @@ -66,7 +66,7 @@ public async Task SeekAsync__Content_not_matched_the_pattern__Returns_bad_status // Arrange var product = CreateSampleProduct(sourcesCount: 1); decimal? price; - _agentHandlerMock.Setup(x => x.Handle(It.IsAny(), It.IsAny(), out price)) + A.CallTo(() => _agentHandlerMock.Handle(A._, A._, out price)) .Returns(AgentHandlingStatus.CouldNotMatch); // Act @@ -75,8 +75,8 @@ public async Task SeekAsync__Content_not_matched_the_pattern__Returns_bad_status // Verify Assert.Single(result); Assert.Equal(AgentHandlingStatus.CouldNotMatch, result[0].Status); - _fileMock.Verify(x => x.WriteTextToFile(It.IsAny(), It.IsAny()), Times.Once); - TestHelpers.VerifyLogger(_loggerMock, LogLevel.Error); + A.CallTo(() => _fileMock.WriteTextToFile(A._, A._)).MustHaveHappenedOnceExactly(); + Assert.Single(_logger.Logs, x => x.LogLevel is LogLevel.Error); } [Fact] @@ -85,7 +85,7 @@ public async Task SeekAsync__Price_is_invalid__Returns_bad_status() // Arrange var product = CreateSampleProduct(sourcesCount: 1); decimal? price; - _agentHandlerMock.Setup(x => x.Handle(It.IsAny(), It.IsAny(), out price)) + A.CallTo(() => _agentHandlerMock.Handle(A._, A._, out price)) .Returns(AgentHandlingStatus.InvalidPrice); // Act @@ -102,7 +102,7 @@ public async Task SeekAsync__Price_is_not_convertible__Returns_bad_status() // Arrange var product = CreateSampleProduct(sourcesCount: 1); decimal? price; - _agentHandlerMock.Setup(x => x.Handle(It.IsAny(), It.IsAny(), out price)) + A.CallTo(() => _agentHandlerMock.Handle(A._, A._, out price)) .Returns(AgentHandlingStatus.CouldNotParse); // Act @@ -125,9 +125,10 @@ private Product CreateSampleProduct(int sourcesCount = 3) //, char delimiter = ' .Create(); var content = _fixture.Create(); - _httpMock.Setup(x => x.DownloadContent( - It.Is(url => url == string.Format(productSource.Agent.Url, productSource.AgentArgument)), It.IsAny())) - .ReturnsAsync(content); + A.CallTo(() => _httpMock.DownloadContentAsync( + A.That.IsEqualTo(string.Format(productSource.Agent.Url, productSource.AgentArgument)), + A._)) + .Returns(content); } return product; } diff --git a/PriceChecker.Core.Tests/TestHelpers.cs b/PriceChecker.Core.Tests/TestHelpers.cs deleted file mode 100644 index e1f8931..0000000 --- a/PriceChecker.Core.Tests/TestHelpers.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using Microsoft.Extensions.Logging; -using Moq; - -namespace Genius.PriceChecker.Core.Tests; - -public static class TestHelpers -{ - private static readonly Random _random = new(); - - public static T TakeRandom(this ICollection source) - { - var index = _random.Next(0, source.Count); - return source.ElementAt(index); - } - - public static ICollection RandomizeOrder(this IEnumerable source) - { - return source.OrderBy(_ => _random.Next()).ToArray(); - } - - public static void VerifyLogger(Mock> loggerMock, LogLevel logLevel, Times? times = null) - { - if (times == null) - times = Times.Once(); - loggerMock.Verify(x => x.Log(logLevel, - It.IsAny(), - It.IsAny(), - It.IsAny(), - (Func) It.IsAny()), times.Value); - } -} diff --git a/PriceChecker.Core/AgentHandlers/SimpleRegex.cs b/PriceChecker.Core/AgentHandlers/SimpleRegex.cs index 7f2c459..0a04384 100644 --- a/PriceChecker.Core/AgentHandlers/SimpleRegex.cs +++ b/PriceChecker.Core/AgentHandlers/SimpleRegex.cs @@ -48,7 +48,7 @@ private bool TryParsePrice(Match match, char decimalDelimiter, out decimal? pric var priceConverted = decimal.TryParse(priceString, out var priceValue); if (!priceConverted) { - _logger.LogError("Could not convert the price '{priceString}' to decimal.", priceString); + _logger.LogError("Could not convert the price '{PriceString}' to decimal.", priceString); price = null; return false; } diff --git a/PriceChecker.Core/CommandHandlers/ProductCreateOrUpdateCommandHandler.cs b/PriceChecker.Core/CommandHandlers/ProductCreateOrUpdateCommandHandler.cs index 4e35817..0f68163 100644 --- a/PriceChecker.Core/CommandHandlers/ProductCreateOrUpdateCommandHandler.cs +++ b/PriceChecker.Core/CommandHandlers/ProductCreateOrUpdateCommandHandler.cs @@ -1,4 +1,3 @@ -using System.Threading.Tasks; using Genius.Atom.Infrastructure.Commands; using Genius.Atom.Infrastructure.Events; using Genius.PriceChecker.Core.Commands; diff --git a/PriceChecker.Core/CommandHandlers/ProductEnqueueScanCommandHandler.cs b/PriceChecker.Core/CommandHandlers/ProductEnqueueScanCommandHandler.cs index fa576ac..506f41d 100644 --- a/PriceChecker.Core/CommandHandlers/ProductEnqueueScanCommandHandler.cs +++ b/PriceChecker.Core/CommandHandlers/ProductEnqueueScanCommandHandler.cs @@ -1,5 +1,5 @@ -using System.Threading.Tasks; using Genius.Atom.Infrastructure.Commands; +using Genius.Atom.Infrastructure.Tasks; using Genius.PriceChecker.Core.Commands; using Genius.PriceChecker.Core.Services; @@ -16,7 +16,7 @@ public ProductEnqueueScanCommandHandler(IProductPriceManager productMng) public Task ProcessAsync(ProductEnqueueScanCommand command) { - _productMng.EnqueueScanAsync(command.ProductId); + _productMng.EnqueueScanAsync(command.ProductId).RunAndForget(); return Task.CompletedTask; } diff --git a/PriceChecker.Core/Models/Product.cs b/PriceChecker.Core/Models/Product.cs index ac44024..0e27369 100644 --- a/PriceChecker.Core/Models/Product.cs +++ b/PriceChecker.Core/Models/Product.cs @@ -7,7 +7,7 @@ public class Product : EntityBase public string? Category { get; set; } public string Name { get; set; } = null!; public string? Description { get; set; } - public ProductSource[] Sources { get; set; } = Array.Empty(); + public ProductSource[] Sources { get; set; } = []; public ProductPrice? Lowest { get; set; } - public ProductPrice[] Recent { get; set; } = Array.Empty(); + public ProductPrice[] Recent { get; set; } = []; } diff --git a/PriceChecker.Core/Models/ProductPrice.cs b/PriceChecker.Core/Models/ProductPrice.cs index 944effe..dc2f10e 100644 --- a/PriceChecker.Core/Models/ProductPrice.cs +++ b/PriceChecker.Core/Models/ProductPrice.cs @@ -9,6 +9,10 @@ public class ProductPrice public ProductSource ProductSource { get; set; } = null!; // Is being initialized in `repo.FillUpRelations` public AgentHandlingStatus Status { get; set; } public decimal? Price { get; set; } + + /// + /// Date when the price was found, in UTC time. + /// public DateTime FoundDate { get; set; } public override string ToString() diff --git a/PriceChecker.Core/PriceChecker.Core.csproj b/PriceChecker.Core/PriceChecker.Core.csproj index e469b5e..22c1424 100644 --- a/PriceChecker.Core/PriceChecker.Core.csproj +++ b/PriceChecker.Core/PriceChecker.Core.csproj @@ -1,35 +1,19 @@ - - net6.0 - Genius.PriceChecker.Core - - - - - - - + + true + true + + + - - - ..\..\atom\Atom.Data\bin\Debug\net6.0\Genius.Atom.Data.dll - - - ..\..\atom\Atom.Infrastructure\bin\Debug\net6.0\Genius.Atom.Infrastructure.dll - - - - - - diff --git a/PriceChecker.Core/Services/PriceSeeker.cs b/PriceChecker.Core/Services/PriceSeeker.cs index 12d55b3..88ccf72 100644 --- a/PriceChecker.Core/Services/PriceSeeker.cs +++ b/PriceChecker.Core/Services/PriceSeeker.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Genius.Atom.Infrastructure.Io; using Genius.Atom.Infrastructure.Net; using Genius.PriceChecker.Core.AgentHandlers; @@ -33,32 +34,32 @@ public PriceSeeker(ITrickyHttpClient trickyHttpClient, IFileService io, public async Task SeekAsync(Product product, CancellationToken cancel) { var result = product.Sources.AsParallel().Select(async (productSource) => - await Seek(productSource, cancel)); + await SeekAsync(productSource, cancel)); return await Task.WhenAll(result); } - private async Task Seek(ProductSource productSource, CancellationToken cancel) + private async Task SeekAsync(ProductSource productSource, CancellationToken cancel) { var agent = productSource.Agent; - var url = string.Format(agent.Url, productSource.AgentArgument); + var url = string.Format(CultureInfo.InvariantCulture, agent.Url, productSource.AgentArgument); string? content; var resultTemplate = new PriceSeekResult(AgentHandlingStatus.Success, productSource.Id, agent.Key, null); try { - content = await _trickyHttpClient.DownloadContent(url, cancel); + content = await _trickyHttpClient.DownloadContentAsync(url, cancel); } catch (Exception ex) { - _logger.LogError(ex, "Failed loading content for source `{productSourceAgentKey}`, url = `{url}`", productSource.AgentKey, url); + _logger.LogError(ex, "Failed loading content for source `{ProductSourceAgentKey}`, url = `{Url}`", productSource.AgentKey, url); throw; } if (content is null) return resultTemplate with { Status = AgentHandlingStatus.CouldNotFetch }; var handler = _agentHandlersProvider.FindByName(agent.Handler) - ?? throw new Exception($"Handler `{agent.Handler}` not found"); + ?? throw new InvalidOperationException($"Handler `{agent.Handler}` not found"); var result = handler.Handle(agent, content, out var price); @@ -69,7 +70,7 @@ private async Task Seek(ProductSource productSource, Cancellati { _io.WriteTextToFile(dumpFileName, content); } - _logger.LogError("Cannot match price from the given content. File = '{dumpFileName}', Url = '{url}'", dumpFileName, url); + _logger.LogError("Cannot match price from the given content. File = '{DumpFileName}', Url = '{Url}'", dumpFileName, url); return resultTemplate with { Status = result }; } else if (result == AgentHandlingStatus.CouldNotParse) @@ -78,7 +79,7 @@ private async Task Seek(ProductSource productSource, Cancellati } else if (result == AgentHandlingStatus.InvalidPrice) { - _logger.LogError("Invalid price from the given content. Url = '{url}', Product = {product}, Agent = {agent}, Price = {price}", url, productSource.AgentArgument, agent.Key, price); + _logger.LogError("Invalid price from the given content. Url = '{Url}', Product = {Product}, Agent = {Agent}, Price = {Price}", url, productSource.AgentArgument, agent.Key, price); return resultTemplate with { Status = result }; } diff --git a/PriceChecker.Core/Services/ProductPriceManager.cs b/PriceChecker.Core/Services/ProductPriceManager.cs index 5c0a67a..1e5fc4c 100644 --- a/PriceChecker.Core/Services/ProductPriceManager.cs +++ b/PriceChecker.Core/Services/ProductPriceManager.cs @@ -15,6 +15,7 @@ public interface IProductPriceManager : IDisposable internal sealed class ProductPriceManager : IProductPriceManager { + private readonly IDateTime _dateTime; private readonly IProductRepository _productRepo; private readonly IProductQueryService _productQuery; private readonly IPriceSeeker _priceSeeker; @@ -25,20 +26,23 @@ internal sealed class ProductPriceManager : IProductPriceManager private IDisposable? _scheduledAutoRefresh; private int _previousAutoRefreshMinutes; - private readonly TimeSpan RecentPeriod = TimeSpan.FromHours(3); + private readonly TimeSpan _recentPeriod = TimeSpan.FromHours(3); - public ProductPriceManager(IProductRepository productRepo, + public ProductPriceManager( + IDateTime dateTime, + IProductRepository productRepo, IProductQueryService productQuery, IPriceSeeker priceSeeker, IEventBus eventBus, ISettingsRepository settingsRepo, ILogger logger) { - _productRepo = productRepo; - _productQuery = productQuery; - _priceSeeker = priceSeeker; - _eventBus = eventBus; - _settingsRepo = settingsRepo; - _logger = logger; + _dateTime = dateTime.NotNull(); + _productRepo = productRepo.NotNull(); + _productQuery = productQuery.NotNull(); + _priceSeeker = priceSeeker.NotNull(); + _eventBus = eventBus.NotNull(); + _settingsRepo = settingsRepo.NotNull(); + _logger = logger.NotNull(); eventBus.WhenFired().Subscribe(args => { if (args.Settings.AutoRefreshEnabled && _scheduledAutoRefresh is null @@ -55,11 +59,11 @@ public async Task EnqueueScanAsync(Guid productId) var product = await _productQuery.FindByIdAsync(productId); if (product == null) { - _logger.LogError("Product with ID '{productId}' was not found.", productId); + _logger.LogError("Product with ID '{ProductId}' was not found.", productId); return; } - await EnqueueScan(product, ignoreRecentDate: true, CancellationToken.None); + await EnqueueScanAsync(product, ignoreRecentDate: true, CancellationToken.None); } public void Dispose() @@ -89,7 +93,7 @@ public void AutoRefreshInitialize() var products = (await _productQuery.GetAllAsync()).ToList(); _eventBus.Publish(new ProductAutoScanStartedEvent(products.Count)); foreach (var product in products) - tasks.Add(EnqueueScan(product, ignoreRecentDate: false, cancel)); + tasks.Add(EnqueueScanAsync(product, ignoreRecentDate: false, cancel)); } await Task.WhenAll(tasks); @@ -98,7 +102,7 @@ public void AutoRefreshInitialize() }); } - private Task EnqueueScan(Product product, bool ignoreRecentDate, CancellationToken cancel) + private Task EnqueueScanAsync(Product product, bool ignoreRecentDate, CancellationToken cancel) { return Task.Run(async() => { @@ -118,19 +122,18 @@ private async Task ScanForPricesAsync(Product product, bool ignoreRecentDate, Ca { if (!ignoreRecentDate && IsTooRecent(product)) { - _logger.LogTrace("Price scanning '{productName}' cancelled due to recent results", product.Name); - //_eventBus.Publish(new ProductScanFailedEvent(product.Id, "Scan cancelled due to recent results")); + _logger.LogTrace("Price scanning '{ProductName}' cancelled due to recent results", product.Name); _eventBus.Publish(new ProductScannedEvent(product.Id, ProductScanStatus.ScannedOk)); return; } _eventBus.Publish(new ProductScanStartedEvent(product.Id)); - _logger.LogTrace("Processing '{productName}'", product.Name); + _logger.LogTrace("Processing '{ProductName}'", product.Name); var results = await _priceSeeker.SeekAsync(product, cancel); if (!results.Any()) { - _logger.LogWarning("Price scanning for '{productName}' failed or no results retrieved", product.Name); + _logger.LogWarning("Price scanning for '{ProductName}' failed or no results retrieved", product.Name); _eventBus.Publish(new ProductScanFailedEvent(product.Id, "Scan failed or no results retrieved")); return; } @@ -166,7 +169,7 @@ private bool IsTooRecent(Product product) return false; var recentDate = product.Recent.Max(x => x.FoundDate); - return DateTime.Now - recentDate < RecentPeriod; + return _dateTime.NowUtc - recentDate < _recentPeriod; } private ProductPrice[] LogAndConvert(Product product, IEnumerable results) @@ -176,10 +179,10 @@ private ProductPrice[] LogAndConvert(Product product, IEnumerable x.FoundDate) > OutdatedPeriod) + if (_dateTime.NowUtc - product.Recent.Max(x => x.FoundDate) > _outdatedPeriod) return ProductScanStatus.Outdated; if (product.Recent.Any(x => x.Status != AgentHandlingStatus.Success)) diff --git a/PriceChecker.UI.Tests/Helpers/TrackerScanContextTests.cs b/PriceChecker.UI.Tests/Helpers/TrackerScanContextTests.cs index d062095..340a1b4 100644 --- a/PriceChecker.UI.Tests/Helpers/TrackerScanContextTests.cs +++ b/PriceChecker.UI.Tests/Helpers/TrackerScanContextTests.cs @@ -1,25 +1,23 @@ -using System.Reactive.Subjects; -using Genius.Atom.UI.Forms.TestingUtil; +using Genius.Atom.Infrastructure.TestingUtil.Events; using Genius.PriceChecker.Core.Messages; using Genius.PriceChecker.Core.Models; using Genius.PriceChecker.UI.Helpers; namespace Genius.PriceChecker.UI.Tests.Helpers; -public class TrackerScanContextTests : TestBase +public class TrackerScanContextTests { + private readonly Fixture _fixture = new(); + private readonly FakeEventBus _eventBus = new(); private readonly TrackerScanContext _sut; // Session values: - private readonly Subject _productAutoScanStartedEventSubject; - private TrackerScanStatus? _lastStatus = null; - private double? _lastProgress = null; + private TrackerScanStatus? _lastStatus; + private double? _lastProgress; public TrackerScanContextTests() { - _productAutoScanStartedEventSubject = CreateEventSubject(); - - _sut = new TrackerScanContext(EventBusMock.Object); + _sut = new TrackerScanContext(_eventBus); _sut.ScanProgress.Subscribe(x => { _lastStatus = x.Status; @@ -89,7 +87,7 @@ public void NotifyProgressChange__When_scanned_last_job__Finishes_progress() public void NotifyProgressChange__When_ScannedWithErrors__Reports_about_errors() { // Arrange - _sut.NotifyStarted(Fixture.Create()); + _sut.NotifyStarted(_fixture.Create()); // Act _sut.NotifyProgressChange(ProductScanStatus.ScannedWithErrors); @@ -103,7 +101,7 @@ public void NotifyProgressChange__When_ScannedWithErrors__Reports_about_errors() public void NotifyProgressChange__When_ScannedNewLowest__Reports_about_new_lowest() { // Arrange - _sut.NotifyStarted(Fixture.Create()); + _sut.NotifyStarted(_fixture.Create()); // Act _sut.NotifyProgressChange(ProductScanStatus.ScannedNewLowest); @@ -119,7 +117,7 @@ public void ProductAutoScanStartedEvent_fired__Calls_NotifyStarted() const int count = 10; // Act - _productAutoScanStartedEventSubject.OnNext(new ProductAutoScanStartedEvent(count)); + _eventBus.Publish(new ProductAutoScanStartedEvent(count)); // Verify const double expectedProgress = 1d / (count * 2); diff --git a/PriceChecker.UI.Tests/PriceChecker.UI.Tests.csproj b/PriceChecker.UI.Tests/PriceChecker.UI.Tests.csproj index 8252960..14755ec 100644 --- a/PriceChecker.UI.Tests/PriceChecker.UI.Tests.csproj +++ b/PriceChecker.UI.Tests/PriceChecker.UI.Tests.csproj @@ -1,42 +1,17 @@ - - net6.0-windows - Genius.PriceChecker.UI.Tests - false - - - - - ..\..\atom\Atom.Data\bin\Debug\net6.0\Genius.Atom.Data.dll - - - ..\..\atom\Atom.Infrastructure\bin\Debug\net6.0\Genius.Atom.Infrastructure.dll - - - ..\..\atom\Atom.UI.Forms\bin\Debug\net6.0-windows\Genius.Atom.UI.Forms.dll - - - ..\..\atom\Atom.UI.Forms.TestingUtil\bin\Debug\net6.0-windows\Genius.Atom.UI.Forms.TestingUtil.dll - + + true + true + true + - - - ..\..\atom\Atom.UI.Forms.Demo\bin\Debug\net6.0-windows\DotNetProjects.Input.Toolkit.dll - - - ..\..\atom\Atom.UI.Forms.Demo\bin\Debug\net6.0-windows\WpfAnimatedGif.dll - - - - - - - + + diff --git a/PriceChecker.UI.Tests/ViewModels/MainViewModelTests.cs b/PriceChecker.UI.Tests/Views/MainViewModelTests.cs similarity index 50% rename from PriceChecker.UI.Tests/ViewModels/MainViewModelTests.cs rename to PriceChecker.UI.Tests/Views/MainViewModelTests.cs index f3c9778..3472cf7 100644 --- a/PriceChecker.UI.Tests/ViewModels/MainViewModelTests.cs +++ b/PriceChecker.UI.Tests/Views/MainViewModelTests.cs @@ -4,18 +4,19 @@ using Genius.Atom.UI.Forms.TestingUtil; using Genius.Atom.UI.Forms.ViewModels; using Genius.PriceChecker.UI.Helpers; -using Genius.PriceChecker.UI.ViewModels; +using Genius.PriceChecker.UI.Views; -namespace Genius.PriceChecker.UI.Tests.ViewModels; +namespace Genius.PriceChecker.UI.Tests.Views; -public class MainViewModelTests : TestBase +public class MainViewModelTests { - 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 Fixture _fixture = new(); + private readonly FakeTab _fakeTrackerTab = new(); + private readonly FakeTab _fakeAgentsTab = new(); + private readonly FakeTab _fakeSettingsTab = new(); + private readonly FakeTab _fakeLogsTab = new(); + private readonly ITrackerScanContext _fakeScanContext = A.Fake(); + private readonly INotifyIconViewModel _fakeNotifyViewModel = A.Fake(); private readonly MainViewModel _sut; @@ -24,10 +25,10 @@ public class MainViewModelTests : TestBase public MainViewModelTests() { - _scanContextMock.SetupGet(x => x.ScanProgress).Returns(_scanProgressSubject); + A.CallTo(() => _fakeScanContext.ScanProgress).Returns(_scanProgressSubject); - _sut = new(_trackerMock.Object, _agentsMock.Object, _settingsMock.Object, - _logsMock.Object, _scanContextMock.Object, _notifyViewModelMock.Object); + _sut = new(_fakeTrackerTab.Instance, _fakeAgentsTab.Instance, _fakeSettingsTab.Instance, + _fakeLogsTab.Instance, _fakeScanContext, _fakeNotifyViewModel); } [Fact] @@ -35,10 +36,10 @@ 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]); + Assert.Equal(_fakeTrackerTab.Instance, _sut.Tabs[0]); + Assert.Equal(_fakeAgentsTab.Instance, _sut.Tabs[1]); + Assert.Equal(_fakeSettingsTab.Instance, _sut.Tabs[2]); + Assert.Equal(_fakeLogsTab.Instance, _sut.Tabs[3]); } [Fact] @@ -46,20 +47,20 @@ public void SelectedTabIndex_changed__Tab_is_Activated_and_old_deactivated() { // Arrange _sut.SelectedTabIndex = 0; - _trackerMock.DropHistory(); - _agentsMock.DropHistory(); - _settingsMock.DropHistory(); - _logsMock.DropHistory(); + _fakeTrackerTab.DropHistory(); + _fakeAgentsTab.DropHistory(); + _fakeSettingsTab.DropHistory(); + _fakeLogsTab.DropHistory(); // Act const int 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); + Assert.True(_fakeTrackerTab.OnlyOneDeactivated); + Assert.Equal(0, _fakeAgentsTab.ActivatedCalls + _fakeAgentsTab.DeactivatedCalls); + Assert.True(_fakeSettingsTab.OnlyOneActivated); + Assert.Equal(0, _fakeLogsTab.ActivatedCalls + _fakeLogsTab.DeactivatedCalls); } [Fact] @@ -67,7 +68,7 @@ public void ScanProgress_changed__InProgress__Progress_state_highlighted_green() { // Arrange _sut.ProgressState = TaskbarItemProgressState.None; - var progress = Fixture.Create(); + var progress = _fixture.Create(); // Act _scanProgressSubject.OnNext((TrackerScanStatus.InProgress, progress)); @@ -82,7 +83,7 @@ public void ScanProgress_changed__InProgressWithErrors__Progress_state_highlight { // Arrange _sut.ProgressState = TaskbarItemProgressState.None; - var progress = Fixture.Create(); + var progress = _fixture.Create(); // Act _scanProgressSubject.OnNext((TrackerScanStatus.InProgressWithErrors, progress)); @@ -97,10 +98,10 @@ public void ScanProgress_changed__Finished__Progress_state_dropped_and_message_s { // Arrange _sut.ProgressState = TaskbarItemProgressState.Normal; - _sut.ProgressValue = Fixture.Create(); + _sut.ProgressValue = _fixture.Create(); // Act - _scanProgressSubject.OnNext((TrackerScanStatus.Finished, Fixture.Create())); + _scanProgressSubject.OnNext((TrackerScanStatus.Finished, _fixture.Create())); // Verify Assert.Equal(TaskbarItemProgressState.None, _sut.ProgressState); @@ -108,21 +109,24 @@ public void ScanProgress_changed__Finished__Progress_state_dropped_and_message_s } } -internal class TabMock : Mock +internal class FakeTab where T: class, ITabViewModel { - public int ActivatedCalls = 0; - public int DeactivatedCalls = 0; + private readonly T _fakeTab; + public int ActivatedCalls; + public int DeactivatedCalls; - public TabMock() + public FakeTab() { - var activatedCommandMock = new Mock(); - activatedCommandMock.Setup(x => x.Execute(null)).Callback((object _) => ActivatedCalls++); - SetupGet(x => x.Activated).Returns(activatedCommandMock.Object); + _fakeTab = A.Fake(); - var deactivatedCommandMock = new Mock(); - deactivatedCommandMock.Setup(x => x.Execute(null)).Callback((object _) => DeactivatedCalls++); - SetupGet(x => x.Deactivated).Returns(deactivatedCommandMock.Object); + var activatedCommandFake = A.Fake(); + A.CallTo(() => activatedCommandFake.Execute(null)).Invokes((object _) => ActivatedCalls++); + A.CallTo(() => _fakeTab.Activated).Returns(activatedCommandFake); + + var deactivatedCommandFake = A.Fake(); + A.CallTo(() => deactivatedCommandFake.Execute(null)).Invokes((object _) => DeactivatedCalls++); + A.CallTo(() => _fakeTab.Deactivated).Returns(deactivatedCommandFake); } public void DropHistory() @@ -131,6 +135,8 @@ public void DropHistory() DeactivatedCalls = 0; } + public T Instance => _fakeTab; + 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/Views/TrackerViewModelTests.cs similarity index 55% rename from PriceChecker.UI.Tests/ViewModels/TrackerViewModelTests.cs rename to PriceChecker.UI.Tests/Views/TrackerViewModelTests.cs index fce5516..6f000d5 100644 --- a/PriceChecker.UI.Tests/ViewModels/TrackerViewModelTests.cs +++ b/PriceChecker.UI.Tests/Views/TrackerViewModelTests.cs @@ -1,6 +1,10 @@ +using System.ComponentModel; using System.Reactive; using System.Reactive.Subjects; using Genius.Atom.Infrastructure.Commands; +using Genius.Atom.Infrastructure.TestingUtil.Commands; +using Genius.Atom.Infrastructure.TestingUtil.Events; +using Genius.Atom.Infrastructure.Threading; using Genius.Atom.UI.Forms; using Genius.Atom.UI.Forms.TestingUtil; using Genius.PriceChecker.Core.Commands; @@ -8,34 +12,44 @@ using Genius.PriceChecker.Core.Models; using Genius.PriceChecker.Core.Repositories; using Genius.PriceChecker.UI.Helpers; -using Genius.PriceChecker.UI.ViewModels; +using Genius.PriceChecker.UI.Views; +using WinRT; -namespace Genius.PriceChecker.UI.Tests.ViewModels; +namespace Genius.PriceChecker.UI.Tests.Views; -public class TrackerViewModelTests : TestBase +public class TrackerViewModelTests { - private readonly Mock _productQueryMock = new(); - private readonly Mock _vmFactoryMock = new(); - private readonly Mock _uiMock = new(); - private readonly Mock _scanContextMock = new(); - private readonly Mock _commandBusMock = new(); - - // Session values: - private readonly Subject _productScanStartedEventSubject; - private readonly Subject _productScannedEventSubject; - private readonly Subject _productScanFailedEventSubject; + private readonly Fixture _fixture = new(); + private readonly FakeEventBus _eventBus = new(); + private readonly IProductQueryService _fakeProductQuery = A.Fake(); + private readonly IViewModelFactory _fakeVmFactory = A.Fake(); + private readonly IUserInteraction _fakeUi = A.Fake(); + private readonly ITrackerScanContext _fakeScanContext = A.Fake(); + private readonly FakeCommandBus _commandBus = new(); 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()))); + A.CallTo(() => _fakeVmFactory.CreateTrackerProduct(A.Ignored)) + .ReturnsLazily((Product p) => { + var commitProductCommand = A.Fake(); + A.CallTo(() => commitProductCommand.Executed).Returns(new Subject()); + + var vm = A.Fake(); + A.CallTo(() => vm.Id).Returns(p == null ? Guid.Empty : p.Id); + A.CallTo(() => vm.RefreshPriceCommand).Returns(A.Fake()); + A.CallTo(() => vm.CommitProductCommand).Returns(commitProductCommand); + A.CallToSet(() => vm.Status) + .Invokes((ProductScanStatus status) => + { + object? statusObj; + A.CallTo(() => vm.Status).Returns(status); + A.CallTo(() => vm.TryGetPropertyValue(nameof(vm.Status), out statusObj)) + .Returns(true) + .AssignsOutAndRefParameters(status); + vm.PropertyChanged += Raise.FreeForm.With(vm, new PropertyChangedEventArgs(nameof(vm.Status))); + }); + return vm; + }); } [Fact] @@ -45,11 +59,11 @@ public void Constructor__RefreshOptions_are_defined_and_list_reloaded() var products = SampleProducts(); // Act - var sut = CreateSystemUnderTest(); + using var sut = CreateSystemUnderTest(); // Verify Assert.NotEmpty(sut.RefreshOptions); - Assert.Equal(products, sut.Products.Select(x => x.Id.Value)); + Assert.Equal(products, sut.Products.Select(x => x.Id!.Value)); } [Fact] @@ -57,7 +71,7 @@ public void RefreshAllCommand__Enqueues_all_products_for_scan() { // Arrange var products = SampleProducts(); - var sut = CreateSystemUnderTest(); + using var sut = CreateSystemUnderTest(); sut.IsAddEditProductVisible = true; // Act @@ -65,10 +79,10 @@ public void RefreshAllCommand__Enqueues_all_products_for_scan() // Verify Assert.False(sut.IsAddEditProductVisible); - _scanContextMock.Verify(x => x.NotifyStarted(products.Count)); + A.CallTo(() => _fakeScanContext.NotifyStarted(products.Count)).MustHaveHappenedOnceExactly(); foreach (var product in sut.Products) { - Mock.Get(product.RefreshPriceCommand).Verify(x => x.Execute(null), Times.Once); + A.CallTo(() => product.RefreshPriceCommand.Execute(null)).MustHaveHappenedOnceExactly(); } } @@ -76,8 +90,8 @@ public void RefreshAllCommand__Enqueues_all_products_for_scan() public void RefreshAllCommand__Enqueues_selected_products_for_scan() { // Arrange - var products = SampleProducts(); - var sut = CreateSystemUnderTest(); + SampleProducts(); + using var sut = CreateSystemUnderTest(); sut.Products[0].IsSelected = true; sut.Products[2].IsSelected = true; @@ -85,11 +99,11 @@ public void RefreshAllCommand__Enqueues_selected_products_for_scan() sut.RefreshSelectedCommand.Execute(null); // Verify - _scanContextMock.Verify(x => x.NotifyStarted(2)); + A.CallTo(() => _fakeScanContext.NotifyStarted(2)).MustHaveHappenedOnceExactly(); 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); + var times = product == sut.Products[0] || product == sut.Products[2] ? 1 : 0; + A.CallTo(() => product.RefreshPriceCommand.Execute(null)).MustHaveHappened(times, Times.Exactly); } } @@ -98,7 +112,7 @@ public void OpenAddProductFlyoutCommand__When_flyout_is_closed__Flyout_shows_up( { // Arrange SampleProducts(); - var sut = CreateSystemUnderTest(); + using var sut = CreateSystemUnderTest(); sut.IsAddEditProductVisible = false; sut.EditingProduct = null; @@ -116,7 +130,7 @@ public void OpenAddProductFlyoutCommand__When_flyout_is_opened__Flyout_closed() { // Arrange SampleProducts(); - var sut = CreateSystemUnderTest(); + using var sut = CreateSystemUnderTest(); sut.IsAddEditProductVisible = true; // Act @@ -130,16 +144,18 @@ public void OpenAddProductFlyoutCommand__When_flyout_is_opened__Flyout_closed() public void OpenAddProductFlyoutCommand__Product_committed__List_reloaded_and_flyout_closed() { // Arrange - var products = SampleProducts(); - var sut = CreateSystemUnderTest(); + TestModule.Initialize(); + SampleProducts(); + using var sut = CreateSystemUnderTest(); sut.OpenAddProductFlyoutCommand.Execute(null); // trigger to open flyout // Act - ((Subject)sut.EditingProduct!.CommitProductCommand.Executed).OnNext(Unit.Default); + sut.EditingProduct!.CommitProductCommand.Execute(null); + ((Subject)sut.EditingProduct!.CommitProductCommand.Executed).OnNext(true); // Verify Assert.False(sut.IsAddEditProductVisible); - _productQueryMock.Verify(x => x.GetAllAsync(), Times.Exactly(2)); + A.CallTo(() => _fakeProductQuery.GetAllAsync()).MustHaveHappenedTwiceExactly(); } [Fact] @@ -147,7 +163,7 @@ public void OpenEditProductFlyoutCommand__When_flyout_is_closed__Flyout_shows_up { // Arrange var products = SampleProducts(); - var sut = CreateSystemUnderTest(); + using var sut = CreateSystemUnderTest(); sut.IsAddEditProductVisible = false; sut.Products[0].IsSelected = true; sut.EditingProduct = null; @@ -166,7 +182,7 @@ public void OpenEditProductFlyoutCommand__When_flyout_is_opened__Flyout_closed() { // Arrange SampleProducts(); - var sut = CreateSystemUnderTest(); + using var sut = CreateSystemUnderTest(); sut.IsAddEditProductVisible = true; // Act @@ -181,12 +197,12 @@ public void OpenEditProductFlyoutCommand__Product_committed__Flyout_closed() { // Arrange SampleProducts(); - var sut = CreateSystemUnderTest(); + using 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); + ((Subject)sut.EditingProduct!.CommitProductCommand.Executed).OnNext(true); // Verify Assert.False(sut.IsAddEditProductVisible); @@ -197,10 +213,10 @@ public void DeleteProductCommand__User_confirmed__Flyout_closed_and_product_remo { // Arrange var products = SampleProducts(); - var sut = CreateSystemUnderTest(); + using var sut = CreateSystemUnderTest(); sut.Products[1].IsSelected = true; sut.IsAddEditProductVisible = true; - _uiMock.Setup(x => x.AskForConfirmation(It.IsAny(), It.IsAny())).Returns(true); + A.CallTo(() => _fakeUi.AskForConfirmation(A.Ignored, A.Ignored)).Returns(true); // Act sut.DeleteProductCommand.Execute(null); @@ -210,7 +226,7 @@ public void DeleteProductCommand__User_confirmed__Flyout_closed_and_product_remo Assert.False(sut.IsAddEditProductVisible); Assert.DoesNotContain(sut.Products, x => x.Id == deletedProductId); Assert.Equal(products.Count - 1, sut.Products.Count); - _commandBusMock.Verify(x => x.SendAsync(It.Is(c => c.ProductId == deletedProductId))); + _commandBus.AssertSingleCommand(x => x.ProductId == deletedProductId); } [Fact] @@ -218,10 +234,10 @@ public void DeleteProductCommand__User_not_confirmed__Flyout_closed_and_product_ { // Arrange var products = SampleProducts(); - var sut = CreateSystemUnderTest(); + using var sut = CreateSystemUnderTest(); sut.Products[1].IsSelected = true; sut.IsAddEditProductVisible = true; - _uiMock.Setup(x => x.AskForConfirmation(It.IsAny(), It.IsAny())).Returns(false); + A.CallTo(() => _fakeUi.AskForConfirmation(A.Ignored, A.Ignored)).Returns(false); // Act sut.DeleteProductCommand.Execute(null); @@ -231,7 +247,7 @@ public void DeleteProductCommand__User_not_confirmed__Flyout_closed_and_product_ Assert.False(sut.IsAddEditProductVisible); Assert.Contains(sut.Products, x => x.Id == deletingProductId); Assert.Equal(products.Count, sut.Products.Count); - _commandBusMock.Verify(x => x.SendAsync(It.IsAny()), Times.Never); + _commandBus.AssertNoCommandOfType(); } [Fact] @@ -239,7 +255,7 @@ public void DeleteProductCommand__No_product_selected__Flyout_closed_and_operati { // Arrange var products = SampleProducts(); - var sut = CreateSystemUnderTest(); + using var sut = CreateSystemUnderTest(); sut.IsAddEditProductVisible = true; // Act @@ -248,8 +264,8 @@ public void DeleteProductCommand__No_product_selected__Flyout_closed_and_operati // Verify Assert.False(sut.IsAddEditProductVisible); Assert.Equal(products.Count, sut.Products.Count); - _commandBusMock.Verify(x => x.SendAsync(It.IsAny()), Times.Never); - _uiMock.Verify(x => x.AskForConfirmation(It.IsAny(), It.IsAny()), Times.Never); + _commandBus.AssertNoCommandOfType(); + A.CallTo(() => _fakeUi.AskForConfirmation(A.Ignored, A.Ignored)).MustNotHaveHappened(); } [Fact] @@ -257,11 +273,11 @@ public void ProductScanStartedEvent_fired__Appropriate_product_changed_status() { // Arrange var products = SampleProducts(); - var sut = CreateSystemUnderTest(); + using var sut = CreateSystemUnderTest(); const int productScanningIndex = 1; // Act - _productScanStartedEventSubject.OnNext(new ProductScanStartedEvent(products.ElementAt(productScanningIndex))); + _eventBus.Publish(new ProductScanStartedEvent(products.ElementAt(productScanningIndex))); // Verify Assert.Equal(ProductScanStatus.Scanning, sut.Products[productScanningIndex].Status); @@ -272,15 +288,15 @@ public void ProductScannedEvent_fired__Appropriate_product_changed_status() { // Arrange var products = SampleProducts(); - var sut = CreateSystemUnderTest(); - var status = Fixture.Create(); + using var sut = CreateSystemUnderTest(); + var status = _fixture.Create(); const int productScannedIndex = 1; // Act - _productScannedEventSubject.OnNext(new ProductScannedEvent(products.ElementAt(productScannedIndex), status)); + _eventBus.Publish(new ProductScannedEvent(products.ElementAt(productScannedIndex), status)); // Verify - Mock.Get(sut.Products[productScannedIndex]).Verify(x => x.Reconcile(status), Times.Once); + A.CallTo(() => sut.Products[productScannedIndex].Reconcile(status)).MustHaveHappenedOnceExactly(); } [Fact] @@ -288,15 +304,15 @@ public void ProductScanFailedEvent_fired__Appropriate_product_set_to_failed() { // Arrange var products = SampleProducts(); - var sut = CreateSystemUnderTest(); + using var sut = CreateSystemUnderTest(); const int productFailedIndex = 1; - var errorMessage = Fixture.Create(); + var errorMessage = _fixture.Create(); // Act - _productScanFailedEventSubject.OnNext(new ProductScanFailedEvent(products.ElementAt(productFailedIndex), errorMessage)); + _eventBus.Publish(new ProductScanFailedEvent(products.ElementAt(productFailedIndex), errorMessage)); // Verify - Mock.Get(sut.Products[productFailedIndex]).Verify(x => x.SetFailed(errorMessage), Times.Once); + A.CallTo(() => sut.Products[productFailedIndex].SetFailed(errorMessage)).MustHaveHappenedOnceExactly(); } [Fact] @@ -304,7 +320,7 @@ public void Deactivated__Flyout_closed() { // Arrange SampleProducts(); - var sut = CreateSystemUnderTest(); + using var sut = CreateSystemUnderTest(); sut.IsAddEditProductVisible = true; // Act @@ -318,30 +334,29 @@ public void Deactivated__Flyout_closed() public void Product_status_changed__Scan_context_notified() { // Arrange - var products = SampleProducts(); - var sut = CreateSystemUnderTest(); + SampleProducts(); + using var sut = CreateSystemUnderTest(); var product = sut.Products[1]; - var status = Fixture.Create>() + var status = _fixture.Create>() .First(x => x != product.Status); // Act - RaisePropertyChanged(Mock.Get(product), x => x.Status, status); + product.Status = status; // Verify - _scanContextMock.Verify(x => x.NotifyProgressChange(status)); + A.CallTo(() => _fakeScanContext.NotifyProgressChange(status)).MustHaveHappenedOnceExactly(); } private TrackerViewModel CreateSystemUnderTest() { - return new TrackerViewModel(EventBusMock.Object, _productQueryMock.Object, - _vmFactoryMock.Object, _uiMock.Object, _scanContextMock.Object, - _commandBusMock.Object); + return new TrackerViewModel(_eventBus, _fakeProductQuery, + _fakeVmFactory, new FakeUiDispatcher(), _fakeUi, _fakeScanContext, _commandBus); } private ICollection SampleProducts() { - var products = Fixture.CreateMany().ToList(); - _productQueryMock.Setup(x => x.GetAllAsync()).Returns(Task.FromResult(products.AsEnumerable())); + var products = _fixture.CreateMany().ToList(); + A.CallTo(() => _fakeProductQuery.GetAllAsync()).Returns(products.AsEnumerable()); return products.ConvertAll(x => x.Id); } } diff --git a/PriceChecker.UI/App.xaml.cs b/PriceChecker.UI/App.xaml.cs index c29adc8..e570552 100644 --- a/PriceChecker.UI/App.xaml.cs +++ b/PriceChecker.UI/App.xaml.cs @@ -1,13 +1,14 @@ global using System.Windows; global using Genius.Atom.Infrastructure; - +global using Genius.Atom.Infrastructure.Attributes; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Genius.PriceChecker.Core.Services; using Genius.PriceChecker.UI.Helpers; -using Genius.PriceChecker.UI.ViewModels; using Genius.PriceChecker.UI.Views; using Hardcodet.Wpf.TaskbarNotification; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Genius.PriceChecker.UI; @@ -19,19 +20,25 @@ public partial class App : Application public static IServiceProvider ServiceProvider { get; private set; } #pragma warning restore CS8618 + [Dangerous("Shouldn't be used from anywhere, except from unit tests of non-injectable classes.")] + internal static void OverrideServiceProvider(IServiceProvider serviceProvider) + { + ServiceProvider = serviceProvider; + } + protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); - _notifyIcon = (TaskbarIcon) FindResource("NotifyIcon"); + _notifyIcon = (TaskbarIcon)FindResource("NotifyIcon"); var serviceCollection = new ServiceCollection(); ConfigureServices(serviceCollection); - serviceCollection.AddSingleton((NotifyIconViewModel)_notifyIcon.DataContext); - ServiceProvider = serviceCollection.BuildServiceProvider(); - Core.Module.Initialize(ServiceProvider); + Atom.Data.Module.Initialize(ServiceProvider); + Atom.Infrastructure.Module.Initialize(ServiceProvider); + PriceChecker.Core.Module.Initialize(ServiceProvider); Atom.UI.Forms.Module.Initialize(ServiceProvider); var mainWindow = ServiceProvider.GetRequiredService(); @@ -48,19 +55,16 @@ protected override void OnExit(ExitEventArgs e) _notifyIcon.Dispose(); } - private static void ConfigureServices(IServiceCollection services) + private void ConfigureServices(IServiceCollection services) { Atom.Data.Module.Configure(services); Atom.Infrastructure.Module.Configure(services); - Atom.UI.Forms.Module.Configure(services); + var configuration = Atom.UI.Forms.Module.Configure(services, this); Core.Module.Configure(services); - // Framework: - services.AddLogging(); - - // Views, View models, and the View model factory - services.AddTransient(); - services.AddTransient(); + // Views, View models, View model factories + services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -68,13 +72,27 @@ private static void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); + // AutoGrid builders + // TODO: ... + // Services and Helpers: + services.AddSingleton((NotifyIconViewModel)_notifyIcon.DataContext); services.AddTransient(); services.AddSingleton(); } private void Application_DispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e) { + try + { + var logger = ServiceProvider.GetService>(); + logger?.LogCritical(e.Exception, e.Exception.Message); + } + catch (Exception ex) + { + Trace.TraceError(ex.ToString()); + } + #if !DEBUG MessageBox.Show("An unhandled exception just occurred: " + e.Exception.Message, "Unhandled Exception", MessageBoxButton.OK, MessageBoxImage.Warning); e.Handled = true; diff --git a/PriceChecker.UI/Helpers/CollectionExtensions.cs b/PriceChecker.UI/Helpers/CollectionExtensions.cs index fdcffca..f0f6a92 100644 --- a/PriceChecker.UI/Helpers/CollectionExtensions.cs +++ b/PriceChecker.UI/Helpers/CollectionExtensions.cs @@ -2,26 +2,6 @@ namespace Genius.PriceChecker.UI.Helpers; public static class CollectionExtensions { - public static void ReplaceItems(this ICollection collection, IEnumerable items) - { - collection.Clear(); - foreach (var item in items) - { - collection.Add(item); - } - } - - // TODO: Not used yet. - public static void ReplaceItemsGently(this IList collection, IEnumerable items) - { - foreach (var itemToRemove in collection.Except(items).ToList()) - { - collection.Remove(itemToRemove); - } - - AppendItems(collection, items); - } - public static void AppendItems(this IList collection, IEnumerable items) { var listItems = items.ToList(); diff --git a/PriceChecker.UI/Helpers/ProductInteraction.cs b/PriceChecker.UI/Helpers/ProductInteraction.cs index 2c1ebad..832d472 100644 --- a/PriceChecker.UI/Helpers/ProductInteraction.cs +++ b/PriceChecker.UI/Helpers/ProductInteraction.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using Genius.PriceChecker.Core.Models; using Genius.PriceChecker.Core.Repositories; @@ -30,7 +31,7 @@ public async Task ShowProductInBrowserAsync(ProductSource? productSource) { return; } - var url = string.Format(agent.Url, productSource.AgentArgument); + var url = string.Format(CultureInfo.CurrentCulture, agent.Url, productSource.AgentArgument); url = url.Replace("&", "^&"); Process.Start(new ProcessStartInfo("cmd", $"/c start {url}") { CreateNoWindow = true }); diff --git a/PriceChecker.UI/NotifyIconResources.xaml b/PriceChecker.UI/NotifyIconResources.xaml index a248a21..33726b7 100644 --- a/PriceChecker.UI/NotifyIconResources.xaml +++ b/PriceChecker.UI/NotifyIconResources.xaml @@ -2,7 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:tb="http://www.hardcodet.net/taskbar" - xmlns:vm="clr-namespace:Genius.PriceChecker.UI.ViewModels"> + xmlns:vm="clr-namespace:Genius.PriceChecker.UI.Views"> diff --git a/PriceChecker.UI/NotifyIconResources.xaml.cs b/PriceChecker.UI/NotifyIconResources.xaml.cs index 0dfb11d..3c8af83 100644 --- a/PriceChecker.UI/NotifyIconResources.xaml.cs +++ b/PriceChecker.UI/NotifyIconResources.xaml.cs @@ -1,6 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Windows; -using Genius.PriceChecker.UI.ViewModels; +using Genius.PriceChecker.UI.Views; using Hardcodet.Wpf.TaskbarNotification; namespace Genius.PriceChecker.UI; diff --git a/PriceChecker.UI/PriceChecker.UI.csproj b/PriceChecker.UI/PriceChecker.UI.csproj index 4804221..0c2fbf9 100644 --- a/PriceChecker.UI/PriceChecker.UI.csproj +++ b/PriceChecker.UI/PriceChecker.UI.csproj @@ -2,11 +2,9 @@ WinExe - net6.0-windows true - Genius.PriceChecker.UI PriceChecker - false + Assets/Logo.ico @@ -14,53 +12,31 @@ DEBUG - - - - - - + + + true + true + true + + - - + - + + + - - - ..\..\atom\Atom.Data\bin\Debug\net6.0\Genius.Atom.Data.dll - - - ..\..\atom\Atom.Infrastructure\bin\Debug\net6.0\Genius.Atom.Infrastructure.dll - - - ..\..\atom\Atom.UI.Forms\bin\Debug\net6.0-windows\Genius.Atom.UI.Forms.dll - - - - - ..\..\atom\Atom.UI.Forms.Demo\bin\Debug\net6.0-windows\DotNetProjects.Input.Toolkit.dll - - - ..\..\atom\Atom.UI.Forms.Demo\bin\Debug\net6.0-windows\WpfAnimatedGif.dll - - - - - - - diff --git a/PriceChecker.UI/Validation/MustBeUniqueValidationRule.cs b/PriceChecker.UI/Validation/MustBeUniqueValidationRule.cs index 1705964..c5a263e 100644 --- a/PriceChecker.UI/Validation/MustBeUniqueValidationRule.cs +++ b/PriceChecker.UI/Validation/MustBeUniqueValidationRule.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; using System.Globalization; -using System.Linq; using System.Windows.Controls; using Genius.Atom.UI.Forms; diff --git a/PriceChecker.UI/ViewModels/AgentViewModel.cs b/PriceChecker.UI/Views/AgentViewModel.cs similarity index 95% rename from PriceChecker.UI/ViewModels/AgentViewModel.cs rename to PriceChecker.UI/Views/AgentViewModel.cs index c94d01e..011c6bb 100644 --- a/PriceChecker.UI/ViewModels/AgentViewModel.cs +++ b/PriceChecker.UI/Views/AgentViewModel.cs @@ -4,7 +4,7 @@ using Genius.PriceChecker.UI.Validation; using Genius.PriceChecker.Core.AgentHandlers; -namespace Genius.PriceChecker.UI.ViewModels; +namespace Genius.PriceChecker.UI.Views; public interface IAgentViewModel : IViewModel, IHasDirtyFlag, ISelectable { @@ -23,8 +23,8 @@ internal sealed class AgentViewModel : ViewModelBase, IAgentViewModel public AgentViewModel(IAgentsViewModel owner, Agent? agent, IAgentHandlersProvider agentHandlersProvider) { - _agentHandlersProvider = agentHandlersProvider; - _owner = owner; + _agentHandlersProvider = agentHandlersProvider.NotNull(); + _owner = owner.NotNull(); _agent = agent; ResetForm(true); diff --git a/PriceChecker.UI/ViewModels/AgentsViewModel.cs b/PriceChecker.UI/Views/AgentsViewModel.cs similarity index 79% rename from PriceChecker.UI/ViewModels/AgentsViewModel.cs rename to PriceChecker.UI/Views/AgentsViewModel.cs index 0990d77..3bc72f1 100644 --- a/PriceChecker.UI/ViewModels/AgentsViewModel.cs +++ b/PriceChecker.UI/Views/AgentsViewModel.cs @@ -1,17 +1,16 @@ -using System.Collections.ObjectModel; using Genius.Atom.UI.Forms; using Genius.Atom.Infrastructure.Commands; using Genius.PriceChecker.Core.Commands; using Genius.PriceChecker.Core.Models; using Genius.PriceChecker.Core.Repositories; -using Genius.PriceChecker.UI.Helpers; using Genius.PriceChecker.Core.AgentHandlers; +using Genius.Atom.Infrastructure.Tasks; -namespace Genius.PriceChecker.UI.ViewModels; +namespace Genius.PriceChecker.UI.Views; public interface IAgentsViewModel : ITabViewModel { - ObservableCollection Agents { get; } + DelayedObservableCollection Agents { get; } } internal sealed class AgentsViewModel : TabViewModelBase, IAgentsViewModel, IHasDirtyFlag @@ -22,16 +21,14 @@ internal sealed class AgentsViewModel : TabViewModelBase, IAgentsViewModel, IHas public AgentsViewModel(IAgentQueryService agentQuery, IViewModelFactory vmFactory, IUserInteraction ui, ICommandBus commandBus, IAgentHandlersProvider agentHandlersProvider) { - _commandBus = commandBus; - _vmFactory = vmFactory; + // Dependencies: + _commandBus = commandBus.NotNull(); + _vmFactory = vmFactory.NotNull(); + // Member initialization: AgentHandlers = agentHandlersProvider.GetNames().ToList(); - var agentVms = agentQuery.GetAllAsync().GetAwaiter().GetResult() - .OrderBy(x => x.Key) - .Select(x => CreateAgentViewModel(x)); - Agents.ReplaceItems(agentVms); - + // Actions: AddAgentCommand = new ActionCommand(_ => { Agents.Add(CreateAgentViewModel(null)); @@ -56,7 +53,7 @@ public AgentsViewModel(IAgentQueryService agentQuery, IViewModelFactory vmFactor } }); - CommitAgentsCommand = new ActionCommand(_ => CommitAgents(), + CommitAgentsCommand = new ActionCommand(async _ => await CommitAgentsAsync(), _ => IsDirty && !HasErrors); ResetChangesCommand = new ActionCommand(_ => { @@ -66,6 +63,18 @@ public AgentsViewModel(IAgentQueryService agentQuery, IViewModelFactory vmFactor } SetNotDirty(); }, _ => IsDirty); + + // Final preparation: + FetchAgentsAsync(agentQuery).RunAndForget(); + } + + private async Task FetchAgentsAsync(IAgentQueryService agentQuery) + { + var agents = await agentQuery.GetAllAsync(); + var agentVms = agents + .OrderBy(x => x.Key) + .Select(x => CreateAgentViewModel(x)); + Agents.ReplaceItems(agentVms); } private IAgentViewModel CreateAgentViewModel(Agent? x) @@ -75,7 +84,7 @@ private IAgentViewModel CreateAgentViewModel(Agent? x) return agentVm; } - private async Task CommitAgents() + private async Task CommitAgentsAsync() { if (HasErrors) { @@ -98,8 +107,8 @@ private void SetNotDirty() } } - public ObservableCollection Agents { get; } - = new TypedObservableList(); + public DelayedObservableCollection Agents { get; } + = new TypedObservableCollection(); public IReadOnlyCollection AgentHandlers { get; } diff --git a/PriceChecker.UI/ViewModels/MainViewModel.cs b/PriceChecker.UI/Views/MainViewModel.cs similarity index 78% rename from PriceChecker.UI/ViewModels/MainViewModel.cs rename to PriceChecker.UI/Views/MainViewModel.cs index aa15699..00faf12 100644 --- a/PriceChecker.UI/ViewModels/MainViewModel.cs +++ b/PriceChecker.UI/Views/MainViewModel.cs @@ -4,7 +4,7 @@ using Genius.PriceChecker.UI.Helpers; using Hardcodet.Wpf.TaskbarNotification; -namespace Genius.PriceChecker.UI.ViewModels; +namespace Genius.PriceChecker.UI.Views; public interface IMainViewModel : IViewModel { @@ -23,32 +23,35 @@ public MainViewModel( ITrackerScanContext scanContext, INotifyIconViewModel notifyViewModel) { - _scanContext = scanContext; - _notifyViewModel = notifyViewModel; + // Dependencies: + _scanContext = scanContext.NotNull(); + _notifyViewModel = notifyViewModel.NotNull(); - Tabs = new() { - tracker, - agents, - settings, - logs - }; + // Member initialization: + Tabs = [ + tracker.NotNull(), + agents.NotNull(), + settings.NotNull(), + logs.NotNull() + ]; - scanContext.ScanProgress.Subscribe(args => UpdateProgress(args.Status, args.Progress)); + // Subscriptions: + _scanContext.ScanProgress.Subscribe(args => UpdateProgress(args.Status, args.Progress)); } private void UpdateProgress(TrackerScanStatus status, double progress) { - if (status == Helpers.TrackerScanStatus.InProgress) + if (status == TrackerScanStatus.InProgress) { ProgressState = TaskbarItemProgressState.Normal; ProgressValue = progress; } - else if (status == Helpers.TrackerScanStatus.InProgressWithErrors) + else if (status == TrackerScanStatus.InProgressWithErrors) { ProgressState = TaskbarItemProgressState.Paused; ProgressValue = progress; } - else if (status == Helpers.TrackerScanStatus.Finished) + else if (status == TrackerScanStatus.Finished) { var message = _scanContext.HasNewLowestPrice ? "Prices for some products have become even lower! Check it out." : diff --git a/PriceChecker.UI/Views/MainWindow.xaml.cs b/PriceChecker.UI/Views/MainWindow.xaml.cs index 2f2eea7..19ceb29 100644 --- a/PriceChecker.UI/Views/MainWindow.xaml.cs +++ b/PriceChecker.UI/Views/MainWindow.xaml.cs @@ -1,5 +1,5 @@ using System.Diagnostics.CodeAnalysis; -using Genius.PriceChecker.UI.ViewModels; +using Genius.PriceChecker.UI.Views; using MahApps.Metro.Controls; namespace Genius.PriceChecker.UI.Views; diff --git a/PriceChecker.UI/ViewModels/NotifyIconViewModel.cs b/PriceChecker.UI/Views/NotifyIconViewModel.cs similarity index 91% rename from PriceChecker.UI/ViewModels/NotifyIconViewModel.cs rename to PriceChecker.UI/Views/NotifyIconViewModel.cs index 025f86c..8c8e51e 100644 --- a/PriceChecker.UI/ViewModels/NotifyIconViewModel.cs +++ b/PriceChecker.UI/Views/NotifyIconViewModel.cs @@ -1,9 +1,8 @@ -using System.Windows; -using System.Windows.Input; +using System.Windows.Input; using Genius.Atom.UI.Forms; using Hardcodet.Wpf.TaskbarNotification; -namespace Genius.PriceChecker.UI.ViewModels; +namespace Genius.PriceChecker.UI.Views; public interface INotifyIconViewModel { diff --git a/PriceChecker.UI/ViewModels/SettingsViewModel.cs b/PriceChecker.UI/Views/SettingsViewModel.cs similarity index 86% rename from PriceChecker.UI/ViewModels/SettingsViewModel.cs rename to PriceChecker.UI/Views/SettingsViewModel.cs index 8a5b90e..fc3cd4c 100644 --- a/PriceChecker.UI/ViewModels/SettingsViewModel.cs +++ b/PriceChecker.UI/Views/SettingsViewModel.cs @@ -1,8 +1,7 @@ -using System.Linq; using Genius.PriceChecker.Core.Repositories; using Genius.Atom.UI.Forms; -namespace Genius.PriceChecker.UI.ViewModels; +namespace Genius.PriceChecker.UI.Views; public interface ISettingsViewModel : ITabViewModel { @@ -12,9 +11,12 @@ internal sealed class SettingsViewModel : TabViewModelBase, ISettingsViewModel { public SettingsViewModel(ISettingsRepository repo) { + Guard.NotNull(repo); + + // Member initialization: var settings = repo.Get(); - AutoRefreshMinuteOptions = new [] { + AutoRefreshMinuteOptions = [ #if DEBUG new AutoRefreshOption("1 minute (DEBUG ONLY)", 1), #endif @@ -22,13 +24,14 @@ public SettingsViewModel(ISettingsRepository repo) new AutoRefreshOption("3 hours", 180), new AutoRefreshOption("8 hours", 480), new AutoRefreshOption("1 day", 1440) - }; + ]; AutoRefreshEnabled = settings.AutoRefreshEnabled; AutoRefreshMinutes = AutoRefreshMinuteOptions.FirstOrDefault(x => x.Value == settings.AutoRefreshMinutes) ?? AutoRefreshMinuteOptions[0]; - this.PropertyChanged += (sender, args) => { + // Subscriptions: + PropertyChanged += (sender, args) => { settings.AutoRefreshEnabled = AutoRefreshEnabled; settings.AutoRefreshMinutes = AutoRefreshMinutes.Value; repo.Store(settings); diff --git a/PriceChecker.UI/Views/Tracker.xaml b/PriceChecker.UI/Views/Tracker.xaml index a8f5c95..852af23 100644 --- a/PriceChecker.UI/Views/Tracker.xaml +++ b/PriceChecker.UI/Views/Tracker.xaml @@ -6,7 +6,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mah="clr-namespace:MahApps.Metro.Controls;assembly=MahApps.Metro" xmlns:autogrid="clr-namespace:Genius.Atom.UI.Forms.Controls.AutoGrid;assembly=Genius.Atom.UI.Forms" - xmlns:vm="clr-namespace:Genius.PriceChecker.UI.ViewModels" + xmlns:vm="clr-namespace:Genius.PriceChecker.UI.Views" mc:Ignorable="d"> diff --git a/PriceChecker.UI/Views/Tracker.xaml.cs b/PriceChecker.UI/Views/Tracker.xaml.cs index decba03..e125a52 100644 --- a/PriceChecker.UI/Views/Tracker.xaml.cs +++ b/PriceChecker.UI/Views/Tracker.xaml.cs @@ -2,8 +2,8 @@ using System.Windows.Controls; using System.Windows.Data; using System.Windows.Input; -using Genius.Atom.UI.Forms; -using Genius.PriceChecker.UI.ViewModels; +using Genius.Atom.UI.Forms.Wpf; +using Genius.PriceChecker.UI.Views; namespace Genius.PriceChecker.UI.Views; diff --git a/PriceChecker.UI/ViewModels/TrackerProductSourceViewModel.cs b/PriceChecker.UI/Views/TrackerProductSourceViewModel.cs similarity index 95% rename from PriceChecker.UI/ViewModels/TrackerProductSourceViewModel.cs rename to PriceChecker.UI/Views/TrackerProductSourceViewModel.cs index ee632d3..c97330c 100644 --- a/PriceChecker.UI/ViewModels/TrackerProductSourceViewModel.cs +++ b/PriceChecker.UI/Views/TrackerProductSourceViewModel.cs @@ -5,12 +5,15 @@ using Genius.PriceChecker.Core.Models; using Genius.PriceChecker.UI.Helpers; -namespace Genius.PriceChecker.UI.ViewModels; +namespace Genius.PriceChecker.UI.Views; internal sealed class TrackerProductSourceViewModel : ViewModelBase { public TrackerProductSourceViewModel(IProductInteraction productInteraction, ProductSource? productSource, decimal? lastPrice) { + Guard.NotNull(productInteraction); + + // Member initialization: InitializeProperties(() => { Id = productSource?.Id ?? Guid.NewGuid(); AgentKey = productSource?.AgentKey ?? string.Empty; @@ -18,6 +21,7 @@ public TrackerProductSourceViewModel(IProductInteraction productInteraction, Pro LastPrice = lastPrice; }); + // Actions: ShowInBrowserCommand = new ActionCommand(async _ => await productInteraction.ShowProductInBrowserAsync(productSource)); } diff --git a/PriceChecker.UI/ViewModels/TrackerProductViewModel.cs b/PriceChecker.UI/Views/TrackerProductViewModel.cs similarity index 95% rename from PriceChecker.UI/ViewModels/TrackerProductViewModel.cs rename to PriceChecker.UI/Views/TrackerProductViewModel.cs index a1a8fcb..5abe18f 100644 --- a/PriceChecker.UI/ViewModels/TrackerProductViewModel.cs +++ b/PriceChecker.UI/Views/TrackerProductViewModel.cs @@ -17,7 +17,7 @@ using Genius.PriceChecker.UI.ValueConverters; using ReactiveUI; -namespace Genius.PriceChecker.UI.ViewModels; +namespace Genius.PriceChecker.UI.Views; public interface ITrackerProductViewModel : IViewModel, ISelectable, IDisposable { @@ -51,14 +51,17 @@ public TrackerProductViewModel(Product? product, IEventBus eventBus, IUserInteraction ui, IProductInteraction productInteraction) { - _agentQuery = agentQuery; - _productQuery = productQuery; - _statusProvider = statusProvider; - _commandBus = commandBus; - _product = product; - _ui = ui; - _productInteraction = productInteraction; - + // Dependencies: + _agentQuery = agentQuery.NotNull(); + _productQuery = productQuery.NotNull(); + _statusProvider = statusProvider.NotNull(); + _commandBus = commandBus.NotNull(); + _product = product.NotNull(); + _ui = ui.NotNull(); + _productInteraction = productInteraction.NotNull(); + + // Member initialization: + // TODO: Fix warning InitializeProperties(async () => { await RefreshAgentsAsync(); @@ -71,7 +74,8 @@ public TrackerProductViewModel(Product? product, IEventBus eventBus, } }); - CommitProductCommand = new ActionCommand(_ => CommitProduct()); + // Actions: + CommitProductCommand = new ActionCommand(_ => CommitProductAsync()); ShowInBrowserCommand = new ActionCommand(async _ => await productInteraction.ShowProductInBrowserAsync(_product?.Lowest?.ProductSource)); @@ -96,6 +100,7 @@ public TrackerProductViewModel(Product? product, IEventBus eventBus, await commandBus.SendAsync(new ProductEnqueueScanCommand(product!.Id)); }, _ => _product is not null && Status != ProductScanStatus.Scanning); + // Subscriptions: eventBus.WhenFired() .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(async _ => @@ -172,7 +177,7 @@ public void SetFailed(string errorMessage) StatusText = errorMessage; } - private async Task CommitProduct() + private async Task CommitProductAsync() { if (string.IsNullOrEmpty(Name)) { diff --git a/PriceChecker.UI/ViewModels/TrackerViewModel.cs b/PriceChecker.UI/Views/TrackerViewModel.cs similarity index 87% rename from PriceChecker.UI/ViewModels/TrackerViewModel.cs rename to PriceChecker.UI/Views/TrackerViewModel.cs index b55876e..a4aff96 100644 --- a/PriceChecker.UI/ViewModels/TrackerViewModel.cs +++ b/PriceChecker.UI/Views/TrackerViewModel.cs @@ -1,24 +1,23 @@ -using System.Collections.ObjectModel; +using System.Reactive.Disposables; using System.Reactive.Linq; using System.Windows.Input; -using Genius.PriceChecker.Core.Messages; -using Genius.PriceChecker.Core.Repositories; -using Genius.Atom.Infrastructure.Events; -using Genius.Atom.UI.Forms; -using Genius.PriceChecker.UI.Helpers; using ReactiveUI; using Genius.Atom.Infrastructure.Commands; +using Genius.Atom.Infrastructure.Events; +using Genius.Atom.Infrastructure.Tasks; +using Genius.Atom.UI.Forms; using Genius.PriceChecker.Core.Commands; -using System.Reactive.Disposables; +using Genius.PriceChecker.Core.Messages; +using Genius.PriceChecker.Core.Repositories; +using Genius.PriceChecker.UI.Helpers; -namespace Genius.PriceChecker.UI.ViewModels; +namespace Genius.PriceChecker.UI.Views; public interface ITrackerViewModel : ITabViewModel { } internal sealed class TrackerViewModel : TabViewModelBase, ITrackerViewModel, IDisposable { - private readonly IEventBus _eventBus; private readonly IProductQueryService _productQuery; private readonly IViewModelFactory _vmFactory; private readonly ITrackerScanContext _scanContext; @@ -27,15 +26,20 @@ internal sealed class TrackerViewModel : TabViewModelBase, ITrackerViewModel, ID public TrackerViewModel(IEventBus eventBus, IProductQueryService productQuery, IViewModelFactory vmFactory, + IUiDispatcher uiDispatcher, IUserInteraction ui, ITrackerScanContext scanContext, ICommandBus commandBus) { - _eventBus = eventBus; - _productQuery = productQuery; - _vmFactory = vmFactory; - _scanContext = scanContext; + Guard.NotNull(eventBus); + Guard.NotNull(uiDispatcher); + + // Dependencies: + _productQuery = productQuery.NotNull(); + _vmFactory = vmFactory.NotNull(); + _scanContext = scanContext.NotNull(); + // Actions: RefreshAllCommand = new ActionCommand(_ => { IsAddEditProductVisible = false; EnqueueScan(Products); @@ -87,17 +91,18 @@ public TrackerViewModel(IEventBus eventBus, } }); - _eventBus.WhenFired() + // Subscriptions: + eventBus.WhenFired() .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(ev => Products.First(x => x.Id == ev.ProductId).Status = Core.Models.ProductScanStatus.Scanning ) .DisposeWith(_disposables); - _eventBus.WhenFired() + eventBus.WhenFired() .Subscribe(ev => Products.First(x => x.Id == ev.ProductId).Reconcile(ev.Status)) .DisposeWith(_disposables); - _eventBus.WhenFired() + eventBus.WhenFired() .Subscribe(ev => Products.First(x => x.Id == ev.ProductId).SetFailed(ev.ErrorMessage)) .DisposeWith(_disposables); @@ -106,12 +111,12 @@ public TrackerViewModel(IEventBus eventBus, .Subscribe(_ => IsAddEditProductVisible = false) .DisposeWith(_disposables); + // Final preparation: RefreshOptions = new List { new DropDownMenuItem("Refresh all", RefreshAllCommand), new DropDownMenuItem("Refresh selected", RefreshSelectedCommand), }; - - ReloadListAsync(); + uiDispatcher.InvokeAsync(ReloadListAsync).RunAndForget(); } public void Dispose() @@ -154,8 +159,8 @@ private void DisposeEditingProductIfNeeded() public List RefreshOptions { get; } - public ObservableCollection Products { get; } - = new TypedObservableList(); + public DelayedObservableCollection Products { get; } + = new TypedObservableCollection(); [FilterContext] public string Filter diff --git a/PriceChecker.UI/ViewModels/ViewModelFactory.cs b/PriceChecker.UI/Views/ViewModelFactory.cs similarity index 81% rename from PriceChecker.UI/ViewModels/ViewModelFactory.cs rename to PriceChecker.UI/Views/ViewModelFactory.cs index 6793d3e..8dc15e4 100644 --- a/PriceChecker.UI/ViewModels/ViewModelFactory.cs +++ b/PriceChecker.UI/Views/ViewModelFactory.cs @@ -8,7 +8,7 @@ using Genius.PriceChecker.Core.AgentHandlers; using Genius.PriceChecker.UI.Helpers; -namespace Genius.PriceChecker.UI.ViewModels; +namespace Genius.PriceChecker.UI.Views; public interface IViewModelFactory { @@ -36,14 +36,14 @@ public ViewModelFactory(IEventBus eventBus, IAgentHandlersProvider agentHandlersProvider, IProductInteraction productInteraction) { - _eventBus = eventBus; - _commandBus = commandBus; - _agentQuery = agentQuery; - _productQuery = productQuery; - _statusProvider = statusProvider; - _ui = ui; - _agentHandlersProvider = agentHandlersProvider; - _productInteraction = productInteraction; + _eventBus = eventBus.NotNull(); + _commandBus = commandBus.NotNull(); + _agentQuery = agentQuery.NotNull(); + _productQuery = productQuery.NotNull(); + _statusProvider = statusProvider.NotNull(); + _ui = ui.NotNull(); + _agentHandlersProvider = agentHandlersProvider.NotNull(); + _productInteraction = productInteraction.NotNull(); } public IAgentViewModel CreateAgent(IAgentsViewModel owner, Agent? agent) diff --git a/PriceChecker.UI/appsettings.json b/PriceChecker.UI/appsettings.json new file mode 100644 index 0000000..35f244c --- /dev/null +++ b/PriceChecker.UI/appsettings.json @@ -0,0 +1,19 @@ +{ + "Serilog": { + "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ], + "MinimumLevel": "Verbose", + "Enrich": [ "FromLogContext", "WithThreadId" ], + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "File", + "Args": { + "path": "PriceChecker_.log", + "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level}] <{ThreadId}> {SourceContextName} - {Message}{NewLine}{Exception}", + "rollingInterval": "Month", + "retainedFileCountLimit": 5 + } + } + ] + } +} diff --git a/README.md b/README.md index 43c230a..0b3f97f 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,20 @@ # Price Checker + A simple price checker for the selected products to catch the cheapest deal _Status of Last Deployment:_

-### Main window: +## Features + +### Main window + ![Main window](docs/assets/MainWindow.png) -### Product edit: +### Product edit + ![Product edit](docs/assets/ProductEdit.png) -### Product with updated lowest price: +### Product with updated lowest price + ![Product with updated lowest price](docs/assets/ProductWithUpdatedLowestPrice.png) diff --git a/TestsGlobalSuppressions.cs b/TestsGlobalSuppressions.cs new file mode 100644 index 0000000..2edc2b7 --- /dev/null +++ b/TestsGlobalSuppressions.cs @@ -0,0 +1,12 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +// SonarLint-related analyzers cannot be disabled in .editorconfig file, therefore disabling them here. + +[assembly: SuppressMessage("", "S1144:Unused private types or members should be removed", Justification = "")] +[assembly: SuppressMessage("", "S3459:Unassigned members should be removed", Justification = "")] +[assembly: SuppressMessage("", "S6562:Always set the \"DateTimeKind\" when creating new \"DateTime\" instances", Justification = "")] diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..e501e99 --- /dev/null +++ b/nuget.config @@ -0,0 +1,10 @@ + + + + + + + + + +