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 2cebae6..0626117 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -1,27 +1,41 @@ -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: CI + +on: + pull_request: + branches: [ main ] + push: + branches: [ 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 + working-directory: ./src/ + + - name: Build + run: dotnet build --configuration Release --no-restore + working-directory: ./src/ + + - 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 b61e1e5..58ddd00 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@v6 + 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@v5 + 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/README.md b/README.md index dbcfbbe..c3b4948 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,152 @@ -# 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 + +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) +[![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 + +```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 Apache 2.0 License - see the [LICENSE](LICENSE) file for details. 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)' 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/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: [] 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/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 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/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/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..929e0d3 --- /dev/null +++ b/src/KnightwareCoreTests/Collections/ListExtensionsTests.cs @@ -0,0 +1,196 @@ +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.HasCount(1, destination); + 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/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/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..14fe0ef --- /dev/null +++ b/src/KnightwareCoreTests/PropertyChangedBaseTests.cs @@ -0,0 +1,77 @@ +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 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 7f4d6bc..58d8e6d 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; } } @@ -73,7 +74,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 +103,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,25 +188,17 @@ 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(); 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( @@ -229,6 +223,7 @@ await TestSimpleSetup( { batchesSizesProcessed.Add(itemsProcessedInCurrentBatch); itemsProcessedInCurrentBatch = 0; + batchCompleteSignal.TrySetResult(true); }, minimumTimeIntervalMs: minMs, maximumTimeIntervalMs: 10000, @@ -237,19 +232,24 @@ await TestSimpleSetup( //Run our test duration, adding items as needed for(int i=0; i(); + + //Add item(s) for this batch + List batchTasks = new(); for (int j = 0; j < expectedItemsPerBatch; j++) { - Task t1 = processor.EnqueueAsync(0); + batchTasks.Add(processor.EnqueueAsync(0)); } - await Task.Delay(minMs * 2); + + //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; } - //Wait for last batch to finish... - await Task.Delay(1000); - //Verify we processed the correct number of batches - Assert.AreEqual(expectedBatches, batchesSizesProcessed.Count, "Incorrect number of batches processed"); + Assert.HasCount(expectedBatches, batchesSizesProcessed, "Incorrect number of batches processed"); foreach(int batchSize in batchesSizesProcessed) Assert.AreEqual(expectedItemsPerBatch, batchSize, "Incorrect batch size processed"); 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); + } + } +}