diff --git a/dotnet-tools.json b/.config/dotnet-tools.json similarity index 85% rename from dotnet-tools.json rename to .config/dotnet-tools.json index 73f9597..bbab4bd 100644 --- a/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-reportgenerator-globaltool": { - "version": "5.3.6", + "version": "5.4.3", "commands": [ "reportgenerator" ] diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index eced69d..286d2ce 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,8 +9,7 @@ on: - '**/ci.yaml' - '**/xunit.runner.json' branches: - - main - - '**release**' # Trigger on branches with 'release' in the name + - '**' # Trigger on all branches pull_request: paths: # Trigger when source code, sln or prj files, this file, or the xunit config file is changed. - '**/*.cs' @@ -42,7 +41,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v4 with: - dotnet-version: '7.x' # Adjust to your target .NET version + dotnet-version: '9.x' # Adjust to your target .NET version # Uncomment this process if you want the windows build to take 3mins extra but get rid of the 'Workload updates are available. Run `dotnet workload list` for more information' msg after building #- name: Update dotnet workloads (Windows) @@ -55,15 +54,15 @@ jobs: - name: Build solution without restore run: dotnet build tests/EStimLibrary.UnitTests/EStimLibrary.UnitTests.sln --no-restore --configuration Release - - name: Install ReportGenerator - run: dotnet tool install -g dotnet-reportgenerator-globaltool + - name: Restore .NET Tools + run: dotnet tool restore - name: Run unit tests and collect coverage run: dotnet test tests/EStimLibrary.UnitTests/EStimLibrary.UnitTests.csproj --no-build --configuration Release --collect:"XPlat Code Coverage" - name: Generate coverage report run: | - reportgenerator -reports:tests/**/coverage.cobertura.xml -targetdir:coverage -reporttypes:Html + dotnet tool run reportgenerator -reports:tests/**/coverage.cobertura.xml -targetdir:coverage -reporttypes:Html - name: Upload coverage report uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e6628f8..700305d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -24,7 +24,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v4 with: - dotnet-version: '7.x' # Adjust to your target .NET version + dotnet-version: '9.x' # Adjust to your target .NET version - name: Restore dependencies run: dotnet restore $SLN_PATH diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f1884ae --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "_comment1": "specify which solution to load for C# Dev Kit tooling. Switch if working on a different sln", + "_comment2": "examples/EStimLibrary.ConsoleAppDemo/EStimLibrary.ConsoleAppDemo.sln", + "dotrush.roslyn.projectOrSolutionFiles": [ + "tests/EStimLibrary.UnitTests/EStimLibrary.UnitTests.sln" + ] +} diff --git a/README.md b/README.md index 7811b97..49bb23a 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,90 @@ # EStimLibrary A C# library to facilitate electrical stimulation research and development. -More details coming soon. +## Table of Contents +* [Dependencies](#dependencies) + * [Extensions for VSCode Users](#extensions-for-vscode-users) +* [Repo Contents](#repo-contents) + * [Top-Level Structures](#top-level-structure) + * [Projects and Solutions](#projects-and-solutions) +* [Library Structure](#library-structure) +* [Diagrams](#diagrams) +* [Usage](#usage) + * [VisualStudio](#visualstudio) + * [VSCode or your other preferred IDE](#vscode-or-your-other-preferred-ide) -## Repo Structure -* `src/` - the library source code -* `tests/` - the test projects +## Dependencies +* .NET 9.0 + * 10-minute [Tim Corey video](https://www.youtube.com/watch?v=sXEsvqCCTTc): how to upgrade or install fresh + * Windows: Use VisualStudio + * MacOS, Linux: download and install manually to access `dotnet` command-line interface (CLI) +* `nuget` packages: + * For base library: + * System.IO.Ports + * MathNet.Numerics + * Newtonsoft.Json + * For xUnit testing project: + * Microsoft.NET.Test.Sdk + * xunit + * xunit.runner.visualstudio + * xunit.runner.console + * coverlet.collector + * Versions are listed in the `.csproj` files and should be pulled automatically when building projects and solutions in this repo. + +### Extensions for VSCode Users +Optional but highly recommended: +* C# Dev Kit: provides much of the same C# support and within-solution navigation features as VisualStudio + +## Repo Contents +### Top-Level Structure +* `src/` - the library source code project +* `tests/` - the test project(s) * `models/` - examples of spatial model definition files -* `examples/` - example applications using the library's high-level API + * contains [`EStimLibrary.SpatialModels`](https://github.com/lhmcgann/EStimLibrary.SpatialModels/) as a submodule +* `examples/` - example applications (projects) using the library's high-level API * `docs/` - additional documentation files, including a dictionary and coding conventions -## UML Diagrams -Last updated: March 2024 -* [Class Diagram](https://lucid.app/lucidchart/298700bf-4e2e-4c7d-bb06-76365f1efb98/edit?viewport_loc=-7329%2C-1594%2C17982%2C8602%2C0_0&invitationId=inv_11b76774-377e-4967-87a5-c173614a4ef3) -* [Sequence and Use Case Diagrams](https://lucid.app/lucidchart/e774e654-27e9-4168-aee4-39f6f8133738/edit?viewport_loc=-403%2C-342%2C1628%2C779%2C0_0&invitationId=inv_cb3b6cf3-cb84-4091-bd72-88547d50cb3e) +### Projects and Solutions +There are currently 3 projects in this repo: +1. `src/EStimLibrary/EStimLibrary.csproj`: the base library project itself + * Builds into a `.dll` +2. `tests/EStimLibrary.UnitTests/EStimLibrary.UnitTests.csproj`: the xUnit test project + * The unit tests for the library +3. `examples/EStimLibrary.ConsoleAppDemo/EStimLibrary.ConsoleAppDemo.csproj`: an example console app project that either runs through a hard-coded session config and execution, or walks through the config step-by-step, asking for user input at each step + * Highlights the `Utils` reflection capabilities, + * and exemplifies the major `HapticSession` config components. + +There are 3 corresponding solutions in this repo: +1. `EStimLibrary.sln`: builds only the base library project +2. `tests/EStimLibrary.UnitTests/EStimLibrary.UnitTests.sln`: builds the base library and the xUnit test projects +3. `examples/EStimLibrary.ConsoleAppDemo/EStimLibrary.ConsoleAppDemo.sln`: builds the base library and the example console app projects + +## Library Structure +The main library, in `src/EStimLibrary/`, is broken down into two top-level folders: +* `Core/`: the base declarations and implementatsions of interfaces, abstract classes, and core classes +* `Extensions/`: provided implementations of some core interfaces and abstract classes that are commonly used + +The `Core/` folder contains the following structure: +* `Data/`: primarily `IDataLimits` +* `Haptics/`: classes on the haptic event side of the pipeline, e.g., `HapticSession`, `HapticEvent`, and `HapticTransducer` +* `HardwareInterfaces/`: leads, cables, neural interfaces +* `SpatialModel/`: location, area, body model, and related content +* `Stimulation/`: stimulators and stimulation representations +* Miscellaneous infrastructure interfaces and classes are in the top level of the `Core/` folder, e.g., `Utils`, `ResourceManager`, `ReusableIdPool` + +The `Extensions/` folder mimics this structure to the extent that example implementations are provided. + +The `EStimLibrary.UnitTests` project also mimics the `Core/`, `Extensions/`, and sub-directory structures. + +## Diagrams +Last updated: February 2025 + +These diagrams are based on UML formatting but adhere to that to varying degrees. +* [Key Components](https://lucid.app/lucidchart/7b9b75ff-8dee-41d8-b650-082adc9bcb8f/edit?viewport_loc=-1212%2C-695%2C5508%2C2636%2C0_0&invitationId=inv_0a3eaaf0-7a4f-4fcd-a813-b786f47e548f) +* [Class Diagram - High-Level](https://lucid.app/lucidchart/1366a885-086c-45d0-8763-63448fe11a86/edit?viewport_loc=-3539%2C-4330%2C16143%2C7727%2C0_0&invitationId=inv_10ca78f7-8fcc-4d34-8466-aa8da86d8716) +* [`StringHierarchy` Class Diagram](https://lucid.app/lucidchart/8f0c6f70-c343-444e-9e6f-5c51557384ec/edit?viewport_loc=-2888%2C-1102%2C7382%2C3534%2C0_0&invitationId=inv_7e104f4d-b695-4ff7-84b2-87b3bff9a9fb) +* [Sequence Diagrams](https://lucid.app/lucidchart/312bfb4e-807b-4f90-9e56-7cdc64a0172d/edit?viewport_loc=-858%2C313%2C2104%2C917%2C0_0&invitationId=inv_94e9dfe8-1656-45c9-abc7-d433c77d7a95) +* [Use Case Diagrams](https://lucid.app/lucidchart/85097f76-39d7-49a4-b1a6-5fa749e0ad9f/edit?viewport_loc=-3588%2C-303%2C10396%2C4532%2C0_0&invitationId=inv_c1be5bbe-3b3e-4b08-b021-a66debdfbed8) Beneficial in the future: * package diagram showing the interface exposed to the public @@ -23,3 +94,27 @@ Beneficial in the future: See [UML.md](./docs/UML.md) for a UML intro. + +## Usage +To use the library, clone the repo and checkout the `release-v2.0` branch for the most recent development: +``` +# To clone submodules as well: call git clone with the --recurse-submodules option if you have Git 2.13 or later, else --recursive +git clone git@github.com:lhmcgann/EStimLibrary.git +cd EStimLibrary +git checkout release-v2.0 +``` + +### VisualStudio +1. Open the `.sln` file corresponding to the projects you want to open. +2. Press the "play" button in the top left to build (and run, if applicable) the loaded solution. +3. To run the test project, click the green "play" button in Test Explorer. + +### VSCode or your other preferred IDE +1. Open the `EStimLibrary/` folder in your workspace. +2. In Terminal, navigate to the folder with the desired project or solution. +3. Use `dotnet` CLI commands. See a cheatsheet [here](https://www.lostindetails.com/articles/dotnet-cheatsheet). + 1. `dotnet build` to build any project + 2. `dotnet run` to run the example console app + 3. `dotnet test` to run the xUnit test suite + + diff --git a/docs/images/association_arrows-techvidvan.png b/docs/images/association_arrows-techvidvan.png index 7642f51..54ff5ff 100644 Binary files a/docs/images/association_arrows-techvidvan.png and b/docs/images/association_arrows-techvidvan.png differ diff --git a/docs/images/association_multiplicity_annotations-vertabelo.png b/docs/images/association_multiplicity_annotations-vertabelo.png index 9a00cb8..0d2d1d6 100644 Binary files a/docs/images/association_multiplicity_annotations-vertabelo.png and b/docs/images/association_multiplicity_annotations-vertabelo.png differ diff --git a/docs/images/association_multiplicity_concept-techvidvan.png b/docs/images/association_multiplicity_concept-techvidvan.png index 4379615..ca68409 100644 Binary files a/docs/images/association_multiplicity_concept-techvidvan.png and b/docs/images/association_multiplicity_concept-techvidvan.png differ diff --git a/docs/images/class_notation-tutorialspoint.jpeg b/docs/images/class_notation-tutorialspoint.jpeg index 198e734..ef737b2 100644 Binary files a/docs/images/class_notation-tutorialspoint.jpeg and b/docs/images/class_notation-tutorialspoint.jpeg differ diff --git a/docs/images/uml_arrows-stack_overflow.png b/docs/images/uml_arrows-stack_overflow.png index 3fd8533..56809c2 100644 Binary files a/docs/images/uml_arrows-stack_overflow.png and b/docs/images/uml_arrows-stack_overflow.png differ diff --git a/examples/EStimLibrary.ConsoleAppDemo/EStimLibrary.ConsoleAppDemo.csproj b/examples/EStimLibrary.ConsoleAppDemo/EStimLibrary.ConsoleAppDemo.csproj index c47af07..eb43873 100644 --- a/examples/EStimLibrary.ConsoleAppDemo/EStimLibrary.ConsoleAppDemo.csproj +++ b/examples/EStimLibrary.ConsoleAppDemo/EStimLibrary.ConsoleAppDemo.csproj @@ -2,7 +2,7 @@ Exe - net7.0 + net9.0 enable enable diff --git a/examples/EStimLibrary.ConsoleAppDemo/Program.cs b/examples/EStimLibrary.ConsoleAppDemo/Program.cs index 5a44cf7..296aa60 100644 --- a/examples/EStimLibrary.ConsoleAppDemo/Program.cs +++ b/examples/EStimLibrary.ConsoleAppDemo/Program.cs @@ -660,12 +660,14 @@ "left hand, index finger, distal phalanx | palmar"); foreach (var pVal in pressureValues) { - var pNormVec = Vector.Build.Dense(new double[] { pVal }); + //var pNormVec = Vector.Build.Dense(new double[] { pVal }); + var hapticParams = new Dictionary(); + hapticParams.Add(HapticParam.P, pVal); var event1 = new HapticEvent(DateTime.Now, null, new() { { "left hand", new List() { eventArea1 } }//, eventArea2 } } }, - pNormVec); + hapticParams); session.AddEvent(event1); } session.Stop(); diff --git a/src/EStimLibrary/Core/Data/IDataLimits.cs b/src/EStimLibrary/Core/Data/IDataLimits.cs new file mode 100644 index 0000000..3a2462c --- /dev/null +++ b/src/EStimLibrary/Core/Data/IDataLimits.cs @@ -0,0 +1,28 @@ +namespace EStimLibrary.Core.Data; + + +/// +/// A structure to represent the limits on a specific associated parameter or +/// other data value, supplying data type information and bounding/limiting +/// validation. +/// +public interface IDataLimits : ISelectable +{ + /// + /// The data type that is valid for the associated data. + /// + public Type ValidDataType { get; } + /// + /// A strign description of the limits this structure imposes on the + /// associated data. Meant to be open-ended to accommodate listing discrete + /// options, explaining bounds, etc. Basically a 'help' message. + /// + public string Description { get; } + /// + /// Validate a specific data value. Must be non-null and of the + /// ValidDataType, in addition to any other validation checks. + /// + /// + /// True if the value is valid, false if not. + bool IsValidDataValue(object value); +} \ No newline at end of file diff --git a/src/EStimLibrary/Core/DataLimits.cs b/src/EStimLibrary/Core/DataLimits.cs deleted file mode 100644 index 2d04ef3..0000000 --- a/src/EStimLibrary/Core/DataLimits.cs +++ /dev/null @@ -1,249 +0,0 @@ -namespace EStimLibrary.Core; - - -/// -/// A structure to represent the limits on a specific associated parameter or -/// other data value, supplying data type information and bounding/limiting -/// validation. -/// -public interface IDataLimits : ISelectable -{ - /// - /// The data type that is valid for the associated data. - /// - public Type ValidDataType { get; } - /// - /// A strign description of the limits this structure imposes on the - /// associated data. Meant to be open-ended to accommodate listing discrete - /// options, explaining bounds, etc. Basically a 'help' message. - /// - public string Description { get; } - /// - /// Validate a specific data value. Must be non-null and of the - /// ValidDataType, in addition to any other validation checks. - /// - /// - /// True if the value is valid, false if not. - bool IsValidDataValue(object value); -} - -public record StringDataLimits : IDataLimits -{ - public string Name => "String Data Limits"; - - public Type ValidDataType => typeof(string); - public string Description => "Any string-type data."; - public bool IsValidDataValue(object value) - { - return value.GetType() == this.ValidDataType; - } -} - -/// -/// A limit structure for data that can only have values from a specific list. -/// -/// The data type of the specific options for the -/// associated data. -/// The specific values the associated data can be. -/// -public record FixedOptionDataLimits( - SortedSet DataOptions) : IDataLimits -{ - public string Name => "Fixed Option Data Limits"; - public Type ValidDataType => typeof(DataType); - public string Description => $"Data of type '{typeof(DataType)}' with " + - $"discrete options:\n\t" + - string.Join($"\n\t", this.DataOptions); - - public bool IsValidDataValue(object value) - { - return value is not null && - value.GetType() == this.ValidDataType && - this.DataOptions.Contains((DataType)value); - } -} - -/// -/// A limit structure for data that can have values within a continuous, -/// real-valued range. -/// -/// The lower bound, inclusive. -/// The upper bound, inclusive. -public record ContinuousDataLimits(double MinBound, double MaxBound) : - IDataLimits -{ - public string Name => "Continuous Data Limits"; - public Type ValidDataType => typeof(double); - public string Description => $"{this.Name}: [{this.MinBound}, " + - $"{this.MaxBound}]"; - - public bool IsValidDataValue(object value) - { - return value is not null && - value.GetType() == this.ValidDataType && - this.MinBound <= (double)value && - (double)value <= this.MaxBound; - } -} - -/// -/// A limit structure for data that can have values within a continuous, -/// integer-valued range. -/// -/// The lower bound, inclusive. -/// The upper bound, inclusive. -public record ContinuousIntDataLimits(int MinBound, int MaxBound) : - IDataLimits -{ - public string Name => "Continuous Integer Data Limits"; - public Type ValidDataType => typeof(int); - public string Description => $"{this.Name}: [{this.MinBound}, " + - $"{this.MaxBound}]"; - - public bool IsValidDataValue(object value) - { - return value is not null && - value.GetType() == this.ValidDataType && - this.MinBound <= (int)value && - (int)value <= this.MaxBound; - } -} - -/// -/// A limit structure for data with run-time-dependent limits or some other form -/// of dynamic validation. -/// -/// The data type of the associated data. -/// The dynamic validation function that is called -/// within the IsValidDataValue() method of this limits record. The function -/// must take in the data value (of the correct data type) and return a boolean -/// indicating if the data value is valid at the time when the function is -/// called. -/// The string description of the dynamic validation. -/// Will be used as the overall description for the limits imposed. -public record DynamicDataLimits( - Func CheckFunction, string Description) : IDataLimits -{ - public string Name => "Dynamic Data Limits"; - public Type ValidDataType => typeof(DataType); - - public bool IsValidDataValue(object value) - { - return value is not null && - value.GetType() == this.ValidDataType && - this.CheckFunction((DataType)value); - } -} - -/// -/// A limit structure for data that is itself a sequence of data values, each -/// element with its own subsequent data limits. -/// -/// A list of string names for each element in -/// the sequence. The names must be unique and in the desired order. -/// The corresponding data limits for each element, -/// keyed by the element's string name which must be present in -/// OrderedElementNames. -/// An optional parameter: the string names of -/// any elements in the sequence that are themselves optional and don't have to -/// be included in an actual sequence data record. This set must only contain -/// strings found in OrderedElementNames. Value is null by default, i.e., if all -/// elements are required. -public record SequenceDataLimits(List OrderedElementNames, - Dictionary ElementLimits, - IEnumerable OptionalElements = null) : IDataLimits -{ - public string Name => "Sequence Data Limits"; - // At bare minimum, data instances valid for further validation of this - // against this sequence limitations are ordered lists of sequence element - // name-value pairs. - public Type ValidDataType => typeof(List>); - public string Description - { - get - { - string description = "A sequence of data values, each with their " + - $"own data limits, as follows:\n"; - // Add the description of each element's data limits. - for (int i = 0; i < this.OrderedElementNames.Count; i++) - { - string elementName = this.OrderedElementNames[i]; - - // Add a '*' to the beginning of the element description if it - // is an optional element in the sequence. - bool isOptional = this.OptionalElements is not null && this.OptionalElements.Contains(elementName); - string optionalPrefix = isOptional ? "*" : ""; - - description = description + $"\t{optionalPrefix}" + - $"Element {i + 1} = '{elementName}': " + - $"{this.ElementLimits[elementName].Description}\n"; - } - return description; - } - } - - public bool IsValidDataValue(object value) - { - // Fail immediately if null or not a list of name-value pairs is given. - if (value is null || value.GetType() != this.ValidDataType) - { - return false; - } - - // Extract the sequence, a list of element name-value pairs. - var sequence = (List>)value; - - // Iterate through each element name-value pair. - int headerIndex = 0; - for (int dataIndex = 0; dataIndex < sequence.Count; dataIndex++) - { - // Return failure if data values provided but no more expected - // in the sequence. - if (headerIndex >= this.OrderedElementNames.Count) - { - return false; - } - - // Get the element name-value pair. - (string givenName, object givenValue) = sequence[dataIndex]; - - // Get the name of the element expected to be at this index. - var expectedName = this.OrderedElementNames[headerIndex]; - - // Check if the expected and given names match. - if (!givenName.Equals(expectedName)) - { - // If names don't match, check if the expected element is - // optional. Simply go to next header index if so. - if (this.OptionalElements is not null && - this.OptionalElements.Contains(expectedName)) - { - // Decrement the data index so this value is checked again - // and not skipped. - dataIndex--; - } - // Otherwise, return failure: the sequence is missing an - // element. - else - { - return false; - } - } - - // Otherwise, the names match, so get the data limits. - var limits = this.ElementLimits[givenName]; - // Return failure if an invalid element value is given. - if (!limits.IsValidDataValue(givenValue)) - { - return false; - } - - // Otherwise, it's valid data so far. Look at the next expected - // value. - headerIndex++; - } - - // If made it to the end of the loop, all data valid. Return success. - return true; - } -} \ No newline at end of file diff --git a/src/EStimLibrary/Core/Haptics/HapticEvent.cs b/src/EStimLibrary/Core/Haptics/HapticEvent.cs index 5159a2a..dbe500f 100644 --- a/src/EStimLibrary/Core/Haptics/HapticEvent.cs +++ b/src/EStimLibrary/Core/Haptics/HapticEvent.cs @@ -24,19 +24,13 @@ namespace EStimLibrary.Core.Haptics; /// conforming to the body model. Parameter may be null or contain otherwise /// invalid data if LocalizeByArea is false. /// -/// +/// The dictionary of parameters for the haptic +/// event. Key is the haptic param label (see HapticParamEnum). /// True if this event should be localized on the /// body model using this event's Area, False if this event should be localized /// on the body model using this event's Location. public record HapticEvent(DateTime Timestamp, Dictionary> Locations, Dictionary> Areas, - Vector HapticParamData, - bool LocalizeByArea = true); - -// TODO: add labels/headers to stim params. Could either change this record to -// by default include the below (but need to figure out how to do with enums and -// allowing extension), or add a record inheriting from this one that is a -// ParameterizedHapticEvent. If did add this, would need to add a config step to -// select haptic params. -// public SortedSet HapticParams { get; init; } \ No newline at end of file + Dictionary HapticParamData, + bool LocalizeByArea = true); \ No newline at end of file diff --git a/src/EStimLibrary/Core/Haptics/HapticSession.cs b/src/EStimLibrary/Core/Haptics/HapticSession.cs index 8bf0163..5ed2181 100644 --- a/src/EStimLibrary/Core/Haptics/HapticSession.cs +++ b/src/EStimLibrary/Core/Haptics/HapticSession.cs @@ -466,7 +466,7 @@ public bool TryMapLeadPool(SortedSet leadIds, string bodyModelKey, this._stimManager.TryGetStimulator(stimId, out var stimulator); // Create config data: [stimId, leads, availableParams] ThreadConfigDataPerStimulator data = new(stimId, stimLeads, - stimulator.StimParamData, stimulator.ModulatableStimParams); + stimulator.StimParamSpecs, stimulator.ModulatableStimParams); // Add to total set of config data. allConfigData.Add(stimId, data); } diff --git a/src/EStimLibrary/Core/Haptics/HapticTransducer.cs b/src/EStimLibrary/Core/Haptics/HapticTransducer.cs index 2087606..2b14249 100644 --- a/src/EStimLibrary/Core/Haptics/HapticTransducer.cs +++ b/src/EStimLibrary/Core/Haptics/HapticTransducer.cs @@ -9,6 +9,8 @@ public abstract class HapticTransducer : ISelectable { public abstract string Name { get; } // ISelectable + // TODO: property that says which parameters are modulated + /// /// Transduce a haptic event into stimulation data and push the data changes /// to stimulator hardware. A new programmatic thread is created for each diff --git a/src/EStimLibrary/Core/HardwareInterfaces/Lead.cs b/src/EStimLibrary/Core/HardwareInterfaces/Lead.cs index 9a155e3..2e0d4db 100644 --- a/src/EStimLibrary/Core/HardwareInterfaces/Lead.cs +++ b/src/EStimLibrary/Core/HardwareInterfaces/Lead.cs @@ -1,7 +1,4 @@ -using EStimLibrary.Core; - - -namespace EStimLibrary.Core.HardwareInterfaces; +namespace EStimLibrary.Core.HardwareInterfaces; /// @@ -21,9 +18,18 @@ public record Lead(SortedSet ContactSet, SortedSet OutputSet, Constants.CurrentDirection CurrentDirection) : IIdentifiable { - // Manager-given ID of the lead, -1 if unset. - public int Id => this._Id; // IIdentifiable - internal int _Id = -1; // to be set by the manager. + + /// + /// The manager-given ID of the lead. + /// + /// The ID of the lead, or -1 if unset. + public int Id => this._Id; // IIdentifiable + + /// + /// Internal storage for the lead ID, to be set by the manager. + /// + /// The ID of the lead, default is -1. + internal int _Id = -1; // to be set by the manager. /// /// Get which outputs are connected to a given output or contact by this @@ -44,11 +50,12 @@ public bool GetConnectedOutputs(int id, bool searchIsAnOutput, // Output the set of outputs even if the requested ID is invalid. connectedOutputs = new(this.OutputSet); - var validId = false; + bool validId; // Search by output or contact ID, respectively. - if (searchIsAnOutput && (validId = this.OutputSet.Contains(id))) + if (searchIsAnOutput) { // Exclude the search output ID from the returned set. + validId = this.OutputSet.Contains(id); connectedOutputs.ExceptWith(new int[] { id }); } else @@ -71,7 +78,7 @@ public bool GetConnectedOutputs(int id, bool searchIsAnOutput, /// contact, False if the given search ID is of an output. /// An output parameter: the set of /// contacts connected to the searched contact or output. If a contact was - /// searched, the set will exclude that contact. If the method returns False, + /// searched, the set will exclude that contact. If the method returns False /// this will just be the set of all contacts in this Lead. /// True if the given search ID was found in this Lead and the /// returned contact ID set is valid, False if not. @@ -81,10 +88,11 @@ public bool GetConnectedContacts(int id, bool searchIsAContact, // Output the set of contacts even if the requested ID is invalid. connectedContacts = new(this.ContactSet); - var validId = false; + bool validId; // Search by contact or output ID, respectively. - if (searchIsAContact && (validId = this.ContactSet.Contains(id))) + if (searchIsAContact) { + validId = this.ContactSet.Contains(id); // Exclude the search contact ID from the returned set. connectedContacts.ExceptWith(new int[] { id }); } diff --git a/src/EStimLibrary/Core/HardwareInterfaces/LeadManager.cs b/src/EStimLibrary/Core/HardwareInterfaces/LeadManager.cs index e9c8158..04e5912 100644 --- a/src/EStimLibrary/Core/HardwareInterfaces/LeadManager.cs +++ b/src/EStimLibrary/Core/HardwareInterfaces/LeadManager.cs @@ -30,7 +30,7 @@ public class LeadManager : ResourceManager //TODO? public Dictionary ContactOutputMap { get; protected set; } - public LeadManager() + public LeadManager() : base() { // Initialize wired ID sets. this._WiredContacts = new(); diff --git a/src/EStimLibrary/Core/IFactory.cs b/src/EStimLibrary/Core/IFactory.cs index ef0f6ae..033c374 100644 --- a/src/EStimLibrary/Core/IFactory.cs +++ b/src/EStimLibrary/Core/IFactory.cs @@ -1,4 +1,7 @@ -namespace EStimLibrary.Core; +using EStimLibrary.Core.Data; + + +namespace EStimLibrary.Core; public interface IFactory diff --git a/src/EStimLibrary/Core/ILimitable.cs b/src/EStimLibrary/Core/ILimitable.cs index 1aa7430..f9fc984 100644 --- a/src/EStimLibrary/Core/ILimitable.cs +++ b/src/EStimLibrary/Core/ILimitable.cs @@ -1,4 +1,4 @@ -using EStimLibrary.Core; +using EStimLibrary.Core.Data; namespace EStimLibrary; diff --git a/src/EStimLibrary/Core/SpatialModel/BodyModelBuilderBase.cs b/src/EStimLibrary/Core/SpatialModel/BodyModelBuilderBase.cs index b56a09a..833e98b 100644 --- a/src/EStimLibrary/Core/SpatialModel/BodyModelBuilderBase.cs +++ b/src/EStimLibrary/Core/SpatialModel/BodyModelBuilderBase.cs @@ -1,4 +1,8 @@ -namespace EStimLibrary.Core.SpatialModel; +using EStimLibrary.Core.Data; +using EStimLibrary.Extensions.Data; + + +namespace EStimLibrary.Core.SpatialModel; public abstract class BodyModelBuilderBase : ISelectable, IFactory diff --git a/src/EStimLibrary/Core/SpatialModel/IBodyModel.cs b/src/EStimLibrary/Core/SpatialModel/IBodyModel.cs index 98cb7b9..cabbf8e 100644 --- a/src/EStimLibrary/Core/SpatialModel/IBodyModel.cs +++ b/src/EStimLibrary/Core/SpatialModel/IBodyModel.cs @@ -1,4 +1,4 @@ -using EStimLibrary.Core; +using EStimLibrary.Core.Data; namespace EStimLibrary.Core.SpatialModel; diff --git a/src/EStimLibrary/Core/Stimulation/Data/BaseStimParams.cs b/src/EStimLibrary/Core/Stimulation/Data/BaseStimParams.cs deleted file mode 100644 index 0ce7f65..0000000 --- a/src/EStimLibrary/Core/Stimulation/Data/BaseStimParams.cs +++ /dev/null @@ -1,171 +0,0 @@ -using EStimLibrary.Extensions.Stimulation.Phases; -using EStimLibrary.Core; - - -namespace EStimLibrary.Core.Stimulation.Data; - -// TODO: DELETE once implemented w/ individual enums -// TODO: OR make these Flags so can use bit arithmetic and flagging principles, -// along with the SortedSet list of params available or used, to mark -// simultaneously when and which parameters are changed - -// TODO: describe each param more as a flag for something the stimulator can do, -// rather than as a data value, even though the two may correspond - -//public enum BaseStimParam -//{ -// // Phase Params -// PA, // phase amplitude-- the amplitude in mA of the cathodic pulse phase -// PW, // phase width-- the width in us of the cathodic pulse phase -// PhaseShape, // phase shape-- refers to the shape of each phase of the pulse (default is rectangular) - -// // Pulse Params -// IPD, // interphase delay-- the time in us between two phases of a pulse -// AnodeRatio, // anode ratio-- the multiplier of the anodic phase's PW/divisor of the PA for charge balance. ex: AR = 2 -> anodic phase is twice the PW and half the PA of the cathodic phase -// AnodeFirst, // anode first-- binary that indicates that the anodic phase of the biphasic pulse should go first (default 0 is cathode-first) - -// // Pattern Params -// Period, // pattern period-- the period in ms of a pattern; all pulses (and the delays between them) in the pattern must fit within this time window; classically 1/PF given a pattern with only 1 pulse - -// // Train Params -// FixedRepeats // number of repeats-- the number of pattern/period repeats this train should run for -//} - -/// -/// A static class of *Extension Methods* to check which sub-group a stim param -/// falls under. -/// Ref.: https://stackoverflow.com/questions/9299279/how-to-group-enum-values -/// -public static class BaseStimParams -{ - public const string PA = "PA"; - public const string PW = "PW"; - public const string PhaseShape = "PhaseShape"; - public const string IPD = "IPD"; - public const string AnodeRatio = "AnodeRatio"; - public const string AnodeFirst = "AnodeFirst"; - public const string Period = "Period"; - public const string FixedRepeats = "FixedRepeats"; - - public static Dictionary ParamOrderIndices = new() - { - {PA, 0 }, - {PW, 1 }, - {PhaseShape, 2 }, - {IPD, 3 }, - {AnodeRatio, 4 }, - {AnodeFirst, 5 }, - {Period, 6 }, - {FixedRepeats, 7 } - }; - - // TODO: put in the actual limits for each param; rn just semi-dummy values - public static Dictionary> - ExampleParamData => new() - { - // Phase amplitude in mA - { PA, new( - new ContinuousDataLimits(0.0, 100), - 12.0)}, - // Phase width in us - { PW, new( - new ContinuousDataLimits(1.0, 250.0), - 4.0)}, - // Phase shape - { PhaseShape, new( - new FixedOptionDataLimits(new() - { - typeof(SquarePhaseData) - }), - typeof(SquarePhaseData))}, - // Inter-phase delay in us - { IPD, new( - new ContinuousDataLimits(1.0, 10.0), - 1.0)}, - { AnodeRatio, new( - new ContinuousDataLimits(0.0, 10.0), - 8.0)}, - { AnodeFirst, new( - new FixedOptionDataLimits(new() - { - Constants.ANODE_FIRST, - Constants.ANODE_SECOND - }), - Constants.ANODE_SECOND)}, - // Pulse period in s (1/Hz) - { Period, new( - new ContinuousDataLimits(0.0, 1/250.0), - 1/60.0)}, - { FixedRepeats, new( - new ContinuousIntDataLimits(0, Constants.POS_INFINITY), - 10)} - }; - - //public const int FirstPhaseParamIdx = (int)BaseStimParam.PA; - //public const int FirstPulseParamIdx = (int)BaseStimParam.IPD; - //public const int FirstPatternParamIdx = (int)BaseStimParam.Period; - //public const int FirstTrainParamIdx = (int)BaseStimParam.FixedRepeats; - - public static bool IsPhaseParam(string param) - { - switch (param) - { - case BaseStimParams.PA: - case BaseStimParams.PW: - case BaseStimParams.PhaseShape: - return true; - - default: - return false; - } - } - - public static bool IsPulseParam(string param) - { - switch (param) - { - case BaseStimParams.IPD: - case BaseStimParams.AnodeRatio: - case BaseStimParams.AnodeFirst: - return true; - - default: - return false; - } - } - - public static bool IsPatternParam(string param) - { - switch (param) - { - case BaseStimParams.Period: - return true; - - default: - return false; - } - } - - public static bool IsTrainParam(string param) - { - switch (param) - { - case BaseStimParams.FixedRepeats: - return true; - - default: - return false; - } - } - - /// - /// Sort parameter string keys by paired integer value. - /// - /// The parameter key: order index pairs. - /// An ordered list of parameter keys. - public static List SortParams(Dictionary paramIndices) - { - return paramIndices.OrderBy(pair => pair.Value) - .Select(pair => pair.Key).ToList(); - } -} diff --git a/src/EStimLibrary/Core/Stimulation/StimulatorManager.cs b/src/EStimLibrary/Core/Stimulation/StimulatorManager.cs index 5a19169..8f08ac9 100644 --- a/src/EStimLibrary/Core/Stimulation/StimulatorManager.cs +++ b/src/EStimLibrary/Core/Stimulation/StimulatorManager.cs @@ -55,12 +55,6 @@ public StimulatorManager() : base() this._OutputIdPool = new(); this._OutputStimulatorIdMap = new(); this._OutputsPerStimulatorUsage = new(); - - // Create stimulator ability dict with empty sets for all stim params. - foreach (string p in BaseStimParams.ParamOrderIndices.Keys) - { - this._stimulatorsWithAbilities.Add(p, new()); - } } /// @@ -113,6 +107,13 @@ public bool TryCreateAndRegisterStimulator(Type stimulatorType, // 4) Add the new stim ID to the set of each pulse param it supports. foreach (string p in stim.ModulatableStimParams) { + // If new param, add an empty set entry for it in the dictionary + if (!this._stimulatorsWithAbilities.ContainsKey(p)) + { + this._stimulatorsWithAbilities[p] = new(); + } + + // Add the global stimulator ID this._stimulatorsWithAbilities[p].Add(globalStimId); } @@ -368,6 +369,7 @@ public void UpdateStim(StimThread stimThread) // the value of Tuple(trains, globalToLocalOutputIds). //ThreadPool.QueueUserWorkItem(state => stim.UpdateStim(state), // (trains, globalToLocalOutputIds)); + // TODO: try-catch exception here; make error user-accessible but continue on safely stim.UpdateStim((trainsParams, localOutputAssignments)); } // Do nothing if invalid stim ID. TODO: how best to indicate this diff --git a/src/EStimLibrary/Core/Stimulation/Stimulators/Stimulator.cs b/src/EStimLibrary/Core/Stimulation/Stimulators/Stimulator.cs index e37287f..4b46771 100644 --- a/src/EStimLibrary/Core/Stimulation/Stimulators/Stimulator.cs +++ b/src/EStimLibrary/Core/Stimulation/Stimulators/Stimulator.cs @@ -1,6 +1,6 @@ using EStimLibrary.Core.Stimulation.Data; using EStimLibrary.Core.Stimulation.Functions; -using EStimLibrary.Core.Stimulation.Trains; +using EStimLibrary.Core.Data; namespace EStimLibrary.Core.Stimulation.Stimulators; @@ -35,26 +35,51 @@ protected abstract ValidateStimParamDataDelegate StimParamDataCheckFunction // Derived classes must implement the get() of the abstract properties. // e.g., SpecificStimulator.NumOutputs get { return constNumOutputs; }. - public abstract Dictionary> StimParamData + // TODO: define (in Extensions) a type that is a NestedParam; maybe it's a + // nested param DataLimits, idk, but a tool ppl who are running into the + // "trainMods: List" case can use; maybe it's just an example + // implementation, but something + + /// + /// The definition of available stimulation parameters on the stimulator, + /// their data limits, and their default/fixed value. + /// {"paramNameOrKey": (dataLimitsObject, defaultOrFixedValue), ...} + /// typeof(defaultOrFixedValue) must match dataLimitsObject.ValidDataType. + /// dataLimitsObject.IsValidDataValue(defaultOrFixedValue) must return True. + /// + public abstract Dictionary> + StimParamSpecs { get; } + + /// + /// The set of string keys of stimluation parameters that can be modulated + /// on the fly at run-time. All keys specified must be in StimParamSpecs. + /// public abstract SortedSet ModulatableStimParams { get; } - // Fixed-value params must be all params available that aren't modulatable. + + /// + /// All stimulation parameters available that are not modulatable on the fly + /// at run-time. All keys listed will be in StimParamSpecs. + /// public SortedSet FixedStimParams => new(this._StimParamsAvailable.Except(this.ModulatableStimParams)); - //public abstract Dictionary FixedStimParamValues - //{ get; } - // Essentially a validation check on stim param specification after - // Stimulator construction. + + /// + /// A validation check on stimulation parameter specification after + /// Stimulator construction. True if: + /// 1) all specified default/fixed values are within the specified data + /// limits per specified parameter, and + /// 2) all param keys marked as modulatable are real specified parameters + /// public bool ValidStimParamSpecification => - // a) at least the base stim params are included in the enum. - BaseStimParams.ParamOrderIndices.Keys.All( - this.StimParamData.Keys.Contains) && - // b) all available parameters are from the same enum - this.StimParamData.Keys.Select(param => param.GetType()) - .Distinct().Count() == 1; - - // Instantiated in constructor based on the keys in StimParamData. + // 1) Spec provides value default/fixed values + StimParamSpecs.All( + kvp => kvp.Value.Item1.IsValidDataValue(kvp.Value.Item2)) && + // 2) All param keys in modulatable list are specified + ModulatableStimParams.All(k => StimParamSpecs.Keys.Contains(k)); + + // Instantiated in constructor based on the keys in StimParamSpecs. protected readonly SortedSet _StimParamsAvailable; public SortedSet StimParamsAvailable => this._StimParamsAvailable; //new SortedSet((Enum[])Enum.GetValues(typeof(StimParamType))); @@ -96,7 +121,7 @@ protected Stimulator()//, int baseOutputConfigId) this.Id = -1; // Store the param options. - this._StimParamsAvailable = new(this.StimParamData.Keys); + this._StimParamsAvailable = new(this.StimParamSpecs.Keys); // Init empty output config dict and array of used markings per output. this._outputConfigs = new(); @@ -116,14 +141,21 @@ protected Stimulator()//, int baseOutputConfigId) /// public abstract bool IsValidOutputWiring(IEnumerable localOutputIds); + // TODO: use this somewhere!!! e.g., UpdateStim() public bool IsValidParamValue(string stimParam, object paramValue) { - var (paramLims, defaultVal) = this.StimParamData[stimParam]; + var (paramLims, defaultVal) = this.StimParamSpecs[stimParam]; return paramLims.IsValidDataValue(paramValue); } + + // TODO: calibration/comfort safety check function, then also call that + // somewhere like IsValidParamValue, e.g., in UpdateStim + + // MAIN UPDATE STIM METHOD PASSED TO EACH THREAD DURING CONFIG AND CALLED // BY TRANSDUCER ON UPDATE + // TODO; propogate exception public bool UpdateStim(object state) { // TODO: there is probably a better way to do this global-local output @@ -135,10 +167,13 @@ public bool UpdateStim(object state) Dictionary))state; var trainsParams = data.Item1; var localOutputAssignments = data.Item2; + // TODO: apply check functions return this.HW_UpdateStim(trainsParams, localOutputAssignments); } //protected abstract bool HW_UpdateStim(IEnumerable stimTrains, // Dictionary globalToLocalOutputIds); + // TODO @Rachel: implement for WSS! NOT UpdateStim + // TODO: throw exception upon failure protected abstract bool HW_UpdateStim( IEnumerable> trainsParams, Dictionary localOutputAssignments); @@ -234,6 +269,4 @@ protected void SendMessage(byte[] data) /// /// The full byte array of data to send. protected abstract void HW_SendMessage(byte[] data); - -} - +} \ No newline at end of file diff --git a/src/EStimLibrary/Core/Stimulation/ThreadConfigDataPerStimulator.cs b/src/EStimLibrary/Core/Stimulation/ThreadConfigDataPerStimulator.cs index b1308cd..d981b77 100644 --- a/src/EStimLibrary/Core/Stimulation/ThreadConfigDataPerStimulator.cs +++ b/src/EStimLibrary/Core/Stimulation/ThreadConfigDataPerStimulator.cs @@ -1,4 +1,5 @@ using EStimLibrary.Core.HardwareInterfaces; +using EStimLibrary.Core.Data; namespace EStimLibrary.Core.Stimulation; @@ -6,7 +7,7 @@ namespace EStimLibrary.Core.Stimulation; public record ThreadConfigDataPerStimulator(int GlobalStimId, IEnumerable IndependentLeads, - Dictionary> StimParamData, + Dictionary> StimParamSpecs, // TODO: edit once have sorted how will resolve StimParams and enum stuff. SortedSet ModulatableStimParams); diff --git a/src/EStimLibrary/Core/Utils.cs b/src/EStimLibrary/Core/Utils.cs index c01ebf5..4371f8c 100644 --- a/src/EStimLibrary/Core/Utils.cs +++ b/src/EStimLibrary/Core/Utils.cs @@ -2,7 +2,7 @@ using System.IO.Ports; using System.Reflection; -using EStimLibrary.Core.Haptics; +using EStimLibrary.Core.Data; using EStimLibrary.Core.Stimulation.Stimulators; diff --git a/src/EStimLibrary/EStimLibrary.csproj b/src/EStimLibrary/EStimLibrary.csproj index d7cd0ff..1831c35 100644 --- a/src/EStimLibrary/EStimLibrary.csproj +++ b/src/EStimLibrary/EStimLibrary.csproj @@ -1,7 +1,7 @@ - net7.0 + net9.0 enable enable @@ -25,6 +25,8 @@ + + @@ -44,9 +46,11 @@ + + - + diff --git a/src/EStimLibrary/Extensions/Data/ContinuousDataLimits.cs b/src/EStimLibrary/Extensions/Data/ContinuousDataLimits.cs new file mode 100644 index 0000000..908e030 --- /dev/null +++ b/src/EStimLibrary/Extensions/Data/ContinuousDataLimits.cs @@ -0,0 +1,30 @@ +using EStimLibrary.Core.Data; + + +namespace EStimLibrary.Extensions.Data; + + +/// +/// A limit structure for data that can have values within a continuous, +/// real-valued range. +/// +/// The lower bound, inclusive. +/// The upper bound, inclusive. +/// TODO: add resolution? and rounding scheme param? or leave rounding scheme +/// for elsewhere to do since this is just validation, not subsequent action... +public record ContinuousDataLimits(double MinBound, double MaxBound) : + IDataLimits +{ + public string Name => "Continuous Data Limits"; + public Type ValidDataType => typeof(double); + public string Description => $"{this.Name}: [{this.MinBound}, " + + $"{this.MaxBound}]"; + + public bool IsValidDataValue(object value) + { + return value is not null && + value.GetType() == this.ValidDataType && + this.MinBound <= (double)value && + (double)value <= this.MaxBound; + } +} \ No newline at end of file diff --git a/src/EStimLibrary/Extensions/Data/ContinuousIntDataLimits.cs b/src/EStimLibrary/Extensions/Data/ContinuousIntDataLimits.cs new file mode 100644 index 0000000..d4565a0 --- /dev/null +++ b/src/EStimLibrary/Extensions/Data/ContinuousIntDataLimits.cs @@ -0,0 +1,28 @@ +using EStimLibrary.Core.Data; + + +namespace EStimLibrary.Extensions.Data; + + +/// +/// A limit structure for data that can have values within a continuous, +/// integer-valued range. +/// +/// The lower bound, inclusive. +/// The upper bound, inclusive. +public record ContinuousIntDataLimits(int MinBound, int MaxBound) : + IDataLimits +{ + public string Name => "Continuous Integer Data Limits"; + public Type ValidDataType => typeof(int); + public string Description => $"{this.Name}: [{this.MinBound}, " + + $"{this.MaxBound}]"; + + public bool IsValidDataValue(object value) + { + return value is not null && + value.GetType() == this.ValidDataType && + this.MinBound <= (int)value && + (int)value <= this.MaxBound; + } +} \ No newline at end of file diff --git a/src/EStimLibrary/Extensions/Data/DynamicDataLimits.cs b/src/EStimLibrary/Extensions/Data/DynamicDataLimits.cs new file mode 100644 index 0000000..05e84b1 --- /dev/null +++ b/src/EStimLibrary/Extensions/Data/DynamicDataLimits.cs @@ -0,0 +1,31 @@ +using EStimLibrary.Core.Data; + + +namespace EStimLibrary.Extensions.Data; + + +/// +/// A limit structure for data with run-time-dependent limits or some other form +/// of dynamic validation. +/// +/// The data type of the associated data. +/// The dynamic validation function that is called +/// within the IsValidDataValue() method of this limits record. The function +/// must take in the data value (of the correct data type) and return a boolean +/// indicating if the data value is valid at the time when the function is +/// called. +/// The string description of the dynamic validation. +/// Will be used as the overall description for the limits imposed. +public record DynamicDataLimits( + Func CheckFunction, string Description) : IDataLimits +{ + public string Name => "Dynamic Data Limits"; + public Type ValidDataType => typeof(DataType); + + public bool IsValidDataValue(object value) + { + return value is not null && + value.GetType() == this.ValidDataType && + this.CheckFunction((DataType)value); + } +} \ No newline at end of file diff --git a/src/EStimLibrary/Extensions/Data/FixedOptionDataLimits.cs b/src/EStimLibrary/Extensions/Data/FixedOptionDataLimits.cs new file mode 100644 index 0000000..c17c9ce --- /dev/null +++ b/src/EStimLibrary/Extensions/Data/FixedOptionDataLimits.cs @@ -0,0 +1,29 @@ +using EStimLibrary.Core.Data; + + +namespace EStimLibrary.Extensions.Data; + + +/// +/// A limit structure for data that can only have values from a specific list. +/// +/// The data type of the specific options for the +/// associated data. +/// The specific values the associated data can be. +/// +public record FixedOptionDataLimits( + SortedSet DataOptions) : IDataLimits +{ + public string Name => "Fixed Option Data Limits"; + public Type ValidDataType => typeof(DataType); + public string Description => $"Data of type '{typeof(DataType)}' with " + + $"discrete options:\n\t" + + string.Join($"\n\t", this.DataOptions); + + public bool IsValidDataValue(object value) + { + return value is not null && + value.GetType() == this.ValidDataType && + this.DataOptions.Contains((DataType)value); + } +} \ No newline at end of file diff --git a/src/EStimLibrary/Extensions/Data/SequenceDataLimits.cs b/src/EStimLibrary/Extensions/Data/SequenceDataLimits.cs new file mode 100644 index 0000000..a6f6f6e --- /dev/null +++ b/src/EStimLibrary/Extensions/Data/SequenceDataLimits.cs @@ -0,0 +1,118 @@ +using EStimLibrary.Core.Data; + + +namespace EStimLibrary.Extensions.Data; + + +/// +/// A limit structure for data that is itself a sequence of data values, each +/// element with its own subsequent data limits. +/// +/// A list of string names for each element in +/// the sequence. The names must be unique and in the desired order. +/// The corresponding data limits for each element, +/// keyed by the element's string name which must be present in +/// OrderedElementNames. +/// An optional parameter: the string names of +/// any elements in the sequence that are themselves optional and don't have to +/// be included in an actual sequence data record. This set must only contain +/// strings found in OrderedElementNames. Value is null by default, i.e., if all +/// elements are required. +public record SequenceDataLimits(List OrderedElementNames, + Dictionary ElementLimits, + IEnumerable OptionalElements = null) : IDataLimits +{ + public string Name => "Sequence Data Limits"; + // At bare minimum, data instances valid for further validation of this + // against this sequence limitations are ordered lists of sequence element + // name-value pairs. + public Type ValidDataType => typeof(List>); + public string Description + { + get + { + string description = "A sequence of data values, each with their " + + $"own data limits, as follows:\n"; + // Add the description of each element's data limits. + for (int i = 0; i < this.OrderedElementNames.Count; i++) + { + string elementName = this.OrderedElementNames[i]; + + // Add a '*' to the beginning of the element description if it + // is an optional element in the sequence. + bool isOptional = this.OptionalElements is not null && this.OptionalElements.Contains(elementName); + string optionalPrefix = isOptional ? "*" : ""; + + description = description + $"\t{optionalPrefix}" + + $"Element {i + 1} = '{elementName}': " + + $"{this.ElementLimits[elementName].Description}\n"; + } + return description; + } + } + + public bool IsValidDataValue(object value) + { + // Fail immediately if null or not a list of name-value pairs is given. + if (value is null || value.GetType() != this.ValidDataType) + { + return false; + } + + // Extract the sequence, a list of element name-value pairs. + var sequence = (List>)value; + + // Iterate through each element name-value pair. + int headerIndex = 0; + for (int dataIndex = 0; dataIndex < sequence.Count; dataIndex++) + { + // Return failure if data values provided but no more expected + // in the sequence. + if (headerIndex >= this.OrderedElementNames.Count) + { + return false; + } + + // Get the element name-value pair. + (string givenName, object givenValue) = sequence[dataIndex]; + + // Get the name of the element expected to be at this index. + var expectedName = this.OrderedElementNames[headerIndex]; + + // Check if the expected and given names match. + if (!givenName.Equals(expectedName)) + { + // If names don't match, check if the expected element is + // optional. Simply go to next header index if so. + if (this.OptionalElements is not null && + this.OptionalElements.Contains(expectedName)) + { + // Decrement the data index so this value is checked again + // and not skipped. + dataIndex--; + } + // Otherwise, return failure: the sequence is missing an + // element. + else + { + return false; + } + } + + // Otherwise, the names match, so get the data limits. + var limits = this.ElementLimits[givenName]; + // Return failure if an invalid element value is given. + if (!limits.IsValidDataValue(givenValue)) + { + return false; + } + + // Otherwise, it's valid data so far. Look at the next expected + // value. + headerIndex++; + } + + // If made it to the end of the loop, all data valid. Return success. + return true; + } +} \ No newline at end of file diff --git a/src/EStimLibrary/Extensions/Data/StringDataLimits.cs b/src/EStimLibrary/Extensions/Data/StringDataLimits.cs new file mode 100644 index 0000000..71396dd --- /dev/null +++ b/src/EStimLibrary/Extensions/Data/StringDataLimits.cs @@ -0,0 +1,17 @@ +using EStimLibrary.Core.Data; + + +namespace EStimLibrary.Extensions.Data; + + +public record StringDataLimits : IDataLimits +{ + public string Name => "String Data Limits"; + + public Type ValidDataType => typeof(string); + public string Description => "Any string-type data."; + public bool IsValidDataValue(object value) + { + return value.GetType() == this.ValidDataType; + } +} \ No newline at end of file diff --git a/src/EStimLibrary/Extensions/Haptics/ClassicDirectTransducer.cs b/src/EStimLibrary/Extensions/Haptics/ClassicDirectTransducer.cs index 8709cbb..da1dde6 100644 --- a/src/EStimLibrary/Extensions/Haptics/ClassicDirectTransducer.cs +++ b/src/EStimLibrary/Extensions/Haptics/ClassicDirectTransducer.cs @@ -3,6 +3,7 @@ using EStimLibrary.Core.Haptics; using EStimLibrary.Core.SpatialModel; using EStimLibrary.Core; +using EStimLibrary.Extensions.Data; namespace EStimLibrary.Extensions.Haptics; @@ -19,15 +20,11 @@ public class ClassicDirectTransducer : HapticTransducer public ClassicDirectTransducer(string modParam) { // TODO: adjust this validation to be flexible to user-input param - // lists. + // lists? or just relegate validation to session config?? // TODO: how to also factor in stimulator-specific modulation abilities? // e.g., even if valid param name, the stimulator used for a given event // may not be able to mod it... - if (!BaseStimParams.ParamOrderIndices.Keys.Contains(modParam)) - { - throw new ArgumentException($"{this.Name} Constructor Error: " + - $"{modParam} is not a valid stim param."); - } + this.ModulatedParam = modParam; } @@ -59,8 +56,7 @@ protected override IEnumerable _TransduceHapticEvent( // i.e., P not included at all (otherwise would be first), use the first // value. // Should be between 0 and 1 - // TODO: make sure array len >0 before indexing into - double modValue = hapticEvent.HapticParamData[0]; + double modValue = hapticEvent.HapticParamData[HapticParam.P]; // StimThread properties: // PerStimulatorConfigs (MAIN constructor input; next 2 derive from it) diff --git a/src/EStimLibrary/Extensions/HardwareInterfaces/ContactGroup.cs b/src/EStimLibrary/Extensions/HardwareInterfaces/ContactGroup.cs index 876b8d8..afe8df1 100644 --- a/src/EStimLibrary/Extensions/HardwareInterfaces/ContactGroup.cs +++ b/src/EStimLibrary/Extensions/HardwareInterfaces/ContactGroup.cs @@ -4,16 +4,38 @@ namespace EStimLibrary.Extensions.HardwareInterfaces; +/// +/// A generic neural interface containing 1+ contacts. +/// public class ContactGroup : NeuralInterfaceHardware { + /// + /// The name of this neural interface type. + /// public override string Name => "Contact Group"; + /// + /// The integer number of contacts in this neural interface. + /// public override int NumContacts => this._NumContacts; protected int _NumContacts; + /// + /// Create a contact group with a positive integer number of contacts. + /// + /// Number of contacts. + /// Invalid argument, numContact must be + /// positive. public ContactGroup(int numContacts) : base() { - this._NumContacts = numContacts; + if (numContacts > 0) + { + this._NumContacts = numContacts; + } + else + { + throw new ArgumentException("Number of contacts must be positive"); + } } } diff --git a/src/EStimLibrary/Extensions/HardwareInterfaces/GelPad.cs b/src/EStimLibrary/Extensions/HardwareInterfaces/GelPad.cs index 28ad9fe..cafbf7f 100644 --- a/src/EStimLibrary/Extensions/HardwareInterfaces/GelPad.cs +++ b/src/EStimLibrary/Extensions/HardwareInterfaces/GelPad.cs @@ -4,12 +4,24 @@ namespace EStimLibrary.Extensions.HardwareInterfaces; +/// +/// A single gel pad electrode. +/// public class GelPad : NeuralInterfaceHardware { + /// + /// The name of this neural interface type. + /// public override string Name => "Gel Pad"; + /// + /// A gel pad represents a single contact. + /// public override int NumContacts => 1; + /// + /// Create a gel pad. + /// public GelPad() : base() { } diff --git a/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyAreaFactory.cs b/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyAreaFactory.cs index f2bedd2..3a4582c 100644 --- a/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyAreaFactory.cs +++ b/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyAreaFactory.cs @@ -1,4 +1,6 @@ using EStimLibrary.Core; +using EStimLibrary.Core.Data; +using EStimLibrary.Extensions.Data; using EStimLibrary.Core.SpatialModel; diff --git a/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyBodyModel.cs b/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyBodyModel.cs index 45b872d..3436dab 100644 --- a/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyBodyModel.cs +++ b/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyBodyModel.cs @@ -1,4 +1,5 @@ using EStimLibrary.Core; +using EStimLibrary.Core.Data; using EStimLibrary.Core.SpatialModel; diff --git a/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyLocationFactory.cs b/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyLocationFactory.cs index b07b0b1..3d04e62 100644 --- a/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyLocationFactory.cs +++ b/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyLocationFactory.cs @@ -1,4 +1,6 @@ using EStimLibrary.Core; +using EStimLibrary.Core.Data; +using EStimLibrary.Extensions.Data; using EStimLibrary.Core.SpatialModel; diff --git a/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyRegion.cs b/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyRegion.cs index fe56217..d8523f1 100644 --- a/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyRegion.cs +++ b/src/EStimLibrary/Extensions/SpatialModel/StringHierarchy/StringHierarchyRegion.cs @@ -11,21 +11,31 @@ public class StringHierarchyRegion /// The base name of this region, e.g., hand. /// public string BaseName { get; init; } + /// - /// The parent region of this region. + /// The parent region of this region. Null if this region is the root + /// region. /// - public StringHierarchyRegion ParentRegion { get; set; } + public StringHierarchyRegion? ParentRegion { get; set; } + /// - /// The all possible option strings through the parent tree to get to this - /// region. + /// All region options specified through the path through the parent tree + /// to this region. /// public HashSet ParentOptions { get; set; } /// - /// Options of the base region that can be selected, e.g., [left, right]. + /// Options of this base region that can be selected, e.g., [left, right]. /// Ensured to be non-null. Empty if none. /// public HashSet Options { get; set; } + /// + /// Whether or not there are options that must be specified at this base + /// region. + /// public bool HasOptions => this.Options.Count > 0; + /// + /// All optioned region names that can be specified at this base region. + /// public List OptionedRegionNames { get @@ -34,16 +44,18 @@ public List OptionedRegionNames if (this.HasOptions) { return this.Options.Select( - option => $"{option}{StringHierarchySpec.OPTION_REGION_DELIMITER}" + - $"{this.BaseName}").ToList(); + option => $"{option}" + + $"{StringHierarchySpec.OPTION_REGION_DELIMITER}" + + $"{this.BaseName}").ToList(); } - // Else, add include the base name. + // Else, just include the base name. else { return new() { this.BaseName }; } } } + /// /// Directional modifiers that could be applied to this region. Dictionary /// keyed by directional axis name. Value set contains possible modifiers @@ -58,9 +70,27 @@ public Dictionary> Modifiers _UpdateFrequencyDict(); } } + /// + /// Whether or not there are directional modifiers specified at this base + /// region. + /// public bool HasModifiers => this.Modifiers.Count > 0; - private Dictionary> _modifiers; + /// + /// Directional modifiers that could be applied to this region. Dictionary + /// keyed by directional axis name. Value set contains possible modifier + /// values along that axis. Ensured to be non-null. Empty if none. Accessed + /// by the Modifiers property. + /// + private Dictionary> _modifiers = new(); + /// + /// Dictionary of how frequently a modifier value occurs across all + /// specified modifier axes. Keyed by modifier value. Integer value is how + /// many axes contain that modifier value. Used when parsing string + /// hierarchy modifier specs to handle axes with the same modifier value, + /// e.g., x: center, y: center. Updated by the Mofidiers property setter. + /// private Dictionary _modifierFrequencyDict = new(); + /// /// The subregions of this region, keyed by string name. Ensured to be /// non-null. Empty if none, meaning this region is a "leaf" in the nodal @@ -71,7 +101,14 @@ public Dictionary Subregions get; protected set; } + /// + /// Whether or not this region has subregions. + /// public bool HasSubregions => this.Subregions.Count > 0; + /// + /// Whether or not this region is a "leaf" in the nodal graph, i.e., has no + /// subregions. + /// public bool IsLeaf => this.Subregions.Count == 0; /// @@ -83,17 +120,41 @@ public Dictionary Subregions /// public SortedSet SavedAreas { get; set; } - // Deep copies made of data structs but NOT of any referenced - // StringHierarchyRegions. References retained to those objects. - public StringHierarchyRegion(string baseName, StringHierarchyRegion parent, - HashSet parentOptions = null, - HashSet options = null, - Dictionary> modifiers = null, - Dictionary subregions = null) + /// + /// Create a new StringHierarchyRegion with the given base name and parent + /// region. Optionally provide parent options, region options, modifiers, + /// and subregions. Ensures all properties non-null after construction. + /// Deep copies made of collections passed in but NOT of any referenced + /// StringHierarchyRegions. References retained to those objects. + /// + /// The base name of this region, e.g., "hand". + /// + /// The parent region of this region (default: null, + /// meaning root region). + /// The specification of option values passed + /// to this region by the parent region (default: empty). + /// The possible option values for this region + /// (default: empty) + /// The directional modifiers that can be specified + /// at this region (default: empty). Key by modifier axis name. Valued by + /// set of possible modifier values along that directional axis. + /// The subregions of this region, if any + /// (default: empty). If none, this region is a "leaf" in the nodal graph. + /// + public StringHierarchyRegion(string baseName, + StringHierarchyRegion? parent = null, + // Note: the ! alleviates null warning, promising the value will be set + // to not-null in the constructor. + HashSet parentOptions = null!, + HashSet options = null!, + Dictionary> modifiers = null!, + Dictionary subregions = null!) { + // Store base name and reference to parent region. this.BaseName = baseName; this.ParentRegion = parent; - // Deep copy options and modifier structs. + + // Deep copy options and modifier collection structs. this.ParentOptions = (parentOptions is not null) ? new(parentOptions) : new(); this.Options = (options is not null) ? new(options) : new(); @@ -103,6 +164,8 @@ public StringHierarchyRegion(string baseName, StringHierarchyRegion parent, // Copy the dictionary but keep references to same subregion objects. this.Subregions = (subregions is not null) ? new(subregions) : new(); + // Initialize sets noting the IDs of saved locations and areas that + // point to this region. this.SavedLocations = new(); this.SavedAreas = new(); } @@ -113,18 +176,22 @@ public StringHierarchyRegion(string baseName, StringHierarchyRegion parent, /// any existing subregion. Sets the parent reference of the added subregion /// to be this region. /// - /// The subregion to add. Shallow copied. - /// Parent reference set. + /// The subregion to add. Shallow copied. Parent + /// reference set. /// An output parameter: the replaced but - /// unaltered existing subregion if any, else null. + /// unaltered existing subregion of the same base name if any, else null. + /// + /// The provided subregion + /// argument is null. public void AddSubregion(StringHierarchyRegion subregion, out StringHierarchyRegion? existingSubregion) { - // Fill the out parameter with the existing subregion if exists. + // Fill the output parameter with the existing subregion if exists. if (this.Subregions.TryGetValue(subregion.BaseName, out existingSubregion)) { - // Overwrite the stored subregion with this basename. + // Replace the existing subregion at this basename with the new + // one. this.Subregions[subregion.BaseName] = subregion; } // Else add the new subregion keyed by its basename. @@ -137,19 +204,19 @@ public void AddSubregion(StringHierarchyRegion subregion, subregion.ParentRegion = this; } - // TODO: TEST THE HECK OUT OF THIS /// /// Try to get a given subregion of this region. /// - /// The specified region to search for, given as a - /// string sequence of appropriately delimited option-region names. + /// The string specification of the region to + /// search for, given as a string sequence of appropriately delimited + /// option-region names. /// An output parameter: the searched /// subregion if found, null if not. /// True if the subregion could be found, False if not. public bool TryGetSubregion(string regionSpec, - out StringHierarchyRegion foundSubregion) + out StringHierarchyRegion? foundSubregion) { - // Split full linked name into sequence of option+region names. + // Split full region spec into sequence of option+region names. var regionSet = StringHierarchySpec.ParseRegionSpec(regionSpec); // Navigate the nodal graph to find the region. @@ -182,7 +249,7 @@ public bool TryGetSubregion(string regionSpec, if (searchCurrentRegion) { // If search name found, look for next item in subregions. - if (foundSubregion.OptionedRegionNames.Contains( + if (foundSubregion!.OptionedRegionNames.Contains( optionedRegionName)) { searchCurrentRegion = false; @@ -194,33 +261,54 @@ public bool TryGetSubregion(string regionSpec, return false; } } - // Else try to find matching subregion. + // Else search subregion for the option+region name. else { - // Fail if search region found in subregion, else search for next - // item in that subregion's subregions. - if (!(foundSubregion.Subregions.TryGetValue(searchBaseName, - out foundSubregion) && (searchOption.Equals("") || - foundSubregion.Options.Contains(searchOption)))) + // Check if search basename is in subregions. + // Sets foundSubregion to the subregion if found. + bool viableSubregion = foundSubregion!.Subregions.TryGetValue( + searchBaseName, out foundSubregion); + + // Fail if search basename not found in subregions or search + // option exists and not found in subregion matching search + // basename. + if (!(viableSubregion && + (searchOption.Equals("") || + foundSubregion!.Options.Contains(searchOption)))) { foundSubregion = null; return false; } + + // Else, search continues in located matching subregion. } } - // If made it here, subregion is found. Fill output param and return. + // If made it here, subregion is found. Output param already filled. + // Return success. return true; } + /// + /// Check if a given modifier specification is valid within this region. + /// Valid if: + /// - formatted (delimited) correctly + /// - all modifiers values are found in the possible value sets of the + /// modifier axes of this region + /// - each modifier axis is used at most once + /// + /// The modifier specification to check. + /// T/F if the spec is valid in this region. public bool IsValidModifierSpec(string modifierSpec) { - // Split into modifier set. + // Parse modifier spec into a set of modifier values. var modifierSet = StringHierarchySpec.ParseModifierSpec(modifierSpec); // Sort modifiers by their frequency across axis option sets, ascending // order so duplicate modifier values (e.g., "center" as a valid value // on two axes) doesn't use the only axis another modifier value may be - // valid for. + // valid for. I.e., duplicate modifier values "used" for the most + // restricted axis first. + // TODO: FIX THIS!!! var sortedModifierSet = modifierSet.OrderBy(modifier => this._modifierFrequencyDict.ContainsKey(modifier) ? this._modifierFrequencyDict[modifier] : 0) @@ -256,6 +344,10 @@ public bool IsValidModifierSpec(string modifierSpec) return true; } + /// + /// Update the frequency dictionary of modifier values across all axes + /// based on current Modifiers. + /// private void _UpdateFrequencyDict() { // Clear the frequency dictionary @@ -278,7 +370,6 @@ private void _UpdateFrequencyDict() } } - // TODO: TEST!!! /// /// Create a deep copy of the whole subtree starting at this region. /// @@ -291,8 +382,9 @@ public StringHierarchyRegion DeepCopy(bool retainParentReference = true) { var parentRegion = (retainParentReference) ? this.ParentRegion : null; // Create new region. Inherently deep copies options and modifiers. - var newRegion = new StringHierarchyRegion(this.BaseName, parentRegion, - options: this.Options, modifiers: this.Modifiers); + var newRegion = new StringHierarchyRegion(this.BaseName, parent: parentRegion!, + parentOptions: this.ParentOptions, options: this.Options, + modifiers: this.Modifiers); // Add deep copies of all subregions. foreach (var (_, subregion) in this.Subregions) @@ -306,13 +398,32 @@ public StringHierarchyRegion DeepCopy(bool retainParentReference = true) return newRegion; } + /// + /// Create a string representation of this region and all subregions. + /// Override default behavior to provide a more detailed string output. + /// + /// A string representation of this region. public override string ToString() { - return s_BuildSpecOptionsString("", 0, this); + return s_BuildSpecOptionsString(this); } - private static string s_BuildSpecOptionsString(string parentRegionSpec, - int indentLevel, StringHierarchyRegion region) + /// + /// Create a string representation of the given region, recursing to contain + /// all subregions. + /// + /// The region to build a string representation of. + /// + /// The string specification of the parent + /// region (default: empty string, assuming given region is a root). + /// + /// Depth in the tree and thus number of indents + /// to included in the string output (default: 0, assuming given region is + /// a root). + /// A printable string representation of the given region. + /// + private static string s_BuildSpecOptionsString(StringHierarchyRegion region, + string parentRegionSpec = "", int indentLevel = 0) { // Output: [prev regionSpec], [options] baseName | [mod1Options], ... @@ -347,8 +458,9 @@ private static string s_BuildSpecOptionsString(string parentRegionSpec, List subregionStrings = new() { fullSpec }; foreach (var (_, subregion) in region.Subregions) { - subregionStrings.Add(s_BuildSpecOptionsString(regionSpec, - indentLevel + 1, subregion)); + subregionStrings.Add(s_BuildSpecOptionsString(subregion, + parentRegionSpec: regionSpec, + indentLevel: indentLevel + 1)); } // Return the single string. diff --git a/src/EStimLibrary/Extensions/Stimulation/Stimulators/EchoStimulator.cs b/src/EStimLibrary/Extensions/Stimulation/Stimulators/EchoStimulator.cs index 2c762ca..7347861 100644 --- a/src/EStimLibrary/Extensions/Stimulation/Stimulators/EchoStimulator.cs +++ b/src/EStimLibrary/Extensions/Stimulation/Stimulators/EchoStimulator.cs @@ -1,10 +1,8 @@ using EStimLibrary.Core.Stimulation.Stimulators; -using EStimLibrary.Core.Stimulation.Data; using EStimLibrary.Core; using EStimLibrary.Core.Stimulation.Functions; -using EStimLibrary.Core.Stimulation.Trains; -using EStimLibrary.Extensions.Stimulation.Phases; -using Newtonsoft.Json.Linq; +using EStimLibrary.Core.Data; +using EStimLibrary.Extensions.Data; namespace EStimLibrary.Extensions.Stimulation.Stimulators; @@ -28,14 +26,77 @@ public EchoStimulator(string port, int numOutputs) // Only allow half as many configs as there are outputs. public override int MaxNumOutputConfigs => this._NumOutputs / 2; - // Use example limits and defaults for all base stim params. + #region Convenience StimParam String Name Defines + protected const string PA = "PA"; + protected const string PW = "PW"; + protected const string StimPhaseShape = "StimPhaseShape"; + protected const string RechargePhaseShape = "RechargePhaseShape"; + protected const string IPD = "IPD"; + protected const string AnodeRatio = "AnodeRatio"; + protected const string AnodeFirst = "AnodeFirst"; + protected const string Period = "Period"; + #endregion + + /// + /// Example stimulation parameter specification. + /// {"paramNameOrKey": (dataLimitsObject, defaultOrFixedValue), ...} + /// public override Dictionary> - StimParamData => BaseStimParams.ExampleParamData; - // Params that can be dynamically modulated + StimParamSpecs => new() + { + // Phase amplitude in mA + { PA, new( + new ContinuousDataLimits(0.0, 10.0), + 0.0)}, + // Phase width in us + { PW, new( + new ContinuousDataLimits(0.0, 250.0), + 0.0)}, + // Activation/stimulation phase shape + { StimPhaseShape, new( + new FixedOptionDataLimits(new() + { + "square", + "sine", + "triangle" + }), + "sine")}, + // Recharge phase shape + { RechargePhaseShape, new( + new FixedOptionDataLimits(new() + { + "square", + "sine", + "triangle" + }), + "sine")}, + // Inter-phase delay in us + { IPD, new( + new ContinuousDataLimits(0.0, 150.0), + 100.0)}, + { AnodeRatio, new( + new ContinuousDataLimits(0.0, 12.0), + 12.0)}, + { AnodeFirst, new( + new FixedOptionDataLimits(new() + { + Constants.ANODE_FIRST, + Constants.ANODE_SECOND + }), + Constants.ANODE_SECOND)}, + // Pulse period in s (1/Hz) + { Period, new( + new ContinuousDataLimits(0.0, 1/250.0), + 1/100.0)} + }; + + /// + /// Example set of specified parameters that can be dynamically modulated. + /// public override SortedSet ModulatableStimParams => new() { - BaseStimParams.PA, - BaseStimParams.PW + PA, + PW }; #region TODO diff --git a/tests/EStimLibrary.UnitTests/Core/Haptics/HapticEventTests.cs b/tests/EStimLibrary.UnitTests/Core/Haptics/HapticEventTests.cs new file mode 100644 index 0000000..973857a --- /dev/null +++ b/tests/EStimLibrary.UnitTests/Core/Haptics/HapticEventTests.cs @@ -0,0 +1,63 @@ +using EStimLibrary.Core.Haptics; +using EStimLibrary.Core.SpatialModel; + + +namespace EStimLibrary.UnitTests.Core.Haptics; + + +public class HapticEventTests +{ + private readonly ITestOutputHelper _output; + + // Test class constructor creates an output helper so can write console + // output. + public HapticEventTests(ITestOutputHelper testOutputHelper) + { + this._output = testOutputHelper; + } + + // Test method naming convention: LibClassMethodName_ScenarioShouldExpectn + + /// + /// Test record initialization. + /// + [Fact] + public void Constuctor_ShouldInit() + { + var timestamp = DateTime.Now; + var locations = new Dictionary>(); + var areas = new Dictionary>(); + var hapticParams = new Dictionary(); + hapticParams.Add(HapticParam.P, 2.5); + hapticParams.Add(HapticParam.dP, 1.0); + var hapticEvent = new HapticEvent(timestamp, locations, areas, hapticParams); + Assert.NotNull(hapticEvent); + Assert.Equal(timestamp, hapticEvent.Timestamp); + Assert.Equal(locations, hapticEvent.Locations); + Assert.Equal(areas, hapticEvent.Areas); + Assert.Equal(hapticParams, hapticEvent.HapticParamData); + Assert.True(hapticEvent.LocalizeByArea); + } + + /// + /// Test record copying. + /// + [Fact] + public void With_ShouldCopy() + { + var timestamp = DateTime.Now; + var locations = new Dictionary>(); + var areas = new Dictionary>(); + var hapticParams = new Dictionary(); + hapticParams.Add(HapticParam.P, 2.5); + hapticParams.Add(HapticParam.dP, 1.0); + var hapticEvent1 = new HapticEvent(timestamp, locations, areas, hapticParams); + var hapticEvent2 = hapticEvent1 with { }; + var hapticEvent3 = hapticEvent2 with { Timestamp = DateTime.Now }; + var hapticEvent4 = hapticEvent3 with { Areas = new Dictionary>() }; + + Assert.Equal(hapticEvent1, hapticEvent2); + Assert.NotEqual(hapticEvent2, hapticEvent3); + Assert.NotEqual(hapticEvent3, hapticEvent4); + } +} \ No newline at end of file diff --git a/tests/EStimLibrary.UnitTests/Core/HardwareInterfaces/LeadTests.cs b/tests/EStimLibrary.UnitTests/Core/HardwareInterfaces/LeadTests.cs new file mode 100644 index 0000000..647ddc4 --- /dev/null +++ b/tests/EStimLibrary.UnitTests/Core/HardwareInterfaces/LeadTests.cs @@ -0,0 +1,1103 @@ +using EStimLibrary.Core; +using EStimLibrary.Core.HardwareInterfaces; + + +namespace EStimLibrary.UnitTests.Core.HardwareInterfaces; + + +/// +/// A class for unit testing the Lead class +/// +public class LeadTests +{ + /// + /// Test the parameterized Lead constructor with different data values. + /// + /// The set of global contact IDs the lead is + /// connected to. Assumed to be valid. + /// The set of global output IDs the lead is + /// connected to. Assumed to be valid. + /// The default direction of current on the + /// lead. + [Theory] + [MemberData(nameof(Constructor_TestData))] + public void Constructor_ShouldInitSortedSetsAndDirection( + SortedSet contactSet, SortedSet outputSet, + Constants.CurrentDirection currentDirection) + { + // Act + var lead = new Lead(contactSet, outputSet, currentDirection); + + // Assert + Assert.Equal(contactSet, lead.ContactSet); + Assert.Equal(outputSet, lead.OutputSet); + Assert.Equal(currentDirection, lead.CurrentDirection); + } + + /// + /// Test parameter data for the Lead constructor, following the form: + /// globalContactIdSet, + /// globalOutputIdSet, + /// defaultCurrentDirection + /// Assuming all global IDs are valid (existing non-negative integers) in + /// the session. + /// + public static IEnumerable Constructor_TestData() + { + return new List + { + // 1:1 mapping + new object[] + { + new SortedSet { 1 }, + new SortedSet { 0 }, + Constants.CurrentDirection.SOURCE + }, + // 1:many mapping; non-consecutive + new object[] + { + new SortedSet { 1 }, + new SortedSet { 0, 2, 4 }, + Constants.CurrentDirection.SOURCE + }, + // many:1 mapping; non-consecutive; double-digit IDs + new object[] + { + new SortedSet { 1, 3, 6, 10, 21 }, + new SortedSet { 0 }, + Constants.CurrentDirection.SOURCE + }, + // many:many mapping, same number + new object[] + { + new SortedSet { 1, 2, 3 }, + new SortedSet { 0, 2, 3 }, + Constants.CurrentDirection.SOURCE + }, + // Different output and contact set sizes + new object[] + { + new SortedSet { 4, 5, 6 }, + new SortedSet { 1, 2 }, + Constants.CurrentDirection.SOURCE + }, + // Different current direction + new object[] + { + new SortedSet { 7, 8, 9 }, + new SortedSet { 3, 5, 7 }, + Constants.CurrentDirection.SINK + } + }; + } + + /// + /// Test for GetConnectedOutputs. + /// + /// The output or contact ID to check the connections of. + /// + /// Indicates if the search is for an output + /// (true) or a contact (false). + /// The lead instance to test. + /// The expected output IDs found. + /// The expected boolean result of the search. + /// + [Theory] + [MemberData(nameof(GetConnectedOutputs_TestData))] + public void GetConnectedOutputs_ShouldReturnExpectedResults(int id, + bool searchIsAnOutput, Lead lead, SortedSet expectedOutputs, + bool expectedResult) + { + var originalOutputs = lead.OutputSet; + var originalContacts = lead.ContactSet; + + var expectedOriginalOutputs = new SortedSet(originalOutputs); + var expectedOriginalContacts = new SortedSet(originalContacts); + + var result = lead.GetConnectedOutputs(id, searchIsAnOutput, + out var connectedOutputs); + + // Make sure method outputs are correct. + Assert.Equal(expectedResult, result); + Assert.Equal(expectedOutputs, connectedOutputs); + + // Make sure the values of the lead's output and contact sets have not + // changed. + Assert.Equal(expectedOriginalOutputs, lead.OutputSet); + Assert.Equal(expectedOriginalContacts, lead.ContactSet); + // Make sure the objects (instances) of the lead's output and contact + // sets have not been replaced. + Assert.Same(originalOutputs, lead.OutputSet); + Assert.Same(originalContacts, lead.ContactSet); + } + + + /// + /// Test parameter data for GetConnectedOutputs, following the form: + /// id (to search for), + /// boolean (searchIsAContact), + /// leadInstance, + /// globalOutputIdSet, + /// boolean (expectedResult) + /// Assuming all global IDs and leads are valid in the session. + /// + public static IEnumerable GetConnectedOutputs_TestData() + { + return new List + { + #region Search by Output ID + // Testing with an output as input when that output is connected to + // the lead. We expect true and an output set without the given + // output. + new object[] + { + 3, + true, + new Lead(new SortedSet { 1, 2, 3 }, + new SortedSet { 3, 4, 5 }, + Constants.CurrentDirection.SINK), + new SortedSet { 4, 5 }, + true + }, + // Test when contactSet is much smaller than outputSet + new object[] + { + 8, + true, + new Lead(new SortedSet { 1 }, + new SortedSet { 3, 4, 5, 6, 8 }, + Constants.CurrentDirection.SINK), + new SortedSet { 3, 4, 5, 6 }, + true + }, + // Test when outputSet is much smaller than contactSet + new object[] + { + 3, + true, + new Lead(new SortedSet { 2, 3, 4, 8 }, + new SortedSet { 3 }, + Constants.CurrentDirection.SINK), + new SortedSet { }, + true + }, + // Testing with an output ID that is not connected to the lead + // (although there is a connected contact of that ID). We expect to + // get false and a full output set. + new object[] + { + 6, + true, + new Lead(new SortedSet { 1, 2, 6 }, + new SortedSet { 3, 4, 5 }, + Constants.CurrentDirection.SOURCE), + new SortedSet { 3, 4, 5 }, + false + }, + // Testing with an empty outputSet, so output ID DNE. + new object[] + { + 6, + true, + new Lead(new SortedSet { 1, 2 }, + new SortedSet { }, + Constants.CurrentDirection.SOURCE), + new SortedSet { }, + false + }, + // Testing with a very large outputSet. Output ID DNE. + new object[] + { + 1, + true, + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 2, 3, 4, 5, 6, 7, 8 }, + Constants.CurrentDirection.SOURCE), + new SortedSet { 2, 3, 4, 5, 6, 7, 8 }, + false + }, + #endregion Search by Output ID + #region Search by Contact ID + // Testing with a connected contact ID. ID also exists as a + // connected output ID. We expect to get true but a full output + // set. + new object[] + { + 1, + false, + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 1, 3, 4, 5 }, + Constants.CurrentDirection.SINK), + new SortedSet { 1, 3, 4, 5 }, + true + }, + // Testing with many things in the outputSet + new object[] + { + 2, + false, + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 3, 4, 5, 6, 7 }, + Constants.CurrentDirection.SINK), + new SortedSet { 3, 4, 5, 6, 7 }, + true + }, + // Testing with an empty outputSet but contact ID found. + new object[] + { + 2, + false, + new Lead(new SortedSet { 2 }, + new SortedSet { }, + Constants.CurrentDirection.SINK), + new SortedSet { }, + true + }, + // Testing with only one ID in the outputSet + new object[] + { + 2, + false, + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 1 }, + Constants.CurrentDirection.SINK), + new SortedSet { 1 }, + true + }, + // Testing with a contact as the input which is not connected to + // the lead. We expect to get false and a full output set + new object[] + { + 1, + false, + new Lead(new SortedSet { 2 }, + new SortedSet { 3, 4, 5 }, + Constants.CurrentDirection.SINK), + new SortedSet { 3, 4, 5 }, + false + }, + // Testing with an empty contact set + new object[] + { + 2, + false, + new Lead(new SortedSet { }, + new SortedSet { 3, 4, 5, 6, 7 }, + Constants.CurrentDirection.SINK), + new SortedSet { 3, 4, 5, 6, 7 }, + false + }, + // Testing with an empty outputSet and contact ID not found + new object[] + { + 2, + false, + new Lead(new SortedSet { 9 }, + new SortedSet { }, + Constants.CurrentDirection.SINK), + new SortedSet { }, + false + }, + // Testing with a large contactSet and an outputSet with only one + // element in it + new object[] + { + 2, + false, + new Lead(new SortedSet { 5, 6, 7, 8 }, + new SortedSet { 1 }, + Constants.CurrentDirection.SINK), + new SortedSet { 1 }, + false + } + #endregion Search by Contact ID + }; + } + + /// + /// Test for GetConnectedContacts. + /// + /// The contact or output ID to check the connections of. + /// + /// Indicates if the search is for a contact + /// (true) or an output (false). + /// The lead instance to test. + /// The expected contact IDs found. + /// The expected boolean result of the search. + /// + [Theory] + [MemberData(nameof(GetConnectedContacts_TestData))] + public void GetConnectedContacts_ShouldReturnExpectedResults(int id, + bool searchIsAContact, Lead lead, SortedSet expectedContacts, + bool expectedResult) + { + var originalOutputs = lead.OutputSet; + var originalContacts = lead.ContactSet; + + var expectedOriginalOutputs = new SortedSet(originalOutputs); + var expectedOriginalContacts = new SortedSet(originalContacts); + + var result = lead.GetConnectedContacts(id, searchIsAContact, + out var connectedContacts); + + // Make sure method outputs are correct. + Assert.Equal(expectedResult, result); + Assert.Equal(expectedContacts, connectedContacts); + + // Make sure the values of the lead's output and contact sets have not + // changed. + Assert.Equal(expectedOriginalOutputs, lead.OutputSet); + Assert.Equal(expectedOriginalContacts, lead.ContactSet); + // Make sure the objects (instances) of the lead's output and contact + // sets have not been replaced. + Assert.Same(originalOutputs, lead.OutputSet); + Assert.Same(originalContacts, lead.ContactSet); + } + + /// + /// Test parameter data for GetConnectedContacts, following the form: + /// id, + /// boolean (searchIsAContact), + /// leadInstance, + /// globalContactIdSet, + /// boolean (expectedResult) + /// Assuming all global IDs and leads are valid in the session. + /// + public static IEnumerable GetConnectedContacts_TestData() + { + return new List + { + #region Search by Contact ID + // Testing when a connected contact ID is given. We expect true and + // a non-inclusive set of contacts. + new object[] + { + 3, + true, + new Lead(new SortedSet { 1, 2, 3 }, + new SortedSet { 3, 4, 5 }, + Constants.CurrentDirection.SINK), + new SortedSet { 1, 2 }, + true + }, + // Testing with only one ID in the contactSet + new object[] + { + 2, + true, + new Lead(new SortedSet { 2 }, + new SortedSet { 3, 4, 5 }, + Constants.CurrentDirection.SOURCE), + new SortedSet { }, + true + }, + // Testing with an empty outputSet + new object[] + { + 3, + true, + new Lead(new SortedSet { 2, 3 }, + new SortedSet { }, + Constants.CurrentDirection.SINK), + new SortedSet { 2 }, + true + }, + // Testing with only one contact in the contactSet + new object[] + { + 3, + true, + new Lead(new SortedSet { 3 }, + new SortedSet { 2, 3, 4 }, + Constants.CurrentDirection.SOURCE), + new SortedSet { }, + true + }, + // Testing when an unconnected contact ID is given. We expect false + // and a full set of contacts. + new object[] + { + 4, + true, + new Lead(new SortedSet { 1, 2, 3 }, + new SortedSet { 3, 5 }, + Constants.CurrentDirection.SINK), + new SortedSet { 1, 2, 3 }, + false + }, + // Testing with a different current direction + new object[] + { + 5, + true, + new Lead(new SortedSet { 1, 2, 3 }, + new SortedSet { 3, 4, 5 }, + Constants.CurrentDirection.SOURCE), + new SortedSet { 1, 2, 3 }, + false + }, + // Testing with an empty contactSet + new object[] + { + 4, + true, + new Lead(new SortedSet { }, + new SortedSet { 3, 5 }, + Constants.CurrentDirection.SINK), + new SortedSet { }, + false + }, + // Testing with an empty contactSet but ID matches an output. + // Searching by contact, so should still return false + new object[] + { + 4, + true, + new Lead(new SortedSet { }, + new SortedSet { 4, 3, 5 }, + Constants.CurrentDirection.SINK), + new SortedSet { }, + false + }, + // Testing with a contact set of size 1 + new object[] + { + 2, + true, + new Lead(new SortedSet { 1 }, + new SortedSet { 3, 4, 5 }, + Constants.CurrentDirection.SOURCE), + new SortedSet { 1 }, + false + }, + #endregion Search by Contact ID + #region Search by Output ID + // Testing when a connected output is given. We expect true and a + // full set of contacts. + new object[] + { + 4, + false, + new Lead(new SortedSet { 1, 2, 3 }, + new SortedSet { 3, 4, 5 }, + Constants.CurrentDirection.SINK), + new SortedSet { 1, 2, 3 }, + true + }, + // Testing with an outputSet of size 1 + new object[] + { + 3, + false, + new Lead(new SortedSet { 1, 2, 3 }, + new SortedSet { 3 }, + Constants.CurrentDirection.SOURCE), + new SortedSet { 1, 2, 3 }, + true + }, + // Testing with a contactSet of size 1 + new object[] + { + 3, + false, + new Lead(new SortedSet { 1 }, + new SortedSet { 3 }, + Constants.CurrentDirection.SOURCE), + new SortedSet { 1 }, + true + }, + // Testing with an empty contact set + new object[] + { + 4, + false, + new Lead(new SortedSet { }, + new SortedSet { 3, 4 }, + Constants.CurrentDirection.SOURCE), + new SortedSet { }, + true + }, + // Testing when an unconnected output is given. We expect false and + // a full set of contacts. + new object[] + { + 4, + false, + new Lead(new SortedSet { 1, 2, 3, 4 }, + new SortedSet { 3, 5 }, + Constants.CurrentDirection.SINK), + new SortedSet { 1, 2, 3, 4 }, + false + }, + // Testing with an empty outputSet + new object[] + { + 5, + false, + new Lead(new SortedSet { 1 }, + new SortedSet { }, + Constants.CurrentDirection.SOURCE), + new SortedSet { 1 }, + false + }, + // Testing with an empty contactSet + new object[] + { + 4, + false, + new Lead(new SortedSet { }, + new SortedSet { 3, 5 }, + Constants.CurrentDirection.SINK), + new SortedSet { }, + false + }, + // Testing with no overlap between the output and contact set ids. + new object[] + { + 2, + false, + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 3 }, + Constants.CurrentDirection.SOURCE), + new SortedSet { 1, 2 }, + false + } + #endregion Search by Output ID + }; + } + + + /// + /// Test for IsFullyIndependent. + /// + /// The first Lead instance to check for independence. + /// + /// The second Lead instance to compare against. + /// + /// The expected result indicating whether or + /// or not the two Leads are fully independent. + [Theory] + [MemberData(nameof(IsFullyIndependent_TestData))] + public void IsFullyIndependent_ShouldReturnExpectedResults(Lead lead1, + Lead lead2, bool expectedResult) + { + // Act + var result = lead1.IsFullyIndependent(lead2); + + // Assert + Assert.Equal(expectedResult, result); + } + + /// + /// Test parameter data for isFullyIndependent, following the form: + /// leadInstance, + /// leadInstance, + /// boolean (expectedResult) + /// Assuming all leads are valid in the session. + /// + public static IEnumerable IsFullyIndependent_TestData() + { + return new List + { + #region Test Independent Leads + // Test when the leads are fully independent + new object[] + { + new Lead(new SortedSet { 1 }, + new SortedSet { 1 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { 2 }, + new SortedSet { 2 }, + Constants.CurrentDirection.SOURCE), + true + }, + // Testing on two empty leads + new object[] + { + new Lead(new SortedSet { }, + new SortedSet { }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { }, + new SortedSet { }, + Constants.CurrentDirection.SINK), + true + }, + // Testing on leads with many contacts and outputs. Some contact + // IDs match the other Lead's output IDs and vice versa, but still + // independent. + new object[] + { + new Lead(new SortedSet { 1, 2, 3 }, + new SortedSet { 1, 4, 5 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 4, 5, 6 }, + new SortedSet { 2, 3, 6 }, + Constants.CurrentDirection.SOURCE), + true + }, + // Testing where contact and output sets are the same IDs within + // each Lead. + new object[] + { + new Lead(new SortedSet { 1, 4, 5 }, + new SortedSet { 1, 4, 5 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 2, 3, 6 }, + new SortedSet { 2, 3, 6 }, + Constants.CurrentDirection.SOURCE), + true + }, + // Testing where one lead has many contacts and outputs and the + // other has few. + new object[] + { + new Lead(new SortedSet { 1 }, + new SortedSet { 4 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { 2, 3, 6 }, + new SortedSet { 2, 3, 6 }, + Constants.CurrentDirection.SINK), + true + }, + // Testing where one lead has empty sets + new object[] + { + new Lead(new SortedSet { 1 }, + new SortedSet { 4 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { }, + new SortedSet { }, + Constants.CurrentDirection.SINK), + true + }, + // Test for when the contact set of one lead is many but the other + // is one, and the opposite for the output sets. + new object[] + { + new Lead(new SortedSet { 1, 2, 4 }, + new SortedSet { 4 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { 3 }, + new SortedSet { 1, 2, 3, 5, 6, 7, 8 }, + Constants.CurrentDirection.SINK), + true + }, + #endregion Test Independent Leads + #region Test Non-Independent Leads + // Tests for when the leads are not fully independent + new object[] + { + new Lead(new SortedSet { 1 }, + new SortedSet { 1 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { 1 }, + new SortedSet { 1 }, + Constants.CurrentDirection.SOURCE), + false + }, + // Test for when the leads are the exact same but with larger + // contact sets than output sets. + new object[] + { + new Lead(new SortedSet { 1, 2, 3 }, + new SortedSet { 1 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { 1, 2, 3 }, + new SortedSet { 1 }, + Constants.CurrentDirection.SOURCE), + false + }, + // Test for when the leads are the exact same but with larger + // output sets than contact sets + new object[] + { + new Lead(new SortedSet { 1 }, + new SortedSet { 1, 2, 3 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { 1 }, + new SortedSet { 1, 2, 3 }, + Constants.CurrentDirection.SOURCE), + false + }, + // Test for when the outputs are the exact same but different and + // larger contact sets than output sets. + new object[] + { + new Lead(new SortedSet { 1, 2, 3 }, + new SortedSet { 1 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { 4, 5, 6 }, + new SortedSet { 1 }, + Constants.CurrentDirection.SOURCE), + false + }, + // Test for when one Lead has only one output and the other has + // many. Contact IDs conflict. + new object[] + { + new Lead(new SortedSet { 1 }, + new SortedSet { 1, 2, 4 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { 1 }, + new SortedSet { 3 }, + Constants.CurrentDirection.SOURCE), + false + }, + // Test for when both Leads have very different sized output and + // contact sets, but one output conflicts. + new object[] + { + new Lead(new SortedSet { 1 }, + new SortedSet { 1, 2, 3, 4 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { 2, 3, 4, 5 }, + new SortedSet { 4 }, + Constants.CurrentDirection.SOURCE), + false + }, + // Test for when both leads have the same current direction. + // One output conflicts. + new object[] + { + new Lead(new SortedSet { 8, 9, 10 }, + new SortedSet { 3, 4 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 1, 2, 3, 9 }, + new SortedSet { 1 }, + Constants.CurrentDirection.SOURCE), + false + }, + // Test for when one contact and one output conflict. + // Same current direction. + new object[] + { + new Lead(new SortedSet { 8, 9, 10 }, + new SortedSet { 1, 3, 4 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 1, 2, 3, 9 }, + new SortedSet { 1, 5 }, + Constants.CurrentDirection.SOURCE), + false + }, + // Test for when multiple contacts and one output conflict. + // Same current direction. + new object[] + { + new Lead(new SortedSet { 7, 9, 10 }, + new SortedSet { 1, 3, 4 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 1, 2, 3, 7, 9, 12 }, + new SortedSet { 1, 5 }, + Constants.CurrentDirection.SOURCE), + false + }, + // Test for when one contact and multiple outputs conflict. + // Same current direction. + new object[] + { + new Lead(new SortedSet { 8, 9, 10 }, + new SortedSet { 1, 3, 4 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 1, 2, 3, 9 }, + new SortedSet { 1, 4, 5, 6 }, + Constants.CurrentDirection.SOURCE), + false + }, + // Test for when multiple contacts and multiple outputs conflict. + // Same current direction. + new object[] + { + new Lead(new SortedSet { 7, 8, 9, 10 }, + new SortedSet { 1, 3, 4 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 1, 2, 3, 7, 9, 12 }, + new SortedSet { 1, 4, 5, 6 }, + Constants.CurrentDirection.SOURCE), + false + } + #endregion Test Non-Independent Leads + }; + } + + /// + /// Test for IndependentLeadsExist. + /// + /// A collection of Lead instances to check for an + /// independent pair. + /// The expected result indicating whether or + /// not at least one pair of independent Leads exist. + [Theory] + [MemberData(nameof(IndependentLeadsExist_TestData))] + public void IndependentLeadsExist_ShouldReturnExpectedResults( + IEnumerable leads, bool expectedResult) + { + // Act + var result = Lead.IndependentLeadsExist(leads); + + // Assert + Assert.Equal(expectedResult, result); + } + + /// + /// Test parameter data for IndependentLeadsExist, following the form: + /// IEnumerableLeadSet, + /// boolean (expectedResult) + /// Assuming all leads are valid in the session. + /// + public static IEnumerable IndependentLeadsExist_TestData() + { + return new List + { + // Testing where 2 leads are fully independent + new object[] + { + new List + { + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 3, 4 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 5, 6 }, + new SortedSet { 7, 8 }, + Constants.CurrentDirection.SINK) + }, + true + }, + // Testing where the outputSets have only one ID and leads are + // dependent (single conflicting contact) + new object[] + { + new List + { + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 3 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { 1, 3 }, + new SortedSet { 4 }, + Constants.CurrentDirection.SINK) + }, + false + }, + // Testing where currentDirection is opposite, but all conflicting + // contacts + new object[] + { + new List + { + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 3, 4 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 5 }, + Constants.CurrentDirection.SINK) + }, + false + }, + // Testing where the outputSets have only one ID and leads are + // independent + new object[] + { + new List + { + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 3 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 4, 5 }, + new SortedSet { 6 }, + Constants.CurrentDirection.SINK) + }, + true + }, + // Testing where sets are the same size and dependent: conflicting + // single contact and output + new object[] + { + new List + { + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 3, 4 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 2, 3 }, + new SortedSet { 4, 5 }, + Constants.CurrentDirection.SOURCE) + }, + false + }, + // Testing with more than 2 leads in list. One conflicting pair by + // single output ID, but independent pairs exist + new object[] + { + new List + { + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 3 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 4, 5 }, + new SortedSet { 6, 7 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { 8, 9 }, + new SortedSet { 3 }, + Constants.CurrentDirection.SOURCE) + }, + true + }, + // Testing with more than 2 leads where all current directions are + // the same + new object[] + { + new List + { + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 3 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 3 }, + new SortedSet { 4 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 4 }, + new SortedSet { 5 }, + Constants.CurrentDirection.SOURCE) + }, + true + }, + // Testing with leads with conflicting outputs but different + // contacts + new object[] + { + new List + { + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 3 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 4 }, + new SortedSet { 3, 5 }, + Constants.CurrentDirection.SINK) + }, + false + }, + // Testing with an empty lead set + new object[] + { + new List(), + false + }, + // Testing with a single lead + new object[] + { + new List + { + new Lead(new SortedSet { 1 }, + new SortedSet { 2 }, + Constants.CurrentDirection.SOURCE) + }, + false + }, + // Testing with large fully independent lead set + new object[] + { + new List + { + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 3, 4 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 5, 6 }, + new SortedSet { 7, 8 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { 9, 10 }, + new SortedSet { 11 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet {11, 12, 13 }, + new SortedSet { 12 }, + Constants.CurrentDirection.SINK) + }, + true + }, + // Testing with large mostly independent lead set + new object[] + { + new List + { + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 3, 4 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 5, 6 }, // contact 5 + new SortedSet { 7, 8 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { 9, 10 }, + new SortedSet { 11 }, // output 11 + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet {5, 12, 13 }, // contact 5 + new SortedSet { 11 }, // output 11 + Constants.CurrentDirection.SINK) + }, + true + }, + // Testing with large lead set where some conflicting but a few + // independent pairs exist. + new object[] + { + new List + { + new Lead(new SortedSet { 1, 2 }, // indp A B + new SortedSet { 3 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 2, 3 }, // indp C + new SortedSet { 4 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 4, 5 }, // indp B + new SortedSet { 5 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 5 }, // indp A C + new SortedSet { 6 }, + Constants.CurrentDirection.SOURCE) + }, + true + }, + // Testing with large lead set with mixed independence + new object[] + { + new List + { + new Lead(new SortedSet { 1, 2 }, // contact 2 + new SortedSet { 3 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 3, 4 }, + new SortedSet { 5 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 6, 7 }, + new SortedSet { 8 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet { 9, 10 }, // contact 9 + new SortedSet { 11 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 2, 9 }, // contact 2, 9 + new SortedSet { 10 }, + Constants.CurrentDirection.SINK) + }, + true + }, + // Testing with longer list, and all conflict with each other in + // some way. + new object[] + { + new List + { + new Lead(new SortedSet { 1, 2 }, + new SortedSet { 3 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 3, 4 }, + new SortedSet { 5, 3}, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet { 3, 7 }, + new SortedSet { 8, 3 }, + Constants.CurrentDirection.SINK), + new Lead(new SortedSet {1, 3, 9, 10 }, + new SortedSet { 11 }, + Constants.CurrentDirection.SOURCE), + new Lead(new SortedSet {2, 3, 9 }, + new SortedSet { 10 }, + Constants.CurrentDirection.SINK) + }, + false + } + }; + } +} diff --git a/tests/EStimLibrary.UnitTests/EStimLibrary.UnitTests.csproj b/tests/EStimLibrary.UnitTests/EStimLibrary.UnitTests.csproj index 84cf4a8..f4931e2 100644 --- a/tests/EStimLibrary.UnitTests/EStimLibrary.UnitTests.csproj +++ b/tests/EStimLibrary.UnitTests/EStimLibrary.UnitTests.csproj @@ -1,7 +1,7 @@ - net7.0 + net9.0 enable enable @@ -10,17 +10,17 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -33,9 +33,6 @@ - - - PreserveNewest diff --git a/tests/EStimLibrary.UnitTests/Extensions/HardwareInterfaces/ContactGroupTests.cs b/tests/EStimLibrary.UnitTests/Extensions/HardwareInterfaces/ContactGroupTests.cs new file mode 100644 index 0000000..4c23901 --- /dev/null +++ b/tests/EStimLibrary.UnitTests/Extensions/HardwareInterfaces/ContactGroupTests.cs @@ -0,0 +1,46 @@ +using EStimLibrary.Extensions.HardwareInterfaces; + + +namespace EStimLibrary.UnitTests.Extensions.HardwareInterfaces; + + +/// +/// A test class for the ContactGroup INeuralInterfaceHardware class. +/// +public class ContactGroupTests +{ + /// + /// Test the parameterized constructor with different valid parameters, + /// i.e., positive integer values for number of contacts. The neural + /// interface object should initialize with correct NumContact and Id + /// values. + /// + /// The number of contacts to test ContactGroup + /// construction with. + [Theory] + [InlineData(1)] // Valid single contact. + [InlineData(10)] // Valid multiple contacts. + public void Constructor_ShouldInit(int numContacts) + { + var contactGroup = new ContactGroup(numContacts); + + Assert.Equal(-1, contactGroup.Id); + Assert.Equal(numContacts, contactGroup.NumContacts); + } + + /// + /// Test the parameterized constructor with different invalid parameters, + /// i.e., 0 or negative integer values for number of contacts. + /// Constructioin should fail and throw an argument exception. + /// + /// The number of contacts to test ContactGroup + /// construction with. + [Theory] + [InlineData(0)] // Invalid 0 contacts. + [InlineData(-1)] // Invalid single negative contact number. + [InlineData(-10)] // Invalid multiple negative contact number. + public void Constructor_ShouldThrowException(int numContacts) + { + Assert.Throws(() => new ContactGroup(numContacts)); + } +} diff --git a/tests/EStimLibrary.UnitTests/Extensions/HardwareInterfaces/GelPadTests.cs b/tests/EStimLibrary.UnitTests/Extensions/HardwareInterfaces/GelPadTests.cs new file mode 100644 index 0000000..707f09b --- /dev/null +++ b/tests/EStimLibrary.UnitTests/Extensions/HardwareInterfaces/GelPadTests.cs @@ -0,0 +1,28 @@ +using EStimLibrary.Extensions.HardwareInterfaces; + + +namespace EStimLibrary.UnitTests.Extensions.HardwareInterfaces; + + +/// +/// A test class for the GelPad INeuralInterfaceHardware class. +/// +public class GelPadTests +{ + /// + /// Make sure ID is -1 after constructor (ID is only set once added to a + /// session). + /// Number of contacts for gel pad should be 1. + /// + [Fact] + public void Constructor_ShouldInitWithCorrectParams() + { + // Arrange (Init new object) + var gelPad = new GelPad(); + + // Assert (Test if initialized params are correct) + Assert.Equal(-1, gelPad.Id); + Assert.Equal(1, gelPad.NumContacts); + + } +} diff --git a/tests/EStimLibrary.UnitTests/Extensions/SpatialModel/StringHierarchy/StringHierarchyRegionTests.cs b/tests/EStimLibrary.UnitTests/Extensions/SpatialModel/StringHierarchy/StringHierarchyRegionTests.cs new file mode 100644 index 0000000..a461f33 --- /dev/null +++ b/tests/EStimLibrary.UnitTests/Extensions/SpatialModel/StringHierarchy/StringHierarchyRegionTests.cs @@ -0,0 +1,1074 @@ +using EStimLibrary.Extensions.SpatialModel.StringHierarchy; + + +namespace EStimLibrary.UnitTests.Extensions.SpatialModel.StringHierarchy; + + +/// +/// Test class for StringHierarchyRegion. +/// +public class StringHierarchyRegionTests +{ + private readonly ITestOutputHelper _output; + + /// + /// Construct an empty root region. Test helper. + /// + /// An empty root region. + private static StringHierarchyRegion ConstructEmptyRoot() + { + return new StringHierarchyRegion("rootEmpty"); + } + + /// + /// Construct a two-level root region. Test helper. + /// + /// The root region of a two-level tree constructed manually + /// without method use. + private static StringHierarchyRegion ConstructTwoLevelRoot() + { + StringHierarchyRegion rootTwoLevel; + StringHierarchyRegion middleTwoLevelA; + StringHierarchyRegion middleTwoLevelB; + StringHierarchyRegion leafTwoLevelAa; + StringHierarchyRegion leafTwoLevelAb; + + // root: arm + // / \ + // midA: hand midB: middleTwoLevelB + // / \ + // leafAa: finger leafAb: handbody + // Leaf regions + leafTwoLevelAa = new StringHierarchyRegion("finger", + parentOptions: new HashSet { "left", "right" }, + options: new HashSet { "thumb", "index", "middle", "ring", "pinky" }, + modifiers: new Dictionary> { + { "x", new HashSet { "lateral", "medial" } }, + { "y", new HashSet { "proximal", "middle", "distal" } }, + { "z", new HashSet { "palmar", "dorsal" } } } + ); + leafTwoLevelAb = new StringHierarchyRegion("handbody", + parentOptions: new HashSet { "left", "right" }, + modifiers: new Dictionary> { + { "x", new HashSet { "lateral", "medial" } }, + { "y", new HashSet { "proximal", "middle", "distal" } }, + { "z", new HashSet { "palmar", "dorsal" } } } + ); + // Middle regions: one data-full, one minimal + middleTwoLevelA = new StringHierarchyRegion("hand", + options: new HashSet { "left", "right" }, + modifiers: new Dictionary> { + { "x", new HashSet { "lateral", "medial" } }, + { "y", new HashSet { "proximal", "middle", "distal" } }, + { "z", new HashSet { "palmar", "dorsal" } } }, + subregions: new Dictionary { + { "finger", leafTwoLevelAa }, + { "handbody", leafTwoLevelAb } } + ); + middleTwoLevelB = new StringHierarchyRegion("middleTwoLevelB", + options: new HashSet { "ulnar", "radial" }); + // Root region: minimal data; just subregions + rootTwoLevel = new StringHierarchyRegion("rootTwoLevel", + subregions: new Dictionary { + { "hand", middleTwoLevelA }, + { "middleTwoLevelB", middleTwoLevelB } } + ); + + // Set parent references for all regions. + leafTwoLevelAa.ParentRegion = middleTwoLevelA; + leafTwoLevelAb.ParentRegion = middleTwoLevelA; + middleTwoLevelA.ParentRegion = rootTwoLevel; + middleTwoLevelB.ParentRegion = rootTwoLevel; + + return rootTwoLevel; + } + + /// + /// Test class constructor creates an output helper so it can write console + /// output. + /// + /// + public StringHierarchyRegionTests(ITestOutputHelper testOutputHelper) + { + this._output = testOutputHelper; + } + + #region Constructor + /// + /// Test the constructor with null parameter values: + /// no parent region, + /// optional params passed null. + /// + [Fact] + public void Constructor_ShouldAcceptNull() + { + var stringHierarchyRegion = new StringHierarchyRegion("base", null!, + null!, null!, null!, null!); + + // Check that all fields correctly instantiate + Assert.Equal("base", stringHierarchyRegion.BaseName); + Assert.Null(stringHierarchyRegion.ParentRegion); + Assert.Empty(stringHierarchyRegion.ParentOptions); + Assert.Empty(stringHierarchyRegion.Options); + Assert.False(stringHierarchyRegion.HasOptions); + Assert.Single(stringHierarchyRegion.OptionedRegionNames); + Assert.Equal("base", stringHierarchyRegion.OptionedRegionNames[0]); + Assert.Empty(stringHierarchyRegion.Modifiers); + Assert.False(stringHierarchyRegion.HasModifiers); + Assert.Empty(stringHierarchyRegion.Subregions); + Assert.False(stringHierarchyRegion.HasSubregions); + Assert.True(stringHierarchyRegion.IsLeaf); + Assert.Empty(stringHierarchyRegion.SavedLocations); + Assert.Empty(stringHierarchyRegion.SavedAreas); + } + + /// + /// Test the constructor with unspecified parameter values. + /// + [Fact] + public void Constructor_ShouldDefaultNull() + { + // Create region with all default parameters unspecified + var stringHierarchyRegion = new StringHierarchyRegion("base", null!); + + // Check that all fields correctly instantiate + Assert.Equal("base", stringHierarchyRegion.BaseName); + Assert.Null(stringHierarchyRegion.ParentRegion); + Assert.Empty(stringHierarchyRegion.ParentOptions); + Assert.Empty(stringHierarchyRegion.Options); + Assert.False(stringHierarchyRegion.HasOptions); + Assert.Single(stringHierarchyRegion.OptionedRegionNames); + Assert.Equal("base", stringHierarchyRegion.OptionedRegionNames[0]); + Assert.Empty(stringHierarchyRegion.Modifiers); + Assert.False(stringHierarchyRegion.HasModifiers); + Assert.Empty(stringHierarchyRegion.Subregions); + Assert.False(stringHierarchyRegion.HasSubregions); + Assert.True(stringHierarchyRegion.IsLeaf); + Assert.Empty(stringHierarchyRegion.SavedLocations); + Assert.Empty(stringHierarchyRegion.SavedAreas); + } + + /// + /// Test the constructor with non-null, empty parameter values. + /// + [Fact] + public void Constructor_ShouldAcceptEmpty() + { + // Create simple parent region + var parent = new StringHierarchyRegion("root", null!); + + // Create region with all parameters non-null + var stringHierarchyRegion = new StringHierarchyRegion("base", parent, + parent.Options, new HashSet(), + new Dictionary>(), + new Dictionary()); + + // Check that all fields correctly instantiate + Assert.Equal("base", stringHierarchyRegion.BaseName); + Assert.StrictEqual(parent, stringHierarchyRegion.ParentRegion); + Assert.Empty(stringHierarchyRegion.ParentOptions); + Assert.Empty(stringHierarchyRegion.Options); + Assert.False(stringHierarchyRegion.HasOptions); + Assert.Single(stringHierarchyRegion.OptionedRegionNames); + Assert.Equal("base", stringHierarchyRegion.OptionedRegionNames[0]); + Assert.Empty(stringHierarchyRegion.Modifiers); + Assert.False(stringHierarchyRegion.HasModifiers); + Assert.Empty(stringHierarchyRegion.Subregions); + Assert.False(stringHierarchyRegion.HasSubregions); + Assert.True(stringHierarchyRegion.IsLeaf); + Assert.Empty(stringHierarchyRegion.SavedLocations); + Assert.Empty(stringHierarchyRegion.SavedAreas); + } + + /// + /// Test the constructor with non-null, non-empty parameter values. + /// + [Fact] + public void Constructor_ShouldDeepCopy() + { + // Create parent region with non-null object parameters for deep copy + // testing + var parent = new StringHierarchyRegion("root", null!, null!, + new HashSet(), new Dictionary>(), + new Dictionary()); + // Create non-empty options, modifiers, and subregions parameters + var regionOptions = new HashSet() { "right", "left" }; + var regionModifiers = new Dictionary> + { + { "right", new HashSet() { "mod1", "mod2", "mod2" } }, + { "left", new HashSet() { "mod2", "mod3", "mod4" } } + }; + var regionSubregions = new Dictionary + { + { "child1", new StringHierarchyRegion("child1", null!) }, + { "child2", new StringHierarchyRegion("child2", null!) } + }; + // Create region with the above parameters for deep copy testing + var stringHierarchyRegion = new StringHierarchyRegion("base", parent, + parent.Options, regionOptions, regionModifiers, regionSubregions); + + // Check that all non-region object fields are deep copies of + // parameters and all region fields are shallow copies (i.e., + // check for value equality and reference equality) + + Assert.Equal("base", stringHierarchyRegion.BaseName); + // Check parent shallow copy + Assert.Same(parent, stringHierarchyRegion.ParentRegion); + // Check parent options deep copy + Assert.Equal(parent.Options, stringHierarchyRegion.ParentOptions); + Assert.NotSame(parent.Options, stringHierarchyRegion.ParentOptions); + // Check options deep copy + Assert.Equal(regionOptions, stringHierarchyRegion.Options); + Assert.NotSame(regionOptions, stringHierarchyRegion.Options); + Assert.True(stringHierarchyRegion.HasOptions); + // Check optioned region names (since not tested previously) + Assert.Equal(2, stringHierarchyRegion.OptionedRegionNames.Count); + Assert.Equal(new List() { + $"right{StringHierarchySpec.OPTION_REGION_DELIMITER}base", + $"left{StringHierarchySpec.OPTION_REGION_DELIMITER}base"}, + stringHierarchyRegion.OptionedRegionNames); + // Check modifiers deep copy + Assert.Equal(regionModifiers, stringHierarchyRegion.Modifiers); + Assert.NotSame(regionModifiers, stringHierarchyRegion.Modifiers); + Assert.True(stringHierarchyRegion.HasModifiers); + // Check subregions deep copy dictionary but shallow copy subregions + Assert.Equal(regionSubregions, stringHierarchyRegion.Subregions); + Assert.NotSame(regionSubregions, stringHierarchyRegion.Subregions); + Assert.Same(regionSubregions["child1"], + stringHierarchyRegion.Subregions["child1"]); + Assert.Same(regionSubregions["child2"], + stringHierarchyRegion.Subregions["child2"]); + // Check miscellaneous subregion-related fields (since not tested + // previously) + Assert.True(stringHierarchyRegion.HasSubregions); + Assert.False(stringHierarchyRegion.IsLeaf); + Assert.Empty(stringHierarchyRegion.SavedLocations); + Assert.Empty(stringHierarchyRegion.SavedAreas); + + // Create region with above region as parent for further parent testing + var child = new StringHierarchyRegion("child", stringHierarchyRegion, + stringHierarchyRegion.Options, new HashSet(), + new Dictionary>(), + new Dictionary()); + + // Check for parent and parent option equality with non-null parent options + Assert.Same(stringHierarchyRegion, child.ParentRegion); + Assert.Equal(stringHierarchyRegion.Options, child.ParentOptions); + Assert.NotSame(stringHierarchyRegion.Options, child.ParentOptions); + } + #endregion Constructor + + /// + /// Test the ToString method. + /// + [Fact] + public void ToString_ShouldOutputStringRep() + { + // Create region with null parameter values + var nullRegion = new StringHierarchyRegion("base", null!); + // Create region with non-null but empty options, modifiers, and + // subregions + var emptyRegion = new StringHierarchyRegion("root", null!, null!, + new HashSet(), new Dictionary>(), + new Dictionary()); + // Create non-empty options, modifiers, and subregions parameters + // to cover all components of string representation + var regionOptions = new HashSet() { "right", "left", "center" }; + var regionModifiers = new Dictionary> + { + { "modSet1", new HashSet() { "mod1", "mod2" } }, + { "modSet2", new HashSet() { "mod3", "mod4" } } + }; + var subregionSubregions = new Dictionary + { + { "grandchild", new StringHierarchyRegion("grandchild", null!) } + }; + var regionSubregions = new Dictionary + { + { "child1", new StringHierarchyRegion("child1", null!, null!, + new HashSet { "opt1", "opt2" }, null!, + subregionSubregions) }, + { "child2", new StringHierarchyRegion("child2", null!) } + }; + // Create region with the above parameters + var stringHierarchyRegion = new StringHierarchyRegion("base", + emptyRegion, emptyRegion.Options, regionOptions, regionModifiers, + regionSubregions); + + // Check string outputs for correctness + Assert.Equal("base", nullRegion.ToString()); + Assert.Equal("root", emptyRegion.ToString()); + Assert.Equal("[right,left,center] base | [mod1,mod2], [mod3,mod4]\n" + + " [right,left,center] base, [opt1,opt2] child1\n" + + " [right,left,center] base, [opt1,opt2] child1, grandchild\n" + + " [right,left,center] base, child2", stringHierarchyRegion.ToString()); + } + + #region TryGetSubregion + /// + /// Test the TryGetSubregion method with invalid region specifications. + /// + /// The root region to search within. + /// The invalid region spec. + [Theory] + [MemberData(nameof(TryGetSubregion_InvalidOR_Fail_TestData))] + public void TryGetSubregion_InvalidOptionRegionName_ShouldFail( + StringHierarchyRegion rootRegion, string regionSpec) + { + // Call the method. + bool res = rootRegion.TryGetSubregion(regionSpec, out var foundRegion); + + // Check failed: return is false, output is null. + Assert.False(res); + Assert.Null(foundRegion); + } + + /// + /// Test data for TryGetSubregion method that tries to get a subregion via + /// and invalid option-region combo in the region specification and should + /// return failure. + /// + /// + public static IEnumerable TryGetSubregion_InvalidOR_Fail_TestData() + { + return new List + { + // { rootRegion, searchRegionSpec } + new object[] { ConstructEmptyRoot(), ""}, + new object[] { ConstructEmptyRoot(), "left foot"}, + new object[] { ConstructEmptyRoot(), "anythingThatsNotRootEmpty"}, + // Correct would be from: + // rootTwoLevel + // [left right] hand + // [thumb index middle ring pinky] finger + // handbody + // [ulnar radial] middleTwoLevelB + new object[] { ConstructTwoLevelRoot(), + // Fail on empty region spec + "" }, + new object[] { ConstructTwoLevelRoot(), + // Fail on root level: would need "rootTwoLevel" first! + "left hand" }, + new object[] { ConstructTwoLevelRoot(), + // Fail on second level: incorrect region name + "rootTwoLevel, left hands" }, + new object[] { ConstructTwoLevelRoot(), + // Fail on second level: incorrect option name + "rootTwoLevel, my hand" }, + new object[] { ConstructTwoLevelRoot(), + // Fail on second level: missing option + "rootTwoLevel, hand" }, + new object[] { ConstructTwoLevelRoot(), + // Fail on thirs level: extra option + "rootTwoLevel, left hand, right handbody" } + }; + } + + /// + /// Test the TryGetSubregion method with various malformed or otherwise + /// incorrect regions. + /// + [Fact] + public void TryGetSubregion_ShouldOutputNull() + { + // Create simple parent region + var parent = new StringHierarchyRegion("root", null!, null!, + new HashSet(), new Dictionary>(), + new Dictionary()); + // Create non-empty options, modifiers, and subregions parameters + var regionOptions = new HashSet() { "right", "left" }; + var regionModifiers = new Dictionary> + { + { "modSet1", new HashSet() { "mod1", "mod2" } }, + { "modSet2", new HashSet() { "mod3", "mod4" } } + }; + var regionSubregions = new Dictionary + { + { "child1", new StringHierarchyRegion("child1", null!) }, + { "child2", new StringHierarchyRegion("child2", null!) } + }; + // Create region with the above parameters + var stringHierarchyRegion = new StringHierarchyRegion("base", parent, + parent.Options, regionOptions, regionModifiers, regionSubregions); + + // Test with empty string representation + StringHierarchyRegion? foundSubregion; + var output = stringHierarchyRegion.TryGetSubregion("", + out foundSubregion); + Assert.False(output); + Assert.Null(foundSubregion); + // Test with empty base region + output = stringHierarchyRegion.TryGetSubregion(", left base", + out foundSubregion); + Assert.False(output); + Assert.Null(foundSubregion); + // Test with invalid base region + output = stringHierarchyRegion.TryGetSubregion("base2", + out foundSubregion); + Assert.False(output); + Assert.Null(foundSubregion); + // Test with valid base region but invalid option + output = stringHierarchyRegion.TryGetSubregion("middle base", + out foundSubregion); + Assert.False(output); + Assert.Null(foundSubregion); + // Test with valid base region but no options specified + output = stringHierarchyRegion.TryGetSubregion("base", + out foundSubregion); + Assert.False(output); + Assert.Null(foundSubregion); + // Test valid base region and option but specified modifier + output = stringHierarchyRegion.TryGetSubregion("left base | modSet1", + out foundSubregion); + Assert.False(output); + Assert.Null(foundSubregion); + // Test with valid subregion but no base region + output = stringHierarchyRegion.TryGetSubregion("child1", + out foundSubregion); + Assert.False(output); + Assert.Null(foundSubregion); + // Test with invalid subregion + output = stringHierarchyRegion.TryGetSubregion("left base, ", + out foundSubregion); + Assert.False(output); + Assert.Null(foundSubregion); + // Test with multiple options + output = stringHierarchyRegion.TryGetSubregion("right left base", + out foundSubregion); + Assert.False(output); + Assert.Null(foundSubregion); + // Test with additional whitespace between option and region + output = stringHierarchyRegion.TryGetSubregion("left base", + out foundSubregion); + Assert.False(output); + Assert.Null(foundSubregion); + } + + /// + /// Test the TryGetSubregion method with region present in current level + /// and region present in subregion. + /// + [Fact] + public void TryGetSubregion_ShouldOutputRegion() + { + // Create simple parent region + var parent = new StringHierarchyRegion("root", null!, null!, + new HashSet(), new Dictionary>(), + new Dictionary()); + // Create non-empty options, modifiers, and subregions parameters + var regionOptions = new HashSet() { "right", "left" }; + var regionModifiers = new Dictionary> + { + { "modSet1", new HashSet() { "mod1", "mod2" } }, + { "modSet2", new HashSet() { "mod3", "mod4" } } + }; + var subregionSubregions = new Dictionary + { + { "grandchild", new StringHierarchyRegion("grandchild", null!) } + }; + var regionSubregions = new Dictionary + { + { "child1", new StringHierarchyRegion("child1", null!, null!, + new HashSet { "opt1", "opt2" }, null!, + subregionSubregions) }, + { "child2", new StringHierarchyRegion("child2", null!) } + }; + // Create region with the above parameters + var stringHierarchyRegion = new StringHierarchyRegion("base", parent, + parent.Options, regionOptions, regionModifiers, regionSubregions); + + // Test with valid option and base region + StringHierarchyRegion? foundSubregion; + var output = stringHierarchyRegion.TryGetSubregion("right base", + out foundSubregion); + Assert.True(output); + Assert.Equal(stringHierarchyRegion, foundSubregion); + // Test with valid subregion + output = stringHierarchyRegion.TryGetSubregion("left base, child2", + out foundSubregion); + Assert.True(output); + Assert.Equal(regionSubregions["child2"], foundSubregion); + // Test with valid subregion option + output = stringHierarchyRegion.TryGetSubregion("left base, opt2 child1", + out foundSubregion); + Assert.True(output); + Assert.Equal(regionSubregions["child1"], foundSubregion); + // Test with valid subregion of subregion + output = stringHierarchyRegion.TryGetSubregion("left base, opt2 child1, " + + "grandchild", out foundSubregion); + Assert.True(output); + Assert.Equal(subregionSubregions["grandchild"], foundSubregion); + // Test with additional whitespace between regions + output = stringHierarchyRegion.TryGetSubregion("left base, child1", + out foundSubregion); + Assert.True(output); + Assert.Equal(regionSubregions["child1"], foundSubregion); + } + #endregion TryGetSubregion + + #region AddSubregion + /// + /// Test the AddSubregion method with new subregions. + /// + [Fact] + public void AddSubregion_ShouldAddSubregion() + { + // Create simple parent region + var parent = new StringHierarchyRegion("root", null!, null!, + new HashSet(), new Dictionary>(), + new Dictionary()); + // Create non-empty options, modifiers, and subregions parameters + var regionOptions = new HashSet() { "right", "left" }; + var regionModifiers = new Dictionary> + { + { "modSet1", new HashSet() { "mod1", "mod2" } }, + { "modSet2", new HashSet() { "mod3", "mod4" } } + }; + var regionSubregions = new Dictionary + { + { "child1", new StringHierarchyRegion("child1", null!) }, + { "child2", new StringHierarchyRegion("child2", null!) } + }; + // Create region with the above parameters + var stringHierarchyRegion = new StringHierarchyRegion("base", parent, + parent.Options, regionOptions, regionModifiers, regionSubregions); + + // Create variable to store old region output + StringHierarchyRegion? oldRegion; + + // Create new region with different name to existing subregions + var newChildRegion = new StringHierarchyRegion("child3", null!); + + // Check shallow copy of subregion into base region and old subregion + // output + stringHierarchyRegion.AddSubregion(newChildRegion, out oldRegion); + Assert.Null(oldRegion); + Assert.Same(newChildRegion, + stringHierarchyRegion.Subregions["child3"]); + } + + /// + /// Test the AddSubregion method with duplicate subregions. + /// + [Fact] + public void AddSubregion_ShouldReplaceSubregion() + { + // Create simple parent region + var parent = new StringHierarchyRegion("root", null!, null!, + new HashSet(), new Dictionary>(), + new Dictionary()); + // Create non-empty options, modifiers, and subregions parameters + var regionOptions = new HashSet() { "right", "left" }; + var regionModifiers = new Dictionary> + { + { "modSet1", new HashSet() { "mod1", "mod2" } }, + { "modSet2", new HashSet() { "mod3", "mod4" } } + }; + var regionSubregions = new Dictionary + { + { "child1", new StringHierarchyRegion("child1", null!) }, + { "child2", new StringHierarchyRegion("child2", null!) } + }; + // Create region with the above parameters + var stringHierarchyRegion = new StringHierarchyRegion("base", parent, + parent.Options, regionOptions, regionModifiers, regionSubregions); + + // Create variable to store old region output + StringHierarchyRegion? oldRegion; + + // Test re-adding existing subregion + stringHierarchyRegion.AddSubregion(regionSubregions["child2"], + out oldRegion); + Assert.Equal(regionSubregions["child2"], oldRegion); + Assert.Same(regionSubregions["child2"], + stringHierarchyRegion.Subregions["child2"]); + + // Create new region with same name as existing subregion + var newChildRegion = new StringHierarchyRegion("child1", null!); + + // Test adding new subregion with same name as existing subregion + stringHierarchyRegion.AddSubregion(newChildRegion, out oldRegion); + Assert.Equal(regionSubregions["child1"], oldRegion); + Assert.Same(newChildRegion, + stringHierarchyRegion.Subregions["child1"]); + Assert.Same(stringHierarchyRegion, newChildRegion.ParentRegion); + } + #endregion AddSubregion + + #region DeepCopy + /// + /// Test the DeepCopy method with various root region structures. + /// + /// The root region of the tree to deep copy. + /// Whether to retain the parent + /// reference(s) in the deep copy or not. + [Theory] + [MemberData(nameof(DeepCopy_TestData))] + public void DeepCopy_ShouldSucceed(StringHierarchyRegion root, + bool retainParentReference) + { + // Create the deep copy + var deepCopy = root.DeepCopy(); + + // Recursive check. + DeepCopy_ShouldSucceed_Helper(root, deepCopy, retainParentReference, + isRoot: true); + } + + /// + /// Recursive helper method for DeepCopy_ShouldSucceed. + /// + /// An original region of the tree that was deep + /// copied. + /// The supposed deep copy of the original region. + /// + /// Whether to the parent reference(s) + /// were supposed to be retained or not in the deep copy. + private void DeepCopy_ShouldSucceed_Helper(StringHierarchyRegion original, + StringHierarchyRegion deepCopy, bool retainParentReference, + bool isRoot = false) + { + // Check root object is different address. + Assert.NotSame(original, deepCopy); + + // Check basename is same value. + Assert.Equal(original.BaseName, deepCopy.BaseName); + + // Check parent region is correct. + if (original.ParentRegion is null) + { + Assert.Null(deepCopy.ParentRegion); + } + // Check parent region is same address if rot and retention specified. + else if (isRoot && retainParentReference) + { + Assert.Same(original.ParentRegion, deepCopy.ParentRegion); + } + // Check parent region is different address if non-root or no retention + // specified. + else + { + Assert.NotSame(original.ParentRegion, deepCopy.ParentRegion); + } + + // Check parent options is different address. + Assert.NotSame(original.ParentOptions, deepCopy.ParentOptions); + + // Check parent options is same value. + Assert.Equal(original.ParentOptions, deepCopy.ParentOptions); + + // Check options is different address. + Assert.NotSame(original.Options, deepCopy.Options); + + // Check options is same value. + Assert.Equal(original.Options, deepCopy.Options); + + // Check modifiers is different address. + Assert.NotSame(original.Modifiers, deepCopy.Modifiers); + + // Check modifiers is same value. + Assert.Equal(original.Modifiers, deepCopy.Modifiers); + + // Check saved locations and areas are different address, same values. + Assert.NotSame(original.SavedLocations, deepCopy.SavedLocations); + Assert.Equal(original.SavedLocations, deepCopy.SavedLocations); + Assert.NotSame(original.SavedAreas, deepCopy.SavedAreas); + Assert.Equal(original.SavedAreas, deepCopy.SavedAreas); + + // Check subregions collection is different address. + Assert.NotSame(original.Subregions, deepCopy.Subregions); + + // Check subregion collections have same length. + Assert.Equal(original.Subregions.Count, deepCopy.Subregions.Count); + + // Check subregions. + foreach (var (subregionName, originalSubregion) in original.Subregions) + { + // Check copy has a subregion of the same name. + Assert.True(deepCopy.Subregions.TryGetValue(subregionName, + // Get copied subregion inherently. + out var copiedSubregion)); + + // Check value (address) of subregion is different. + Assert.NotSame(originalSubregion, copiedSubregion); + + // Recurse into subregion. + DeepCopy_ShouldSucceed_Helper(originalSubregion, copiedSubregion, + retainParentReference); + } + } + + /// + /// Test data for DeepCopy method. + /// + /// + public static IEnumerable DeepCopy_TestData() + { + return new List + { + // { rootRegion, retainParentReference } + new object[] { ConstructEmptyRoot(), true }, + new object[] { ConstructEmptyRoot(), false }, + new object[] { ConstructTwoLevelRoot(), true }, + new object[] { ConstructTwoLevelRoot(), false } + }; + } + + /// + /// Test the DeepCopy method with retaining the parent reference. + /// + [Fact] + public void DeepCopy_ShouldDeepCopy_Fact() + { + // Create region with null parameter values + var nullRegion = new StringHierarchyRegion("base", null!); + // Create region with non-null but empty options, modifiers, and subregions + var emptyRegion = new StringHierarchyRegion("root", null!, null!, + new HashSet(), new Dictionary>(), + new Dictionary()); + // Create non-empty options and modifiers parameters + var regionOptions = new HashSet() { "right", "left" }; + var regionModifiers = new Dictionary> + { + { "modSet1", new HashSet() { "mod1", "mod2" } }, + { "modSet2", new HashSet() { "mod3", "mod4" } } + }; + var regionSubregions = new Dictionary + { + { "child1", new StringHierarchyRegion("child1", null!) }, + { "child2", new StringHierarchyRegion("child2", null!) } + }; + // Create region with the above parameters + var stringHierarchyRegion = new StringHierarchyRegion("base", + emptyRegion, emptyRegion.Options, regionOptions, regionModifiers, + new Dictionary()); + + // Create variable to store old region output + StringHierarchyRegion? oldRegion; + // Add subregions after creating region to ensure correct parent region fields + stringHierarchyRegion.AddSubregion(new StringHierarchyRegion("child1", null!), out oldRegion); + stringHierarchyRegion.AddSubregion(new StringHierarchyRegion("child2", null!), out oldRegion); + + // Check deep copy for region with null parameter values (i.e., value + // equality but not reference equality) + var nullRegionCopy = nullRegion.DeepCopy(true); + Assert.Equivalent(nullRegion, nullRegionCopy, strict: true); + Assert.NotEqual(nullRegion, nullRegionCopy); + Assert.NotSame(nullRegion, nullRegionCopy); + + // Check deep copy for region with empty parameter values (i.e., value + // equality but not reference equality) + var emptyRegionCopy = emptyRegion.DeepCopy(true); + Assert.Equivalent(emptyRegion, emptyRegionCopy, strict: true); + Assert.NotEqual(emptyRegion, emptyRegionCopy); + Assert.NotSame(emptyRegion, emptyRegionCopy); + + // Check deep copy for region with non-empty parameter values (i.e., value + // equality but not reference equality for all non-region fields) + var stringHierarchyRegionCopy = stringHierarchyRegion.DeepCopy(true); + Assert.Equal(stringHierarchyRegion.ToString(), + stringHierarchyRegionCopy.ToString()); + Assert.Same(stringHierarchyRegion.ParentRegion, + stringHierarchyRegionCopy.ParentRegion); + Assert.Equal(stringHierarchyRegion.Options, + stringHierarchyRegionCopy.Options); + Assert.NotSame(stringHierarchyRegion.Options, + stringHierarchyRegionCopy.Options); + Assert.Equal(stringHierarchyRegion.Modifiers, + stringHierarchyRegionCopy.Modifiers); + Assert.NotSame(stringHierarchyRegion.Modifiers, + stringHierarchyRegionCopy.Modifiers); + // Testing Note: Due to the presence of parentRegion in each of the + // subregions and due to the deep copying of the subregions themselves, + // it is not feasible to check equality or equivalence of the + // subregions + Assert.NotEqual(stringHierarchyRegion.Subregions, + stringHierarchyRegionCopy.Subregions); + Assert.NotSame(stringHierarchyRegion.Subregions, + stringHierarchyRegionCopy.Subregions); + } + + /// + /// Test the DeepCopy method without retaining the parent reference. + /// + [Fact] + public void DeepCopy_ShouldDeepCopyResetParent() + { + // Create region with null parameter values + var nullRegion = new StringHierarchyRegion("base", null!); + // Create region with non-null but empty options, modifiers, and subregions + var parent = new StringHierarchyRegion("root", nullRegion, null!, + new HashSet(), new Dictionary>(), + new Dictionary()); + // Create non-empty options, modifiers, and subregions parameters + var regionOptions = new HashSet() { "right", "left" }; + var regionModifiers = new Dictionary> + { + { "modSet1", new HashSet() { "mod1", "mod2" } }, + { "modSet2", new HashSet() { "mod3", "mod4" } } + }; + // Create region with the above parameters + var stringHierarchyRegion = new StringHierarchyRegion("base", parent, + parent.Options, regionOptions, regionModifiers, + new Dictionary()); + + // Create variable to store old region output + StringHierarchyRegion? oldRegion; + // Add subregions after creating region to ensure correct parent region fields + stringHierarchyRegion.AddSubregion(new StringHierarchyRegion("child1", null!), out oldRegion); + stringHierarchyRegion.AddSubregion(new StringHierarchyRegion("child2", null!), out oldRegion); + + // Check deep copy for region with empty parameter values (i.e., value + // equality but not reference equality) before and after setting parent + // region of original to null (should be not equal before, equal after) + var parentCopy = parent.DeepCopy(false); + Assert.NotEqual(parent.ParentRegion, parentCopy.ParentRegion); + parent.ParentRegion = null!; + Assert.Equal(parent.ParentRegion, parentCopy.ParentRegion); + Assert.Equivalent(parent, parentCopy, strict: true); + Assert.NotEqual(parent, parentCopy); + Assert.NotSame(parent, parentCopy); + + // Check deep copy for region with non-empty parameter values (i.e., + // value equality but not reference equality) before and after setting + // parent region of original to null + var stringHierarchyRegionCopy = stringHierarchyRegion.DeepCopy(false); + Assert.NotEqual(stringHierarchyRegion.ParentRegion, + stringHierarchyRegionCopy.ParentRegion); + stringHierarchyRegion.ParentRegion = null!; + Assert.Equal(stringHierarchyRegion.ParentRegion, + stringHierarchyRegionCopy.ParentRegion); + Assert.Equal(stringHierarchyRegion.ToString(), + stringHierarchyRegionCopy.ToString()); + Assert.Same(stringHierarchyRegion.ParentRegion, + stringHierarchyRegionCopy.ParentRegion); + Assert.Equal(stringHierarchyRegion.Options, + stringHierarchyRegionCopy.Options); + Assert.NotSame(stringHierarchyRegion.Options, + stringHierarchyRegionCopy.Options); + Assert.Equal(stringHierarchyRegion.Modifiers, + stringHierarchyRegionCopy.Modifiers); + Assert.NotSame(stringHierarchyRegion.Modifiers, + stringHierarchyRegionCopy.Modifiers); + // Testing Note: Due to the presence of parentRegion in each of the + // subregions and due to the deep copying of the subregions themselves, + // it is not feasible to check equality or equivalence of the + // subregions + Assert.NotEqual(stringHierarchyRegion.Subregions, + stringHierarchyRegionCopy.Subregions); + Assert.NotSame(stringHierarchyRegion.Subregions, + stringHierarchyRegionCopy.Subregions); + } + #endregion DeepCopy + + #region IsValidModifierSpec + /// + /// Test the IsValidModifierSpec method with invalid modifier specs. + /// + [Fact] + public void IsValidModifierSpec_ShouldOutputFalse() + { + // Create simple parent region + var parent = new StringHierarchyRegion("root", null!, null!, + new HashSet(), new Dictionary>(), + new Dictionary()); + // Create non-empty options, modifiers, and subregions parameters + var regionOptions = new HashSet() { "right", "left" }; + var regionModifiers = new Dictionary> + { + { "modSet1", new HashSet() { "mod1", "mod2" } }, + { "modSet2", new HashSet() { "mod3", "mod4" } } + }; + var regionSubregions = new Dictionary + { + { "child1", new StringHierarchyRegion("child1", null!) }, + { "child2", new StringHierarchyRegion("child2", null!) } + }; + // Create region using the above parameters + var stringHierarchyRegion = new StringHierarchyRegion("base", parent, + parent.Options, regionOptions, regionModifiers, regionSubregions); + + // Create invalid test modifier specs + var modSpec1 = ""; + var modSpec2 = "mod5"; + var modSpec3 = "mod1, mod2"; + var modSpec4 = "mod1, mod3, mod4"; + + // Test empty modifier spec + Assert.False(stringHierarchyRegion.IsValidModifierSpec(modSpec1)); + // Test invalid modifier + Assert.False(stringHierarchyRegion.IsValidModifierSpec(modSpec2)); + // Test invalid modifier set (all in same set) + Assert.False(stringHierarchyRegion.IsValidModifierSpec(modSpec3)); + // Test invalid modifier set (pair in same set) + Assert.False(stringHierarchyRegion.IsValidModifierSpec(modSpec4)); + } + + /// + /// Test the IsValidModifierSpec method with valid modifier specs. + /// + [Fact] + public void IsValidModifierSpec_ShouldOutputTrue() + { + // Create simple parent region + var parent = new StringHierarchyRegion("root", null!, null!, + new HashSet(), new Dictionary>(), + new Dictionary()); + // Create non-empty options, modifiers, and subregions parameters + var regionOptions = new HashSet() { "right", "left" }; + var regionModifiers = new Dictionary> + { + { "modSet1", new HashSet() { "mod1", "mod2" } }, + { "modSet2", new HashSet() { "mod3", "mod4" } } + }; + var regionSubregions = new Dictionary + { + { "child1", new StringHierarchyRegion("child1", null!) }, + { "child2", new StringHierarchyRegion("child2", null!) } + }; + // Create region using the above parameters + var stringHierarchyRegion = new StringHierarchyRegion("base", parent, + parent.Options, regionOptions, regionModifiers, regionSubregions); + + // Create invalid test modifier specs + var modSpec1 = "mod3"; + var modSpec2 = "mod1, mod4"; + + // Test valid modifier + Assert.True(stringHierarchyRegion.IsValidModifierSpec(modSpec1)); + // Test valid modifier set + Assert.True(stringHierarchyRegion.IsValidModifierSpec(modSpec2)); + } + + /// + /// Test the IsValidModifierSpec method handles cases with same-valued + /// modifier axes. + /// + [Theory] + #region Single shared modifier. Same-length axis modifier value sets. + // Shared modifier value used in first axis. + [InlineData( + new string[] { "left", "middle", "right" }, + new string[] { "proximal", "middle", "distal" }, + "middle, proximal")] + // Shared modifier value used in second axis. + [InlineData( + new string[] { "left", "middle", "right" }, + new string[] { "proximal", "middle", "distal" }, + "left, middle")] + // Shared modifier value used in both axes. + [InlineData( + new string[] { "left", "middle", "right" }, + new string[] { "proximal", "middle", "distal" }, + "middle, middle")] + // Shared modifier value used in one axis, no modifier value used in other. + // TODO: Should this non-determinism be allowed!!! + [InlineData( + new string[] { "left", "middle", "right" }, + new string[] { "proximal", "middle", "distal" }, + "middle")] + // Shared modifier not used. + [InlineData( + new string[] { "left", "middle", "right" }, + new string[] { "proximal", "middle", "distal" }, + "distal")] + #endregion + #region Single shared modifier. Different-length axis modifier value sets. + // Shared modifier value used in first (shorter) axis. Listed first. + [InlineData( + new string[] { "left", "middle" }, + new string[] { "proximal", "middle", "distal" }, + "middle, proximal")] + // Shared modifier value used in first (shorter) axis. Listed second. + [InlineData( + new string[] { "left", "middle" }, + new string[] { "proximal", "middle", "distal" }, + "proximal, middle")] + // Shared modifier value used in second (longer) axis. Listed first. + // TODO: this test case fails!!! --> REQUIRES FIX in Region!!! larger refactor/reimplement + // [InlineData( + // new string[] { "left", "middle" }, + // new string[] { "proximal", "middle", "distal" }, + // "middle, left")] + // Shared modifier value used in second (longer) axis. Listed second. + [InlineData( + new string[] { "left", "middle" }, + new string[] { "proximal", "middle", "distal" }, + "left, middle")] + // Shared modifier value used in both axes. + [InlineData( + new string[] { "left", "middle" }, + new string[] { "proximal", "middle", "distal" }, + "middle, middle")] + // Shared modifier value used in one axis, no modifier value used in other. + // TODO: Should this non-determinism be allowed!!! + [InlineData( + new string[] { "left", "middle" }, + new string[] { "proximal", "middle", "distal" }, + "middle")] + // Shared modifier not used. + [InlineData( + new string[] { "left", "middle" }, + new string[] { "proximal", "middle", "distal" }, + "distal")] + #endregion + #region Two shared modifiers. Different-length axis modifier value sets. + // TODO: Which axis are shared modifiers bucketed into? Should this + // pseudo-non-determinism (rly just unknown-to-the-user behavior) be + // allowed???!!! + // Both values used: order 1. + [InlineData( + new string[] { "left", "middle" }, + new string[] { "left", "middle", "right" }, + "middle, left")] + // Both values used: order 2. + [InlineData( + new string[] { "left", "middle" }, + new string[] { "left", "middle", "right" }, + "left, middle")] + // One value use: axis 1, listed first. + [InlineData( + new string[] { "left", "middle" }, + new string[] { "left", "middle", "right" }, + "middle, right")] + // One value used: axis 1, listed second. + [InlineData( + new string[] { "left", "middle" }, + new string[] { "left", "middle", "right" }, + "right, middle")] + #endregion + #region Single shared modifier. One axis only has the shared modifier. + // Shared modifier value used in first (shorter) axis. Listed first. + [InlineData( + new string[] { "middle" }, + new string[] { "proximal", "middle", "distal" }, + "middle, proximal")] + // Shared modifier value used in first (shorter) axis. Listed second. + [InlineData( + new string[] { "middle" }, + new string[] { "proximal", "middle", "distal" }, + "proximal, middle")] + // Shared modifier value used in one axis, no modifier value used in other. + // TODO: Should this non-determinism be allowed!!! + [InlineData( + new string[] { "middle" }, + new string[] { "proximal", "middle", "distal" }, + "middle")] + #endregion + public void IsValidModifierSpec_DuplicateModifierValues_ShouldSucceed( + string[] axis1Values, string[] axis2Values, + string modSpec) + { + // Create a region with the same value on two modifier axes. + var region = new StringHierarchyRegion("hand", null!, + modifiers: new Dictionary> + { + { "x", new HashSet(axis1Values) }, + { "y", new HashSet(axis2Values) } + }); + + // Test valid modifier set + Assert.True(region.IsValidModifierSpec(modSpec)); + } + #endregion IsValidModifierSpec +}