From c98a6ca29dd21cec8ca1eecaf30bba96f3af92ba Mon Sep 17 00:00:00 2001 From: Derek Smithson Date: Sat, 31 Jan 2026 14:00:58 -0700 Subject: [PATCH 01/10] chore: adding GitHub Actions CI pipeines Was using Appveyor on this project in the past, but now I'm moving over to GitHub actions. PRs will build and run unit tests, and tags will generate release builds based on the tag version number and push up to Nuget automatically. --- src/.github/workflows/ci.yml | 38 ++++++++++++++++ src/.github/workflows/release.yml | 74 +++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/.github/workflows/ci.yml create mode 100644 src/.github/workflows/release.yml diff --git a/src/.github/workflows/ci.yml b/src/.github/workflows/ci.yml new file mode 100644 index 0000000..0da8b3a --- /dev/null +++ b/src/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + pull_request: + branches: [ master, main ] + push: + branches: [ master, main ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 10.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Run tests + run: dotnet test --configuration Release --no-build --verbosity normal --logger trx --results-directory "TestResults" + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: TestResults + retention-days: 5 diff --git a/src/.github/workflows/release.yml b/src/.github/workflows/release.yml new file mode 100644 index 0000000..59e6807 --- /dev/null +++ b/src/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + build-and-release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 10.0.x + + - name: Extract version from tag + id: get_version + run: | + VERSION=${GITHUB_REF#refs/tags/v} + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $VERSION" + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Run tests + run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Pack NuGet package + run: dotnet pack KnightwareCore/KnightwareCore.csproj --configuration Release --no-build -p:PackageVersion=${{ steps.get_version.outputs.VERSION }} --output ./nupkg + + - name: Generate release notes + id: release_notes + run: | + # Get the previous tag + PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + + if [ -z "$PREVIOUS_TAG" ]; then + # No previous tag, get all commits + NOTES=$(git log --pretty=format:"- %s (%h)" --no-merges) + else + # Get commits between previous tag and current + NOTES=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges) + fi + + # Write to file to handle multiline + echo "$NOTES" > release_notes.txt + echo "Release notes generated" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: Release v${{ steps.get_version.outputs.VERSION }} + body_path: release_notes.txt + files: ./nupkg/*.nupkg + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish to NuGet + run: dotnet nuget push ./nupkg/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate From d7f4e6a76c74de51f59a6a34b457da5e46896988 Mon Sep 17 00:00:00 2001 From: Derek Smithson Date: Sat, 31 Jan 2026 14:16:25 -0700 Subject: [PATCH 02/10] chore: updating README.md badges --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index dbcfbbe..591dba4 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# KnightwareCore -.Net Core class library providing shim classes for threading, networking, and other primitives not available in .Net Core or otherwise frequently used in my other projects. - -[![Build status](https://ci.appveyor.com/api/projects/status/0lf0r99oe825rln9/branch/master?svg=true)](https://ci.appveyor.com/project/dsmithson/knightwarecore/branch/master) - -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=dsmithson_KnightwareCore&metric=alert_status)](https://sonarcloud.io/dashboard?id=dsmithson_KnightwareCore) - -[![codecov](https://codecov.io/gh/dsmithson/KnightwareCore/branch/master/graph/badge.svg)](https://codecov.io/gh/dsmithson/KnightwareCore) +# KnightwareCore +.Net Core class library providing shim classes for threading, networking, and other primitives not available in .Net Core or otherwise frequently used in my other projects. + +[![CI](https://github.com/dsmithson/KnightwareCore/actions/workflows/ci.yml/badge.svg)](https://github.com/dsmithson/KnightwareCore/actions/workflows/ci.yml) + +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=dsmithson_KnightwareCore&metric=alert_status)](https://sonarcloud.io/dashboard?id=dsmithson_KnightwareCore) + +[![codecov](https://codecov.io/gh/dsmithson/KnightwareCore/branch/master/graph/badge.svg)](https://codecov.io/gh/dsmithson/KnightwareCore) From 9fb1fa5297d98f07ab1e1e968c5d3eef08ab179d Mon Sep 17 00:00:00 2001 From: Derek Smithson Date: Sat, 31 Jan 2026 14:35:06 -0700 Subject: [PATCH 03/10] chore: unit test cleanup - reduced cognitive complexity in CompositeCollection - added a few new tests for CompositeCollection - fixed a few new unit test assert warnings after updating nuget packages --- .../Collections/CompositeCollection.cs | 358 +++++++++--------- .../Collections/CompositeCollectionTests.cs | 289 ++++++++------ .../Threading/Tasks/BatchProcessorTests.cs | 10 +- 3 files changed, 359 insertions(+), 298 deletions(-) diff --git a/src/KnightwareCore/Collections/CompositeCollection.cs b/src/KnightwareCore/Collections/CompositeCollection.cs index 650c0e9..4dff2c7 100644 --- a/src/KnightwareCore/Collections/CompositeCollection.cs +++ b/src/KnightwareCore/Collections/CompositeCollection.cs @@ -1,174 +1,184 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Collections.Specialized; -using System.Linq; - -namespace Knightware.Collections -{ - public class CompositeCollection : ICollection, INotifyCollectionChanged - { - private readonly object syncRoot = new object(); - private readonly ObservableCollection collections = new ObservableCollection(); - private readonly List registeredNotifyingCollections = new List(); - public ObservableCollection Collections - { - get { return collections; } - } - - public event NotifyCollectionChangedEventHandler CollectionChanged; - - protected void OnCollectionChanged(NotifyCollectionChangedEventArgs e) - { - if (CollectionChanged != null) - CollectionChanged(this, e); - } - - public CompositeCollection() - { - collections.CollectionChanged += collections_CollectionChanged; - } - - public void Add(IList collection) - { - Remove(collection); - collections.Add(collection); - } - - public void RemoveAt(int index) - { - collections.RemoveAt(index); - } - - public void Remove(IList collection) - { - if (collections.Contains(collection)) - collections.Remove(collection); - } - - public void Clear() - { - collections.Clear(); - } - - void collections_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) - { - if (e.Action == NotifyCollectionChangedAction.Reset) - { - //De-register all items - while (registeredNotifyingCollections.Count > 0) - { - registeredNotifyingCollections[0].CollectionChanged -= observable_CollectionChanged; - registeredNotifyingCollections.RemoveAt(0); - } - } - - if (e.OldItems != null && e.OldItems.Count > 0) - { - foreach (IList collection in e.OldItems) - { - var observable = collection as INotifyCollectionChanged; - if (observable != null) - { - observable.CollectionChanged -= observable_CollectionChanged; - if (registeredNotifyingCollections.Contains(observable)) - registeredNotifyingCollections.Remove(observable); - } - } - } - - if (e.NewItems != null && e.NewItems.Count > 0) - { - foreach (IList collection in e.NewItems) - { - var observable = collection as INotifyCollectionChanged; - if (observable != null) - { - observable.CollectionChanged += observable_CollectionChanged; - registeredNotifyingCollections.Add(observable); - } - } - } - - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - } - - void observable_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) - { - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - } - - public void CopyTo(Array array, int index) - { - foreach (object item in this) - { - array.SetValue(item, index++); - } - } - - public int Count - { - get { return collections.Sum(collection => collection.Count); } - } - - public bool IsSynchronized - { - get { return false; } - } - - public object SyncRoot - { - get { return syncRoot; } - } - - public IEnumerator GetEnumerator() - { - return new CompositeCollectionEnumerator(collections); - } - - protected class CompositeCollectionEnumerator : IEnumerator - { - private readonly ObservableCollection collections; - private IEnumerator currentEnumerator; - private int currentCollectionIndex; - - public CompositeCollectionEnumerator(ObservableCollection collections) - { - this.collections = collections; - Reset(); - } - - public object Current - { - get { return (currentEnumerator == null ? null : currentEnumerator.Current); } - } - - public bool MoveNext() - { - if (currentEnumerator == null) - return false; - - if (currentEnumerator.MoveNext()) - return true; - - //If we returned false above, lets move to the next collection - while (++currentCollectionIndex < collections.Count) - { - //Lets move to the next collection, and try to move it to the first position - currentEnumerator = collections[currentCollectionIndex].GetEnumerator(); - if (currentEnumerator.MoveNext()) - return true; - } - - //No more collections to iterate - return false; - } - - public void Reset() - { - currentEnumerator = (collections.Count == 0 ? null : collections[0].GetEnumerator()); - } - } - } -} +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; + +namespace Knightware.Collections +{ + public class CompositeCollection : ICollection, INotifyCollectionChanged + { + private readonly object syncRoot = new object(); + private readonly ObservableCollection collections = new ObservableCollection(); + private readonly List registeredNotifyingCollections = new List(); + public ObservableCollection Collections + { + get { return collections; } + } + + public event NotifyCollectionChangedEventHandler CollectionChanged; + + protected void OnCollectionChanged(NotifyCollectionChangedEventArgs e) + { + if (CollectionChanged != null) + CollectionChanged(this, e); + } + + public CompositeCollection() + { + collections.CollectionChanged += collections_CollectionChanged; + } + + public void Add(IList collection) + { + Remove(collection); + collections.Add(collection); + } + + public void RemoveAt(int index) + { + collections.RemoveAt(index); + } + + public void Remove(IList collection) + { + if (collections.Contains(collection)) + collections.Remove(collection); + } + + public void Clear() + { + collections.Clear(); + } + + void collections_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Reset) + { + UnregisterAllCollections(); + } + + UnregisterRemovedCollections(e.OldItems); + RegisterNewCollections(e.NewItems); + + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + private void UnregisterAllCollections() + { + foreach (var notifyingCollection in registeredNotifyingCollections) + { + notifyingCollection.CollectionChanged -= observable_CollectionChanged; + } + registeredNotifyingCollections.Clear(); + } + + private void UnregisterRemovedCollections(IList oldItems) + { + if (oldItems == null || oldItems.Count == 0) + return; + + foreach (IList collection in oldItems) + { + if (collection is INotifyCollectionChanged observable) + { + observable.CollectionChanged -= observable_CollectionChanged; + registeredNotifyingCollections.Remove(observable); + } + } + } + + private void RegisterNewCollections(IList newItems) + { + if (newItems == null || newItems.Count == 0) + return; + + foreach (IList collection in newItems) + { + if (collection is INotifyCollectionChanged observable) + { + observable.CollectionChanged += observable_CollectionChanged; + registeredNotifyingCollections.Add(observable); + } + } + } + + void observable_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + public void CopyTo(Array array, int index) + { + foreach (object item in this) + { + array.SetValue(item, index++); + } + } + + public int Count + { + get { return collections.Sum(collection => collection.Count); } + } + + public bool IsSynchronized + { + get { return false; } + } + + public object SyncRoot + { + get { return syncRoot; } + } + + public IEnumerator GetEnumerator() + { + return new CompositeCollectionEnumerator(collections); + } + + protected class CompositeCollectionEnumerator : IEnumerator + { + private readonly ObservableCollection collections; + private IEnumerator currentEnumerator; + private int currentCollectionIndex; + + public CompositeCollectionEnumerator(ObservableCollection collections) + { + this.collections = collections; + Reset(); + } + + public object Current + { + get { return (currentEnumerator == null ? null : currentEnumerator.Current); } + } + + public bool MoveNext() + { + if (currentEnumerator == null) + return false; + + if (currentEnumerator.MoveNext()) + return true; + + //If we returned false above, lets move to the next collection + while (++currentCollectionIndex < collections.Count) + { + //Lets move to the next collection, and try to move it to the first position + currentEnumerator = collections[currentCollectionIndex].GetEnumerator(); + if (currentEnumerator.MoveNext()) + return true; + } + + //No more collections to iterate + return false; + } + + public void Reset() + { + currentEnumerator = (collections.Count == 0 ? null : collections[0].GetEnumerator()); + } + } + } +} diff --git a/src/KnightwareCoreTests/Collections/CompositeCollectionTests.cs b/src/KnightwareCoreTests/Collections/CompositeCollectionTests.cs index c0d8ff9..3981f2e 100644 --- a/src/KnightwareCoreTests/Collections/CompositeCollectionTests.cs +++ b/src/KnightwareCoreTests/Collections/CompositeCollectionTests.cs @@ -1,119 +1,170 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Knightware.Collections -{ - [TestClass] - public class CompositeCollectionTests - { - [TestMethod] - public void AddTest() - { - const int expectedChangeCount = 5; - int actualChangeCount = 0; - - var compositeCollection = new CompositeCollection(); - compositeCollection.CollectionChanged += (sender, e) => actualChangeCount++; - - PopulateCollection(compositeCollection, expectedChangeCount); - Assert.AreEqual(expectedChangeCount, compositeCollection.Collections.Count, "Collections do not appear to have been added"); - Assert.AreEqual(expectedChangeCount, actualChangeCount, "CollectionChanged event count is incorrect"); - } - - [TestMethod] - public void RemoveTest() - { - const int expectedChangeCount = 5; - int actualChangeCount = 0; - - var compositeCollection = new CompositeCollection(); - PopulateCollection(compositeCollection); - - compositeCollection.CollectionChanged += (sender, e) => actualChangeCount++; - while (compositeCollection.Collections.Count > 0) - { - compositeCollection.Remove(compositeCollection.Collections[0]); - } - Assert.AreEqual(expectedChangeCount, actualChangeCount, "Failed to be notified for all collections removed"); - } - - [TestMethod] - public void RemoveTest2() - { - var compositeCollection = new CompositeCollection(); - PopulateCollection(compositeCollection); - - var lists = new List>(); - foreach (ObservableCollection list in compositeCollection.Collections) - lists.Add(list); - - //Hook for a change count after clearing the list - while (compositeCollection.Collections.Count > 0) - { - compositeCollection.Collections.RemoveAt(0); - } - int changeCount = 0; - compositeCollection.CollectionChanged += (s, e) => changeCount++; - - //Now add to the collections and ensure that the list isn't still hooked to collection changed - foreach (ObservableCollection list in lists) - list.Add("Test"); - - Assert.AreEqual(0, changeCount, "Collection is still hooked to the change notifications of it's previous lists"); - } - - [TestMethod] - public void RemoveAtTest() - { - const int expectedChangeCount = 5; - int actualChangeCount = 0; - - var compositeCollection = new CompositeCollection(); - PopulateCollection(compositeCollection); - - compositeCollection.CollectionChanged += (sender, e) => actualChangeCount++; - while (compositeCollection.Collections.Count > 0) - { - compositeCollection.RemoveAt(0); - } - Assert.AreEqual(expectedChangeCount, actualChangeCount, "Failed to be notified for all collections removed"); - } - - [TestMethod] - public void ClearTest() - { - var compositeCollection = new CompositeCollection(); - PopulateCollection(compositeCollection); - - var lists = new List>(); - foreach (ObservableCollection list in compositeCollection.Collections) - lists.Add(list); - - compositeCollection.Clear(); - Assert.AreEqual(0, compositeCollection.Collections.Count, "Failed to clear internal collections (1)"); - - int changeCount = 0; - compositeCollection.CollectionChanged += (s, e) => changeCount++; - foreach (ObservableCollection list in lists) - { - list.Add("Test"); - } - Assert.AreEqual(0, changeCount, "Collection is still hooked to the change notifications of it's previous lists"); - } - - private void PopulateCollection(CompositeCollection compositeCollection, int count = 5) - { - for (int i = 0; i < count; i++) - { - var collection = new ObservableCollection { "Test 1", "Test 2", "Test 3" }; - compositeCollection.Add(collection); - } - } - } -} +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Knightware.Collections +{ + [TestClass] + public class CompositeCollectionTests + { + [TestMethod] + public void AddTest() + { + const int expectedChangeCount = 5; + int actualChangeCount = 0; + + var compositeCollection = new CompositeCollection(); + compositeCollection.CollectionChanged += (sender, e) => actualChangeCount++; + + PopulateCollection(compositeCollection, expectedChangeCount); + Assert.HasCount(expectedChangeCount, compositeCollection.Collections, "Collections do not appear to have been added"); + Assert.AreEqual(expectedChangeCount, actualChangeCount, "CollectionChanged event count is incorrect"); + } + + [TestMethod] + public void RemoveTest() + { + const int expectedChangeCount = 5; + int actualChangeCount = 0; + + var compositeCollection = new CompositeCollection(); + PopulateCollection(compositeCollection); + + compositeCollection.CollectionChanged += (sender, e) => actualChangeCount++; + while (compositeCollection.Collections.Count > 0) + { + compositeCollection.Remove(compositeCollection.Collections[0]); + } + Assert.AreEqual(expectedChangeCount, actualChangeCount, "Failed to be notified for all collections removed"); + } + + [TestMethod] + public void RemoveTest2() + { + var compositeCollection = new CompositeCollection(); + PopulateCollection(compositeCollection); + + var lists = new List>(); + foreach (ObservableCollection list in compositeCollection.Collections) + lists.Add(list); + + //Hook for a change count after clearing the list + while (compositeCollection.Collections.Count > 0) + { + compositeCollection.Collections.RemoveAt(0); + } + int changeCount = 0; + compositeCollection.CollectionChanged += (s, e) => changeCount++; + + //Now add to the collections and ensure that the list isn't still hooked to collection changed + foreach (ObservableCollection list in lists) + list.Add("Test"); + + Assert.AreEqual(0, changeCount, "Collection is still hooked to the change notifications of it's previous lists"); + } + + [TestMethod] + public void RemoveAtTest() + { + const int expectedChangeCount = 5; + int actualChangeCount = 0; + + var compositeCollection = new CompositeCollection(); + PopulateCollection(compositeCollection); + + compositeCollection.CollectionChanged += (sender, e) => actualChangeCount++; + while (compositeCollection.Collections.Count > 0) + { + compositeCollection.RemoveAt(0); + } + Assert.AreEqual(expectedChangeCount, actualChangeCount, "Failed to be notified for all collections removed"); + } + + [TestMethod] + public void ClearTest() + { + var compositeCollection = new CompositeCollection(); + PopulateCollection(compositeCollection); + + var lists = new List>(); + foreach (ObservableCollection list in compositeCollection.Collections) + lists.Add(list); + + compositeCollection.Clear(); + Assert.HasCount(0, compositeCollection.Collections, "Failed to clear internal collections (1)"); + + int changeCount = 0; + compositeCollection.CollectionChanged += (s, e) => changeCount++; + foreach (ObservableCollection list in lists) + { + list.Add("Test"); + } + Assert.AreEqual(0, changeCount, "Collection is still hooked to the change notifications of it's previous lists"); + } + + [TestMethod] + public void ChildCollectionChangePropagatesToComposite() + { + var compositeCollection = new CompositeCollection(); + var childCollection = new ObservableCollection { "Item1" }; + compositeCollection.Add(childCollection); + + int changeCount = 0; + compositeCollection.CollectionChanged += (s, e) => changeCount++; + + childCollection.Add("Item2"); + Assert.AreEqual(1, changeCount, "Child collection change should propagate to composite"); + + childCollection.Remove("Item1"); + Assert.AreEqual(2, changeCount, "Child collection removal should propagate to composite"); + } + + [TestMethod] + public void NonObservableCollectionCanBeAdded() + { + var compositeCollection = new CompositeCollection(); + var nonObservableList = new List { "Item1", "Item2" }; + + compositeCollection.Add(nonObservableList); + + Assert.HasCount(1, compositeCollection.Collections); + Assert.HasCount(2, compositeCollection); + } + + [TestMethod] + public void MixedObservableAndNonObservableCollections() + { + var compositeCollection = new CompositeCollection(); + var observableList = new ObservableCollection { "Observable1" }; + var nonObservableList = new List { "NonObservable1" }; + + compositeCollection.Add(observableList); + compositeCollection.Add(nonObservableList); + + int changeCount = 0; + compositeCollection.CollectionChanged += (s, e) => changeCount++; + + // Only observable collection changes should trigger events + observableList.Add("Observable2"); + Assert.AreEqual(1, changeCount, "Observable collection change should propagate"); + + // Non-observable changes won't trigger (as expected behavior) + nonObservableList.Add("NonObservable2"); + Assert.AreEqual(1, changeCount, "Non-observable collection change should not propagate"); + } + + private static void PopulateCollection(CompositeCollection compositeCollection, int count = 5) + { + for (int i = 0; i < count; i++) + { + var collection = new ObservableCollection { "Test 1", "Test 2", "Test 3" }; + compositeCollection.Add(collection); + } + } + } +} diff --git a/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs b/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs index 7f4d6bc..6b33898 100644 --- a/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs +++ b/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs @@ -73,7 +73,7 @@ public async Task MinimumElapsedTest() object result = await processor.EnqueueAsync(5); stopwatch.Stop(); - Assert.IsTrue(stopwatch.Elapsed.TotalMilliseconds > timeoutMs, "Not enough time passed before patch was processed"); + Assert.IsGreaterThan(timeoutMs, stopwatch.Elapsed.TotalMilliseconds, "Not enough time passed before patch was processed"); Console.WriteLine("Item was processed in {0} - timeout was configured for {1} milliseconds", stopwatch.Elapsed, timeoutMs); } @@ -102,7 +102,7 @@ await TestSimpleSetup( //Verify we waited the minimum amount of time and that all items were processed in the same batch Assert.IsTrue(results.All(r => (int)r.Result == expectedBatchID), "One or more items were not processed in the same batch as the others"); - Assert.IsTrue(stopwatch.Elapsed.TotalMilliseconds > timeoutMs, "Not enough time passed before patch was processed"); + Assert.IsGreaterThan(timeoutMs, stopwatch.Elapsed.TotalMilliseconds, "Not enough time passed before patch was processed"); Console.WriteLine("Items were processed in {0} - timeout was configured for {1} milliseconds", stopwatch.Elapsed, timeoutMs); } @@ -187,12 +187,12 @@ public async Task MaximumCountMultipleTest() //when hitting the max items limit multiple times const int maxItemsPerBatch = 15; var results = await RunContinuousTestAsync(TimeSpan.FromSeconds(10), 10, 5000, 5000, maxItemsPerBatch); - Assert.IsTrue(results.Count > 1, "Expected more items"); + Assert.IsGreaterThan(1, results.Count, "Expected more items"); for(int i=0 ; i Date: Sat, 31 Jan 2026 15:56:26 -0700 Subject: [PATCH 04/10] chore: adding unit test coverage and fixing up some SonarQube warnings --- .../IO/GZipStreamDecompressor.cs | 43 +-- src/KnightwareCore/IO/XmlDeserializer.cs | 263 +++++++++--------- src/KnightwareCore/Threading/ResourcePool.cs | 176 +++++++----- .../Threading/Tasks/AsyncListProcessor.cs | 250 +++++++++-------- .../Collections/GroupingTests.cs | 63 +++++ .../Collections/ListExtensionsTests.cs | 197 +++++++++++++ .../Primitives/ColorTests.cs | 168 +++++++++++ .../Primitives/PointTests.cs | 44 +++ .../Primitives/RectangleTests.cs | 175 ++++++++++++ .../Primitives/SizeTests.cs | 57 ++++ .../Primitives/ThicknessTests.cs | 71 +++++ .../PropertyChangedBaseTests.cs | 84 ++++++ .../Threading/ResourcePoolTests.cs | 105 +++++++ .../Threading/Tasks/AsyncSemaphoreTests.cs | 73 +++++ .../Threading/Tasks/BatchProcessorTests.cs | 15 +- .../Threading/Tasks/RequestDeferralTests.cs | 68 +++++ .../Threading/Tasks/TaskExtensionsTests.cs | 59 ++++ 17 files changed, 1560 insertions(+), 351 deletions(-) create mode 100644 src/KnightwareCoreTests/Collections/GroupingTests.cs create mode 100644 src/KnightwareCoreTests/Collections/ListExtensionsTests.cs create mode 100644 src/KnightwareCoreTests/Primitives/ColorTests.cs create mode 100644 src/KnightwareCoreTests/Primitives/PointTests.cs create mode 100644 src/KnightwareCoreTests/Primitives/RectangleTests.cs create mode 100644 src/KnightwareCoreTests/Primitives/SizeTests.cs create mode 100644 src/KnightwareCoreTests/Primitives/ThicknessTests.cs create mode 100644 src/KnightwareCoreTests/PropertyChangedBaseTests.cs create mode 100644 src/KnightwareCoreTests/Threading/Tasks/AsyncSemaphoreTests.cs create mode 100644 src/KnightwareCoreTests/Threading/Tasks/RequestDeferralTests.cs create mode 100644 src/KnightwareCoreTests/Threading/Tasks/TaskExtensionsTests.cs diff --git a/src/KnightwareCore/IO/GZipStreamDecompressor.cs b/src/KnightwareCore/IO/GZipStreamDecompressor.cs index 2f75084..0c17890 100644 --- a/src/KnightwareCore/IO/GZipStreamDecompressor.cs +++ b/src/KnightwareCore/IO/GZipStreamDecompressor.cs @@ -1,21 +1,22 @@ -using System.IO; -using System.IO.Compression; - -namespace Knightware.IO -{ - public class GZipStreamDecompressor : IGZipStreamDecompressor - { - public byte[] Decompress(byte[] zipCompressedData, int offset, int count, int uncompressedDataLength) - { - using (MemoryStream compressedStream = new MemoryStream(zipCompressedData, offset, count, false)) - { - using (var decompressor = new GZipStream(compressedStream, CompressionMode.Decompress)) - { - byte[] decompressedBytes = new byte[uncompressedDataLength]; - int read = decompressor.Read(decompressedBytes, 0, uncompressedDataLength); - return (read == uncompressedDataLength ? decompressedBytes : null); - } - } - } - } -} +using System; +using System.IO; +using System.IO.Compression; + +namespace Knightware.IO +{ + public class GZipStreamDecompressor : IGZipStreamDecompressor + { + public byte[] Decompress(byte[] zipCompressedData, int offset, int count, int uncompressedDataLength) + { + using (MemoryStream compressedStream = new MemoryStream(zipCompressedData, offset, count, false)) + { + using (var decompressor = new GZipStream(compressedStream, CompressionMode.Decompress)) + { + byte[] decompressedBytes = new byte[uncompressedDataLength]; + int read = decompressor.Read(decompressedBytes, 0, uncompressedDataLength); + return (read == uncompressedDataLength ? decompressedBytes : Array.Empty()); + } + } + } + } +} diff --git a/src/KnightwareCore/IO/XmlDeserializer.cs b/src/KnightwareCore/IO/XmlDeserializer.cs index 397d5a4..dd707a6 100644 --- a/src/KnightwareCore/IO/XmlDeserializer.cs +++ b/src/KnightwareCore/IO/XmlDeserializer.cs @@ -1,133 +1,130 @@ -using Knightware.Diagnostics; -using System; -using System.IO; -using System.Xml.Linq; - -namespace Knightware.Core -{ - public delegate void XmlReadFailedHandler(object sender, string propertyName); - - public class XmlDeserializer - { - /// - /// Event raised when a read request fails, returning a default value - /// - public event XmlReadFailedHandler ElementReadFailed; - protected void OnElementReadFailed(string elementName) - { - if (ElementReadFailed != null) - ElementReadFailed(this, elementName); - } - - public XDocument GetXDocument(Stream xmlFileStream, bool skipXmlDeclaration = true) - { - if (skipXmlDeclaration) - { - //HACK: Pass over the header (this passes the XML declaration which specifies an encoding of 'us-ascii', which isn't supported on Windows Phone - // - xmlFileStream.Seek(42, SeekOrigin.Begin); - } - - try - { - return XDocument.Load(xmlFileStream); - } - catch (Exception ex) - { - TraceQueue.Trace(null, TracingLevel.Warning, "{0} occurred while loading file stream: {1}", - ex.GetType().Name, ex.Message); - - return null; - } - } - - public int Read(XElement parent, string elementName, int defaultValue) - { - return Read(parent, elementName, defaultValue, (value) => - { - int response; - return int.TryParse(value, out response) ? response : ReturnDefaultValue(elementName, defaultValue); - }); - } - - public float Read(XElement parent, string elementName, float defaultValue) - { - return Read(parent, elementName, defaultValue, (value) => - { - float response; - return float.TryParse(value, out response) ? response : ReturnDefaultValue(elementName, defaultValue); - }); - } - - public bool Read(XElement parent, string elementName, bool defaultValue) - { - return Read(parent, elementName, defaultValue, (value) => - { - if (value == "1") - return true; - else if (value == "0") - return false; - else - { - bool response; - return bool.TryParse(value, out response) ? response : ReturnDefaultValue(elementName, defaultValue); - } - }); - } - - public string Read(XElement parent, string elementName, string defaultValue = "") - { - return Read(parent, elementName, defaultValue, (value) => value); - } - - public TEnum ReadEnum(XElement parent, string elementName, TEnum defaultValue) - where TEnum : struct - { - return Read(parent, elementName, defaultValue, (value) => - { - TEnum response; - return Enum.TryParse(value, out response) ? response : ReturnDefaultValue(elementName, defaultValue); - }); - } - - protected T Read(XElement parent, string elementName, T defaultValue, Func parser) - { - try - { - XElement element = ReadElement(parent, elementName); - if (element != null) - return parser(element.Value); - } - catch (Exception ex) - { - TraceQueue.Trace(null, TracingLevel.Warning, "{0} occurred while deserializing '{1}'. Returning default value.", - ex.GetType().Name, ex.Message); - } - return ReturnDefaultValue(elementName, defaultValue); - } - - public XElement ReadElement(XElement parent, string elementName) - { - if (parent == null) - return null; - - try - { - return parent.Element(elementName); - } - catch (Exception ex) - { - TraceQueue.Trace(null, TracingLevel.Warning, "{0} occurred while deserializing '{1}'. Returning default value.", - ex.GetType().Name, ex.Message); - - return null; - } - } - - protected T ReturnDefaultValue(string elementName, T defaultValue) - { - OnElementReadFailed(elementName); - return defaultValue; - } - } -} +using Knightware.Diagnostics; +using System; +using System.IO; +using System.Xml.Linq; + +namespace Knightware.Core +{ + public delegate void XmlReadFailedHandler(object sender, string propertyName); + + public class XmlDeserializer + { + /// + /// Event raised when a read request fails, returning a default value + /// + public event XmlReadFailedHandler ElementReadFailed; + protected void OnElementReadFailed(string elementName) + { + ElementReadFailed?.Invoke(this, elementName); + } + + public static XDocument GetXDocument(Stream xmlFileStream, bool skipXmlDeclaration = true) + { + if (skipXmlDeclaration) + { + //HACK: Pass over the header (this passes the XML declaration which specifies an encoding of 'us-ascii', which isn't supported on Windows Phone + // + xmlFileStream.Seek(42, SeekOrigin.Begin); + } + + try + { + return XDocument.Load(xmlFileStream); + } + catch (Exception ex) + { + TraceQueue.Trace(null, TracingLevel.Warning, "{0} occurred while loading file stream: {1}", + ex.GetType().Name, ex.Message); + + return null; + } + } + + public int Read(XElement parent, string elementName, int defaultValue) + { + return Read(parent, elementName, defaultValue, (value) => + { + return int.TryParse(value, out int response) ? response : ReturnDefaultValue(elementName, defaultValue); + }); + } + + public float Read(XElement parent, string elementName, float defaultValue) + { + return Read(parent, elementName, defaultValue, (value) => + { + return float.TryParse(value, out float response) ? response : ReturnDefaultValue(elementName, defaultValue); + }); + } + + public bool Read(XElement parent, string elementName, bool defaultValue) + { + return Read(parent, elementName, defaultValue, (value) => + { + if (value == "1") + return true; + else if (value == "0") + return false; + else + { + bool response; + return bool.TryParse(value, out response) ? response : ReturnDefaultValue(elementName, defaultValue); + } + }); + } + + public string Read(XElement parent, string elementName, string defaultValue = "") + { + return Read(parent, elementName, defaultValue, (value) => value); + } + + public TEnum ReadEnum(XElement parent, string elementName, TEnum defaultValue) + where TEnum : struct + { + return Read(parent, elementName, defaultValue, (value) => + { + TEnum response; + return Enum.TryParse(value, out response) ? response : ReturnDefaultValue(elementName, defaultValue); + }); + } + + protected T Read(XElement parent, string elementName, T defaultValue, Func parser) + { + try + { + XElement element = ReadElement(parent, elementName); + if (element != null) + return parser(element.Value); + } + catch (Exception ex) + { + TraceQueue.Trace(null, TracingLevel.Warning, "{0} occurred while deserializing '{1}'. Returning default value.", + ex.GetType().Name, ex.Message); + } + return ReturnDefaultValue(elementName, defaultValue); + } + + public static XElement ReadElement(XElement parent, string elementName) + { + if (parent == null) + return null; + + try + { + return parent.Element(elementName); + } + catch (Exception ex) + { + TraceQueue.Trace(null, TracingLevel.Warning, "{0} occurred while deserializing '{1}'. Returning default value.", + ex.GetType().Name, ex.Message); + + return null; + } + } + + protected T ReturnDefaultValue(string elementName, T defaultValue) + { + OnElementReadFailed(elementName); + return defaultValue; + } + } +} diff --git a/src/KnightwareCore/Threading/ResourcePool.cs b/src/KnightwareCore/Threading/ResourcePool.cs index 0b69615..a1dcbd0 100644 --- a/src/KnightwareCore/Threading/ResourcePool.cs +++ b/src/KnightwareCore/Threading/ResourcePool.cs @@ -252,103 +252,141 @@ public async Task Run(Func> actionToRun private DateTime lastResourceCreationCheck; private DateTime lastResourceClosedCheck; + private async Task connectionPoolMonitor_DoWork(object state) { DateTime now = DateTime.Now; - bool timeIntervalPassed = now.Subtract(lastResourceCreationCheck) > ResourceAllocationInterval; - // Determine if we need to create resources + await TryCreateResourceAsync(now).ConfigureAwait(false); + await AllocateRequestsToAvailableResourcesAsync(now).ConfigureAwait(false); + } + + private async Task TryCreateResourceAsync(DateTime now) + { + bool shouldCreate = await ShouldCreateResourceAsync(now).ConfigureAwait(false); + if (!shouldCreate) + return; + + T connection = await createResourceHandler().ConfigureAwait(false); + + using (await resourcePoolLock.LockAsync().ConfigureAwait(false)) + { + resourcePool.Add(new ResourcePoolEntry(connection)); + + if (resourcePoolInitializing && resourcePool.Count >= InitialConnections) + { + resourcePoolInitializing = false; + } + else if (resourcePoolInitializing) + { + resourcePoolMonitor.Set(); + } + } + + lastResourceCreationCheck = DateTime.Now; + } + + private async Task ShouldCreateResourceAsync(DateTime now) + { + if (resourcePoolInitializing) + return true; + int requestCount; int poolCount; int availableCount; - - using (var requestLock = await requestQueueLock.LockAsync()) - using (var poolLock = await resourcePoolLock.LockAsync()) + + using (await requestQueueLock.LockAsync().ConfigureAwait(false)) + using (await resourcePoolLock.LockAsync().ConfigureAwait(false)) { requestCount = requestQueue.Count; poolCount = resourcePool.Count; availableCount = resourcePool.Count(c => !c.InUse); } - bool needsMoreResources = requestCount > availableCount && poolCount < MaximumConnections; - // Immediately create a resource if there are waiting requests and no available resources - bool urgentNeed = requestCount > 0 && availableCount == 0 && poolCount < MaximumConnections; - bool shouldCreateResource = resourcePoolInitializing || urgentNeed || (needsMoreResources && timeIntervalPassed); - - // Create resource outside the lock - if (shouldCreateResource) - { - T connection = await createResourceHandler(); + if (poolCount >= MaximumConnections) + return false; - using (var poolLock = await resourcePoolLock.LockAsync()) - { - resourcePool.Add(new ResourcePoolEntry(connection)); + bool urgentNeed = requestCount > 0 && availableCount == 0; + if (urgentNeed) + return true; - if (resourcePoolInitializing) - { - if (resourcePool.Count >= InitialConnections) - resourcePoolInitializing = false; - else - resourcePoolMonitor.Set(); - } - } + bool needsMoreResources = requestCount > availableCount; + bool timeIntervalPassed = now.Subtract(lastResourceCreationCheck) > ResourceAllocationInterval; + return needsMoreResources && timeIntervalPassed; + } - lastResourceCreationCheck = DateTime.Now; + private async Task AllocateRequestsToAvailableResourcesAsync(DateTime now) + { + using (await requestQueueLock.LockAsync().ConfigureAwait(false)) + using (await resourcePoolLock.LockAsync().ConfigureAwait(false)) + { + AssignResourcesToRequests(); + TriggerMoreAllocationsIfNeeded(); + TryDeallocateStaleResource(now); } + } + + private void AssignResourcesToRequests() + { + var availableConnections = resourcePool.Where(c => !c.InUse).ToList(); - //Try to allocate pending requests into the connection pool - using (var requestLock = await requestQueueLock.LockAsync()) - using (var poolLock = await resourcePoolLock.LockAsync()) + int index = 0; + while (index < requestQueue.Count && availableConnections.Count > 0) { - var availableConnections = resourcePool.Where(c => !c.InUse).ToList(); + var request = requestQueue[index]; - int index = 0; - while (index < requestQueue.Count && availableConnections.Count > 0) + if (IsRequestBlockedBySerialization(request)) { - var request = requestQueue[index]; + index++; + continue; + } - //Check to see if there is something already running with this serialization key - if (!string.IsNullOrEmpty(request.SerializationKey) && - resourcePool.Any(c => c.SerializationKey == request.SerializationKey)) - { - index++; - continue; - } + var connection = availableConnections[0]; + availableConnections.RemoveAt(0); - //Grab the next available allocation - var availableConnection = availableConnections[0]; - availableConnections.RemoveAt(0); + requestQueue.RemoveAt(index); + connection.Acquire(request.SerializationKey); + request.Tcs.TrySetResult(connection.Connection); + } + } - //Remove the request from the queue and assign the connection - requestQueue.RemoveAt(index); - availableConnection.Acquire(request.SerializationKey); - request.Tcs.TrySetResult(availableConnection.Connection); - } + private bool IsRequestBlockedBySerialization(Request request) + { + return !string.IsNullOrEmpty(request.SerializationKey) && + resourcePool.Any(c => c.SerializationKey == request.SerializationKey); + } - //If there are still pending requests that couldn't be allocated, trigger another iteration - if (requestQueue.Count > 0 && resourcePool.Count < MaximumConnections) - { - resourcePoolMonitor.Set(); - } + private void TriggerMoreAllocationsIfNeeded() + { + if (requestQueue.Count > 0 && resourcePool.Count < MaximumConnections) + { + resourcePoolMonitor.Set(); + } + } - //Check to see if we need to remove stale resources (but not during initialization) - if (!resourcePoolInitializing && requestQueue.Count == 0 - && now.Subtract(lastResourceClosedCheck) > ResourceDeallocationInterval - && resourcePool.Count > MinimumConnections) - { - var staleConnection = resourcePool - .Where(c => !c.InUse && now.Subtract(c.LastUseTime) > ResourceDeallocationInterval) - .OrderBy(c => c.LastUseTime) - .FirstOrDefault(); + private void TryDeallocateStaleResource(DateTime now) + { + if (resourcePoolInitializing || requestQueue.Count > 0) + return; - if (staleConnection != null) - { - resourcePool.Remove(staleConnection); - Task t = closeResourceHandler(staleConnection.Connection); - } - lastResourceClosedCheck = now; - } + if (now.Subtract(lastResourceClosedCheck) <= ResourceDeallocationInterval) + return; + + if (resourcePool.Count <= MinimumConnections) + return; + + var staleConnection = resourcePool + .Where(c => !c.InUse && now.Subtract(c.LastUseTime) > ResourceDeallocationInterval) + .OrderBy(c => c.LastUseTime) + .FirstOrDefault(); + + if (staleConnection != null) + { + resourcePool.Remove(staleConnection); + Task t = closeResourceHandler(staleConnection.Connection); } + + lastResourceClosedCheck = now; } } } diff --git a/src/KnightwareCore/Threading/Tasks/AsyncListProcessor.cs b/src/KnightwareCore/Threading/Tasks/AsyncListProcessor.cs index 4c4f2e7..472cb9d 100644 --- a/src/KnightwareCore/Threading/Tasks/AsyncListProcessor.cs +++ b/src/KnightwareCore/Threading/Tasks/AsyncListProcessor.cs @@ -1,128 +1,144 @@ -using Knightware.Diagnostics; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using System.Threading.Tasks.Dataflow; - +using Knightware.Diagnostics; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; + namespace Knightware.Threading.Tasks { /// /// Manages a collection of items which can be added to in a thread-safe mannor, and will be processed sequentially /// /// - public class AsyncListProcessor + public class AsyncListProcessor : IDisposable { private ActionBlock workerBlock; private CancellationTokenSource cancellationTokenSource; private readonly Func, Task> processItem; private readonly Func checkForContinueMethod; - - public bool IsRunning { get; private set; } - - /// - /// Number of threads that will be used to process items - /// - public int MaxDegreeOfParallelism { get; private set; } - - /// - /// Maximimum items to allow to be in the queue, or 0 for no limit. - /// - public int MaximumQueueCount { get; private set; } - - public AsyncListProcessor(Func, Task> processItem, Func checkForContinueMethod = null, int maxDegreeOfParallelism = 1, int maxQueueCount = 0) - { - this.processItem = processItem ?? throw new ArgumentException("ProcessItem may not be null", "processItem"); - this.checkForContinueMethod = checkForContinueMethod; - this.MaxDegreeOfParallelism = maxDegreeOfParallelism; - } - - public async Task StartupAsync() - { - await ShutdownAsync(); - IsRunning = true; - - this.cancellationTokenSource = new CancellationTokenSource(); - - var workerBlockOptions = new ExecutionDataflowBlockOptions() - { - MaxDegreeOfParallelism = MaxDegreeOfParallelism, - CancellationToken = cancellationTokenSource.Token - }; - if (MaximumQueueCount > 0) - { - workerBlockOptions.BoundedCapacity = MaximumQueueCount; - } - - this.workerBlock = new ActionBlock( - async (item) => - { - try - { - //Check to see that we should still be running - if (IsRunning && (checkForContinueMethod == null || checkForContinueMethod()) && !workerBlockOptions.CancellationToken.IsCancellationRequested) - await processItem(new AsyncListProcessorItemEventArgs(item)); - } - catch (Exception ex) - { - TraceQueue.Trace(this, TracingLevel.Warning, "{0} occurred while processing item: {1}", ex.GetType().Name, ex.Message); - } - }, - workerBlockOptions); - - return true; - } - - /// - /// Shuts down the async list processor. - /// - /// Amount of time, in milliseconds, to wait for a current running task to complete. - /// True if shutdown was successful, or false if maxWait elapsed. A return of false indicates the worker task has not yet completed. - public async Task ShutdownAsync(int maxWait = System.Threading.Timeout.Infinite) - { - IsRunning = false; - - try - { - if (workerBlock != null) - { - workerBlock.Complete(); - cancellationTokenSource.Cancel(); - await workerBlock.Completion; - - workerBlock = null; - cancellationTokenSource = null; - } - return true; - } - catch (TaskCanceledException) - { - workerBlock = null; - cancellationTokenSource = null; - return true; - } - catch (Exception ex) - { - TraceQueue.Trace(this, TracingLevel.Error, "{0} occurred while shutting down AsyncListProcessor: {1}", ex.GetType().Name, ex.Message); - return false; - } - } - - public void Add(T newItem) - { - if (newItem == null || !IsRunning || workerBlock == null) - return; - - workerBlock.Post(newItem); - } - - public void AddRange(IEnumerable newItems) - { - if (newItems != null) - { - foreach (T newItem in newItems) - Add(newItem); - } - } - } -} + private bool disposed; + + public bool IsRunning { get; private set; } + + /// + /// Number of threads that will be used to process items + /// + public int MaxDegreeOfParallelism { get; private set; } + + /// + /// Maximimum items to allow to be in the queue, or 0 for no limit. + /// + public int MaximumQueueCount { get; private set; } + + public AsyncListProcessor(Func, Task> processItem, Func checkForContinueMethod = null, int maxDegreeOfParallelism = 1, int maxQueueCount = 0) + { + this.processItem = processItem ?? throw new ArgumentException("ProcessItem may not be null", "processItem"); + this.checkForContinueMethod = checkForContinueMethod; + this.MaxDegreeOfParallelism = maxDegreeOfParallelism; + } + + public async Task StartupAsync() + { + await ShutdownAsync(); + IsRunning = true; + + this.cancellationTokenSource = new CancellationTokenSource(); + + var workerBlockOptions = new ExecutionDataflowBlockOptions() + { + MaxDegreeOfParallelism = MaxDegreeOfParallelism, + CancellationToken = cancellationTokenSource.Token + }; + if (MaximumQueueCount > 0) + { + workerBlockOptions.BoundedCapacity = MaximumQueueCount; + } + + this.workerBlock = new ActionBlock( + async (item) => + { + try + { + //Check to see that we should still be running + if (IsRunning && (checkForContinueMethod == null || checkForContinueMethod()) && !workerBlockOptions.CancellationToken.IsCancellationRequested) + await processItem(new AsyncListProcessorItemEventArgs(item)); + } + catch (Exception ex) + { + TraceQueue.Trace(this, TracingLevel.Warning, "{0} occurred while processing item: {1}", ex.GetType().Name, ex.Message); + } + }, + workerBlockOptions); + + return true; + } + + /// + /// Shuts down the async list processor. + /// + /// Amount of time, in milliseconds, to wait for a current running task to complete. + /// True if shutdown was successful, or false if maxWait elapsed. A return of false indicates the worker task has not yet completed. + public async Task ShutdownAsync(int maxWait = System.Threading.Timeout.Infinite) + { + IsRunning = false; + + try + { + if (workerBlock != null) + { + workerBlock.Complete(); + cancellationTokenSource.Cancel(); + await workerBlock.Completion; + + workerBlock = null; + cancellationTokenSource?.Dispose(); + cancellationTokenSource = null; + } + return true; + } + catch (TaskCanceledException) + { + workerBlock = null; + cancellationTokenSource?.Dispose(); + cancellationTokenSource = null; + return true; + } + catch (Exception ex) + { + TraceQueue.Trace(this, TracingLevel.Error, "{0} occurred while shutting down AsyncListProcessor: {1}", ex.GetType().Name, ex.Message); + return false; + } + } + + public void Add(T newItem) + { + if (newItem == null || !IsRunning || workerBlock == null) + return; + + workerBlock.Post(newItem); + } + + public void AddRange(IEnumerable newItems) + { + if (newItems != null) + { + foreach (T newItem in newItems) + Add(newItem); + } + } + + public void Dispose() + { + if (!disposed) + { + disposed = true; + IsRunning = false; + cancellationTokenSource?.Cancel(); + cancellationTokenSource?.Dispose(); + cancellationTokenSource = null; + workerBlock = null; + } + } + } +} diff --git a/src/KnightwareCoreTests/Collections/GroupingTests.cs b/src/KnightwareCoreTests/Collections/GroupingTests.cs new file mode 100644 index 0000000..43ce61f --- /dev/null +++ b/src/KnightwareCoreTests/Collections/GroupingTests.cs @@ -0,0 +1,63 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.Linq; + +namespace Knightware.Collections +{ + [TestClass] + public class GroupingTests + { + [TestMethod] + public void KeyTest() + { + var items = new List { "a", "b", "c" }; + var grouping = new Grouping(1, items); + Assert.AreEqual(1, grouping.Key); + } + + [TestMethod] + public void EnumeratorTest() + { + var items = new List { "a", "b", "c" }; + var grouping = new Grouping(1, items); + + var result = grouping.ToList(); + Assert.AreEqual(3, result.Count); + Assert.AreEqual("a", result[0]); + Assert.AreEqual("b", result[1]); + Assert.AreEqual("c", result[2]); + } + + [TestMethod] + public void NonGenericEnumeratorTest() + { + var items = new List { "a", "b", "c" }; + var grouping = new Grouping(1, items); + + var enumerable = (System.Collections.IEnumerable)grouping; + var enumerator = enumerable.GetEnumerator(); + + Assert.IsTrue(enumerator.MoveNext()); + Assert.AreEqual("a", enumerator.Current); + } + + [TestMethod] + public void EmptyGroupingTest() + { + var items = new List(); + var grouping = new Grouping(1, items); + + Assert.AreEqual(0, grouping.Count()); + } + + [TestMethod] + public void IGroupingInterfaceTest() + { + var items = new List { "a", "b" }; + IGrouping grouping = new Grouping(42, items); + + Assert.AreEqual(42, grouping.Key); + Assert.AreEqual(2, grouping.Count()); + } + } +} diff --git a/src/KnightwareCoreTests/Collections/ListExtensionsTests.cs b/src/KnightwareCoreTests/Collections/ListExtensionsTests.cs new file mode 100644 index 0000000..85bbe3b --- /dev/null +++ b/src/KnightwareCoreTests/Collections/ListExtensionsTests.cs @@ -0,0 +1,197 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Knightware.Collections +{ + [TestClass] + public class ListExtensionsTests + { + private class SourceItem + { + public int Id { get; set; } + public string Name { get; set; } + } + + private class DestItem : SourceItem + { + public bool WasUpdated { get; set; } + } + + [TestMethod] + public void CopyToAddsNewItemsTest() + { + var source = new List + { + new SourceItem { Id = 1, Name = "Item1" }, + new SourceItem { Id = 2, Name = "Item2" } + }; + var destination = new List(); + + source.CopyTo( + destination, + s => s.Id, + s => new DestItem { Id = s.Id }, + (s, d) => d.Name = s.Name); + + Assert.AreEqual(2, destination.Count); + Assert.AreEqual("Item1", destination.First(d => d.Id == 1).Name); + Assert.AreEqual("Item2", destination.First(d => d.Id == 2).Name); + } + + [TestMethod] + public void CopyToUpdatesExistingItemsTest() + { + var source = new List + { + new SourceItem { Id = 1, Name = "Updated" } + }; + var destination = new List + { + new DestItem { Id = 1, Name = "Original" } + }; + + source.CopyTo( + destination, + s => s.Id, + s => new DestItem { Id = s.Id }, + (s, d) => d.Name = s.Name); + + Assert.AreEqual(1, destination.Count); + Assert.AreEqual("Updated", destination[0].Name); + } + + [TestMethod] + public void CopyToRemovesMissingItemsTest() + { + var source = new List + { + new SourceItem { Id = 1, Name = "Item1" } + }; + var destination = new List + { + new DestItem { Id = 1, Name = "Item1" }, + new DestItem { Id = 2, Name = "Item2" } + }; + + DestItem removedItem = null; + source.CopyTo( + destination, + s => s.Id, + s => new DestItem { Id = s.Id }, + (s, d) => d.Name = s.Name, + removed => removedItem = removed); + + Assert.AreEqual(1, destination.Count); + Assert.IsNotNull(removedItem); + Assert.AreEqual(2, removedItem.Id); + } + + [TestMethod] + public void CopyToWithNullSourceDoesNotThrowTest() + { + IEnumerable source = null; + var destination = new List(); + + source.CopyTo( + destination, + s => s.Id, + s => new DestItem(), + (s, d) => { }); + } + + [TestMethod] + public void CopyToWithNullDestinationDoesNotThrowTest() + { + var source = new List { new SourceItem { Id = 1 } }; + + source.CopyTo( + null, + s => s.Id, + s => new DestItem(), + (s, d) => { }); + } + + [TestMethod] + public void ConstrainedCopyToDictionaryTest() + { + var source = new Dictionary + { + { "a", new SourceItem { Id = 1, Name = "Item1" } }, + { "b", new SourceItem { Id = 2, Name = "Item2" } } + }; + var destination = new Dictionary(); + + source.ConstrainedCopyTo( + destination, + null, + s => new DestItem { Id = s.Id }, + (s, d) => d.Name = s.Name); + + Assert.AreEqual(2, destination.Count); + Assert.AreEqual("Item1", destination["a"].Name); + } + + [TestMethod] + public void ConstrainedCopyToDictionaryRemovesOldItemsTest() + { + var source = new Dictionary + { + { "a", new SourceItem { Id = 1, Name = "Item1" } } + }; + var destination = new Dictionary + { + { "a", new DestItem { Id = 1, Name = "Original" } }, + { "b", new DestItem { Id = 2, Name = "ToBeRemoved" } } + }; + + DestItem removedItem = null; + source.ConstrainedCopyTo( + destination, + null, + s => new DestItem { Id = s.Id }, + (s, d) => d.Name = s.Name, + removed => removedItem = removed); + + Assert.AreEqual(1, destination.Count); + Assert.AreEqual("Item1", destination["a"].Name); + Assert.IsNotNull(removedItem); + Assert.AreEqual(2, removedItem.Id); + } + + [TestMethod] + public void RemoveWhereTest() + { + var dict = new Dictionary + { + { "a", 1 }, + { "b", 2 }, + { "c", 3 }, + { "d", 4 } + }; + + dict.RemoveWhere(v => v % 2 == 0); + + Assert.AreEqual(2, dict.Count); + Assert.IsTrue(dict.ContainsKey("a")); + Assert.IsTrue(dict.ContainsKey("c")); + Assert.IsFalse(dict.ContainsKey("b")); + Assert.IsFalse(dict.ContainsKey("d")); + } + + [TestMethod] + public void RemoveWhereWithNoMatchesTest() + { + var dict = new Dictionary + { + { "a", 1 }, + { "b", 3 } + }; + + dict.RemoveWhere(v => v % 2 == 0); + + Assert.AreEqual(2, dict.Count); + } + } +} diff --git a/src/KnightwareCoreTests/Primitives/ColorTests.cs b/src/KnightwareCoreTests/Primitives/ColorTests.cs new file mode 100644 index 0000000..c780472 --- /dev/null +++ b/src/KnightwareCoreTests/Primitives/ColorTests.cs @@ -0,0 +1,168 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; + +namespace Knightware.Primitives +{ + [TestClass] + public class ColorTests + { + [TestMethod] + public void ConstructorRgbTest() + { + var color = new Color(100, 150, 200); + Assert.AreEqual(255, color.A); + Assert.AreEqual(100, color.R); + Assert.AreEqual(150, color.G); + Assert.AreEqual(200, color.B); + } + + [TestMethod] + public void ConstructorArgbTest() + { + var color = new Color(128, 100, 150, 200); + Assert.AreEqual(128, color.A); + Assert.AreEqual(100, color.R); + Assert.AreEqual(150, color.G); + Assert.AreEqual(200, color.B); + } + + [TestMethod] + public void CopyConstructorTest() + { + var original = new Color(128, 100, 150, 200); + var copy = new Color(original); + Assert.AreEqual(original.A, copy.A); + Assert.AreEqual(original.R, copy.R); + Assert.AreEqual(original.G, copy.G); + Assert.AreEqual(original.B, copy.B); + } + + [TestMethod] + public void ParseRgbStringTest() + { + var color = new Color("100,150,200"); + Assert.AreEqual(100, color.R); + Assert.AreEqual(150, color.G); + Assert.AreEqual(200, color.B); + } + + [TestMethod] + public void ParseArgbStringTest() + { + var color = new Color("128,100,150,200"); + Assert.AreEqual(128, color.A); + Assert.AreEqual(100, color.R); + Assert.AreEqual(150, color.G); + Assert.AreEqual(200, color.B); + } + + [TestMethod] + public void ParseInvalidStringTest() + { + Assert.ThrowsExactly(() => new Color("")); + Assert.ThrowsExactly(() => new Color("100")); + Assert.ThrowsExactly(() => new Color("100,150")); + } + + [TestMethod] + public void FromRgbTest() + { + var color = Color.FromRgb(100, 150, 200); + Assert.AreEqual(255, color.A); + Assert.AreEqual(100, color.R); + Assert.AreEqual(150, color.G); + Assert.AreEqual(200, color.B); + } + + [TestMethod] + public void FromArgbTest() + { + var color = Color.FromArgb(128, 100, 150, 200); + Assert.AreEqual(128, color.A); + Assert.AreEqual(100, color.R); + Assert.AreEqual(150, color.G); + Assert.AreEqual(200, color.B); + } + + [TestMethod] + public void FromHexStringWithHashTest() + { + var color = Color.FromHexString("#FF6496C8"); + Assert.AreEqual(255, color.A); + Assert.AreEqual(100, color.R); + Assert.AreEqual(150, color.G); + Assert.AreEqual(200, color.B); + } + + [TestMethod] + public void FromHexStringWith0xTest() + { + var color = Color.FromHexString("0xFF6496C8"); + Assert.AreEqual(255, color.A); + Assert.AreEqual(100, color.R); + Assert.AreEqual(150, color.G); + Assert.AreEqual(200, color.B); + } + + [TestMethod] + public void FromHexStringRgbOnlyTest() + { + var color = Color.FromHexString("#6496C8"); + Assert.AreEqual(255, color.A); + Assert.AreEqual(100, color.R); + Assert.AreEqual(150, color.G); + Assert.AreEqual(200, color.B); + } + + [TestMethod] + public void FromHexStringInvalidLengthTest() + { + Assert.ThrowsExactly(() => Color.FromHexString("#12345")); + } + + [TestMethod] + public void EqualsTest() + { + var color1 = new Color(128, 100, 150, 200); + var color2 = new Color(128, 100, 150, 200); + var color3 = new Color(128, 100, 150, 201); + + Assert.IsTrue(color1.Equals(color2)); + Assert.IsFalse(color1.Equals(color3)); + Assert.IsTrue(color1.Equals((object)color2)); + Assert.IsFalse(color1.Equals("not a color")); + } + + [TestMethod] + public void OperatorEqualsTest() + { + var color1 = new Color(128, 100, 150, 200); + var color2 = new Color(128, 100, 150, 200); + var color3 = new Color(128, 100, 150, 201); + + Assert.IsTrue(color1 == color2); + Assert.IsFalse(color1 == color3); + Assert.IsFalse(color1 != color2); + Assert.IsTrue(color1 != color3); + } + + [TestMethod] + public void ToStringTest() + { + var color = new Color(128, 100, 150, 200); + Assert.AreEqual("R=100, G=150, B=200", color.ToString()); + } + + [TestMethod] + public void CopyFromTest() + { + var source = new Color(128, 100, 150, 200); + var dest = new Color(); + dest.CopyFrom(source); + Assert.AreEqual(source.A, dest.A); + Assert.AreEqual(source.R, dest.R); + Assert.AreEqual(source.G, dest.G); + Assert.AreEqual(source.B, dest.B); + } + } +} diff --git a/src/KnightwareCoreTests/Primitives/PointTests.cs b/src/KnightwareCoreTests/Primitives/PointTests.cs new file mode 100644 index 0000000..b28146f --- /dev/null +++ b/src/KnightwareCoreTests/Primitives/PointTests.cs @@ -0,0 +1,44 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Knightware.Primitives +{ + [TestClass] + public class PointTests + { + [TestMethod] + public void ConstructorTest() + { + var point = new Point(10, 20); + Assert.AreEqual(10, point.X); + Assert.AreEqual(20, point.Y); + } + + [TestMethod] + public void EmptyTest() + { + var empty = Point.Empty; + Assert.AreEqual(0, empty.X); + Assert.AreEqual(0, empty.Y); + } + + [TestMethod] + public void EqualsTest() + { + var point1 = new Point(10, 20); + var point2 = new Point(10, 20); + var point3 = new Point(10, 21); + + Assert.IsTrue(point1.Equals(point2)); + Assert.IsFalse(point1.Equals(point3)); + Assert.IsTrue(point1.Equals((object)point2)); + Assert.IsFalse(point1.Equals("not a point")); + } + + [TestMethod] + public void ToStringTest() + { + var point = new Point(10, 20); + Assert.AreEqual("X=10, Y=20", point.ToString()); + } + } +} diff --git a/src/KnightwareCoreTests/Primitives/RectangleTests.cs b/src/KnightwareCoreTests/Primitives/RectangleTests.cs new file mode 100644 index 0000000..63526ad --- /dev/null +++ b/src/KnightwareCoreTests/Primitives/RectangleTests.cs @@ -0,0 +1,175 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Knightware.Primitives +{ + [TestClass] + public class RectangleTests + { + [TestMethod] + public void ConstructorTest() + { + var rect = new Rectangle(10, 20, 100, 200); + Assert.AreEqual(10, rect.X); + Assert.AreEqual(20, rect.Y); + Assert.AreEqual(100, rect.Width); + Assert.AreEqual(200, rect.Height); + } + + [TestMethod] + public void CopyConstructorTest() + { + var original = new Rectangle(10, 20, 100, 200); + var copy = new Rectangle(original); + Assert.AreEqual(original.X, copy.X); + Assert.AreEqual(original.Y, copy.Y); + Assert.AreEqual(original.Width, copy.Width); + Assert.AreEqual(original.Height, copy.Height); + } + + [TestMethod] + public void EmptyTest() + { + var empty = Rectangle.Empty; + Assert.IsTrue(empty.IsEmpty); + Assert.AreEqual(0, empty.X); + Assert.AreEqual(0, empty.Y); + Assert.AreEqual(0, empty.Width); + Assert.AreEqual(0, empty.Height); + } + + [TestMethod] + public void IsEmptyFalseTest() + { + var rect = new Rectangle(1, 0, 0, 0); + Assert.IsFalse(rect.IsEmpty); + } + + [TestMethod] + public void TopLeftRightBottomTest() + { + var rect = new Rectangle(10, 20, 100, 200); + Assert.AreEqual(20, rect.Top); + Assert.AreEqual(10, rect.Left); + Assert.AreEqual(110, rect.Right); + Assert.AreEqual(220, rect.Bottom); + } + + [TestMethod] + public void SetRightTest() + { + var rect = new Rectangle(10, 20, 100, 200); + rect.Right = 150; + Assert.AreEqual(140, rect.Width); + } + + [TestMethod] + public void SetBottomTest() + { + var rect = new Rectangle(10, 20, 100, 200); + rect.Bottom = 300; + Assert.AreEqual(280, rect.Height); + } + + [TestMethod] + public void OffsetPointTest() + { + var rect = new Rectangle(10, 20, 100, 200); + rect.Offset(new Point(5, 10)); + Assert.AreEqual(15, rect.X); + Assert.AreEqual(30, rect.Y); + } + + [TestMethod] + public void OffsetXYTest() + { + var rect = new Rectangle(10, 20, 100, 200); + rect.Offset(5, 10); + Assert.AreEqual(15, rect.X); + Assert.AreEqual(30, rect.Y); + } + + [TestMethod] + public void StaticOffsetPointTest() + { + var rect = new Rectangle(10, 20, 100, 200); + var result = Rectangle.Offset(rect, new Point(5, 10)); + Assert.AreEqual(15, result.X); + Assert.AreEqual(30, result.Y); + Assert.AreEqual(10, rect.X); + } + + [TestMethod] + public void StaticOffsetXYTest() + { + var rect = new Rectangle(10, 20, 100, 200); + var result = Rectangle.Offset(rect, 5, 10); + Assert.AreEqual(15, result.X); + Assert.AreEqual(30, result.Y); + } + + [TestMethod] + public void ContainsPointTest() + { + var rect = new Rectangle(10, 20, 100, 200); + Assert.IsTrue(rect.Contains(new Point(50, 100))); + Assert.IsTrue(rect.Contains(new Point(10, 20))); + Assert.IsTrue(rect.Contains(new Point(110, 220))); + Assert.IsFalse(rect.Contains(new Point(5, 100))); + Assert.IsFalse(rect.Contains(new Point(50, 300))); + } + + [TestMethod] + public void ContainsRectangleTest() + { + var rect = new Rectangle(10, 20, 100, 200); + Assert.IsTrue(rect.Contains(new Rectangle(20, 30, 50, 50))); + Assert.IsFalse(rect.Contains(new Rectangle(5, 30, 50, 50))); + Assert.IsFalse(rect.Contains(new Rectangle(20, 30, 200, 50))); + } + + [TestMethod] + public void EqualsTest() + { + var rect1 = new Rectangle(10, 20, 100, 200); + var rect2 = new Rectangle(10, 20, 100, 200); + var rect3 = new Rectangle(10, 20, 100, 201); + + Assert.IsTrue(rect1.Equals(rect2)); + Assert.IsFalse(rect1.Equals(rect3)); + Assert.IsTrue(rect1.Equals((object)rect2)); + Assert.IsFalse(rect1.Equals("not a rectangle")); + } + + [TestMethod] + public void OperatorEqualsTest() + { + var rect1 = new Rectangle(10, 20, 100, 200); + var rect2 = new Rectangle(10, 20, 100, 200); + var rect3 = new Rectangle(10, 20, 100, 201); + + Assert.IsTrue(rect1 == rect2); + Assert.IsFalse(rect1 == rect3); + Assert.IsFalse(rect1 != rect2); + Assert.IsTrue(rect1 != rect3); + } + + [TestMethod] + public void ToStringTest() + { + var rect = new Rectangle(10, 20, 100, 200); + Assert.AreEqual("Rect: 10, 20, 100, 200", rect.ToString()); + } + + [TestMethod] + public void CopyFromTest() + { + var source = new Rectangle(10, 20, 100, 200); + var dest = new Rectangle(); + dest.CopyFrom(source); + Assert.AreEqual(source.X, dest.X); + Assert.AreEqual(source.Y, dest.Y); + Assert.AreEqual(source.Width, dest.Width); + Assert.AreEqual(source.Height, dest.Height); + } + } +} diff --git a/src/KnightwareCoreTests/Primitives/SizeTests.cs b/src/KnightwareCoreTests/Primitives/SizeTests.cs new file mode 100644 index 0000000..8a9cde3 --- /dev/null +++ b/src/KnightwareCoreTests/Primitives/SizeTests.cs @@ -0,0 +1,57 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Knightware.Primitives +{ + [TestClass] + public class SizeTests + { + [TestMethod] + public void ConstructorTest() + { + var size = new Size(100, 200); + Assert.AreEqual(100, size.Width); + Assert.AreEqual(200, size.Height); + } + + [TestMethod] + public void EmptyTest() + { + var empty = Size.Empty; + Assert.AreEqual(0, empty.Width); + Assert.AreEqual(0, empty.Height); + } + + [TestMethod] + public void EqualsTest() + { + var size1 = new Size(100, 200); + var size2 = new Size(100, 200); + var size3 = new Size(100, 201); + + Assert.IsTrue(size1.Equals(size2)); + Assert.IsFalse(size1.Equals(size3)); + Assert.IsTrue(size1.Equals((object)size2)); + Assert.IsFalse(size1.Equals("not a size")); + } + + [TestMethod] + public void OperatorEqualsTest() + { + var size1 = new Size(100, 200); + var size2 = new Size(100, 200); + var size3 = new Size(100, 201); + + Assert.IsTrue(size1 == size2); + Assert.IsFalse(size1 == size3); + Assert.IsFalse(size1 != size2); + Assert.IsTrue(size1 != size3); + } + + [TestMethod] + public void ToStringTest() + { + var size = new Size(100, 200); + Assert.AreEqual("Width=100, Height=200", size.ToString()); + } + } +} diff --git a/src/KnightwareCoreTests/Primitives/ThicknessTests.cs b/src/KnightwareCoreTests/Primitives/ThicknessTests.cs new file mode 100644 index 0000000..2f4c50b --- /dev/null +++ b/src/KnightwareCoreTests/Primitives/ThicknessTests.cs @@ -0,0 +1,71 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Knightware.Primitives +{ + [TestClass] + public class ThicknessTests + { + [TestMethod] + public void UniformConstructorTest() + { + var thickness = new Thickness(10); + Assert.AreEqual(10, thickness.Left); + Assert.AreEqual(10, thickness.Top); + Assert.AreEqual(10, thickness.Right); + Assert.AreEqual(10, thickness.Bottom); + } + + [TestMethod] + public void IndividualConstructorTest() + { + var thickness = new Thickness(1, 2, 3, 4); + Assert.AreEqual(1, thickness.Left); + Assert.AreEqual(2, thickness.Top); + Assert.AreEqual(3, thickness.Right); + Assert.AreEqual(4, thickness.Bottom); + } + + [TestMethod] + public void EmptyTest() + { + var empty = Thickness.Empty; + Assert.AreEqual(0, empty.Left); + Assert.AreEqual(0, empty.Top); + Assert.AreEqual(0, empty.Right); + Assert.AreEqual(0, empty.Bottom); + } + + [TestMethod] + public void EqualsTest() + { + var t1 = new Thickness(1, 2, 3, 4); + var t2 = new Thickness(1, 2, 3, 4); + var t3 = new Thickness(1, 2, 3, 5); + + Assert.IsTrue(t1.Equals(t2)); + Assert.IsFalse(t1.Equals(t3)); + Assert.IsTrue(t1.Equals((object)t2)); + Assert.IsFalse(t1.Equals("not a thickness")); + } + + [TestMethod] + public void OperatorEqualsTest() + { + var t1 = new Thickness(1, 2, 3, 4); + var t2 = new Thickness(1, 2, 3, 4); + var t3 = new Thickness(1, 2, 3, 5); + + Assert.IsTrue(t1 == t2); + Assert.IsFalse(t1 == t3); + Assert.IsFalse(t1 != t2); + Assert.IsTrue(t1 != t3); + } + + [TestMethod] + public void ToStringTest() + { + var thickness = new Thickness(1, 2, 3, 4); + Assert.AreEqual("Left=1, Top=2, Right=3, Bottom=4", thickness.ToString()); + } + } +} diff --git a/src/KnightwareCoreTests/PropertyChangedBaseTests.cs b/src/KnightwareCoreTests/PropertyChangedBaseTests.cs new file mode 100644 index 0000000..6462d6e --- /dev/null +++ b/src/KnightwareCoreTests/PropertyChangedBaseTests.cs @@ -0,0 +1,84 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Knightware +{ + [TestClass] + public class PropertyChangedBaseTests + { + private class TestPropertyChangedClass : PropertyChangedBase + { + private string _name; + public string Name + { + get => _name; + set + { + _name = value; + OnPropertyChanged(); + } + } + + private int _value; + public int Value + { + get => _value; + set + { + _value = value; + OnPropertyChanged(nameof(Value)); + } + } + + public void RaisePropertyChangedExplicitly(string propertyName) + { + OnPropertyChanged(propertyName); + } + } + + [TestMethod] + public void PropertyChangedEventRaisedTest() + { + var obj = new TestPropertyChangedClass(); + string changedPropertyName = null; + + obj.PropertyChanged += (sender, e) => changedPropertyName = e.PropertyName; + + obj.Name = "Test"; + Assert.AreEqual("Name", changedPropertyName); + } + + [TestMethod] + public void PropertyChangedWithExplicitNameTest() + { + var obj = new TestPropertyChangedClass(); + string changedPropertyName = null; + + obj.PropertyChanged += (sender, e) => changedPropertyName = e.PropertyName; + + obj.Value = 42; + Assert.AreEqual("Value", changedPropertyName); + } + + [TestMethod] + public void PropertyChangedNotRaisedWithoutSubscriberTest() + { + var obj = new TestPropertyChangedClass(); + obj.Name = "Test"; + } + + [TestMethod] + public void MultiplePropertyChangedEventsTest() + { + var obj = new TestPropertyChangedClass(); + int eventCount = 0; + + obj.PropertyChanged += (sender, e) => eventCount++; + + obj.Name = "Test1"; + obj.Name = "Test2"; + obj.Value = 1; + + Assert.AreEqual(3, eventCount); + } + } +} diff --git a/src/KnightwareCoreTests/Threading/ResourcePoolTests.cs b/src/KnightwareCoreTests/Threading/ResourcePoolTests.cs index 435a1a5..b5c51a8 100644 --- a/src/KnightwareCoreTests/Threading/ResourcePoolTests.cs +++ b/src/KnightwareCoreTests/Threading/ResourcePoolTests.cs @@ -186,6 +186,111 @@ public override int GetHashCode() } } + [TestMethod] + public async Task SerializationKeyTest() + { + var config = new ResourcePoolConfig() + { + InitialConnections = 2, + MinimumConnections = 1, + MaximumConnections = 2, + ResourceDeallocationInterval = TimeSpan.FromSeconds(10), + ResourceAllocationInterval = TimeSpan.FromMilliseconds(10), + }; + + await RunResourcePoolTest(config, async pool => + { + var executionOrder = new List(); + var task1Started = new TaskCompletionSource(); + var task1CanFinish = new TaskCompletionSource(); + + // Start first task with serialization key - it will hold the key + var task1 = pool.Run(async resource => + { + executionOrder.Add(1); + task1Started.SetResult(true); + await task1CanFinish.Task; + return true; + }, "key1"); + + await task1Started.Task; + + // Start second task with same key - should wait for task1 + var task2 = pool.Run(async resource => + { + executionOrder.Add(2); + return true; + }, "key1"); + + // Start third task with different key - should run immediately on available resource + var task3Started = new TaskCompletionSource(); + var task3 = pool.Run(async resource => + { + executionOrder.Add(3); + task3Started.SetResult(true); + return true; + }, "key2"); + + // Task 3 should complete before task 1 finishes + await task3Started.Task; + Assert.IsTrue(executionOrder.Contains(3), "Task3 should have started"); + Assert.IsFalse(executionOrder.Contains(2), "Task2 should not have started yet"); + + // Let task1 finish + task1CanFinish.SetResult(true); + await Task.WhenAll(task1, task2, task3); + + Assert.AreEqual(1, executionOrder[0], "Task1 should be first"); + Assert.AreEqual(3, executionOrder[1], "Task3 should be second"); + Assert.AreEqual(2, executionOrder[2], "Task2 should be third"); + }).ConfigureAwait(false); + } + + [TestMethod] + public async Task RunWithExceptionReleasesResourceTest() + { + var config = new ResourcePoolConfig() + { + InitialConnections = 1, + MinimumConnections = 1, + MaximumConnections = 1, + ResourceDeallocationInterval = TimeSpan.FromSeconds(10), + }; + + await RunResourcePoolTest(config, async pool => + { + // Run a task that throws + try + { + await pool.Run(resource => + { + throw new InvalidOperationException("Test exception"); + }); + } + catch (InvalidOperationException) + { + // Expected + } + + // Resource should be released (and marked for shutdown), but pool should still work + // The next acquire should work (pool will create new resource if needed) + var result = await pool.Run(resource => + { + return Task.FromResult(42); + }); + + Assert.AreEqual(42, result); + }).ConfigureAwait(false); + } + + [TestMethod] + public async Task AcquireWhenNotRunningReturnsDefaultTest() + { + var pool = new ResourcePool(); + var result = await pool.Acquire(); + Assert.AreEqual(default(int), result); + } + public TestContext TestContext { get; set; } } } diff --git a/src/KnightwareCoreTests/Threading/Tasks/AsyncSemaphoreTests.cs b/src/KnightwareCoreTests/Threading/Tasks/AsyncSemaphoreTests.cs new file mode 100644 index 0000000..9684283 --- /dev/null +++ b/src/KnightwareCoreTests/Threading/Tasks/AsyncSemaphoreTests.cs @@ -0,0 +1,73 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Threading.Tasks; + +namespace Knightware.Threading.Tasks +{ + [TestClass] + public class AsyncSemaphoreTests + { + [TestMethod] + public async Task WaitAndReleaseTest() + { + var semaphore = new AsyncSemaphore(1); + + var task = semaphore.WaitAsync(); + Assert.IsTrue(task.IsCompleted, "First wait should complete immediately"); + + semaphore.Release(); + } + + [TestMethod] + public async Task MultipleWaitsBlockTest() + { + var semaphore = new AsyncSemaphore(1); + + await semaphore.WaitAsync(); + + var secondWait = semaphore.WaitAsync(); + Assert.IsFalse(secondWait.IsCompleted, "Second wait should block"); + + semaphore.Release(); + await Task.Delay(50); + Assert.IsTrue(secondWait.IsCompleted, "Second wait should complete after release"); + } + + [TestMethod] + public async Task InitialCountTest() + { + var semaphore = new AsyncSemaphore(3); + + var wait1 = semaphore.WaitAsync(); + var wait2 = semaphore.WaitAsync(); + var wait3 = semaphore.WaitAsync(); + + Assert.IsTrue(wait1.IsCompleted); + Assert.IsTrue(wait2.IsCompleted); + Assert.IsTrue(wait3.IsCompleted); + + var wait4 = semaphore.WaitAsync(); + Assert.IsFalse(wait4.IsCompleted, "Fourth wait should block"); + + semaphore.Release(); + await Task.Delay(50); + Assert.IsTrue(wait4.IsCompleted); + } + + [TestMethod] + public void ReleaseWithoutWaitersIncreasesCount() + { + var semaphore = new AsyncSemaphore(1); + + semaphore.Release(); + semaphore.Release(); + + var wait1 = semaphore.WaitAsync(); + var wait2 = semaphore.WaitAsync(); + var wait3 = semaphore.WaitAsync(); + + Assert.IsTrue(wait1.IsCompleted); + Assert.IsTrue(wait2.IsCompleted); + Assert.IsTrue(wait3.IsCompleted); + } + } +} diff --git a/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs b/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs index 6b33898..f555baf 100644 --- a/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs +++ b/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs @@ -9,6 +9,7 @@ namespace Knightware.Threading.Tasks { + [DoNotParallelize] [TestClass] public class BatchProcessorTests { @@ -50,13 +51,13 @@ public async Task TestSimpleSetup(BatchHandler batchHandler, dou } } - [TestMethod] - public void TestCleanup() + [TestCleanup] + public async Task TestCleanup() { //Generic cleanup if (processor != null) { - processor.ShutdownAsync().Wait(); + await processor.ShutdownAsync().ConfigureAwait(false); processor = null; } } @@ -198,14 +199,6 @@ public async Task MaximumCountMultipleTest() } } - [TestMethod] - public void MaximumElapsedMultipleTest() - { - //Runs through multiple iterations of a maximum elapsed timeout test on a single instance of the item processor to ensure it works correctly - //when hitting the max timeout limit multiple times - - } - [TestMethod] public async Task MinimumElapsedMultipleTest() { diff --git a/src/KnightwareCoreTests/Threading/Tasks/RequestDeferralTests.cs b/src/KnightwareCoreTests/Threading/Tasks/RequestDeferralTests.cs new file mode 100644 index 0000000..f94f446 --- /dev/null +++ b/src/KnightwareCoreTests/Threading/Tasks/RequestDeferralTests.cs @@ -0,0 +1,68 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Knightware.Threading.Tasks +{ + [TestClass] + public class RequestDeferralTests + { + [TestMethod] + public async Task CompleteTest() + { + var deferral = new RequestDeferral(); + + var waitTask = deferral.WaitForCompletedAsync(); + Assert.IsFalse(waitTask.IsCompleted); + + deferral.Complete(); + await waitTask; + Assert.IsTrue(waitTask.IsCompleted); + } + + [TestMethod] + public async Task MultipleCompletesDoNotThrowTest() + { + var deferral = new RequestDeferral(); + + deferral.Complete(); + deferral.Complete(); + + await deferral.WaitForCompletedAsync(); + } + + [TestMethod] + public async Task WaitForAllCompletedAsyncTest() + { + var deferrals = new List + { + new RequestDeferral(), + new RequestDeferral(), + new RequestDeferral() + }; + + var waitTask = RequestDeferral.WaitForAllCompletedAsync(deferrals); + Assert.IsFalse(waitTask.IsCompleted); + + deferrals[0].Complete(); + deferrals[1].Complete(); + Assert.IsFalse(waitTask.IsCompleted); + + deferrals[2].Complete(); + await waitTask; + Assert.IsTrue(waitTask.IsCompleted); + } + + [TestMethod] + public async Task WaitForAllCompletedAsyncWithNullTest() + { + await RequestDeferral.WaitForAllCompletedAsync(null); + } + + [TestMethod] + public async Task WaitForAllCompletedAsyncWithEmptyListTest() + { + await RequestDeferral.WaitForAllCompletedAsync(new List()); + } + } +} diff --git a/src/KnightwareCoreTests/Threading/Tasks/TaskExtensionsTests.cs b/src/KnightwareCoreTests/Threading/Tasks/TaskExtensionsTests.cs new file mode 100644 index 0000000..f187ffa --- /dev/null +++ b/src/KnightwareCoreTests/Threading/Tasks/TaskExtensionsTests.cs @@ -0,0 +1,59 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Threading.Tasks; + +namespace Knightware.Threading.Tasks +{ + [TestClass] + public class TaskExtensionsTests + { + [TestMethod] + public async Task AllSuccessWithAllTrueTest() + { + var task1 = Task.FromResult(true); + var task2 = Task.FromResult(true); + var task3 = Task.FromResult(true); + + var result = await TaskExtensions.AllSuccess(task1, task2, task3); + Assert.IsTrue(result); + } + + [TestMethod] + public async Task AllSuccessWithOneFalseTest() + { + var task1 = Task.FromResult(true); + var task2 = Task.FromResult(false); + var task3 = Task.FromResult(true); + + var result = await TaskExtensions.AllSuccess(task1, task2, task3); + Assert.IsFalse(result); + } + + [TestMethod] + public async Task AllSuccessWithEmptyArrayTest() + { + var result = await TaskExtensions.AllSuccess(); + Assert.IsFalse(result); + } + + [TestMethod] + public async Task AllSuccessWithNullArrayTest() + { + var result = await TaskExtensions.AllSuccess(null); + Assert.IsFalse(result); + } + + [TestMethod] + public async Task AllSuccessWithSingleTrueTest() + { + var result = await TaskExtensions.AllSuccess(Task.FromResult(true)); + Assert.IsTrue(result); + } + + [TestMethod] + public async Task AllSuccessWithSingleFalseTest() + { + var result = await TaskExtensions.AllSuccess(Task.FromResult(false)); + Assert.IsFalse(result); + } + } +} From 39c772242c4edc6dd86d2b845c1f8cceeda3378e Mon Sep 17 00:00:00 2001 From: Derek Smithson Date: Sat, 31 Jan 2026 16:14:07 -0700 Subject: [PATCH 05/10] chore: pipeline updates --- .github/workflows/dotnet-build.yml | 54 ++++++++++---------- .github/workflows/publish-nuget.yml | 76 ++++++++++++++++++----------- azure-pipelines.yml | 20 -------- 3 files changed, 75 insertions(+), 75 deletions(-) delete mode 100644 azure-pipelines.yml diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index 2cebae6..22f2771 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -1,27 +1,27 @@ -name: .NET Core - -on: - push: - pull_request: - branches: [ main ] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 - with: - dotnet-version: 3.1 - - name: Install dependencies - run: dotnet restore - working-directory: ./src/ - - name: Build - run: dotnet build --configuration Release --no-restore - working-directory: ./src/ - - name: Test - run: dotnet test src --no-restore --verbosity normal - +name: .NET Core + +on: + push: + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 10.0 + - name: Install dependencies + run: dotnet restore + working-directory: ./src/ + - name: Build + run: dotnet build --configuration Release --no-restore + working-directory: ./src/ + - name: Test + run: dotnet test src --no-restore --verbosity normal + diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index b61e1e5..d69d08d 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -1,28 +1,48 @@ -name: Publish Nuget - -on: - push: - branches: [ main ] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 - with: - dotnet-version: 3.1 - - name: Install dependencies - run: dotnet restore - working-directory: ./src/ - - name: Build - run: dotnet build --configuration Release --no-restore - working-directory: ./src/ - - name: Publish KnightwareCore - uses: brandedoutcast/publish-nuget@v2.5.5 - with: - PROJECT_FILE_PATH: src/KnightwareCore/KnightwareCore.csproj - NUGET_KEY: ${{secrets.NUGET_API_KEY}} \ No newline at end of file +name: Publish Nuget + +on: + push: + tags: + - 'v*' + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Verify tag is on main branch + run: | + if ! git branch -r --contains ${{ github.ref }} | grep -q 'origin/main'; then + echo "Error: Tag must be on the main branch" + exit 1 + fi + - name: Get version from tag + id: get_version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + - name: Setup .NET Core + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0 + - name: Install dependencies + run: dotnet restore + working-directory: ./src/ + - name: Build + run: dotnet build --configuration Release --no-restore + working-directory: ./src/ + - name: Pack + run: dotnet pack --configuration Release --no-build --output ./nupkg -p:PackageVersion=${{ steps.get_version.outputs.VERSION }} + working-directory: ./src/KnightwareCore/ + - name: Publish to NuGet + run: dotnet nuget push ./nupkg/*.nupkg --api-key ${{secrets.NUGET_API_KEY}} --source https://api.nuget.org/v3/index.json --skip-duplicate + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: Release v${{ steps.get_version.outputs.VERSION }} + files: ./src/KnightwareCore/nupkg/*.nupkg + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index 8012e4e..0000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,20 +0,0 @@ -# ASP.NET Core -# Build and test ASP.NET Core projects targeting .NET Core. -# Add steps that run tests, create a NuGet package, deploy, and more: -# https://docs.microsoft.com/azure/devops/pipelines/languages/dotnet-core - -trigger: -- master - -pool: - vmImage: ubuntu-latest - -variables: - buildConfiguration: 'Release' - -steps: -- script: dotnet build --configuration $(buildConfiguration) ./src/KnightwareCore.sln - displayName: 'dotnet build $(buildConfiguration)' - -- script: dotnet test --configuration $(buildConfiguration) ./src/KnightwareCore.sln - displayName: 'dotnet build $(buildConfiguration)' From b24cdc1bfeb02757da6cfd5dd559380a80699d24 Mon Sep 17 00:00:00 2001 From: Derek Smithson Date: Sat, 31 Jan 2026 16:14:19 -0700 Subject: [PATCH 06/10] chore: updating README.md --- README.md | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 148 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 591dba4..2735070 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,153 @@ # KnightwareCore -.Net Core class library providing shim classes for threading, networking, and other primitives not available in .Net Core or otherwise frequently used in my other projects. -[![CI](https://github.com/dsmithson/KnightwareCore/actions/workflows/ci.yml/badge.svg)](https://github.com/dsmithson/KnightwareCore/actions/workflows/ci.yml) +A .NET Standard 2.0 library providing helpful utility classes for threading, networking, collections, and other common patterns frequently used across projects. +[![CI](https://github.com/dsmithson/KnightwareCore/actions/workflows/ci.yml/badge.svg)](https://github.com/dsmithson/KnightwareCore/actions/workflows/ci.yml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=dsmithson_KnightwareCore&metric=alert_status)](https://sonarcloud.io/dashboard?id=dsmithson_KnightwareCore) - [![codecov](https://codecov.io/gh/dsmithson/KnightwareCore/branch/master/graph/badge.svg)](https://codecov.io/gh/dsmithson/KnightwareCore) +[![NuGet](https://img.shields.io/nuget/v/KnightwareCore.svg)](https://www.nuget.org/packages/KnightwareCore/) + +## Installation + +```bash +dotnet add package KnightwareCore +``` + +## Features + +### Threading & Async Primitives (`Knightware.Threading` / `Knightware.Threading.Tasks`) + +| Class | Description | +|-------|-------------| +| `AsyncLock` | A reentrant async-compatible lock, allowing `await` inside critical sections | +| `AsyncAutoResetEvent` | Async-compatible auto-reset event for signaling between tasks | +| `AsyncSemaphore` | Async-compatible semaphore for limiting concurrent access | +| `AsyncListProcessor` | Thread-safe queue that processes items sequentially with configurable parallelism | +| `AutoResetWorker` | Background worker triggered by an auto-reset event with optional periodic execution | +| `BatchProcessor` | Batches incoming requests and processes them based on time/count thresholds | +| `ResourcePool` | Generic connection/resource pool with automatic scaling based on demand | +| `Dispatcher` | `SynchronizationContext`-aware dispatcher for marshaling calls to a specific context | + +**Example: AsyncLock** +```csharp +private readonly AsyncLock _lock = new AsyncLock(); + +public async Task DoWorkAsync() +{ + using (await _lock.LockAsync()) + { + // Thread-safe async work here + await SomeAsyncOperation(); + } +} +``` + +**Example: BatchProcessor** +```csharp +var batchProcessor = new BatchProcessor(); +await batchProcessor.StartupAsync( + async batch => { + // Process all requests in the batch at once + foreach (var item in batch) + item.SetResponse(await ProcessAsync(item.Request)); + }, + minimumTimeInterval: TimeSpan.FromMilliseconds(100), + maximumTimeInterval: TimeSpan.FromSeconds(1), + maximumCount: 50 +); +``` + +### Collections (`Knightware.Collections`) + +| Class | Description | +|-------|-------------| +| `NotifyingObservableCollection` | `ObservableCollection` that raises events when item properties change (via `INotifyPropertyChanged`) | +| `CompositeCollection` | Combines multiple collections into a single virtual collection with change notification | +| `Grouping` | Simple `IGrouping` implementation | +| `ListExtensions` | Extension methods for list manipulation | + +**Example: NotifyingObservableCollection** +```csharp +var collection = new NotifyingObservableCollection(); +collection.CollectionItemChanged += (sender, e) => { + Console.WriteLine($"Item at index {e.Index} property '{e.PropertyName}' changed"); +}; +``` + +### Networking (`Knightware.Net`) + +| Class | Description | +|-------|-------------| +| `TCPSocket` | Async TCP client wrapper with simple startup/shutdown lifecycle | +| `UDPSocket` | Async UDP socket for sending/receiving datagrams | +| `UDPMulticastListener` | Listens for UDP multicast traffic on a specified group | + +**Example: TCPSocket** +```csharp +var socket = new TCPSocket(); +if (await socket.StartupAsync("192.168.1.100", 5000)) +{ + await socket.WriteAsync(data, 0, data.Length); + int bytesRead = await socket.ReadAsync(buffer, 0, buffer.Length); +} +await socket.ShutdownAsync(); +``` + +### Diagnostics (`Knightware.Diagnostics`) + +| Class | Description | +|-------|-------------| +| `TraceQueue` | Static trace/logging queue with configurable tracing levels and async processing | +| `TraceMessage` | Represents a single trace message with timestamp, level, and content | +| `TracingLevel` | Enum defining trace levels (Success, Warning, Error, etc.) | + +**Example: TraceQueue** +```csharp +TraceQueue.TracingLevel = TracingLevel.Warning; +TraceQueue.TraceMessageRaised += msg => Console.WriteLine($"[{msg.Level}] {msg.Message}"); +TraceQueue.Trace(this, TracingLevel.Warning, "Something happened: {0}", details); +``` + +### Primitives (`Knightware.Primitives`) + +Platform-independent primitive types useful in cross-platform scenarios: + +| Struct | Description | +|--------|-------------| +| `Color` | ARGB color with parsing and equality support | +| `Point` | 2D point (X, Y) | +| `Size` | Width and Height | +| `Rectangle` | Position and size combined | +| `Thickness` | Four-sided thickness (Left, Top, Right, Bottom) | + +### IO (`Knightware.IO`) + +| Class | Description | +|-------|-------------| +| `GZipStreamDecompressor` | Decompresses GZip streams | +| `XmlDeserializer` | Helper for deserializing XML content | + +### Base Classes (`Knightware`) + +| Class | Description | +|-------|-------------| +| `PropertyChangedBase` | Base class implementing `INotifyPropertyChanged` with `[CallerMemberName]` support | +| `DispatcherPropertyChangedBase` | `PropertyChangedBase` with automatic dispatcher marshaling for UI binding | +| `TimedCacheWeakReference` | Weak reference that maintains a strong reference for a configurable duration | + +**Example: PropertyChangedBase** +```csharp +public class MyViewModel : PropertyChangedBase +{ + private string _name; + public string Name + { + get => _name; + set { _name = value; OnPropertyChanged(); } + } +} +``` + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. From ba96dcafc2a53b910e57e0f6003c368e7a228733 Mon Sep 17 00:00:00 2001 From: Derek Smithson Date: Sat, 31 Jan 2026 16:27:30 -0700 Subject: [PATCH 07/10] chore: more pipeline updates --- .github/workflows/codeql-analysis.yml | 118 ++++++++++---------------- .github/workflows/dotnet-build.yml | 36 +++++--- .github/workflows/publish-nuget.yml | 4 +- README.md | 4 +- src/.github/workflows/ci.yml | 38 --------- src/.github/workflows/release.yml | 74 ---------------- src/KnightwareCore/GitVersion.yml | 9 -- 7 files changed, 76 insertions(+), 207 deletions(-) delete mode 100644 src/.github/workflows/ci.yml delete mode 100644 src/.github/workflows/release.yml delete mode 100644 src/KnightwareCore/GitVersion.yml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b292ea1..7edb382 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,71 +1,47 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -name: "CodeQL" - -on: - push: - branches: [master] - pull_request: - # The branches below must be a subset of the branches above - branches: [master] - schedule: - - cron: '0 4 * * 2' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - # Override automatic language detection by changing the below list - # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] - language: ['csharp'] - # Learn more... - # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 - - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +name: "CodeQL" + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 4 * * 2' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + security-events: write + + strategy: + fail-fast: false + matrix: + language: ['csharp'] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Build + run: dotnet build --configuration Release + working-directory: ./src/ + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index 22f2771..0626117 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -1,27 +1,41 @@ -name: .NET Core +name: CI on: - push: pull_request: branches: [ main ] + push: + branches: [ main ] jobs: - build: - + build-and-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0 - - name: Install dependencies + dotnet-version: 10.0.x + + - name: Restore dependencies run: dotnet restore working-directory: ./src/ + - name: Build run: dotnet build --configuration Release --no-restore working-directory: ./src/ - - name: Test - run: dotnet test src --no-restore --verbosity normal + + - name: Run tests + run: dotnet test --configuration Release --no-build --verbosity normal --logger trx --results-directory "TestResults" + working-directory: ./src/ + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: src/TestResults + retention-days: 5 diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index d69d08d..58ddd00 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Verify tag is on main branch @@ -24,7 +24,7 @@ jobs: id: get_version run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - name: Setup .NET Core - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0 - name: Install dependencies diff --git a/README.md b/README.md index 2735070..7b1cae9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A .NET Standard 2.0 library providing helpful utility classes for threading, net [![CI](https://github.com/dsmithson/KnightwareCore/actions/workflows/ci.yml/badge.svg)](https://github.com/dsmithson/KnightwareCore/actions/workflows/ci.yml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=dsmithson_KnightwareCore&metric=alert_status)](https://sonarcloud.io/dashboard?id=dsmithson_KnightwareCore) -[![codecov](https://codecov.io/gh/dsmithson/KnightwareCore/branch/master/graph/badge.svg)](https://codecov.io/gh/dsmithson/KnightwareCore) +[![codecov](https://codecov.io/gh/dsmithson/KnightwareCore/branch/main/graph/badge.svg)](https://codecov.io/gh/dsmithson/KnightwareCore) [![NuGet](https://img.shields.io/nuget/v/KnightwareCore.svg)](https://www.nuget.org/packages/KnightwareCore/) ## Installation @@ -150,4 +150,4 @@ public class MyViewModel : PropertyChangedBase ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details. diff --git a/src/.github/workflows/ci.yml b/src/.github/workflows/ci.yml deleted file mode 100644 index 0da8b3a..0000000 --- a/src/.github/workflows/ci.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: CI - -on: - pull_request: - branches: [ master, main ] - push: - branches: [ master, main ] - -jobs: - build-and-test: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 10.0.x - - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --configuration Release --no-restore - - - name: Run tests - run: dotnet test --configuration Release --no-build --verbosity normal --logger trx --results-directory "TestResults" - - - name: Upload test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results - path: TestResults - retention-days: 5 diff --git a/src/.github/workflows/release.yml b/src/.github/workflows/release.yml deleted file mode 100644 index 59e6807..0000000 --- a/src/.github/workflows/release.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - -jobs: - build-and-release: - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 10.0.x - - - name: Extract version from tag - id: get_version - run: | - VERSION=${GITHUB_REF#refs/tags/v} - echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - echo "Extracted version: $VERSION" - - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --configuration Release --no-restore - - - name: Run tests - run: dotnet test --configuration Release --no-build --verbosity normal - - - name: Pack NuGet package - run: dotnet pack KnightwareCore/KnightwareCore.csproj --configuration Release --no-build -p:PackageVersion=${{ steps.get_version.outputs.VERSION }} --output ./nupkg - - - name: Generate release notes - id: release_notes - run: | - # Get the previous tag - PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - - if [ -z "$PREVIOUS_TAG" ]; then - # No previous tag, get all commits - NOTES=$(git log --pretty=format:"- %s (%h)" --no-merges) - else - # Get commits between previous tag and current - NOTES=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges) - fi - - # Write to file to handle multiline - echo "$NOTES" > release_notes.txt - echo "Release notes generated" - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - name: Release v${{ steps.get_version.outputs.VERSION }} - body_path: release_notes.txt - files: ./nupkg/*.nupkg - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish to NuGet - run: dotnet nuget push ./nupkg/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/src/KnightwareCore/GitVersion.yml b/src/KnightwareCore/GitVersion.yml deleted file mode 100644 index a98b2b9..0000000 --- a/src/KnightwareCore/GitVersion.yml +++ /dev/null @@ -1,9 +0,0 @@ -mode: Mainline -next-version: 3.1.0 -branches: - feature: - tag: alpha - master: - tag: '' -ignore: - sha: [] From d706e15caf4a67cfa959eda6f8a1408a915052de Mon Sep 17 00:00:00 2001 From: Derek Smithson Date: Sat, 31 Jan 2026 16:44:43 -0700 Subject: [PATCH 08/10] chore: unit test updates --- .../Collections/ListExtensionsTests.cs | 3 +- .../Drawing/BitmapHelperTest.cs | 52 +++++++++++++------ .../PropertyChangedBaseTests.cs | 7 --- .../Threading/Tasks/BatchProcessorTests.cs | 7 +-- 4 files changed, 41 insertions(+), 28 deletions(-) diff --git a/src/KnightwareCoreTests/Collections/ListExtensionsTests.cs b/src/KnightwareCoreTests/Collections/ListExtensionsTests.cs index 85bbe3b..929e0d3 100644 --- a/src/KnightwareCoreTests/Collections/ListExtensionsTests.cs +++ b/src/KnightwareCoreTests/Collections/ListExtensionsTests.cs @@ -83,7 +83,7 @@ public void CopyToRemovesMissingItemsTest() (s, d) => d.Name = s.Name, removed => removedItem = removed); - Assert.AreEqual(1, destination.Count); + Assert.HasCount(1, destination); Assert.IsNotNull(removedItem); Assert.AreEqual(2, removedItem.Id); } @@ -105,7 +105,6 @@ public void CopyToWithNullSourceDoesNotThrowTest() public void CopyToWithNullDestinationDoesNotThrowTest() { var source = new List { new SourceItem { Id = 1 } }; - source.CopyTo( null, s => s.Id, diff --git a/src/KnightwareCoreTests/Drawing/BitmapHelperTest.cs b/src/KnightwareCoreTests/Drawing/BitmapHelperTest.cs index 83d8181..6d78e82 100644 --- a/src/KnightwareCoreTests/Drawing/BitmapHelperTest.cs +++ b/src/KnightwareCoreTests/Drawing/BitmapHelperTest.cs @@ -1,10 +1,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; -using System.Collections.Generic; -using System.Drawing; using System.IO; -using System.Text; - namespace Knightware.Drawing { @@ -21,19 +17,43 @@ public void DrawSolidBitmapTest() const int width = 808; const int height = 402; - using (var stream = BitmapHelper.GenerateSolidColorBitmap(Primitives.Color.FromArgb(a, r, g, b), width, height)) - { - stream.Seek(0, SeekOrigin.Begin); + using var stream = BitmapHelper.GenerateSolidColorBitmap(Primitives.Color.FromArgb(a, r, g, b), width, height); + stream.Seek(0, SeekOrigin.Begin); + + // Parse BMP header directly (works cross-platform without System.Drawing) + using var reader = new BinaryReader(stream); + + // BMP Header (14 bytes) + var signature = new string(reader.ReadChars(2)); + Assert.AreEqual("BM", signature, "Invalid BMP signature"); + + var fileSize = reader.ReadInt32(); + reader.ReadInt32(); // Reserved + var dataOffset = reader.ReadInt32(); + + // DIB Header + var dibHeaderSize = reader.ReadInt32(); + var bmpWidth = reader.ReadInt32(); + var bmpHeight = reader.ReadInt32(); + + Assert.AreEqual(width, bmpWidth, "Width was incorrect"); + Assert.AreEqual(height, bmpHeight, "Height was incorrect"); + + // Skip to pixel data and verify color + reader.ReadInt16(); // planes + var bitsPerPixel = reader.ReadInt16(); + Assert.AreEqual(24, bitsPerPixel, "Expected 24-bit BMP"); + + stream.Seek(dataOffset, SeekOrigin.Begin); - //Use System.Drawing bitmap to confirm - using (var bitmap = Bitmap.FromStream(stream)) - { - Assert.AreEqual(width, bitmap.Width, "Width was incorrect"); - Assert.AreEqual(height, bitmap.Height, "Height was incorrect"); + // Read first pixel (BGR order in BMP) + byte pixelB = reader.ReadByte(); + byte pixelG = reader.ReadByte(); + byte pixelR = reader.ReadByte(); - //TODO: Test all the pixels for the correct color - } - } + Assert.AreEqual(b, pixelB, "Blue channel was incorrect"); + Assert.AreEqual(g, pixelG, "Green channel was incorrect"); + Assert.AreEqual(r, pixelR, "Red channel was incorrect"); } } -} +} diff --git a/src/KnightwareCoreTests/PropertyChangedBaseTests.cs b/src/KnightwareCoreTests/PropertyChangedBaseTests.cs index 6462d6e..14fe0ef 100644 --- a/src/KnightwareCoreTests/PropertyChangedBaseTests.cs +++ b/src/KnightwareCoreTests/PropertyChangedBaseTests.cs @@ -59,13 +59,6 @@ public void PropertyChangedWithExplicitNameTest() Assert.AreEqual("Value", changedPropertyName); } - [TestMethod] - public void PropertyChangedNotRaisedWithoutSubscriberTest() - { - var obj = new TestPropertyChangedClass(); - obj.Name = "Test"; - } - [TestMethod] public void MultiplePropertyChangedEventsTest() { diff --git a/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs b/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs index f555baf..cc95e22 100644 --- a/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs +++ b/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs @@ -228,18 +228,19 @@ await TestSimpleSetup( maximumCount: int.MaxValue); //Run our test duration, adding items as needed + List tasks = new(); for(int i=0; i Date: Sat, 31 Jan 2026 16:59:14 -0700 Subject: [PATCH 09/10] fix: race condition fixed for BatchProcessorTest --- README.md | 1 - .../Threading/Tasks/BatchProcessorTests.cs | 20 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7b1cae9..c3b4948 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ A .NET Standard 2.0 library providing helpful utility classes for threading, networking, collections, and other common patterns frequently used across projects. [![CI](https://github.com/dsmithson/KnightwareCore/actions/workflows/ci.yml/badge.svg)](https://github.com/dsmithson/KnightwareCore/actions/workflows/ci.yml) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=dsmithson_KnightwareCore&metric=alert_status)](https://sonarcloud.io/dashboard?id=dsmithson_KnightwareCore) [![codecov](https://codecov.io/gh/dsmithson/KnightwareCore/branch/main/graph/badge.svg)](https://codecov.io/gh/dsmithson/KnightwareCore) [![NuGet](https://img.shields.io/nuget/v/KnightwareCore.svg)](https://www.nuget.org/packages/KnightwareCore/) diff --git a/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs b/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs index cc95e22..58d8e6d 100644 --- a/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs +++ b/src/KnightwareCoreTests/Threading/Tasks/BatchProcessorTests.cs @@ -210,6 +210,7 @@ public async Task MinimumElapsedMultipleTest() var batchesSizesProcessed = new List(); int itemsProcessedInCurrentBatch = 0; + var batchCompleteSignal = new TaskCompletionSource(); //Setup a handler that will increment our batch process count per item and add the batch count and a timestamp when a batch completes await TestSimpleSetup( @@ -222,25 +223,30 @@ await TestSimpleSetup( { batchesSizesProcessed.Add(itemsProcessedInCurrentBatch); itemsProcessedInCurrentBatch = 0; + batchCompleteSignal.TrySetResult(true); }, minimumTimeIntervalMs: minMs, maximumTimeIntervalMs: 10000, maximumCount: int.MaxValue); //Run our test duration, adding items as needed - List tasks = new(); for(int i=0; i(); + + //Add item(s) for this batch + List batchTasks = new(); for (int j = 0; j < expectedItemsPerBatch; j++) { - tasks.Add(processor.EnqueueAsync(0)); + batchTasks.Add(processor.EnqueueAsync(0)); } - await Task.Delay(minMs * 2); - } - //Wait for last batch to finish... - await Task.WhenAll(tasks); + //Wait for the batch to fully complete (including onBatchProcessed callback) + //Task.WhenAll only waits for SetResponse, but we need to wait for the callback too + await Task.WhenAll(batchTasks); + await batchCompleteSignal.Task; + } //Verify we processed the correct number of batches Assert.HasCount(expectedBatches, batchesSizesProcessed, "Incorrect number of batches processed"); From 47440c2ce807c0f63158eff6ce35cdb3b962fc20 Mon Sep 17 00:00:00 2001 From: Derek Smithson Date: Sat, 31 Jan 2026 17:07:09 -0700 Subject: [PATCH 10/10] chore: nuget properties cleanup --- src/KnightwareCore/KnightwareCore.csproj | 7 +++---- src/KnightwareCore/KnightwareCore.nuspec | 19 ------------------- 2 files changed, 3 insertions(+), 23 deletions(-) delete mode 100644 src/KnightwareCore/KnightwareCore.nuspec diff --git a/src/KnightwareCore/KnightwareCore.csproj b/src/KnightwareCore/KnightwareCore.csproj index 89cbbcb..c1f99c7 100644 --- a/src/KnightwareCore/KnightwareCore.csproj +++ b/src/KnightwareCore/KnightwareCore.csproj @@ -1,15 +1,14 @@  netstandard2.0 - AnyCPU;x86;x64 Derek Smithson Knightware - 3.1.1 + 0.0.1 Net Standard library providing helpful classes for threading, networking, and other primitives not available in Net Standard. - Copyright 2020 + Copyright $([System.DateTime]::Now.Year) https://github.com/dsmithson/KnightwareCore knightware - MIT + Apache-2.0 https://github.com/dsmithson/KnightwareCore git diff --git a/src/KnightwareCore/KnightwareCore.nuspec b/src/KnightwareCore/KnightwareCore.nuspec deleted file mode 100644 index 198b16e..0000000 --- a/src/KnightwareCore/KnightwareCore.nuspec +++ /dev/null @@ -1,19 +0,0 @@ - - - - KnightwareCore - $version$ - Derek Smithson - Derek Smithson - http://www.apache.org/licenses/LICENSE-2.0.html - https://github.com/dsmithson/KnightwareCore - false - Net Standard library providing helpful classes for threading, networking, and other primitives not available in Net Standard. - Misc fixes and improvements. - Copyright 2019 - knightware - - - - - \ No newline at end of file