diff --git a/Mosey.sln b/Mosey.sln index 32a3f0e..72ee011 100644 --- a/Mosey.sln +++ b/Mosey.sln @@ -6,6 +6,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mosey", "Mosey\Mosey.csproj EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3D609DE7-CA92-45B0-A4A9-93975FA50AA9}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mosey.Tests", "MoseyTests\Mosey.Tests.csproj", "{A6FA9705-788E-4712-BE02-E13D8A652020}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -28,6 +30,18 @@ Global {B1DD8B7D-FFF0-406C-9765-14848EAB126F}.Release|x64.Build.0 = Release|Any CPU {B1DD8B7D-FFF0-406C-9765-14848EAB126F}.Release|x86.ActiveCfg = Release|Any CPU {B1DD8B7D-FFF0-406C-9765-14848EAB126F}.Release|x86.Build.0 = Release|Any CPU + {A6FA9705-788E-4712-BE02-E13D8A652020}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6FA9705-788E-4712-BE02-E13D8A652020}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6FA9705-788E-4712-BE02-E13D8A652020}.Debug|x64.ActiveCfg = Debug|Any CPU + {A6FA9705-788E-4712-BE02-E13D8A652020}.Debug|x64.Build.0 = Debug|Any CPU + {A6FA9705-788E-4712-BE02-E13D8A652020}.Debug|x86.ActiveCfg = Debug|Any CPU + {A6FA9705-788E-4712-BE02-E13D8A652020}.Debug|x86.Build.0 = Debug|Any CPU + {A6FA9705-788E-4712-BE02-E13D8A652020}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6FA9705-788E-4712-BE02-E13D8A652020}.Release|Any CPU.Build.0 = Release|Any CPU + {A6FA9705-788E-4712-BE02-E13D8A652020}.Release|x64.ActiveCfg = Release|Any CPU + {A6FA9705-788E-4712-BE02-E13D8A652020}.Release|x64.Build.0 = Release|Any CPU + {A6FA9705-788E-4712-BE02-E13D8A652020}.Release|x86.ActiveCfg = Release|Any CPU + {A6FA9705-788E-4712-BE02-E13D8A652020}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Mosey/App.xaml.cs b/Mosey/App.xaml.cs index 02096eb..d80e5bb 100644 --- a/Mosey/App.xaml.cs +++ b/Mosey/App.xaml.cs @@ -1,6 +1,8 @@ using System; using System.IO; +using System.IO.Abstractions; using System.Windows; +using System.Runtime.CompilerServices; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -12,6 +14,7 @@ using Mosey.Services.Imaging; using Mosey.ViewModels; +[assembly: InternalsVisibleTo("Mosey.Tests")] namespace Mosey { /// @@ -60,9 +63,11 @@ protected override void OnStartup(StartupEventArgs e) }) // Services - .AddTransient() + .AddSingleton() + .AddTransient, IntervalTimerFactory>() .AddTransient() .AddScoped() + .AddTransient() .AddTransient() .AddSingleton, ScanningDevices>() diff --git a/Mosey/Configuration/AppSettings.cs b/Mosey/Configuration/AppSettings.cs index 34171f9..ed8f9f7 100644 --- a/Mosey/Configuration/AppSettings.cs +++ b/Mosey/Configuration/AppSettings.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; using Mosey.Models; using Mosey.Services; using Mosey.Services.Imaging; diff --git a/Mosey/Models/Device.cs b/Mosey/Models/Device.cs index 0be0860..8898c32 100644 --- a/Mosey/Models/Device.cs +++ b/Mosey/Models/Device.cs @@ -13,13 +13,6 @@ public interface IDeviceCollection where T : IDevice /// IEnumerable Devices { get; } - /// - /// Retrieve the devices in the collection where their property is equal to . - /// - /// The specified property - /// Devices where the is equal to - IEnumerable GetByEnabled(bool enabled); - /// /// Add a to the collection. /// @@ -36,13 +29,6 @@ public interface IDeviceCollection where T : IDevice /// void DisableAll(); - /// - /// Set the property of a specific instance. - /// - /// A instance - /// Sets the property - void SetDeviceEnabled(T device, bool enabled); - /// /// Set the property of a device in the collection. /// diff --git a/Mosey/Models/FileSystemExtensions.cs b/Mosey/Models/FileSystemExtensions.cs index 53a94a0..8cffd95 100644 --- a/Mosey/Models/FileSystemExtensions.cs +++ b/Mosey/Models/FileSystemExtensions.cs @@ -1,6 +1,5 @@ using System; -using System.Collections.Generic; -using System.IO; +using System.IO.Abstractions; using System.Linq; namespace Mosey.Models @@ -14,9 +13,9 @@ public static class FileSystemExtensions /// A instance that represents the logical drive /// /// - public static DriveInfo GetDriveInfo(string driveName) + public static IDriveInfo GetDriveInfo(string driveName, IFileSystem fileSystem) { - return DriveInfo.GetDrives().Where(drive => drive.Name == driveName).FirstOrDefault(); + return fileSystem.DriveInfo.GetDrives().Where(drive => drive.Name == driveName).FirstOrDefault(); } /// @@ -26,9 +25,9 @@ public static DriveInfo GetDriveInfo(string driveName) /// The available free space, in bytes /// /// - public static long AvailableFreeSpace(string driveName) + public static long AvailableFreeSpace(string driveName, IFileSystem fileSystem) { - return GetDriveInfo(driveName).AvailableFreeSpace; + return GetDriveInfo(driveName, fileSystem).AvailableFreeSpace; } /// @@ -36,15 +35,15 @@ public static long AvailableFreeSpace(string driveName) /// /// The path to verify /// if is a UNC path - public static bool IsNetworkPath(string path) + public static bool IsNetworkPath(string path, IFileSystem fileSystem) { if (!path.StartsWith(@"/") && !path.StartsWith(@"\")) { // Path may not start with a slash, but could be a network drive - string rootPath = Path.GetPathRoot(path); - DriveInfo driveInfo = new DriveInfo(rootPath); + string rootPath = fileSystem.Path.GetPathRoot(path); + var driveInfo = fileSystem.DriveInfo.FromDriveName(rootPath); - return driveInfo.DriveType == DriveType.Network; + return driveInfo.DriveType == System.IO.DriveType.Network; } return true; diff --git a/Mosey/Mosey.csproj b/Mosey/Mosey.csproj index ce28654..ac647d7 100644 --- a/Mosey/Mosey.csproj +++ b/Mosey/Mosey.csproj @@ -3,6 +3,7 @@ WinExe netcoreapp3.1 + 9.0 true Mosey.ico @@ -46,6 +47,7 @@ + diff --git a/Mosey/Services/Imaging/Extensions/ImageFormatExtensions.cs b/Mosey/Services/Imaging/Extensions/ImageFormatExtensions.cs index edfcf28..238298a 100644 --- a/Mosey/Services/Imaging/Extensions/ImageFormatExtensions.cs +++ b/Mosey/Services/Imaging/Extensions/ImageFormatExtensions.cs @@ -41,7 +41,7 @@ public static ImageFormat ToDrawingImageFormat(this ScanningDevice.ImageFormat v /// Convert to a instance. /// /// - /// A instance + /// A instance public static DNTScanner.Core.WiaImageFormat ToWIAImageFormat(this ScanningDevice.ImageFormat value) { return (DNTScanner.Core.WiaImageFormat)typeof(DNTScanner.Core.WiaImageFormat) diff --git a/Mosey/Services/Imaging/ISystemDevices.cs b/Mosey/Services/Imaging/ISystemDevices.cs new file mode 100644 index 0000000..7bc3973 --- /dev/null +++ b/Mosey/Services/Imaging/ISystemDevices.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using DNTScanner.Core; +using Mosey.Models; + +namespace Mosey.Services.Imaging +{ + /// + /// Provides access to the WIA driver devices via DNTScanner.Core + /// + public interface ISystemDevices + { + /// + /// Retrieve image(s) from a scanner. + /// + /// A instance representing a physical device + /// Device settings used when capturing an image + /// The image format used internally for storing the image + /// A list of retrieved images as byte arrays, in + IEnumerable PerformScan(ScannerSettings settings, IImagingDeviceConfig config, ScanningDevice.ImageFormat format); + + /// + /// The number of attempts to try connecting to the WIA driver, after + /// The time in millseconds between attempts + IEnumerable PerformScan(ScannerSettings settings, IImagingDeviceConfig config, ScanningDevice.ImageFormat format, int connectRetries, int delay); + + /// + /// Lists the static properties of scanners connected to the system. + /// + /// Use the function to retrieve full device instances. + /// + /// + /// Static device properties are limited, but can be retrieved without establishing a connection to the device. + /// + /// A list of the static device properties + public IList> ScannerProperties(); + + /// + /// The number of retry attempts allowed if connecting to the WIA driver was unsuccessful + public IList> ScannerProperties(int connectRetries); + + /// + /// A collection of representing physical devices connected to the system. + /// + /// A collection of representing physical devices connected to the system. + public IEnumerable ScannerSettings(); + + /// + /// The number of retry attempts allowed if connecting to the WIA driver was unsuccessful + public IEnumerable ScannerSettings(int connectRetries); + } +} diff --git a/Mosey/Services/Imaging/ScanningDevice.cs b/Mosey/Services/Imaging/ScanningDevice.cs index 1ad73ea..af00825 100644 --- a/Mosey/Services/Imaging/ScanningDevice.cs +++ b/Mosey/Services/Imaging/ScanningDevice.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Drawing.Imaging; using System.IO; +using System.IO.Abstractions; using System.Linq; using System.Runtime.InteropServices; @@ -78,26 +79,35 @@ public enum ImageFormat private bool _isEnabled; private bool _isImaging; private int _scanRetries = 5; - private ScannerSettings _scannerSettings; + private readonly IFileSystem _fileSystem; + private readonly ScannerSettings _scannerSettings; + private readonly ISystemDevices _systemDevices; /// /// Initialize a new instance using a instance that represents a physical scanner. /// /// A instance representing a physical device - public ScanningDevice(ScannerSettings settings) - { - _scannerSettings = settings; - } + public ScanningDevice(ScannerSettings settings) : this(settings, null, null, null) { } + + /// + /// Initialize a new instance using a instance that represents a physical scanner. + /// + /// A instance representing a physical device + /// Device settings used when capturing an image + public ScanningDevice(ScannerSettings settings, IImagingDeviceConfig config) : this(settings, config, null, null) { } /// /// Initialize a new instance using a instance that represents a physical scanner. /// /// A instance representing a physical device /// Device settings used when capturing an image - public ScanningDevice(ScannerSettings settings, IImagingDeviceConfig config) + /// An instance that provide access to the WIA driver devices + public ScanningDevice(ScannerSettings settings, IImagingDeviceConfig config, ISystemDevices systemDevices, IFileSystem fileSystem) { _scannerSettings = settings; ImageSettings = config; + _systemDevices = systemDevices ?? new SystemDevices(); + _fileSystem = fileSystem ?? new FileSystem(); } public void ClearImages() @@ -113,12 +123,12 @@ public void GetImage() /// /// Retrieve an image from the physical imaging device. /// - /// The image format used internally for storing the image + /// The image transfer format used when capturing the image /// If the scanner is not connected /// If the is not supported by the device /// If the operation fails during scanning /// - /// Images are converted to before being stored as byte arrays. + /// Images are converted to , if possible, before being stored as byte arrays. /// public void GetImage(ImageFormat format) { @@ -144,7 +154,7 @@ public void GetImage(ImageFormat format) IsImaging = true; try { - var images = SystemDevices.PerformScan(_scannerSettings, deviceConfig, format); + var images = _systemDevices.PerformScan(_scannerSettings, deviceConfig, format); // Remove any existing images ClearImages(); @@ -152,9 +162,17 @@ public void GetImage(ImageFormat format) // Store images for processing etc foreach (var image in images) { - // Convert image to PNG format before storing byte array - // Greatly reduces memory footprint compared to raw BMP - Images.Add(image.AsFormat(ImageFormat.Png.ToDrawingImageFormat())); + try + { + // Convert image to PNG format before storing byte array + // Greatly reduces memory footprint compared to raw BMP + Images.Add(image.AsFormat(ImageFormat.Png.ToDrawingImageFormat())); + } + catch (ArgumentException) + { + // Store the image in its original format + Images.Add(image); + } } } catch (Exception ex) when (ex is COMException || ex is InvalidOperationException) @@ -175,7 +193,7 @@ public void GetImage(ImageFormat format) /// public void SaveImage() { - string directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures).ToString(), System.Reflection.Assembly.GetExecutingAssembly().GetName().Name); + string directory = _fileSystem.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures).ToString(), System.Reflection.Assembly.GetExecutingAssembly().GetName().Name); SaveImage("image", directory, ImageFormat.Png); } @@ -195,16 +213,18 @@ public IEnumerable SaveImage(string fileName, string directory, string f } /// - /// Write an image captured with to disk - /// Images are stored losslessly (in supported formats) with a colour depth of 24 bit per pixel + /// Write an image captured with to disk. + /// Images are stored losslessly (in supported formats) with a colour depth of 24 bit per pixel. /// /// The image file name, the file extension ignored and instead inferred from /// The directory path to use when storing the image /// The used to store the image /// A collection of file path s for the newly created images + /// If is or whitespace + /// If the property returns no images to save public IEnumerable SaveImage(string fileName, string directory, ImageFormat imageFormat = ImageFormat.Png) { - if (Images == null | Images.Count == 0) + if (Images == null || Images.Count == 0) { throw new InvalidOperationException($"No images available. Please call the {nameof(GetImage)} method first."); } @@ -214,33 +234,26 @@ public IEnumerable SaveImage(string fileName, string directory, ImageFor } // Get full filename and path - Directory.CreateDirectory(directory); - fileName = Path.Combine(directory, fileName); - fileName = Path.ChangeExtension(fileName, imageFormat.ToString().ToLower()); + _fileSystem.Directory.CreateDirectory(directory); + fileName = _fileSystem.Path.Combine(directory, fileName); + fileName = _fileSystem.Path.ChangeExtension(fileName, imageFormat.ToString().ToLower()); // Use lossless compression with highest quality - using (EncoderParameters encoderParameters = new EncoderParameters().AddParams( + using (var encoderParameters = new EncoderParameters().AddParams( compression: EncoderValue.CompressionLZW, quality: 100, colorDepth: 24 )) { - // Write all images to disk - foreach (var imageBytes in Images) - { - imageBytes.ToImage().Save( - fileName, - imageFormat.ToDrawingImageFormat().CodecInfo(), - encoderParameters - ); - yield return fileName; - } + // Write image(s) to disk using specified encoding + // Ensure we enumerate here otherwise the encoderParameters will go out of context + return SaveImagesToDisk(Images, fileName, imageFormat, encoderParameters).ToList(); } } public bool Equals(IImagingDevice device) { - return null != device && DeviceID == device.DeviceID; + return device is not null && DeviceID == device.DeviceID; } public override bool Equals(object obj) @@ -253,6 +266,58 @@ public override int GetHashCode() return DeviceID.GetHashCode(); } + /// + /// Store image byte arrays to disk. + /// + /// + /// The image count is appended to the filename in case of multiple images. + /// + /// An image byte array + /// The full file path used to store the images + /// The used to store the images + /// Specify image encoding when writing the images + /// A collection of file path s for the newly created images + protected internal IEnumerable SaveImagesToDisk(IEnumerable images, string filePath, ImageFormat format = ImageFormat.Png, EncoderParameters encoderParams = null) + { + int count = 1; + string fileName = Path.GetFileName(filePath); + + // Write all images to disk + foreach (var imageBytes in Images) + { + // Append count to filename in case of multiple images + string savePath = filePath; + if (images.Count() > 1) + savePath = savePath.Replace( + fileName, + $"{Path.GetFileNameWithoutExtension(fileName)}_{count}{Path.GetExtension(filePath)}"); + + SaveImageToDisk(imageBytes, savePath, format, encoderParams); + + yield return savePath; + + count++; + } + } + + /// + /// Store an image byte array to disk + /// + /// An image byte array + /// The full file path used to store the image + /// The used to store the image + /// Specify image encoding when writing the image + protected internal virtual void SaveImageToDisk(byte[] image, string filePath, ImageFormat format = ImageFormat.Png, EncoderParameters encoderParams = null) + { + using (var fileStream = _fileSystem.File.Create(filePath)) + { + image.ToImage().Save( + fileStream, + format.ToDrawingImageFormat().CodecInfo(), + encoderParams); + } + } + /// /// A simplified version of the unique device identifier, /// diff --git a/Mosey/Services/Imaging/ScanningDeviceSettings.cs b/Mosey/Services/Imaging/ScanningDeviceSettings.cs index ca42485..2074de8 100644 --- a/Mosey/Services/Imaging/ScanningDeviceSettings.cs +++ b/Mosey/Services/Imaging/ScanningDeviceSettings.cs @@ -1,16 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Text; -using DNTScanner.Core; +using DNTScanner.Core; using Mosey.Models; -using Mosey.Services.Imaging.Extensions; +using System; namespace Mosey.Services.Imaging { /// /// Device settings used by a when capturing an image. /// - public class ScanningDeviceSettings : IImagingDeviceConfig + public class ScanningDeviceSettings : IImagingDeviceConfig, IEquatable { public ImageColorFormat ColorFormat { get; set; } = ImageColorFormat.Color; public int Resolution { get; set; } @@ -38,8 +35,19 @@ public ScanningDeviceSettings(ImageColorFormat colorFormat, int resolution, int } public object Clone() + => MemberwiseClone(); + + public override bool Equals(object obj) + => Equals(obj as ScanningDeviceSettings); + + public bool Equals(ScanningDeviceSettings other) { - return MemberwiseClone(); + return other is not null && + (ColorFormat, Resolution, Brightness, Contrast).Equals( + (other.ColorFormat, other.Resolution, other.Brightness, other.Contrast)); } + + public override int GetHashCode() + => (ColorFormat, Resolution, Brightness, Contrast).GetHashCode(); } } diff --git a/Mosey/Services/Imaging/ScanningDevices.cs b/Mosey/Services/Imaging/ScanningDevices.cs index 922b0f3..543ceaf 100644 --- a/Mosey/Services/Imaging/ScanningDevices.cs +++ b/Mosey/Services/Imaging/ScanningDevices.cs @@ -1,9 +1,9 @@ using System; using System.Linq; using System.Collections.Generic; -using System.Threading; using Microsoft.Extensions.Configuration; using Mosey.Models; +using DNTScanner.Core; namespace Mosey.Services.Imaging { @@ -14,6 +14,9 @@ namespace Mosey.Services.Imaging /// public class ScanningDevices : IImagingDevices { + private readonly ICollection _devices = new ObservableItemsCollection(); + private readonly ISystemDevices _systemDevices; + /// /// The number of time to attempt reconnection to the WIA driver. /// @@ -22,25 +25,25 @@ public class ScanningDevices : IImagingDevices /// /// A collection of s, representing physical scanners. /// - public IEnumerable Devices { get { return _devices; } } + public IEnumerable Devices => _devices; - public bool IsEmpty { get { return (_devices.Count == 0); } } - public bool IsImagingInProgress { get { return _devices.Any(x => x.IsImaging is true); } } - private ICollection _devices = new ObservableItemsCollection(); + public bool IsEmpty => !_devices.Any(); + public bool IsImagingInProgress => _devices.Any(x => x.IsImaging is true); /// /// Initialize an empty collection. /// - public ScanningDevices() - { - } + /// An instance that provide access to the WIA driver devices + public ScanningDevices(ISystemDevices systemDevices) : this(null, systemDevices) { } /// /// Initialize the collection s with the specified . /// /// Used to initialize the collection's s - public ScanningDevices(IImagingDeviceConfig deviceConfig) + /// An instance that provide access to the WIA driver devices + public ScanningDevices(IImagingDeviceConfig deviceConfig, ISystemDevices systemDevices) { + _systemDevices = systemDevices ?? new SystemDevices(); GetDevices(deviceConfig); } @@ -54,6 +57,8 @@ public ScanningDevice AddDevice(string deviceID) return AddDevice(deviceID, null); } + /// + /// If a device with the same already exists in the collection public void AddDevice(IDevice device) { AddDevice((ScanningDevice)device); @@ -68,7 +73,7 @@ public void AddDevice(ScanningDevice device) { if (!_devices.Contains(device)) { - _devices.Add((IImagingDevice)device); + _devices.Add(device); } else { @@ -88,7 +93,7 @@ public ScanningDevice AddDevice(string deviceID, IImagingDeviceConfig config) ScanningDevice device = null; // Attempt to connect a device matching the deviceID - var settings = SystemDevices.ScannerSettings(ConnectRetries).Where(x => x.Id == deviceID).FirstOrDefault(); + var settings = _systemDevices.ScannerSettings(ConnectRetries).FirstOrDefault(x => x.Id == deviceID); if (settings != null) { @@ -109,33 +114,25 @@ public void EnableAll() SetByEnabled(true); } - public IEnumerable GetByEnabled(bool enabled) - { - return _devices.Where(x => x.IsEnabled == enabled).AsEnumerable(); - } - /// /// Retrieve s that are connected to the system and add them to the collection. /// Update the status of any devices are already present in the collection. /// public void RefreshDevices() { - // Get a new collection of devices if none already present - if (IsEmpty) - { - GetDevices(); - } - else - { - RefreshDevices(new ScanningDeviceSettings()); - } + RefreshDevices(new ScanningDeviceSettings()); } - /// + /// public void RefreshDevices(IImagingDeviceConfig deviceConfig, bool enableDevices = true) { - IList> deviceProperties = SystemDevices.ScannerProperties(connectRetries: ConnectRetries); - if (deviceProperties.Count == 0) + const string DEVICE_ID_KEY = "Unique Device ID"; + var deviceIds = new List(); + + // Check the static properties for changed IDs and only retrieve ScannerSetting instances if necessary. + // This saves connecting the devices via the WIA driver and is much faster + var deviceProperties = _systemDevices.ScannerProperties(connectRetries: ConnectRetries); + if (deviceProperties is null || !deviceProperties.Any()) { // No devices detected, any current devices have been disconnected foreach (ScanningDevice device in _devices) @@ -147,62 +144,66 @@ public void RefreshDevices(IImagingDeviceConfig deviceConfig, bool enableDevices // Check if devices not already in the collection // Or already in the collection, but not connected - foreach (IDictionary properties in deviceProperties) + var currentDevices = Devices.Select(d => d.DeviceID); + foreach (var properties in deviceProperties) { - string deviceID = properties["Unique Device ID"].ToString(); - - if (!_devices.Where(d => d.DeviceID == deviceID).Any()) + if (properties.TryGetValue(DEVICE_ID_KEY, out var id)) { - // Create a new device and add it to the collection - ScanningDevice device = AddDevice(deviceID, deviceConfig); - device.IsEnabled = enableDevices; - } - else - { - ScanningDevice existingDevice = (ScanningDevice)_devices.Where(d => d.DeviceID == deviceID && !d.IsConnected).FirstOrDefault(); - if (existingDevice != null) - { - // Remove the existing device from the collection - bool enabled = existingDevice.IsEnabled; - _devices.Remove(existingDevice); - - // Replace with the new and updated device - ScanningDevice device = AddDevice(deviceID, deviceConfig); - device.IsEnabled = enabled; - } + deviceIds.Add((string)id); } + } + + // These devices can no longer be found and are disconnected + foreach (var deviceId in currentDevices.Except(deviceIds)) + { + var device = (ScanningDevice)_devices.FirstOrDefault(d => d.DeviceID == deviceId); + device.IsConnected = false; + } + + // These are new devices, add them to the collection + foreach (var deviceId in deviceIds.Except(currentDevices)) + { + var device = AddDevice(deviceId, deviceConfig); + device.IsEnabled = enableDevices; + } - // If the device is in the collection but no longer found - IEnumerable devicesRemoved = _devices.Where(l1 => !deviceProperties.Any(l2 => l1.DeviceID == l2["Unique Device ID"].ToString())); - if (devicesRemoved.Count() > 0) + // These devices are already in the collection, but previously disconnected + foreach (var deviceId in currentDevices.Intersect(deviceIds)) + { + var existingDevice = (ScanningDevice)_devices.FirstOrDefault(d => d.DeviceID == deviceId && !d.IsConnected); + if (existingDevice is not null) { - foreach (ScanningDevice device in devicesRemoved) - { - device.IsConnected = false; - } + // Remove the existing device and replace with the updated + bool enabled = existingDevice.IsEnabled; + _devices.Remove(existingDevice); + + var device = AddDevice(deviceId, deviceConfig); + device.IsEnabled = enabled; } } } public void SetDeviceEnabled(string deviceID, bool enabled) { - _devices.Where(x => x.DeviceID == deviceID).First().IsEnabled = enabled; - } - - public void SetDeviceEnabled(IImagingDevice device, bool enabled) - { - _devices.Where(x => x.DeviceID == device.DeviceID).First().IsEnabled = enabled; + _devices.First(x => x.DeviceID == deviceID).IsEnabled = enabled; } /// - /// Retrieve s that are connected to the system and add them to the collection. - /// The exisiting items in the collection are cleared first. + /// A collection of instances representing physical devices connected to the system. /// - /// The number of devices added to the collection - private int GetDevices() + /// Used to initialize the instances + /// A collection of instances representing physical scanning devices connected to the system + private IEnumerable ScannerDevices(IImagingDeviceConfig deviceConfig) + => ScannerDevices(deviceConfig, 1); + + /// + /// The number of retry attempts allowed if connecting to the WIA driver was unsuccessful + private IEnumerable ScannerDevices(IImagingDeviceConfig deviceConfig, int connectRetries = 1) { - // Populate a new collection of scanners using default image settings - return GetDevices(new ScanningDeviceSettings()); + foreach (var settings in _systemDevices.ScannerSettings(connectRetries)) + { + yield return new ScanningDevice(settings, deviceConfig); + } } /// @@ -214,11 +215,11 @@ private int GetDevices() /// The number of devices added to the collection private int GetDevices(IImagingDeviceConfig deviceConfig) { - // Empty the collection - _devices.Clear(); + deviceConfig ??= new ScanningDeviceSettings(); // Populate a new collection of scanners using specified image settings - foreach (ScanningDevice device in SystemDevices.ScannerDevices(deviceConfig, ConnectRetries)) + _devices.Clear(); + foreach (ScanningDevice device in ScannerDevices(deviceConfig, ConnectRetries)) { device.IsEnabled = true; AddDevice(device); diff --git a/Mosey/Services/Imaging/SystemDevices.cs b/Mosey/Services/Imaging/SystemDevices.cs index 87aec71..3e6b5ae 100644 --- a/Mosey/Services/Imaging/SystemDevices.cs +++ b/Mosey/Services/Imaging/SystemDevices.cs @@ -12,22 +12,21 @@ namespace Mosey.Services.Imaging /// /// Provides access to the WIA driver devices via DNTScanner.Core /// - internal static class SystemDevices + internal sealed class SystemDevices : ISystemDevices { private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); - /// - /// Retrieve image(s) from a scanner. - /// - /// A instance representing a physical device - /// Device settings used when capturing an image - /// The image format used internally for storing the image - /// The number of attempts to try connecting to the WIA driver, after - /// The time in millseconds between attempts - /// A list of retrieved images as byte arrays, in + /// + /// If an error occurs within the specified number of + /// If an error occurs within the specified number of + public IEnumerable PerformScan(ScannerSettings settings, IImagingDeviceConfig config, ScanningDevice.ImageFormat format) + { + return PerformScan(settings, config, format, 1, 1000); + } + /// /// If an error occurs within the specified number of /// If an error occurs within the specified number of - internal static IEnumerable PerformScan(ScannerSettings settings, IImagingDeviceConfig config, ScanningDevice.ImageFormat format, int connectRetries = 1, int delay = 1000) + public IEnumerable PerformScan(ScannerSettings settings, IImagingDeviceConfig config, ScanningDevice.ImageFormat format, int connectRetries, int delay) { IEnumerable images = new List(); @@ -41,95 +40,55 @@ internal static IEnumerable PerformScan(ScannerSettings settings, IImagi return images; } - /// - /// A collection of instances representing physical devices connected to the system. - /// - /// Used to initialize the instances - /// The number of retry attempts allowed if connecting to the WIA driver was unsuccessful - /// A semaphore to coordinate connections to the WIA driver - /// A collection of instances representing physical scanning devices connected to the system + /// /// If an error occurs within the specified number of /// If an error occurs within the specified number of - internal static IEnumerable ScannerDevices(IImagingDeviceConfig deviceConfig, int connectRetries = 1) + public IList> ScannerProperties() { - var devices = new List(); - - foreach (var settings in ScannerSettings(connectRetries)) - { - // Store the device in the collection - devices.Add(new ScanningDevice(settings, deviceConfig)); - } - - return devices; + return ScannerProperties(1); } - /// - /// Lists the static properties of scanners connected to the system. - /// Use the function to retrieve full device instances. - /// - /// - /// Static device properties are limited, but can be retrieved without establishing a connection to the device. - /// - /// The number of retry attempts allowed if connecting to the WIA driver was unsuccessful - /// A list of the static device properties + /// /// If an error occurs within the specified number of /// If an error occurs within the specified number of - internal static IList> ScannerProperties(int connectRetries = 1) + public IList> ScannerProperties(int connectRetries) { IList> properties = new List>(); properties = WIARetry( DNTScanner.Core.SystemDevices.GetScannerDeviceProperties, connectRetries, - _semaphore - ); + _semaphore); return properties; } - /// - /// A collection of representing physical devices connected to the system. - /// - /// The number of retry attempts allowed if connecting to the WIA driver was unsuccessful - /// A collection of representing physical devices connected to the system. + /// /// If an error occurs within the specified number of /// If an error occurs within the specified number of - internal static IEnumerable ScannerSettings(int connectRetries = 1) + public IEnumerable ScannerSettings() { - var deviceList = new List(); + return ScannerSettings(1); + } - var systemScanners = WIARetry( + /// + /// If an error occurs within the specified number of + /// If an error occurs within the specified number of + public IEnumerable ScannerSettings(int connectRetries) + { + return WIARetry( DNTScanner.Core.SystemDevices.GetScannerDevices, connectRetries, - _semaphore - ); - - // Check that at least one scanner can be found - if (systemScanners.FirstOrDefault() == null) - { - return deviceList; - } - - foreach (ScannerSettings settings in systemScanners) - { - // Store the device in the collection - deviceList.Add(settings); - } - - return deviceList; + _semaphore).AsEnumerable(); } /// /// Create and configure a new instance. /// - /// - /// If the resolution specified in is not available, - /// the closest value in will be used. - /// /// A instance representing a physical device /// Device settings used when capturing an image /// A instance configured using - private static ScannerDevice ConfiguredScannerDevice(ScannerSettings settings, IImagingDeviceConfig config) + private ScannerDevice ConfiguredScannerDevice(ScannerSettings settings, IImagingDeviceConfig config) { var device = new ScannerDevice(settings); var supportedResolutions = settings.SupportedResolutions; @@ -183,6 +142,7 @@ private static void WIARetry(Action method, int connectRetries = 1, SemaphoreSli /// The WIA driver will produce COMExceptions if attempting to connect to a device that is /// not ready, cannot be found etc. In many cases a successful connection can be made by reattempting /// shortly after. + /// /// The DNTScanner wrapper does not provide any events or async methods that would allow for simply /// awaiting the WIA driver directly /// diff --git a/Mosey/Services/IntervalTimerFactory.cs b/Mosey/Services/IntervalTimerFactory.cs new file mode 100644 index 0000000..b3375f7 --- /dev/null +++ b/Mosey/Services/IntervalTimerFactory.cs @@ -0,0 +1,12 @@ +using Mosey.Models; + +namespace Mosey.Services +{ + internal class IntervalTimerFactory : IFactory + { + public IIntervalTimer Create() + { + return new IntervalTimer(); + } + } +} diff --git a/Mosey/ViewModels/MainViewModel.cs b/Mosey/ViewModels/MainViewModel.cs index ac752ba..10d2ecc 100644 --- a/Mosey/ViewModels/MainViewModel.cs +++ b/Mosey/ViewModels/MainViewModel.cs @@ -11,19 +11,22 @@ using AsyncAwaitBestPractices.MVVM; using Mosey.Models; using Mosey.Configuration; +using System.IO.Abstractions; namespace Mosey.ViewModels { public class MainViewModel : ViewModelBase, IViewModelParent, IClosing, IDisposable { // From IoC container - private readonly IIntervalTimer _scanTimer; + private readonly IFactory _timerFactory; private readonly Services.UIServices _uiServices; private readonly IViewModel _settingsViewModel; private readonly IOptionsMonitor _appSettings; + private readonly IFileSystem _fileSystem; private readonly ILogger _log; // From constructor + private readonly IIntervalTimer _scanTimer; private readonly IIntervalTimer _uiTimer; private readonly DialogViewModel _dialog; @@ -94,7 +97,7 @@ public long ImagesRequiredDiskSpace get { return ScanRepetitions - * ScanningDevices.GetByEnabled(true).Count() + * ScanningDevices.Devices.Count(d => d.IsEnabled) * _userDeviceConfig.GetResolutionMetaData(_imageConfig.Resolution).FileSize; } } @@ -266,22 +269,25 @@ public SettingsViewModel SettingsViewModel #endregion Properties public MainViewModel( - IIntervalTimer intervalTimer, + IFactory intervalTimerFactory, IImagingDevices imagingDevices, Services.UIServices uiServices, IViewModel settingsViewModel, IOptionsMonitor appSettings, + IFileSystem fileSystem, ILogger logger ) { - _scanTimer = intervalTimer; + _timerFactory = intervalTimerFactory; ScanningDevices = imagingDevices; _uiServices = uiServices; _settingsViewModel = settingsViewModel; _appSettings = appSettings; + _fileSystem = fileSystem; _log = logger; - _uiTimer = (IIntervalTimer)intervalTimer.Clone(); + _scanTimer = intervalTimerFactory.Create(); + _uiTimer = intervalTimerFactory.Create(); _dialog = new DialogViewModel(this, _uiServices, _log); Initialize(); @@ -290,13 +296,13 @@ ILogger logger public override IViewModel Create() { return new MainViewModel( - intervalTimer: _scanTimer, + intervalTimerFactory: _timerFactory, imagingDevices: ScanningDevices, uiServices: _uiServices, settingsViewModel: _settingsViewModel, appSettings: _appSettings, - logger: _log - ); + fileSystem: _fileSystem, + logger: _log); } #region Commands @@ -439,7 +445,7 @@ private void Initialize() System.Windows.Data.BindingOperations.EnableCollectionSynchronization(ScanningDevices.Devices, _scanningDevicesLock); // Register event callbacks - _appSettings.OnChange(UpdateConfig); + _appSettings.OnChange(UpdateConfig); _scanTimer.Tick += ScanTimer_Tick; _scanTimer.Complete += ScanTimer_Complete; _uiTimer.Tick += UITimer_Tick; @@ -456,7 +462,7 @@ private void Initialize() /// Update local configuration from supplied . /// /// Application configuration settings - private void UpdateConfig(AppSettings settings) + internal void UpdateConfig(AppSettings settings) { if (!IsScanRunning) { @@ -488,12 +494,12 @@ public void StartScan() } /// - /// Begin repeated scanning, after first if checking interval time and free disk space are sufficient. + /// Begin repeated scanning, after first checking if checking interval time and free disk space are sufficient. /// public async void StartScanWithDialog() { // Check that interval time is sufficient for selected resolution - TimeSpan imagingTime = ScanningDevices.GetByEnabled(true).Count() * _userDeviceConfig.GetResolutionMetaData(_imageConfig.Resolution).ImagingTime; + var imagingTime = ScanningDevices.Devices.Count(d => d.IsEnabled) * _userDeviceConfig.GetResolutionMetaData(_imageConfig.Resolution).ImagingTime; if (imagingTime * 1.5 > TimeSpan.FromMinutes(ScanInterval)) { if (!await _dialog.ImagingTimeDialog(TimeSpan.FromMinutes(ScanInterval), imagingTime)) @@ -506,7 +512,9 @@ public async void StartScanWithDialog() // Check that disk space is sufficient for selected resolution try { - long availableDiskSpace = FileSystemExtensions.AvailableFreeSpace(Path.GetPathRoot(_imageFileConfig.Directory)); + long availableDiskSpace = FileSystemExtensions.AvailableFreeSpace( + _fileSystem.Path.GetPathRoot(_imageFileConfig.Directory), + _fileSystem); if (ImagesRequiredDiskSpace * 1.5 > availableDiskSpace) { if (!await _dialog.DiskSpaceDialog(ImagesRequiredDiskSpace, availableDiskSpace)) @@ -634,7 +642,7 @@ private void RefreshDevices() /// The duration between refreshes /// Used to stop the refresh loop /// - private async Task RefreshDevicesAsync(int intervalSeconds = 1, CancellationToken cancellationToken = default) + internal async Task RefreshDevicesAsync(int intervalSeconds = 1, CancellationToken cancellationToken = default) { _log.LogDebug($"Device refresh initiated with {nameof(RefreshDevicesAsync)}"); while (true) @@ -720,8 +728,8 @@ public List Scan(CancellationToken cancellationToken = default) { _log.LogDebug("{ImageCount} images retrieved from scanner #{DeviceID}", scanner.Images.Count(), scanner.ID); string fileName = string.Join("_", _imageFileConfig.Prefix, saveDateTime); - string directory = Path.Combine(saveDirectory, string.Join(string.Empty, "Scanner", scannerIDStr)); - Directory.CreateDirectory(directory); + string directory = _fileSystem.Path.Combine(saveDirectory, string.Join(string.Empty, "Scanner", scannerIDStr)); + _fileSystem.Directory.CreateDirectory(directory); // Write image(s) to filesystem and retrieve a list of saved file names IEnumerable savedImages = scanner.SaveImage(fileName, directory: directory, fileFormat: ImageFormat); @@ -805,7 +813,7 @@ await Task.Factory.StartNew(() => private void ImageDirectoryDialog() { // Go up one level so users can see the initial directory instead of starting inside it - string initialDirectory = Directory.GetParent(ImageSavePath).FullName; + string initialDirectory = _fileSystem.Directory.GetParent(ImageSavePath).FullName; if (string.IsNullOrWhiteSpace(initialDirectory)) initialDirectory = ImageSavePath; string selectedDirectory = _dialog.FolderBrowserDialog( diff --git a/Mosey/ViewModels/SettingsViewModel.cs b/Mosey/ViewModels/SettingsViewModel.cs index eda9e8d..0152b04 100644 --- a/Mosey/ViewModels/SettingsViewModel.cs +++ b/Mosey/ViewModels/SettingsViewModel.cs @@ -6,16 +6,18 @@ using Mosey.Configuration; using Mosey.Models; using Mosey.Services; +using System.IO.Abstractions; namespace Mosey.ViewModels { public class SettingsViewModel : ViewModelBase { private ILogger _log; - private readonly UIServices _uiServices; private IWritableOptions _appSettings; private AppSettings _userSettings; + private readonly UIServices _uiServices; private readonly DialogViewModel _dialog; + private readonly IFileSystem _fileSystem; #region Properties public string ImageSavePath @@ -168,14 +170,15 @@ public string Version public SettingsViewModel( ILogger logger, UIServices uiServices, - IWritableOptions appSettings - ) + IWritableOptions appSettings, + IFileSystem fileSystem) { _log = logger; _uiServices = uiServices; _appSettings = appSettings; _userSettings = appSettings.Get("UserSettings"); _dialog = new DialogViewModel(this, _uiServices, _log); + _fileSystem = fileSystem; } public override IViewModel Create() @@ -183,8 +186,8 @@ public override IViewModel Create() return new SettingsViewModel( logger: _log, uiServices: _uiServices, - appSettings: _appSettings - ); + appSettings: _appSettings, + fileSystem: _fileSystem); } #region Commands @@ -246,7 +249,7 @@ private void ResetUserOptions() private void ImageDirectoryDialog() { // Go up one level so users can see the initial directory instead of starting inside it - string initialDirectory = Directory.GetParent(ImageSavePath).FullName; + string initialDirectory = _fileSystem.Directory.GetParent(ImageSavePath).FullName; if (string.IsNullOrWhiteSpace(initialDirectory)) initialDirectory = ImageSavePath; string selectedDirectory = _dialog.FolderBrowserDialog( diff --git a/MoseyTests/AutoData/AutoNSubstituteDataAttribute.cs b/MoseyTests/AutoData/AutoNSubstituteDataAttribute.cs new file mode 100644 index 0000000..ed667ee --- /dev/null +++ b/MoseyTests/AutoData/AutoNSubstituteDataAttribute.cs @@ -0,0 +1,32 @@ +using System; +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; +using AutoFixture; +using AutoFixture.AutoNSubstitute; +using AutoFixture.NUnit3; + +namespace MoseyTests.AutoData +{ + public class AutoNSubstituteDataAttribute : AutoDataAttribute + { + public AutoNSubstituteDataAttribute(Action initialize) : base(() => + { + var fixture = new Fixture(); + + fixture.Customize(new AutoNSubstituteCustomization() + { + ConfigureMembers = true, + GenerateDelegates = true + }); + + fixture.Register(() => new MockFileSystem()); + + initialize(fixture); + + return fixture; + }) + { } + + public AutoNSubstituteDataAttribute() : this(fixture => { }) { } + } +} diff --git a/MoseyTests/AutoData/CollectionSizeAttribute.cs b/MoseyTests/AutoData/CollectionSizeAttribute.cs new file mode 100644 index 0000000..687cdf9 --- /dev/null +++ b/MoseyTests/AutoData/CollectionSizeAttribute.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using AutoFixture; +using AutoFixture.Kernel; +using AutoFixture.NUnit3; + +namespace MoseyTests.AutoData +{ + public class CollectionSizeAttribute : CustomizeAttribute + { + private readonly int _size; + + public CollectionSizeAttribute(int size) + { + _size = size; + } + + public override ICustomization GetCustomization(ParameterInfo parameter) + { + if (parameter == null) throw new ArgumentNullException(nameof(parameter)); + + var objectType = parameter.ParameterType.GetGenericArguments()[0]; + + var isTypeCompatible = + parameter.ParameterType.IsGenericType + && parameter.ParameterType.GetGenericTypeDefinition().MakeGenericType(objectType).IsAssignableFrom(typeof(List<>).MakeGenericType(objectType)) + ; + if (!isTypeCompatible) + { + throw new InvalidOperationException($"{nameof(CollectionSizeAttribute)} specified for type incompatible with List: {parameter.ParameterType} {parameter.Name}"); + } + + var customizationType = typeof(CollectionSizeCustomization<>).MakeGenericType(objectType); + return (ICustomization)Activator.CreateInstance(customizationType, parameter, _size); + } + + public class CollectionSizeCustomization : ICustomization + { + private readonly ParameterInfo _parameter; + private readonly int _repeatCount; + + public CollectionSizeCustomization(ParameterInfo parameter, int repeatCount) + { + _parameter = parameter; + _repeatCount = repeatCount; + } + + public void Customize(IFixture fixture) + { + fixture.Customizations.Add(new FilteringSpecimenBuilder( + new FixedBuilder(fixture.CreateMany(_repeatCount).ToList()), + new EqualRequestSpecification(_parameter) + )); + } + } + } +} diff --git a/MoseyTests/AutoData/MainViewModelAutoDataAttribute.cs b/MoseyTests/AutoData/MainViewModelAutoDataAttribute.cs new file mode 100644 index 0000000..1f7b13b --- /dev/null +++ b/MoseyTests/AutoData/MainViewModelAutoDataAttribute.cs @@ -0,0 +1,15 @@ +using AutoFixture; +using Mosey.Models; +using Mosey.Services; + +namespace MoseyTests.AutoData +{ + public class MainViewModelAutoDataAttribute : AutoNSubstituteDataAttribute + { + public MainViewModelAutoDataAttribute() : base(fixture => + { + fixture.Register>(fixture.Create); + }) + { } + } +} diff --git a/MoseyTests/AutoData/ScanningDeviceAutoDataAttribute.cs b/MoseyTests/AutoData/ScanningDeviceAutoDataAttribute.cs new file mode 100644 index 0000000..7d82d4d --- /dev/null +++ b/MoseyTests/AutoData/ScanningDeviceAutoDataAttribute.cs @@ -0,0 +1,18 @@ +using AutoFixture; +using MoseyTests.Customizations; + +namespace MoseyTests.AutoData +{ + public class ScanningDeviceAutoDataAttribute : AutoNSubstituteDataAttribute + { + public ScanningDeviceAutoDataAttribute() : base(fixture => + { + fixture.Customize(new CompositeCustomization(new ScannerSettingsCustomization())); + // A concrete class is required when retrieving devices from ISystemDevices + fixture.Customize(new ConcreteScanningDeviceCustomization()); + fixture.Customize(new ImageBytesCustomization()); + fixture.Customize(new SystemDevicesMockCustomization()); + }) + { } + } +} diff --git a/MoseyTests/Customizations/ConcreteScanningDeviceCustomization.cs b/MoseyTests/Customizations/ConcreteScanningDeviceCustomization.cs new file mode 100644 index 0000000..ea17010 --- /dev/null +++ b/MoseyTests/Customizations/ConcreteScanningDeviceCustomization.cs @@ -0,0 +1,14 @@ +using AutoFixture; +using Mosey.Models; +using Mosey.Services.Imaging; + +namespace MoseyTests.Customizations +{ + public class ConcreteScanningDeviceCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Register(fixture.Create); + } + } +} diff --git a/MoseyTests/Customizations/ImageBytesCustomization.cs b/MoseyTests/Customizations/ImageBytesCustomization.cs new file mode 100644 index 0000000..67ddeb1 --- /dev/null +++ b/MoseyTests/Customizations/ImageBytesCustomization.cs @@ -0,0 +1,31 @@ +using System; +using System.Drawing; +using System.IO; +using AutoFixture; + +namespace MoseyTests.Customizations +{ + public class ImageBytesCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Register(() => GetBitmapData()); + } + + internal static byte[] GetBitmapData(int width = 10, int height = 10) + { + var random = new Random(); + byte[] bitmapData; + var image = new Bitmap(width, height); + + image.SetPixel(random.Next(width), random.Next(height), Color.Black); + + using (var memoryStream = new MemoryStream()) + { + image.Save(memoryStream, System.Drawing.Imaging.ImageFormat.Bmp); + bitmapData = memoryStream.ToArray(); + } + return bitmapData; + } + } +} diff --git a/MoseyTests/Customizations/ScannerSettingsCustomization.cs b/MoseyTests/Customizations/ScannerSettingsCustomization.cs new file mode 100644 index 0000000..5373537 --- /dev/null +++ b/MoseyTests/Customizations/ScannerSettingsCustomization.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Linq; +using AutoFixture; +using DNTScanner.Core; +using Mosey.Services.Imaging; +using Mosey.Services.Imaging.Extensions; + +namespace MoseyTests.Customizations +{ + public class ScannerSettingsCustomization : ICustomization + { + public static ScanningDevice.ImageFormat SupportedImageFormat { get; set; } = ScanningDevice.ImageFormat.Jpeg; + + public void Customize(IFixture fixture) + { + fixture.Register(() => + { + var scannerSettings = GetInstance(fixture); + // Ensure that there is at least one real SupportedTransferFormat + scannerSettings + .SupportedTransferFormats + .Add(SupportedImageFormat.ToWIAImageFormat().Value, "SupportedWIAImageFormat"); + + return scannerSettings; + }); + } + + /// + /// Creates a new instance manually to avoid recursion problems + /// + internal static ScannerSettings GetInstance(IFixture fixture) + => new ScannerSettings + { + Id = fixture.Create(), + Name = fixture.Create(), + IsAutomaticDocumentFeeder = fixture.Create(), + IsDuplex = fixture.Create(), + IsFlatbed = fixture.Create(), + SupportedResolutions = fixture.CreateMany().ToList(), + SupportedTransferFormats = fixture.Create>(), + SupportedEvents = fixture.Create>(), + ScannerDeviceSettings = fixture.Create>(), + ScannerPictureSettings = fixture.Create>() + }; + } +} diff --git a/MoseyTests/Customizations/SystemDevicesMockCustomization.cs b/MoseyTests/Customizations/SystemDevicesMockCustomization.cs new file mode 100644 index 0000000..3421ee4 --- /dev/null +++ b/MoseyTests/Customizations/SystemDevicesMockCustomization.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Linq; +using AutoFixture; +using DNTScanner.Core; +using Moq; +using Mosey.Models; +using Mosey.Services.Imaging; + +namespace MoseyTests.Customizations +{ + public class SystemDevicesMockCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + var systemDevices = fixture.Create>(); + var settings = fixture.CreateMany(); + var properties = fixture.Create>>(); + + foreach (var deviceID in settings.Select(s => s.Id)) + { + properties.Add(new Dictionary() { { "Unique Device ID", deviceID } }); + } + + systemDevices + .Setup(x => x.PerformScan( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(fixture.CreateMany()); + + systemDevices + .Setup(mock => mock.ScannerSettings(It.IsAny())) + .Returns(settings); + + systemDevices + .Setup(mock => mock.ScannerProperties(It.IsAny())) + .Returns(properties); + + fixture.Register(() => systemDevices); + } + } +} diff --git a/MoseyTests/Extensions/TestExtensions.cs b/MoseyTests/Extensions/TestExtensions.cs new file mode 100644 index 0000000..1342d89 --- /dev/null +++ b/MoseyTests/Extensions/TestExtensions.cs @@ -0,0 +1,45 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using NUnit.Framework; + +namespace MoseyTests.Extensions +{ + public static class TestExtensions + { + /// + /// Assert that an object's properties have been set and are not default values. + /// + /// + /// An object instance to verify + /// + public static void AssertAllPropertiesAreNotDefault(this T objectToInspect, params Expression>[] getters) + { + var defaultProperties = getters.Where(f => f.Compile()(objectToInspect).Equals(default(T))); + + if (defaultProperties.Any()) + { + var commaSeparatedPropertiesNames = string.Join(", ", defaultProperties.Select(GetName)); + Assert.Fail("Expected properties not to have default values: " + commaSeparatedPropertiesNames); + } + } + + /// + /// Retrieve a property name as a string. + /// + /// + /// + /// A property name as a string + public static string GetName(Expression> exp) + { + // Return type is an object, so type cast expression will be added to value types + if (!(exp.Body is MemberExpression body)) + { + var ubody = (UnaryExpression)exp.Body; + body = ubody.Operand as MemberExpression; + } + + return body.Member.Name; + } + } +} diff --git a/MoseyTests/Mosey.Tests.csproj b/MoseyTests/Mosey.Tests.csproj new file mode 100644 index 0000000..2a348bb --- /dev/null +++ b/MoseyTests/Mosey.Tests.csproj @@ -0,0 +1,31 @@ + + + + netcoreapp3.1 + 9.0 + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/MoseyTests/Services/Imaging/ConfigTests.cs b/MoseyTests/Services/Imaging/ConfigTests.cs new file mode 100644 index 0000000..5e5f96c --- /dev/null +++ b/MoseyTests/Services/Imaging/ConfigTests.cs @@ -0,0 +1,56 @@ +using FluentAssertions; +using NUnit.Framework; +using AutoFixture.NUnit3; +using MoseyTests.AutoData; +using MoseyTests.Extensions; + +namespace Mosey.Services.Imaging.Tests +{ + public class ImageFileConfigTests + { + public class CloneShould + { + [Theory, AutoNSubstituteData] + public void BeEquivalentToClone(ImageFileConfig originalConfig) + { + var clonedConfig = originalConfig.Clone(); + + // Not a deep clone + originalConfig.Should().NotBeSameAs(clonedConfig); + clonedConfig.Should().BeEquivalentTo(originalConfig); + } + } + } + + public class ScanningDeviceSettingsTests + { + public class ConstructorShould + { + [Theory, AutoNSubstituteData] + public void InitializeAllProperties(ScanningDeviceSettings sut) + { + sut.AssertAllPropertiesAreNotDefault(); + } + + [Theory, AutoNSubstituteData] + public void InitializeAllPropertiesWithGreedy([Greedy] ScanningDeviceSettings sut) + { + // AutoFixture uses least greedy constructor by default + sut.AssertAllPropertiesAreNotDefault(); + } + } + + public class CloneShould + { + [Theory, AutoNSubstituteData] + public void BeEquivalent(ScanningDeviceSettings originalSettings) + { + var clonedSettings = originalSettings.Clone(); + + // Not a deep clone + originalSettings.Should().NotBeSameAs(clonedSettings); + clonedSettings.Should().BeEquivalentTo(originalSettings); + } + } + } +} \ No newline at end of file diff --git a/MoseyTests/Services/Imaging/ScanningDeviceTests.cs b/MoseyTests/Services/Imaging/ScanningDeviceTests.cs new file mode 100644 index 0000000..a0cf203 --- /dev/null +++ b/MoseyTests/Services/Imaging/ScanningDeviceTests.cs @@ -0,0 +1,203 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; +using FluentAssertions; +using NUnit.Framework; +using AutoFixture.NUnit3; +using DNTScanner.Core; +using MoseyTests.AutoData; +using MoseyTests.Customizations; +using MoseyTests.Extensions; + +namespace Mosey.Services.Imaging.Tests +{ + public class ScanningDeviceTests + { + public class ConstructorShould + { + [Theory, ScanningDeviceAutoData] + public void InitializeAllProperties(ScanningDevice sut) + { + sut.AssertAllPropertiesAreNotDefault(); + sut.IsConnected.Should().BeTrue(); + sut.IsImaging.Should().BeFalse(); + } + + [Theory, ScanningDeviceAutoData] + public void InitializeAllPropertiesGreedy([Greedy] ScanningDevice sut) + { + sut.AssertAllPropertiesAreNotDefault(); + sut.IsConnected.Should().BeTrue(); + sut.IsImaging.Should().BeFalse(); + } + } + + public class ClearImagesShould + { + [Theory, ScanningDeviceAutoData] + public void EmptyImagesCollection([Frozen] IList images, ScanningDevice sut) + { + sut.Images = images; + sut.Images + .Should().NotBeEmpty() + .And.BeEquivalentTo(images); + + sut.ClearImages(); + + sut.Images + .Should().NotBeNull() + .And.BeEmpty(); + } + } + + public class GetImageShould + { + [Theory, ScanningDeviceAutoData] + public void ReturnImagesForSupportedFormat([Frozen] IEnumerable images, [Greedy] ScanningDevice sut) + { + sut.GetImage(ScannerSettingsCustomization.SupportedImageFormat); + + sut.Images.Count.Should().Be(images.Count()); + + foreach (var (image, deviceImage) in Enumerable.Zip(images, sut.Images)) + { + // Image data won't match exactly because headers will differ due to conversion + image.Should().IntersectWith(deviceImage); + } + } + + [Theory, ScanningDeviceAutoData] + public void ThrowIfImageFormatNotSupported([Greedy] ScanningDevice sut) + { + sut.Invoking(x => x.GetImage(ScanningDevice.ImageFormat.Gif)) + .Should().Throw(); + sut.Images.Should().BeEmpty(); + } + + [Theory, ScanningDeviceAutoData] + public void ThrowCOMExceptionIfDeviceNotConnected([Greedy] ScanningDevice sut) + { + sut.IsConnected = false; + + sut.Invoking(x => x.GetImage(ScannerSettingsCustomization.SupportedImageFormat)) + .Should().Throw(); + sut.Images.Should().BeEmpty(); + } + + [Theory, ScanningDeviceAutoData] + public void RaiseIsImagingPropertyChanged([Greedy] ScanningDevice sut) + { + // IsImaging should only be true during the operation of GetImage() + sut.IsImaging.Should().BeFalse(); + + using (var monitoredSubject = sut.Monitor()) + { + monitoredSubject.Subject.GetImage(ScannerSettingsCustomization.SupportedImageFormat); + + monitoredSubject.Should().RaisePropertyChangeFor(x => x.IsImaging); + } + + sut.IsImaging.Should().BeFalse(); + } + } + + public class SaveImageShould + { + private readonly string filename = "Filename"; + private readonly string directory = new MockFileSystem().Path.Combine("C:", "Directory"); + + [Theory, ScanningDeviceAutoData] + public void ThrowInvalidOperationExceptionIfNoImages([Greedy] ScanningDevice sut) + { + sut.Images = null; + + sut + .Invoking(i => i.SaveImage(filename, directory, ScannerSettingsCustomization.SupportedImageFormat)) + .Should().Throw(); + } + + [Theory, ScanningDeviceAutoData] + public void ThrowArgumentExceptionIfNoFilePath([CollectionSize(1)] IList images, [Greedy] ScanningDevice sut) + { + sut.Images = images; + + sut + .Invoking(i => i.SaveImage(string.Empty, string.Empty, ScannerSettingsCustomization.SupportedImageFormat)) + .Should().Throw() + .WithMessage("A valid filename and directory must be supplied"); + } + + [Theory, ScanningDeviceAutoData] + public void SaveImageWithFilePath([CollectionSize(1)] IList images, [Frozen] IFileSystem fileSystem, [Greedy] ScanningDevice sut) + { + sut.Images = images; + + var result = sut.SaveImage(filename, directory, ScannerSettingsCustomization.SupportedImageFormat); + + result.Should().HaveCount(images.Count); + (fileSystem as MockFileSystem).AllFiles.Should().BeEquivalentTo(result); + foreach (var filePath in result) + { + var expectedPath = fileSystem.Path.Combine(directory, filename); + expectedPath = fileSystem.Path.ChangeExtension(expectedPath, ScannerSettingsCustomization.SupportedImageFormat.ToString()); + filePath.Equals(expectedPath, StringComparison.OrdinalIgnoreCase).Should().BeTrue(); + } + } + + [Theory, ScanningDeviceAutoData] + public void AppendCountToFilePaths([CollectionSize(2)] IList images, [Frozen] IFileSystem fileSystem, [Greedy] ScanningDevice sut) + { + var count = 0; + sut.Images = images; + + var result = sut.SaveImage(filename, directory, ScannerSettingsCustomization.SupportedImageFormat); + + result.Should().HaveCount(images.Count); + (fileSystem as MockFileSystem).AllFiles.Should().BeEquivalentTo(result); + foreach (var filePath in result) + { + var expectedPath = fileSystem.Path.Combine(directory, $"{filename}_{++count}"); + expectedPath = fileSystem.Path.ChangeExtension(expectedPath, ScannerSettingsCustomization.SupportedImageFormat.ToString()); + filePath.Equals(expectedPath, StringComparison.OrdinalIgnoreCase).Should().BeTrue(); + } + } + + [Theory, ScanningDeviceAutoData] + public void WriteImageDataToDisk([CollectionSize(2)] IList images, [Frozen] IFileSystem fileSystem, [Greedy] ScanningDevice sut) + { + var fs = fileSystem as MockFileSystem; + sut.Images = images; + + var result = sut.SaveImage(filename, directory, ScannerSettingsCustomization.SupportedImageFormat); + + result.Should().HaveCount(images.Count); + fs.AllFiles.Should().BeEquivalentTo(result); + foreach (var (filePath, image) in Enumerable.Zip(fs.AllFiles, images)) + { + // Image data won't match exactly because headers will differ due to conversion + fs.GetFile(filePath).Contents.Should().IntersectWith(image); + } + } + } + + public class EqualsShould + { + [Theory, ScanningDeviceAutoData] + public void BeEquivalentToClone([Frozen] ScannerSettings _, ScanningDevice sut, ScanningDevice clone) + { + sut.Equals(clone).Should().BeTrue(); + } + + [Theory, ScanningDeviceAutoData] + public void EqualSettingsHash([Frozen] ScannerSettings settings, ScanningDevice sut) + { + var result = sut.GetHashCode(); + + result.Should().Be(settings.Id.GetHashCode()); + } + } + } +} \ No newline at end of file diff --git a/MoseyTests/Services/Imaging/ScanningDevicesTests.cs b/MoseyTests/Services/Imaging/ScanningDevicesTests.cs new file mode 100644 index 0000000..2df1e13 --- /dev/null +++ b/MoseyTests/Services/Imaging/ScanningDevicesTests.cs @@ -0,0 +1,233 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using NUnit.Framework; +using AutoFixture.NUnit3; +using Moq; +using FluentAssertions; +using DNTScanner.Core; +using Mosey.Models; +using MoseyTests.Extensions; +using MoseyTests.AutoData; + +namespace Mosey.Services.Imaging.Tests +{ + public class ScanningDevicesTests + { + public class ConstructorShould + { + [Theory, ScanningDeviceAutoData] + public void InitializeCollection(ScanningDevices sut) + { + sut.AssertAllPropertiesAreNotDefault(); + sut.IsEmpty.Should().BeFalse(); + sut.Devices.Count().Should().Be(3); + sut.Devices.All(device => device.IsEnabled == true).Should().BeTrue(); + } + + [Theory, ScanningDeviceAutoData] + public void InitializeCollectionGreedy([Greedy] ScanningDevices sut) + { + sut.AssertAllPropertiesAreNotDefault(); + sut.IsEmpty.Should().BeFalse(); + sut.Devices.Count().Should().Be(3); + sut.Devices.All(device => device.IsEnabled == true).Should().BeTrue(); + } + } + + public class AddDeviceShould + { + [Theory, ScanningDeviceAutoData] + public void ThrowIfDeviceIdExists([Frozen, CollectionSize(5)] IEnumerable scannerSettings, ScanningDevices sut) + { + var existingId = scannerSettings.First().Id; + + sut.Invoking(x => x.AddDevice(deviceID: existingId)) + .Should().Throw(); + sut.Devices.Should() + .HaveCount(5) + .And.OnlyHaveUniqueItems() + .And.Contain(i => i.DeviceID == existingId); + } + + [Theory, ScanningDeviceAutoData] + public void ThrowIfDeviceInstanceExists( + [Frozen] IImagingDeviceConfig deviceConfig, + [Frozen] ScannerSettings settings, + [Frozen, CollectionSize(1)] IEnumerable scannerSettings, + ScanningDevices sut) + { + var existingInstance = new ScanningDevice(settings, deviceConfig); + + sut.Invoking(x => x.AddDevice(existingInstance)) + .Should().Throw(); + sut.Devices.Should() + .HaveCount(1) + .And.OnlyHaveUniqueItems() + .And.Contain(i => i.DeviceID == existingInstance.DeviceID); + } + + [Theory, ScanningDeviceAutoData] + public void ContainUniqueDevices( + ScanningDevice device, + [Frozen] IImagingDeviceConfig deviceConfig, + [Frozen, CollectionSize(5)] IEnumerable scannerSettings, + ScanningDevices sut) + { + var scanningDevices = scannerSettings.Select(x => new ScanningDevice(x, deviceConfig)); + + sut.AddDevice(device); + + sut.Devices.Should() + .HaveCount(6) + .And.OnlyHaveUniqueItems() + .And.Contain(scanningDevices) + .And.Contain(i => i.DeviceID == device.DeviceID); + } + } + + public class DisableAllShould + { + [Theory, ScanningDeviceAutoData] + public void SetAllDevicesToDisabled(ScanningDevices sut) + { + sut.Devices.All(device => device.IsEnabled).Should().BeTrue(); + + sut.DisableAll(); + + sut.Devices.All(device => !device.IsEnabled).Should().BeTrue(); + } + } + + public class EnableAllShould + { + [Theory, ScanningDeviceAutoData] + public void SetAllDevicesToEnabled(ScanningDevices sut) + { + foreach (var device in sut.Devices) + { + device.IsEnabled = false; + } + sut.Devices.All(device => !device.IsEnabled).Should().BeTrue(); + + sut.EnableAll(); + + sut.Devices.All(device => device.IsEnabled).Should().BeTrue(); + } + } + + public class RefreshDevicesShould + { + [Theory, ScanningDeviceAutoData] + public void DisconnectAllDevicesIfNotFound(ScanningDevices sut) + { + sut.RefreshDevices(); + + sut.Devices.All(device => !device.IsConnected).Should().BeTrue(); + } + + [Theory, ScanningDeviceAutoData] + public void DisconnectSingleDeviceIfNotFound( + [Frozen] Mock systemDevices, + [Frozen] IEnumerable scannerSettings) + { + systemDevices + .Setup(mock => mock.ScannerSettings(It.IsAny())) + .Returns(scannerSettings); + var sut = new ScanningDevices(systemDevices.Object); + var initalDevices = sut.Devices; + + // Add the existing DeviceIds except one so the device will appear disconnected + var scannerProperties = new List>(); + foreach (var deviceID in sut.Devices.Skip(1).Select(d => d.DeviceID)) + { + scannerProperties.Add(new Dictionary() { { "Unique Device ID", deviceID } }); + } + systemDevices + .Setup(mock => mock.ScannerProperties(It.IsAny())) + .Returns(scannerProperties); + + sut.RefreshDevices(); + + sut.Devices + .Should().HaveCount(3) + .And.BeEquivalentTo(initalDevices); + sut.Devices + .Skip(1).All(device => device.IsConnected) + .Should().BeTrue(); + sut.Devices + .First().IsConnected + .Should().BeFalse(); + } + + [Theory, ScanningDeviceAutoData] + public void AddNewDevicesToCollection( + [Frozen] Mock systemDevices, + [Frozen, CollectionSize(4)] IEnumerable scannerSettings, + ScannerSettings newScannerSettings) + { + systemDevices + .Setup(mock => mock.ScannerSettings(It.IsAny())) + .Returns(scannerSettings); + var sut = new ScanningDevices(systemDevices.Object); + var initalDevices = sut.Devices; + + // Add an extra scanner to the collection + scannerSettings = scannerSettings.Append(newScannerSettings); + var scannerProperties = new List>(); + foreach (var deviceID in sut.Devices.Select(d => d.DeviceID).Append(newScannerSettings.Id)) + { + scannerProperties.Add(new Dictionary() { { "Unique Device ID", deviceID } }); + } + systemDevices + .Setup(mock => mock.ScannerProperties(It.IsAny())) + .Returns(scannerProperties); + systemDevices + .Setup(mock => mock.ScannerSettings(It.IsAny())) + .Returns(scannerSettings); + + sut.RefreshDevices(); + + sut.Devices + .Should().HaveCount(5) + .And.Contain(initalDevices); + sut.Devices + .Any(d => d.DeviceID == newScannerSettings.Id) + .Should().BeTrue(); + } + } + + public class SetDeviceEnabledShould + { + [Theory, ScanningDeviceAutoData] + public void EnableCorrectDevice(ScanningDevices sut) + { + var deviceId = sut.Devices.First().DeviceID; + foreach (var device in sut.Devices) + { + device.IsEnabled = false; + } + + sut.SetDeviceEnabled(deviceId, true); + + sut.Devices.First().IsEnabled.Should().BeTrue(); + sut.Devices.Skip(1).All(d => !d.IsEnabled).Should().BeTrue(); + } + + [Theory, ScanningDeviceAutoData] + public void DisableCorrectDevice(ScanningDevices sut) + { + var deviceId = sut.Devices.First().DeviceID; + foreach (var device in sut.Devices) + { + device.IsEnabled = true; + } + + sut.SetDeviceEnabled(deviceId, true); + + sut.Devices.First().IsEnabled.Should().BeTrue(); + sut.Devices.Skip(1).All(d => d.IsEnabled).Should().BeTrue(); + } + } + } +} \ No newline at end of file diff --git a/MoseyTests/ViewModels/MainViewModelTests.cs b/MoseyTests/ViewModels/MainViewModelTests.cs new file mode 100644 index 0000000..727daa7 --- /dev/null +++ b/MoseyTests/ViewModels/MainViewModelTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using AutoFixture.NUnit3; +using NSubstitute; +using FluentAssertions; +using Mosey.Models; +using MoseyTests.AutoData; +using MoseyTests.Extensions; + +namespace Mosey.ViewModels.Tests +{ + public class MainViewModelTests + { + public class ConstructorShould + { + [Theory, MainViewModelAutoData] + public void InitializeProperties(MainViewModel sut) + { + sut.AssertAllPropertiesAreNotDefault(); + } + + [Theory, MainViewModelAutoData] + public void InitializeScanningDevicesCollection([Frozen] IEnumerable imagingDevices, MainViewModel sut) + { + sut + .ScanningDevices.Devices.Select(d => d.DeviceID) + .Should().BeEquivalentTo(imagingDevices.Select(d => d.DeviceID)); + } + } + + public class StartScanShould + { + [Theory, MainViewModelAutoData] + public void SetScanningProperties(MainViewModel sut) + { + sut.StartScan(); + + sut.IsScanRunning.Should().BeTrue(); + sut.ScanRepetitionsCount.Should().Be(0); + sut.ScanNextTime.Should().Be(TimeSpan.Zero); + } + + [Theory, MainViewModelAutoData] + public void RaisePropertyChanged(MainViewModel sut) + { + using (var monitoredSubject = sut.Monitor()) + { + monitoredSubject.Subject.StartScan(); + + monitoredSubject.Should().RaisePropertyChangeFor(x => x.IsScanRunning); + monitoredSubject.Should().RaisePropertyChangeFor(x => x.ScanFinishTime); + monitoredSubject.Should().RaisePropertyChangeFor(x => x.StartStopScanCommand); + } + } + } + + public class RefreshDevicesAsyncShould + { + [Theory, MainViewModelAutoData] + public async Task RepeatRefreshDevices([Frozen] IImagingDevices imagingDevices, MainViewModel sut) + { + using var cts = new CancellationTokenSource(); + cts.CancelAfter(2000); + + await sut.RefreshDevicesAsync(0, cts.Token); + + imagingDevices + .ReceivedWithAnyArgs().RefreshDevices(null, true); + imagingDevices + .ReceivedCalls().Where(x => x.GetMethodInfo().Name == nameof(imagingDevices.RefreshDevices)) + .Count().Should().BeGreaterThan(1); + } + + [Theory, MainViewModelAutoData] + public async Task CancelTask([Frozen] IImagingDevices imagingDevices, MainViewModel sut) + { + using var cts = new CancellationTokenSource(); + cts.CancelAfter(0); + + await sut.RefreshDevicesAsync(0, cts.Token); + + imagingDevices + .DidNotReceiveWithAnyArgs().RefreshDevices(null, true); + } + + [Theory, MainViewModelAutoData] + public async Task RaisePropertyChanged(MainViewModel sut) + { + using var cts = new CancellationTokenSource(); + cts.CancelAfter(1000); + + using (var monitoredSubject = sut.Monitor()) + { + await sut.RefreshDevicesAsync(0, cts.Token); + + monitoredSubject.Should().RaisePropertyChangeFor(x => x.ScanningDevices); + monitoredSubject.Should().RaisePropertyChangeFor(x => x.StartScanCommand); + monitoredSubject.Should().RaisePropertyChangeFor(x => x.StartStopScanCommand); + } + } + } + } +}