diff --git a/.github/agents/dev-mode.agent.md b/.github/agents/dev-mode.agent.md new file mode 100644 index 00000000..6147e055 --- /dev/null +++ b/.github/agents/dev-mode.agent.md @@ -0,0 +1,159 @@ +--- +name: Dev Mode +description: Toggle the repo between development mode (project references) and release mode (NuGet package references). +tools: ['edit/editFiles', 'search/codebase', 'read/terminalLastCommand'] +--- + +# Development / Release Mode Agent + +Toggle the repository between **development mode** and **release mode** for the `Atmoos.Quantities` project family. + +## Purpose + +When developing new features or quantities in `Atmoos.Quantities`, downstream projects (`Atmoos.Quantities.Units`, `Atmoos.Quantities.Serialization.Text.Json`, `Atmoos.Quantities.Serialization.Newtonsoft`) reference it as a **NuGet package**. This means changes to `Atmoos.Quantities` are invisible to those projects until a release is published. + +This agent switches the three downstream `.csproj` files between: + +- **Development mode**: `ProjectReference` — enables rapid prototyping with unreleased changes. +- **Release mode**: `PackageReference` — restores the NuGet dependency for clean packaging. + +## Instructions + +When the user asks to switch to **development mode** or **release mode**, follow the steps below. + +### Identifying the Current Mode + +Inspect the three target files for the presence of `PackageReference` or `ProjectReference` to `Atmoos.Quantities`: + +| File | Path | +|------|------| +| Units | `source/Atmoos.Quantities.Units/Atmoos.Quantities.Units.csproj` | +| Text.Json | `source/Atmoos.Quantities.Serialization/Text.Json/Atmoos.Quantities.Serialization.Text.Json.csproj` | +| Newtonsoft | `source/Atmoos.Quantities.Serialization/Newtonsoft/Atmoos.Quantities.Serialization.Newtonsoft.csproj` | + +- If they contain `` → currently in **release mode**. +- If they contain `` → currently in **development mode**. + +Report the current mode to the user before making changes. + +--- + +### Switching to Development Mode + +Replace every `PackageReference` to `Atmoos.Quantities` with the corresponding `ProjectReference` in the three files listed above. + +#### Replacements + +**`source/Atmoos.Quantities.Units/Atmoos.Quantities.Units.csproj`** + +Replace: +```xml + +``` +With: +```xml + +``` + +**`source/Atmoos.Quantities.Serialization/Text.Json/Atmoos.Quantities.Serialization.Text.Json.csproj`** + +Replace: +```xml + +``` +With: +```xml + +``` + +**`source/Atmoos.Quantities.Serialization/Newtonsoft/Atmoos.Quantities.Serialization.Newtonsoft.csproj`** + +Replace: +```xml + +``` +With: +```xml + +``` + +#### Important + +- The version number in the `PackageReference` may differ from `2.2.0`. Match whatever version is present. +- Do **not** modify any other `PackageReference` entries (e.g., `Newtonsoft.Json`). +- Only replace the `Atmoos.Quantities` reference, nothing else. + +--- + +### Switching to Release Mode + +Reverse the development mode changes: replace every `ProjectReference` to `Atmoos.Quantities.csproj` with the corresponding `PackageReference`. + +#### Determining the Version + +Before making changes, read the version from `source/Atmoos.Quantities/Atmoos.Quantities.csproj`: + +```xml +2.2.0 +``` + +Use this version for all `PackageReference` entries to ensure consistency. + +#### Replacements + +**`source/Atmoos.Quantities.Units/Atmoos.Quantities.Units.csproj`** + +Replace: +```xml + +``` +With: +```xml + +``` + +**`source/Atmoos.Quantities.Serialization/Text.Json/Atmoos.Quantities.Serialization.Text.Json.csproj`** + +Replace: +```xml + +``` +With: +```xml + +``` + +**`source/Atmoos.Quantities.Serialization/Newtonsoft/Atmoos.Quantities.Serialization.Newtonsoft.csproj`** + +Replace: +```xml + +``` +With: +```xml + +``` + +Where `{version}` is the value read from `source/Atmoos.Quantities/Atmoos.Quantities.csproj`. + +--- + +## Verification + +After making changes, **always** build the solution to confirm everything compiles: + +```bash +dotnet build source/Quantities.sln +``` + +Report the result to the user. + +--- + +## Safety Rules + +1. **Never** modify `source/Atmoos.Quantities/Atmoos.Quantities.csproj` itself. +2. **Never** change references other than `Atmoos.Quantities` (e.g., `Newtonsoft.Json`, `Atmoos.Sphere`). +3. **Never** change version numbers in `` — only the `PackageReference` version attribute. +4. If any of the three files are already in the target mode, skip them and inform the user. +5. Always report which files were changed and which were already in the correct state. diff --git a/.github/agents/new-quantity.agent.md b/.github/agents/new-quantity.agent.md new file mode 100644 index 00000000..5df20482 --- /dev/null +++ b/.github/agents/new-quantity.agent.md @@ -0,0 +1,483 @@ +--- +name: New Quantity +description: Generate a new physical quantity type for the Atmoos.Quantities library. +tools: ['edit/editFiles', 'edit/createDirectory', 'edit/createFile', 'search/codebase', 'read/terminalLastCommand', 'web/fetch', 'execute/runInTerminal', 'microsoftdocs/mcp/*','read/problems'] +--- + +# New Quantity Generation Agent + +Generate a new physical quantity type for the Atmoos.Quantities library. + +## Instructions + +When asked to create a new quantity, follow these steps: + +1. **Identify the quantity category** (see below) based on its SI dimensional analysis. +2. **Create or verify the dimension interface** in the appropriate dimensions file. +3. **Create the quantity struct** in `source/Atmoos.Quantities/Quantities/`. +4. **Add cross-quantity operators** in the appropriate file under `source/Atmoos.Quantities/Physics/`. + +Always adhere to the coding conventions defined in `.github/copilot-instructions.md`. + +--- + +## Quantity Categories + +Every quantity falls into one of these categories, determined by its SI dimensional formula: + +### 1. Scalar (base quantity with a single dimension) + +**When**: The quantity is an SI base quantity or is independently defined with its own linear dimension. + +**Dimension**: `ILinear, IBaseQuantity` (for base) or `ILinear, IDerivedQuantity` (for derived). + +**Struct implements**: `IQuantity, IDimension, IScalar` + +**Examples**: `Length` (ILength), `Time` (ITime), `Mass` (IMass), `ElectricCurrent`, `Power` (IPower), `ElectricPotential`, `ElectricalResistance` + +**Template** (using `Length` as canonical example): + +```csharp +using Atmoos.Quantities.Creation; +using Atmoos.Quantities.Dimensions; +using Atmoos.Quantities.Units; + +namespace Atmoos.Quantities; + +public readonly struct {Name} : IQuantity<{Name}>, I{Name}, IScalar<{Name}, I{Name}> +{ + private readonly Quantity {fieldName}; + internal Quantity Value => this.{fieldName}; + Quantity IQuantity<{Name}>.Value => this.{fieldName}; + + private {Name}(in Quantity value) => this.{fieldName} = value; + + public {Name} To(in Scalar other) + where TUnit : I{Name}, IUnit => new(other.Transform(in this.{fieldName})); + + public static {Name} Of(in Double value, in Scalar measure) + where TUnit : I{Name}, IUnit => new(measure.Create(in value)); + + static {Name} IFactory<{Name}>.Create(in Quantity value) => new(in value); + + public Boolean Equals({Name} other) => this.{fieldName}.Equals(other.{fieldName}); + + public override Boolean Equals(Object? obj) => obj is {Name} {fieldName} && Equals({fieldName}); + + public override Int32 GetHashCode() => this.{fieldName}.GetHashCode(); + + public override String ToString() => this.{fieldName}.ToString(); + + public String ToString(String? format, IFormatProvider? provider) => this.{fieldName}.ToString(format, provider); +} +``` + +**Notes**: +- The constructor is `private` for scalar quantities. +- There is no `using` for `Atmoos.Quantities.Core.Numerics` (not needed). +- `{fieldName}` is the camelCase version of `{Name}`. + +--- + +### 2. PowerOf (quantity that is a power of a linear dimension) + +**When**: The quantity's dimension is a single base dimension raised to a power > 1. + +**Dimension**: `IDimension, IDerivedQuantity` (e.g., `IDimension` for Area). + +**Struct implements**: `IQuantity, IDimension, IPowerOf` + +**Examples**: `Area` (Length²), `Volume` (Length³) + +**Template** (using `Area` as canonical example): + +```csharp +using Atmoos.Quantities.Core.Numerics; +using Atmoos.Quantities.Creation; +using Atmoos.Quantities.Dimensions; +using Atmoos.Quantities.Units; + +namespace Atmoos.Quantities; + +public readonly struct {Name} : IQuantity<{Name}>, I{Name}, IPowerOf<{Name}, I{Name}, I{Linear}, {Exponent}> +{ + private readonly Quantity {fieldName}; + internal Quantity Value => this.{fieldName}; + Quantity IQuantity<{Name}>.Value => this.{fieldName}; + + private {Name}(in Quantity value) => this.{fieldName} = value; + + public {Name} To(in Power other) + where TUnit : I{Linear}, IUnit => new(other.Transform(in this.{fieldName})); + + public {Name} To(in Scalar other) + where TAlias : I{Name}, IPowerOf, IUnit => new(other.Transform(in this.{fieldName}, static f => ref f.AliasOf())); + + public static {Name} Of(in Double value, in Power measure) + where TUnit : I{Linear}, IUnit => new(measure.Create(in value)); + + public static {Name} Of(in Double value, in Scalar measure) + where TAlias : I{Name}, IPowerOf, IUnit => new(measure.Create(in value, static f => ref f.AliasOf())); + + static {Name} IFactory<{Name}>.Create(in Quantity value) => new(in value); + + public Boolean Equals({Name} other) => this.{fieldName}.Equals(other.{fieldName}); + + public override Boolean Equals(Object? obj) => obj is {Name} {fieldName} && Equals({fieldName}); + + public override Int32 GetHashCode() => this.{fieldName}.GetHashCode(); + + public override String ToString() => this.{fieldName}.ToString(); + + public String ToString(String? format, IFormatProvider? provider) => this.{fieldName}.ToString(format, provider); +} +``` + +**Notes**: +- Requires `using Atmoos.Quantities.Core.Numerics;` for the exponent type (`Two`, `Three`, etc.). +- Has two overloads each for `To` and `Of`: one for the power form (e.g., `m²`) and one for alias units (e.g., `Litre` for Volume). +- The constructor is `private`. + +--- + +### 3. Quotient (quantity defined as a ratio of two dimensions) + +**When**: The quantity is dimensionally a ratio of two base/derived dimensions: `Nominator / Denominator`. + +**Dimension**: `IProduct>>` (e.g., Velocity = Length / Time). + +**Struct implements**: `IQuantity, IDimension, IQuotient` + +**Examples**: `Velocity` (Length/Time), `DataRate` (AmountOfInformation/Time) + +**Template** (using `Velocity` as canonical example): + +```csharp +using Atmoos.Quantities.Creation; +using Atmoos.Quantities.Dimensions; +using Atmoos.Quantities.Units; + +namespace Atmoos.Quantities; + +public readonly struct {Name} : IQuantity<{Name}>, I{Name}, IQuotient<{Name}, I{Name}, I{Nominator}, I{Denominator}> +{ + private readonly Quantity {fieldName}; + internal Quantity Value => this.{fieldName}; + Quantity IQuantity<{Name}>.Value => this.{fieldName}; + + internal {Name}(in Quantity value) => this.{fieldName} = value; + + public {Name} To(in Scalar other) + where TUnit : I{Name}, IUnit => new(other.Transform(in this.{fieldName})); + + public {Name} To(in Quotient other) + where TNominator : I{Nominator}, IUnit + where TDenominator : I{Denominator}, IUnit => new(other.Transform(in this.{fieldName})); + + public static {Name} Of(in Double value, in Scalar measure) + where TUnit : I{Name}, IUnit => new(measure.Create(in value)); + + public static {Name} Of(in Double value, in Quotient measure) + where TNominator : I{Nominator}, IUnit + where TDenominator : I{Denominator}, IUnit => new(measure.Create(in value)); + + static {Name} IFactory<{Name}>.Create(in Quantity value) => new(in value); + + public Boolean Equals({Name} other) => this.{fieldName}.Equals(other.{fieldName}); + + public override Boolean Equals(Object? obj) => obj is {Name} {fieldName} && Equals({fieldName}); + + public override Int32 GetHashCode() => this.{fieldName}.GetHashCode(); + + public override String ToString() => this.{fieldName}.ToString(); + + public String ToString(String? format, IFormatProvider? provider) => this.{fieldName}.ToString(format, provider); +} +``` + +**Notes**: +- The constructor is `internal` (not `private`) for quotient quantities, since cross-quantity operators in the `Physics` namespace need to create instances. +- Has two overloads each for `To` and `Of`: one for a scalar alias unit and one for the quotient form. + +--- + +### 4. Quotient with powered denominator + +**When**: The quantity is a ratio where the denominator has an exponent > 1. + +**Dimension**: `IProduct>>`. + +**Struct implements**: `IQuantity, IDimension, IQuotient` + +**Examples**: `Acceleration` (Length/Time²), `Pressure` (Force/Length²) + +**Template** (using `Acceleration` as canonical example): + +```csharp +using Atmoos.Quantities.Core.Numerics; +using Atmoos.Quantities.Creation; +using Atmoos.Quantities.Dimensions; +using Atmoos.Quantities.Units; + +namespace Atmoos.Quantities; + +public readonly struct {Name} : IQuantity<{Name}>, I{Name}, IQuotient<{Name}, I{Name}, I{Nominator}, I{Denominator}, {Exponent}> +{ + private readonly Quantity {fieldName}; + internal Quantity Value => this.{fieldName}; + Quantity IQuantity<{Name}>.Value => this.{fieldName}; + + internal {Name}(in Quantity value) => this.{fieldName} = value; + + public {Name} To(in Scalar other) + where TUnit : I{Name}, IUnit => new(other.Transform(in this.{fieldName})); + + public {Name} To(in Quotient> other) + where TNominator : I{Nominator}, IUnit + where TDenominator : I{Denominator}, IUnit => new(other.Transform(in this.{fieldName})); + + public static {Name} Of(in Double value, in Scalar measure) + where TUnit : I{Name}, IUnit => new(measure.Create(in value)); + + public static {Name} Of(in Double value, in Quotient> measure) + where TNominator : I{Nominator}, IUnit + where TDenominator : I{Denominator}, IUnit => new(measure.Create(in value)); + + static {Name} IFactory<{Name}>.Create(in Quantity value) => new(in value); + + public Boolean Equals({Name} other) => this.{fieldName}.Equals(other.{fieldName}); + + public override Boolean Equals(Object? obj) => obj is {Name} {fieldName} && Equals({fieldName}); + + public override Int32 GetHashCode() => this.{fieldName}.GetHashCode(); + + public override String ToString() => this.{fieldName}.ToString(); + + public String ToString(String? format, IFormatProvider? provider) => this.{fieldName}.ToString(format, provider); +} +``` + +**Notes**: +- Requires `using Atmoos.Quantities.Core.Numerics;` for `Two`, `Three`, etc. +- The quotient form uses `Power` in both `To` and `Of`. +- The constructor is `internal`. + +--- + +### 5. Product (quantity defined as a product of two dimensions) + +**When**: The quantity is dimensionally a product of two dimensions. + +**Dimension**: `IProduct, IDerivedQuantity`. + +**Struct implements**: `IQuantity, IDimension, IProduct` + +**Examples**: `Energy` (Power × Time) + +**Template** (using `Energy` as canonical example): + +```csharp +using Atmoos.Quantities.Creation; +using Atmoos.Quantities.Dimensions; +using Atmoos.Quantities.Units; + +namespace Atmoos.Quantities; + +public readonly struct {Name} : IQuantity<{Name}>, I{Name}, IProduct<{Name}, I{Name}, I{Left}, I{Right}> +{ + private readonly Quantity {fieldName}; + internal Quantity Value => this.{fieldName}; + Quantity IQuantity<{Name}>.Value => this.{fieldName}; + + private {Name}(in Quantity value) => this.{fieldName} = value; + + public {Name} To(in Scalar other) + where TUnit : I{Name}, IUnit => new(other.Transform(in this.{fieldName})); + + public {Name} To(in Product other) + where TLeft : I{Left}, IUnit + where TRight : I{Right}, IUnit => new(other.Transform(in this.{fieldName})); + + public static {Name} Of(in Double value, in Scalar measure) + where TUnit : I{Name}, IUnit => new(measure.Create(in value)); + + public static {Name} Of(in Double value, in Product measure) + where TLeft : I{Left}, IUnit + where TRight : I{Right}, IUnit => new(measure.Create(in value)); + + static {Name} IFactory<{Name}>.Create(in Quantity value) => new(in value); + + public Boolean Equals({Name} other) => this.{fieldName}.Equals(other.{fieldName}); + + public override Boolean Equals(Object? obj) => obj is {Name} {fieldName} && Equals({fieldName}); + + public override Int32 GetHashCode() => this.{fieldName}.GetHashCode(); + + public override String ToString() => this.{fieldName}.ToString(); + + public String ToString(String? format, IFormatProvider? provider) => this.{fieldName}.ToString(format, provider); +} +``` + +**Notes**: +- The constructor is `private` for product quantities. + +--- + +### 6. Invertible (quantity that is the inverse of a base dimension) + +**When**: The quantity is dimensionally the inverse of a single base dimension (exponent = -1). + +**Dimension**: `IDimension>, ILinear, IDerivedQuantity`. + +**Struct implements**: `IQuantity, IDimension, IInvertible` + +**Examples**: `Frequency` (1/Time) + +**Template** (using `Frequency` as canonical example): + +```csharp +using Atmoos.Quantities.Dimensions; +using Atmoos.Quantities.Units; + +namespace Atmoos.Quantities; + +public readonly struct {Name} : IQuantity<{Name}>, I{Name}, IInvertible<{Name}, I{Name}, I{Inverse}> +{ + private readonly Quantity {fieldName}; + internal Quantity Value => this.{fieldName}; + Quantity IQuantity<{Name}>.Value => this.{fieldName}; + + private {Name}(in Quantity value) => this.{fieldName} = value; + + public {Name} To(in Creation.Scalar other) + where TUnit : I{Name}, IInvertible, IUnit => new(other.Transform(in this.{fieldName}, static f => ref f.InverseOf())); + + public static {Name} Of(in Double value, in Creation.Scalar measure) + where TUnit : I{Name}, IInvertible, IUnit => new(measure.Create(in value, static f => ref f.InverseOf())); + + static {Name} IFactory<{Name}>.Create(in Quantity value) => new(in value); + + public Boolean Equals({Name} other) => this.{fieldName}.Equals(other.{fieldName}); + + public override Boolean Equals(Object? obj) => obj is {Name} {fieldName} && Equals({fieldName}); + + public override Int32 GetHashCode() => this.{fieldName}.GetHashCode(); + + public override String ToString() => this.{fieldName}.ToString(); + + public String ToString(String? format, IFormatProvider? provider) => this.{fieldName}.ToString(format, provider); +} +``` + +**Notes**: +- Uses `Creation.Scalar` (qualified) to avoid conflict with the struct name. +- The `To` and `Of` methods constrain `TUnit : IInvertible` additionally. +- Uses `InverseOf` on the factory instead of `AliasOf`. +- The constructor is `private`. + +--- + +## Step-by-Step Procedure + +### Step 1: Determine the dimensional formula + +Analyse the requested quantity using SI dimensional analysis. Express it in terms of existing base dimensions: `ILength`, `ITime`, `IMass`, `IElectricCurrent`, `ITemperature`, `IAmountOfSubstance`, `ILuminousIntensity`, or existing derived dimensions: `IArea`, `IVolume`, `IVelocity`, `IAcceleration`, `IForce`, `IPower`, `IEnergy`, `IFrequency`, `IPressure`, `IElectricPotential`, `IElectricalResistance`, `IAmountOfInformation`, `IInformationRate`. + +### Step 2: Create or verify the dimension interface + +If a matching dimension interface does not already exist: + +- **Base quantities** → add to `source/Atmoos.Quantities/Dimensions/BaseDimensions.cs` +- **Derived quantities** → add to `source/Atmoos.Quantities/Dimensions/DerivedDimensions.cs` +- **Electrical quantities** → add to `source/Atmoos.Quantities/Dimensions/ElectricalDimesions.cs` + +Use the existing patterns: + +```csharp +// Base: linear, independent dimension +public interface IMyQuantity : ILinear, IBaseQuantity; + +// Derived: power of a base dimension +public interface IMyQuantity : IDimension, IDerivedQuantity; + +// Derived: product of dimensions (used for quotients too) +public interface IMyQuantity : IProduct>>, IDerivedQuantity; +``` + +### Step 3: Create the quantity struct + +Create the file `source/Atmoos.Quantities/Quantities/{Name}.cs` using the appropriate template from the categories above. + +### Step 4: Add cross-quantity operators + +Add operators that relate the new quantity to existing quantities according to SI rules. Place them in the appropriate file under `source/Atmoos.Quantities/Physics/`: + +- `MechanicalEngineering.cs` → geometry (Length, Area, Volume) and kinematics (Velocity, Acceleration, Force, Energy, Power, Pressure) +- `ElectricalEngineering.cs` → electrical quantities (Ohm's law, Power laws) +- `ComputerScience.cs` → data and information rate +- `Generic.cs` → frequency/time inversion and other dimensionless relations + +Use the extension method pattern: + +```csharp +using static Atmoos.Quantities.Extensions; + +// Inside appropriate static class: +extension({QuantityType}) +{ + public static {ResultType} operator *|/(in {QuantityType} left, in {OtherType} right) => + Create<{ResultType}>(left.Value *|/ right.Value); +} +``` + +Key rules for operator placement: +- For `A = B * C`, define `operator *` on **both** `B` and `C` (commutativity). +- For `A = B / C`, define `operator /` on `B`. +- For each product `A = B * C`, also define the inverse divisions: `B = A / C` and `C = A / B` on `A`. + +### Step 5: Verify + +Build the solution to ensure everything compiles: + +``` +dotnet build source/Atmoos.Quantities.sln +``` + +--- + +## Quick Reference: Existing Dimension-Quantity Mappings + +| Quantity | Dimension Interface | Category | SI Formula | +| --------------------- | -------------------- | -------------------------- | -------------------- | +| Length | ILength | Scalar (base) | L | +| Time | ITime | Scalar (base) | T | +| Mass | IMass | Scalar (base) | M | +| ElectricCurrent | IElectricCurrent | Scalar (base) | I | +| Temperature | ITemperature | Scalar (base) | Θ | +| Area | IArea | PowerOf(ILength, Two) | L² | +| Volume | IVolume | PowerOf(ILength, Three) | L³ | +| Velocity | IVelocity | Quotient(ILength, ITime) | L·T⁻¹ | +| Acceleration | IAcceleration | Quotient(ILength, ITime²) | L·T⁻² | +| Force | IForce | Scalar (derived) | M·L·T⁻² | +| Power | IPower | Scalar (derived) | M·L²·T⁻³ | +| Energy | IEnergy | Product(IPower, ITime) | M·L²·T⁻² | +| Pressure | IPressure | Quotient(IForce, ILength²) | M·L⁻¹·T⁻² | +| Frequency | IFrequency | Invertible(ITime) | T⁻¹ | +| ElectricPotential | IElectricPotential | Scalar (derived) | M·L²·T⁻³·I⁻¹ | +| ElectricalResistance | IElectricalResistance| Scalar (derived) | M·L²·T⁻³·I⁻² | +| Data | IAmountOfInformation | Scalar (derived) | (information) | +| DataRate | IInformationRate | Quotient(IAmountOfInformation, ITime) | information·T⁻¹ | + +--- + +## Important Conventions + +1. **Namespace**: All quantity structs live in `namespace Atmoos.Quantities;` (not a sub-namespace). +2. **File location**: All quantity struct files are in `source/Atmoos.Quantities/Quantities/`. +3. **Field naming**: The private field is always the camelCase of the quantity name (e.g., `velocity` for `Velocity`). +4. **Constructor access**: `private` for scalar, power-of, product, and invertible quantities; `internal` for quotient quantities. +5. **.NET type aliases**: Always use `Double`, `Boolean`, `Int32`, `String`, `Object` — never `double`, `bool`, `int`, `string`, `object`. +6. **`in` modifier**: Use `in` for all struct parameters consistently. +7. **Cross-quantity operators** use `Create(...)` from `Extensions` via `using static Atmoos.Quantities.Extensions;`. +8. **Operators class**: Do NOT modify `Operators.cs` — it automatically provides `==`, `!=`, `>`, `>=`, `<`, `<=`, `+`, `-`, `*`, `/` for all `IQuantity` types via extensions. diff --git a/.github/agents/new-unit-test.agent.md b/.github/agents/new-unit-test.agent.md new file mode 100644 index 00000000..c00bcbd2 --- /dev/null +++ b/.github/agents/new-unit-test.agent.md @@ -0,0 +1,732 @@ +--- +name: New Unit Test +description: Generate tests for new units (measures) added to the Atmoos.Quantities.Units project. +tools: ['edit/editFiles', 'edit/createDirectory', 'edit/createFile', 'search/codebase', 'read/terminalLastCommand', 'execute/runInTerminal', 'read/problems'] +--- + +# New Unit Test Generation Agent + +Generate xUnit tests for new units (measures) added to the Atmoos.Quantities library. + +## Instructions + +When asked to create tests for a new unit, follow these steps: + +1. **Identify the unit** and its dimension, system, and conversion definition. +2. **Locate the corresponding test file** for the quantity dimension. +3. **Generate tests** following the established patterns for that quantity type. +4. **Build and run tests** to verify everything compiles and passes. + +Always adhere to the coding conventions defined in `.github/copilot-instructions.md`. + +--- + +## Test Project Structure + +All unit tests live in `source/Atmoos.Quantities.Units.Test/`. + +| File | Quantity Dimension | +| ----------------------------- | ------------------------ | +| `LengthTest.cs` | `ILength` | +| `MassTest.cs` | `IMass` | +| `TimeTest.cs` | `ITime` | +| `TemperatureTest.cs` | `ITemperature` | +| `AreaTest.cs` | `IArea` | +| `VolumeTest.cs` | `IVolume` | +| `VelocityTest.cs` | `IVelocity` | +| `AccelerationTest.cs` | `IAcceleration` | +| `ForceTest.cs` | `IForce` | +| `EnergyTest.cs` | `IEnergy` | +| `PowerTest.cs` | `IPower` | +| `PressureTest.cs` | `IPressure` | +| `FrequencyTest.cs` | `IFrequency` | +| `ElectricCurrentTest.cs` | `IElectricCurrent` | +| `ElectricPotentialTest.cs` | `IElectricPotential` | +| `ElectricalResistanceTest.cs` | `IElectricalResistance` | +| `DataTest.cs` | `IAmountOfInformation` | +| `DataRateTest.cs` | `IInformationRate` | + +### Global Imports (Usings.cs) + +The test project has these global usings available in every test file: + +```csharp +global using Atmoos.Quantities.Physics; +global using Atmoos.Quantities.Prefixes; +global using Atmoos.Quantities.Units.Imperial.Length; +global using Atmoos.Quantities.Units.Si; +global using Xunit; +global using static Atmoos.Quantities.Systems; +global using static Atmoos.Quantities.TestTools.Convenience; +global using static Atmoos.Quantities.TestTools.Traits; +``` + +This makes the following available without explicit imports: +- Measure factories: `Si()`, `Si()`, `Metric()`, `Metric()`, `Imperial()`, `NonStandard()`, `Binary()` +- Compound measure builders: `Square()`, `Cubic()`, `.Per()` +- Assertion helpers: `FormattingMatches()`, `Matches()`, `PrecisionIsBounded()`, `IsTrue()`, `IsFalse()` +- Precision constants: `FullPrecision`, `MediumPrecision`, `LowPrecision`, `VeryLowPrecision` +- xUnit attributes: `[Fact]`, `[Theory]`, `[MemberData]` + +--- + +## Test Categories + +Every new unit should have tests from the following categories, selected based on the unit's characteristics. + +### Category 1: Formatting Test (Required for every unit) + +Verifies that the unit's `Representation` property produces the correct symbol in formatted output. + +**Pattern**: + +```csharp +[Fact] +public void {UnitName}ToString() => FormattingMatches(v => {Quantity}.Of(v, {measure}), "{symbol}"); +``` + +**Examples**: + +```csharp +// SI unit +[Fact] +public void MetreToString() => FormattingMatches(v => Length.Of(v, Si()), "m"); + +// SI with prefix +[Fact] +public void KiloMetreToString() => FormattingMatches(v => Length.Of(v, Si()), "km"); + +// Metric unit +[Fact] +public void GramToString() => FormattingMatches(v => Mass.Of(v, Metric()), "g"); + +// Imperial unit +[Fact] +public void MileToString() => FormattingMatches(v => Length.Of(v, Imperial()), "mi"); + +// NonStandard unit +[Fact] +public void DelisleToString() => FormattingMatches(v => Temperature.Of(v, NonStandard()), "°De"); + +// Compound measure +[Fact] +public void KnotToString() => FormattingMatches(v => Velocity.Of(v, NonStandard()), "kn"); +``` + +--- + +### Category 2: SI Conversion Round-Trip (Required for non-SI units) + +Verifies the unit's `ToSi` transformation is correct by converting to the SI reference unit and back. + +**Pattern** (to SI reference): + +```csharp +[Fact] +public void {UnitName}To{SiUnit}() +{ + {Quantity} value = {Quantity}.Of({inputValue}, {unitMeasure}); + {Quantity} expected = {Quantity}.Of({expectedSiValue}, {siMeasure}); + + {Quantity} actual = value.To({siMeasure}); + + actual.Matches(expected); +} +``` + +**Pattern** (from SI reference): + +```csharp +[Fact] +public void {SiUnit}To{UnitName}() +{ + {Quantity} value = {Quantity}.Of({inputSiValue}, {siMeasure}); + {Quantity} expected = {Quantity}.Of({expectedValue}, {unitMeasure}); + + {Quantity} actual = value.To({unitMeasure}); + + actual.Matches(expected); +} +``` + +**Examples**: + +```csharp +[Fact] +public void GramToKilogram() +{ + Mass mass = Mass.Of(1600, Metric()); + Mass expected = Mass.Of(1.6, Si()); + + Mass actual = mass.To(Si()); + + actual.Matches(expected); +} + +[Fact] +public void KilogramToGram() +{ + Mass mass = Mass.Of(3.5, Si()); + Mass expected = Mass.Of(3500, Metric()); + + Mass actual = mass.To(Metric()); + + actual.Matches(expected); +} +``` + +For temperature-like units with offsets, use well-known reference points: + +```csharp +[Fact] +public void CelsiusToKelvin() +{ + Temperature temperature = Temperature.Of(312 - 273.15, Metric()); + Temperature expected = Temperature.Of(312, Si()); + + Temperature actual = temperature.To(Si()); + + actual.Matches(expected); +} +``` + +--- + +### Category 3: Cross-System Conversion (Recommended) + +Verifies conversions between different unit systems (e.g., metric to imperial). + +**Pattern**: + +```csharp +[Fact] +public void {SourceUnit}To{TargetUnit}() +{ + {Quantity} source = {Quantity}.Of({inputValue}, {sourceMeasure}); + {Quantity} expected = {Quantity}.Of({expectedValue}, {targetMeasure}); + + {Quantity} actual = source.To({targetMeasure}); + + actual.Matches(expected); +} +``` + +**Examples**: + +```csharp +[Fact] +public void MileToKilometre() +{ + Length miles = Length.Of(1, Imperial()); + Length kilometres = miles.To(Si()); + PrecisionIsBounded(1.609344, kilometres); +} + +[Fact] +public void KilometreToMile() +{ + Length kilometres = Length.Of(1.609344, Si()); + Length miles = kilometres.To(Imperial()); + PrecisionIsBounded(1d, miles); +} +``` + +--- + +### Category 4: Definition Equivalence (Required for derived units) + +Verifies that a derived unit's definition holds by asserting equivalence with the fundamental definition using `Assert.Equal()`. + +**Pattern**: + +```csharp +[Fact] +public void DefinitionOf{UnitName}Holds() +{ + {Quantity} defined = {Quantity}.Of({definitionValue}, {definedMeasure}); + {Quantity} equivalent = {Quantity}.Of({equivalentValue}, {equivalentMeasure}); + + Assert.Equal(defined, equivalent); +} +``` + +**Examples**: + +```csharp +// 1 mile = 5280 feet +[Fact] +public void FootToMile() +{ + Length feet = Length.Of(5280, Imperial()); + Length expected = Length.Of(1, Imperial()); + + Length actual = feet.To(Imperial()); + + actual.Matches(expected); +} + +// 1 pound = 7000 grains +[Fact] +public void DefinitionOfGrainHolds() +{ + Assert.Equal(onePound, Mass.Of(7000, Imperial())); +} + +// 1 acre = 43560 square feet +[Fact] +public void AcreDividedBySquareFeet() +{ + Area acres = Area.Of(2, Imperial()); + Area squareFeet = Area.Of(2 * 43560, Square(Imperial())); + Assert.Equal(acres, squareFeet); +} +``` + +--- + +### Category 5: Intra-System Conversion (Recommended for related units) + +Verifies conversion between units in the same system that are related by derivation (e.g., `DerivedFrom`). + +**Pattern**: + +```csharp +[Fact] +public void {SourceUnit}To{TargetUnit}() +{ + {Quantity} source = {Quantity}.Of({inputValue}, {sourceMeasure}); + {Quantity} expected = {Quantity}.Of({expectedValue}, {targetMeasure}); + + {Quantity} actual = source.To({targetMeasure}); + + actual.Matches(expected); +} +``` + +**Examples**: + +```csharp +[Fact] +public void OneMileInYards() +{ + Length length = Length.Of(1, Imperial()); + Length expected = Length.Of(1760, Imperial()); + + Length actual = length.To(Imperial()); + + actual.Matches(expected); +} + +[Fact] +public void RoodToPerches() +{ + Area rood = Area.Of(1, Imperial()); + Area expected = Area.Of(40, Imperial()); + + Area actual = rood.To(Imperial()); + + actual.Matches(expected); +} +``` + +--- + +### Category 6: Arithmetic Operations (Optional, for thorough coverage) + +Verifies addition and subtraction between different units of the same quantity. + +**Pattern** (addition): + +```csharp +[Fact] +public void Add{UnitA}To{UnitB}() +{ + {Quantity} a = {Quantity}.Of({valueA}, {measureA}); + {Quantity} b = {Quantity}.Of({valueB}, {measureB}); + {Quantity} result = a + b; + PrecisionIsBounded({expectedValue}, result); +} +``` + +**Pattern** (subtraction): + +```csharp +[Fact] +public void Subtract{UnitB}From{UnitA}() +{ + {Quantity} a = {Quantity}.Of({valueA}, {measureA}); + {Quantity} b = {Quantity}.Of({valueB}, {measureB}); + {Quantity} result = a - b; + PrecisionIsBounded({expectedValue}, result); +} +``` + +**Examples**: + +```csharp +[Fact] +public void AddMilesToKilometres() +{ + Length kilometres = Length.Of(1, Si()); + Length miles = Length.Of(1, Imperial()); + Length result = kilometres + miles; + PrecisionIsBounded(2.609344, result); +} +``` + +--- + +### Category 7: Cross-Quantity Operations (When applicable) + +Verifies that the unit participates correctly in cross-quantity operations (multiplication, division producing other quantities). + +**Pattern**: + +```csharp +[Fact] +public void {QuantityA}Times{QuantityB}Is{ResultQuantity}() +{ + {QuantityA} a = {QuantityA}.Of({valueA}, {measureA}); + {QuantityB} b = {QuantityB}.Of({valueB}, {measureB}); + {ResultQuantity} expected = {ResultQuantity}.Of({expectedValue}, {resultMeasure}); + + {ResultQuantity} actual = a * b; + + actual.Matches(expected); +} +``` + +**Examples**: + +```csharp +[Fact] +public void AreTimesMeterIsCubicMetre() +{ + Area area = Area.Of(1, Metric()); + Length length = Length.Of(10, Si()); + Volume expected = Volume.Of(10 * 10 * 10, Cubic(Si())); + + Volume actual = area * length; + + actual.Matches(expected); +} + +[Fact] +public void NewtonFromPressureAndArea() +{ + Pressure pressure = Pressure.Of(800, Si().Per(Square(Si()))); + Area area = Area.Of(2, Square(Si())); + Force expected = Force.Of(1600, Si()); + + Force actual = pressure * area; + + actual.Matches(expected); +} +``` + +--- + +### Category 8: Prefix Scaling (For metric/SI units that support prefixes) + +Verifies that standard metric prefixes work correctly with the unit. + +**Pattern**: + +```csharp +[Fact] +public void {Prefix}{UnitName}To{UnitName}() +{ + {Quantity} prefixed = {Quantity}.Of(1, {PrefixMeasure}); + {Quantity} expected = {Quantity}.Of({scaleFactor}, {baseMeasure}); + + {Quantity} actual = prefixed.To({baseMeasure}); + + actual.Matches(expected); +} +``` + +**Examples**: + +```csharp +[Fact] +public void MetreToKilometre() +{ + Length metres = Length.Of(1000, Si()); + Length kilometres = metres.To(Si()); + PrecisionIsBounded(1d, kilometres); +} + +[Fact] +public void MetreToMillimetre() +{ + Length metres = Length.Of(1, Si()); + Length millimetres = metres.To(Si()); + PrecisionIsBounded(1000d, millimetres); +} +``` + +--- + +## Assertion Helpers Reference + +### `actual.Matches(expected)` / `actual.Matches(expected, precision)` + +Asserts that two quantities are equal in **both value and measure** (same unit). Use when the unit system is preserved through conversion. Default precision is `FullPrecision` (16 decimal places). + +Use a reduced precision parameter when conversions involve floating-point imprecision: +- `FullPrecision` (16) — exact or near-exact conversions +- `MediumPrecision` (15) — slight floating-point loss +- `LowPrecision` (14) — moderate floating-point loss +- `VeryLowPrecision` (13) — significant floating-point loss + +### `PrecisionIsBounded(expectedValue, actualQuantity)` + +Asserts that the quantity's numeric value matches `expectedValue` to a certain decimal precision AND that precision is bounded (fails at one higher precision). Use for conversion accuracy tests where the focus is on numeric precision rather than measure equality. + +### `Assert.Equal(expected, actual)` + +Asserts value equality regardless of internal measure representation. Use for definition equivalence tests where the units may differ but the physical quantity is the same. + +### `FormattingMatches(factory, expectedSymbol)` + +Asserts that a quantity's string representation ends with the expected unit symbol. The factory `Func` creates the quantity from a value. + +--- + +## Step-by-Step Procedure + +### Step 1: Identify the new unit + +Determine: +- **Unit name** and **symbol** (for the formatting test) +- **Quantity dimension** (which test file to add tests to) +- **Unit system** (SI, Metric, Imperial, NonStandard → which measure factory to use) +- **Conversion definition** (the `ToSi` transformation, for determining expected values) +- **Related units** (units derived from or related to this one, for cross-conversion tests) + +### Step 2: Locate the test file + +Find the corresponding `{Quantity}Test.cs` file in `source/Atmoos.Quantities.Units.Test/`. If the quantity is new and no test file exists, create one following the naming convention `{Quantity}Test.cs` with namespace `Atmoos.Quantities.Units.Test`. + +### Step 3: Add required imports + +Add any new `using` directives needed for the new unit's namespace at the top of the test file. Only add imports that are not already covered by `Usings.cs`. + +Common patterns: +```csharp +using Atmoos.Quantities.Units.Imperial.{Quantity}; +using Atmoos.Quantities.Units.NonStandard.{Quantity}; +using Atmoos.Quantities.Units.Si.Metric; +using Atmoos.Quantities.Units.Si.Derived; +``` + +### Step 4: Generate tests + +Generate tests from the applicable categories: + +| Category | When to generate | +| --- | --- | +| 1. Formatting | **Always** — every unit needs a formatting test | +| 2. SI Round-Trip | **Always** for non-SI units — convert to SI and back | +| 3. Cross-System | **When** there are related units in other systems | +| 4. Definition Equivalence | **When** the unit has a well-known definition relative to another unit | +| 5. Intra-System | **When** the unit is derived from another unit in the same system | +| 6. Arithmetic | **Optional** — adds cross-unit arithmetic coverage | +| 7. Cross-Quantity | **When** the unit participates in physical laws (e.g., Force = Mass × Acceleration) | +| 8. Prefix Scaling | **When** the unit is metric/SI and supports prefixes | + +### Step 5: Compute expected values + +For each test, compute the expected conversion value from the unit's `ToSi` definition: + +1. **Linear scaling** (`factor * self.RootedIn()`): Multiply/divide by the factor. + - Example: `Foot → Metre`: `1 ft × 3048/1e4 = 0.3048 m` +2. **Offset conversion** (`self.RootedIn() + offset`): Apply offset. + - Example: `Celsius → Kelvin`: `°C + 273.15 = K` +3. **Chained derivation** (`DerivedFrom()`): Compose through the base unit. + - Example: `Mile → Metre` via `Foot`: `5280 × 0.3048 = 1609.344 m` +4. **Use well-known reference values** from Wikipedia or standards documents when available. + +### Step 6: Follow the conventions + +- **Test class**: `public sealed class {Quantity}Test` (sealed, no base class) +- **Namespace**: `Atmoos.Quantities.Units.Test` +- **No constructor or setup**: All tests are independent `[Fact]` methods +- **Naming**: Descriptive method names: `{SourceUnit}To{TargetUnit}`, `Add{UnitA}To{UnitB}`, `DefinitionOf{UnitName}Holds` +- **Type aliases**: Always use `Double`, `String`, `Int32`, `Boolean` (never `double`, etc.) +- **Constants**: Use `private const Double` for conversion factors, `private static readonly` for reused quantities +- **`in` modifier**: Use for struct parameters (consistent with project style) + +### Step 7: Build and run tests + +Build and run the tests to verify correctness: + +```bash +dotnet build source/Atmoos.Quantities.sln +dotnet test source/Atmoos.Quantities.Units.Test/ +``` + +Fix any compilation errors or test failures before finishing. + +--- + +## Measure Factory Quick Reference + +| System | Factory | Example | +| --- | --- | --- | +| SI base | `Si()` | `Si()` | +| SI with prefix | `Si()` | `Si()` | +| Metric | `Metric()` | `Metric()` | +| Metric with prefix | `Metric()` | `Metric()` | +| Imperial | `Imperial()` | `Imperial()` | +| NonStandard | `NonStandard()` | `NonStandard()` | +| Binary with prefix | `Binary()` | `Binary()` | +| Square | `Square({measure})` | `Square(Si())` | +| Cubic | `Cubic({measure})` | `Cubic(Si())` | +| Per (quotient) | `{num}.Per({den})` | `Si().Per(Si())` | +| Product (join) | `Join({symbol}, {left}, {right})` | Used for compound product units | + +--- + +## Example: Complete Test Set for a New Imperial Length Unit + +Suppose a new unit `Fathom` is added to `Atmoos.Quantities.Units.Imperial.Length` with: +- Symbol: `"ftm"` +- Definition: 1 fathom = 2 yards = 6 feet +- Conversion: `6 * self.DerivedFrom()` + +The following tests would be generated in `LengthTest.cs`: + +```csharp +// Category 1: Formatting +[Fact] +public void FathomToString() => FormattingMatches(v => Length.Of(v, Imperial()), "ftm"); + +// Category 2: SI Round-Trip (to SI) +[Fact] +public void FathomToMetre() +{ + Length fathom = Length.Of(1, Imperial()); + Length expected = Length.Of(1.8288, Si()); + + Length actual = fathom.To(Si()); + + actual.Matches(expected); +} + +// Category 2: SI Round-Trip (from SI) +[Fact] +public void MetreToFathom() +{ + Length metres = Length.Of(1.8288, Si()); + Length expected = Length.Of(1, Imperial()); + + Length actual = metres.To(Imperial()); + + actual.Matches(expected); +} + +// Category 4: Definition Equivalence +[Fact] +public void DefinitionOfFathomInFeet() +{ + Length fathom = Length.Of(1, Imperial()); + Length feet = Length.Of(6, Imperial()); + + Assert.Equal(fathom, feet); +} + +// Category 5: Intra-System Conversion +[Fact] +public void FathomToYard() +{ + Length fathom = Length.Of(1, Imperial()); + Length expected = Length.Of(2, Imperial()); + + Length actual = fathom.To(Imperial()); + + actual.Matches(expected); +} + +// Category 3: Cross-System Conversion +[Fact] +public void FathomToKilometre() +{ + Length fathoms = Length.Of(1000, Imperial()); + Length expected = Length.Of(1.8288, Si()); + + Length actual = fathoms.To(Si()); + + actual.Matches(expected); +} +``` + +--- + +## Example: Complete Test Set for a New Metric Area Unit + +Suppose a new unit `Barn` is added to `Atmoos.Quantities.Units.Si.Metric` with: +- Symbol: `"b"` +- Definition: 1 barn = 1e-28 m² +- Implements: `IMetricUnit, IArea, IPowerOf` + +The following tests would be generated in `AreaTest.cs`: + +```csharp +// Category 1: Formatting +[Fact] +public void BarnToString() => FormattingMatches(v => Area.Of(v, Metric()), "b"); + +// Category 2: SI Round-Trip +[Fact] +public void BarnToSquareMetre() +{ + Area barn = Area.Of(1e28, Metric()); + Area expected = Area.Of(1, Square(Si())); + + Area actual = barn.To(Square(Si())); + + actual.Matches(expected); +} + +// Category 2: SI Round-Trip (reverse) +[Fact] +public void SquareMetreToBarn() +{ + Area squareMetre = Area.Of(1, Square(Si())); + Area expected = Area.Of(1e28, Metric()); + + Area actual = squareMetre.To(Metric()); + + actual.Matches(expected); +} + +// Category 8: Prefix Scaling +[Fact] +public void MegaBarnToBarn() +{ + Area megaBarn = Area.Of(1, Metric()); + Area expected = Area.Of(1e6, Metric()); + + Area actual = megaBarn.To(Metric()); + + actual.Matches(expected); +} +``` + +--- + +## Important Conventions + +1. **File-per-dimension**: All tests for units of the same dimension go in the same `{Quantity}Test.cs` file. +2. **Sealed test class**: `public sealed class {Quantity}Test`. +3. **No test base class**: Tests are standalone `[Fact]` methods. +4. **Namespace**: Always `Atmoos.Quantities.Units.Test`. +5. **Exact values**: Use exact conversion factors when possible; use well-known reference values from standards. +6. **Precision parameter**: Omit precision in `Matches()` for exact conversions; use `MediumPrecision`, `LowPrecision`, or `VeryLowPrecision` when floating-point loss is expected. +7. **`PrecisionIsBounded`**: Use for tests focused on numeric precision of a single conversion. +8. **`Matches()`**: Use for tests that verify both value and measure are preserved. +9. **`Assert.Equal()`**: Use for definition tests where only value equality matters. +10. **Use `.NET type aliases`**: `Double`, `String`, `Int32`, `Boolean`. +11. **Add `using` directives**: Only for unit namespaces not covered by `Usings.cs`. +12. **AI-generated files**: If creating an entirely new test file, use the `.ai.` infix: `{Quantity}Test.ai.cs`. diff --git a/.github/agents/new-unit.agent.md b/.github/agents/new-unit.agent.md new file mode 100644 index 00000000..ac4b5f63 --- /dev/null +++ b/.github/agents/new-unit.agent.md @@ -0,0 +1,399 @@ +--- +name: New Unit +description: Generate a new unit of measurement for the Atmoos.Quantities library. +tools: ['edit/editFiles', 'edit/createDirectory', 'edit/createFile', 'search/codebase', 'read/terminalLastCommand', 'web/fetch', 'execute/runInTerminal', 'microsoftdocs/mcp/*','read/problems'] +--- + +# New Unit Generation Agent + +Generate a new unit of measurement for the Atmoos.Quantities library. + +## Instructions + +When asked to create a new unit, follow these steps: + +1. **Identify the unit system** (SI base, SI derived, Metric, Imperial, or NonStandard). +2. **Identify the quantity dimension** the unit measures (e.g., `ILength`, `IMass`, `ITime`, `IPower`). +3. **Determine the conversion** to the SI base unit for that dimension. +4. **Create the unit struct** in the correct project and namespace. +5. **Build and verify** the solution compiles. + +Always adhere to the coding conventions defined in `.github/copilot-instructions.md`. + +--- + +## Unit Systems + +Every unit belongs to one of five systems, determined by its marker interface: + +### 1. SI Base Unit (`ISiUnit`) + +**When**: The unit is one of the seven SI base units (Metre, Second, Kilogram, Kelvin, Ampere, Mole, Candela) or an SI derived unit that serves as the canonical reference for its dimension (Newton, Watt, Joule, Pascal, Volt, Ohm). + +**Key property**: SI base/derived units do **not** implement `ITransform` — they define the reference point. Their `ToSi` transformation is implicitly the identity. + +**Project**: `Atmoos.Quantities` (core) for the seven base units. `Atmoos.Quantities.Units` for derived units (Newton, Watt, etc.). + +**Namespace**: `Atmoos.Quantities.Units.Si` (base) or `Atmoos.Quantities.Units.Si.Derived` (derived). + +**Template**: + +```csharp +using Atmoos.Quantities.Dimensions; + +namespace Atmoos.Quantities.Units.Si; // or .Derived for derived units + +// See: https://en.wikipedia.org/wiki/{UnitName} +public readonly struct {Name} : ISiUnit, I{Dimension} +{ + public static String Representation => "{symbol}"; +} +``` + +**Examples**: `Metre`, `Second`, `Kilogram`, `Newton`, `Watt`, `Joule`, `Pascal`, `Volt`, `Ohm` + +--- + +### 2. Metric Unit (`IMetricUnit`) + +**When**: The unit is accepted by the SI system but is not itself an SI unit. Metric units support SI metric prefixes (`Kilo`, `Mega`, `Milli`, etc.). + +**Key property**: Must implement `ITransform` via `ToSi(Transformation)`. Support metric prefixes by default. + +**Project**: `Atmoos.Quantities` (core) for fundamental metric units (Hour, Minute, Litre, Celsius). `Atmoos.Quantities.Units` for additional metric units. + +**Namespace**: `Atmoos.Quantities.Units.Si.Metric` (optionally with a sub-namespace for grouping, e.g., `UnitsOfInformation`). + +**Template** (simple linear conversion): + +```csharp +using Atmoos.Quantities.Dimensions; + +namespace Atmoos.Quantities.Units.Si.Metric; + +// See: https://en.wikipedia.org/wiki/{UnitName} +public readonly struct {Name} : IMetricUnit, I{Dimension} +{ + public static Transformation ToSi(Transformation self) => {factor} * self.RootedIn<{SiBaseUnit}>(); + + public static String Representation => "{symbol}"; +} +``` + +**Examples**: `Hour`, `Minute`, `Gram`, `Tonne`, `Day`, `Week`, `Are`, `Bar`, `AstronomicalUnit`, `Ångström`, `HorsePower`, `Bit`, `Byte` + +--- + +### 3. Imperial Unit (`IImperialUnit`) + +**When**: The unit belongs to the British imperial measurement system. + +**Key property**: Must implement `ITransform` via `ToSi(Transformation)`. + +**Project**: `Atmoos.Quantities` (core) for foundational imperial units (Foot, Inch, Mile, Pound, Fahrenheit, Pint). `Atmoos.Quantities.Units` for additional imperial units. + +**Namespace**: `Atmoos.Quantities.Units.Imperial.{Quantity}` (grouped by quantity, e.g., `Imperial.Length`, `Imperial.Mass`). + +**Template**: + +```csharp +using Atmoos.Quantities.Dimensions; + +namespace Atmoos.Quantities.Units.Imperial.{Quantity}; + +// See: https://en.wikipedia.org/wiki/{UnitName} +public readonly struct {Name} : IImperialUnit, I{Dimension} +{ + public static Transformation ToSi(Transformation self) => {conversion expression}; + + public static String Representation => "{symbol}"; +} +``` + +**Examples**: `Foot`, `Inch`, `Mile`, `Yard`, `Pound`, `Ounce`, `Fahrenheit`, `Acre`, `Gallon`, `PoundForce` + +--- + +### 4. NonStandard Unit (`INonStandardUnit`) + +**When**: The unit does not belong to any recognised system (not SI, not metric, not imperial). + +**Key property**: Must implement `ITransform` via `ToSi(Transformation)`. + +**Project**: `Atmoos.Quantities.Units`. + +**Namespace**: `Atmoos.Quantities.Units.NonStandard.{Quantity}` (grouped by quantity). + +**Template**: + +```csharp +using Atmoos.Quantities.Dimensions; + +namespace Atmoos.Quantities.Units.NonStandard.{Quantity}; + +// See: https://en.wikipedia.org/wiki/{UnitName} +public readonly struct {Name} : INonStandardUnit, I{Dimension} +{ + public static Transformation ToSi(Transformation self) => {conversion expression}; + + public static String Representation => "{symbol}"; +} +``` + +**Examples**: `NauticalMile`, `LightYear`, `Knot`, `StandardAtmosphere`, `Torr`, `Delisle`, `Morgen`, `Pfund` + +--- + +## Special Unit Patterns + +### Alias Units for Power-of Dimensions (Area, Volume) + +**When**: The unit measures a power-of dimension (e.g., area = length², volume = length³) but has its own name rather than being expressed as a power of a length unit (e.g., Acre, Litre, Are). + +**Additional interface**: Must also implement `IPowerOf` and explicitly implement `ISystemInject.Inject()`. + +**Template**: + +```csharp +using Atmoos.Quantities.Dimensions; +using Atmoos.Quantities.Units.{SystemPath}; // for the length unit used in Inject + +namespace Atmoos.Quantities.Units.{System}.{Quantity}; + +// See: https://en.wikipedia.org/wiki/{UnitName} +public readonly struct {Name} : I{SystemMarker}, I{Dimension}, IPowerOf +{ + public static Transformation ToSi(Transformation self) => {conversion expression}; + + static T ISystemInject.Inject(ISystems basis) => basis.{System}<{LengthUnit}>(); + + public static String Representation => "{symbol}"; +} +``` + +The `Inject` method specifies which length unit to use for display purposes when deconstructing. Examples: +- `basis.Si()` for metric/SI area/volume units +- `basis.Imperial()` or `basis.Imperial()` for imperial volume units +- `basis.Imperial()` for imperial area units like Acre + +**Examples**: `Litre`, `Lambda`, `Stere`, `Are`, `Acre`, `Perch`, `Rood`, `Morgen`, `Pint`, `Gallon`, `FluidOunce` + +--- + +### Invertible Units + +**When**: The unit measures a quantity that is the inverse of a base dimension (e.g., Hertz = 1/Time). + +**Additional interface**: Must implement `IInvertible` and explicitly implement `ISystemInject.Inject()`. + +**Template**: + +```csharp +using Atmoos.Quantities.Dimensions; + +namespace Atmoos.Quantities.Units.Si.Derived; + +public readonly struct {Name} : ISiUnit, I{Dimension}, IInvertible +{ + public static Transformation ToSi(Transformation self) => self; + + static T ISystemInject.Inject(ISystems basis) => basis.Si<{InverseUnit}>(); + + public static String Representation => "{symbol}"; +} +``` + +**Examples**: `Hertz` (frequency, inverse of `ITime`, injects `Second`) + +--- + +### Compound Quantity Units (Velocity, DataRate, etc.) + +**When**: The unit measures a compound quantity (quotient, product) using a single named unit rather than a compound expression. + +The unit simply implements the compound dimension interface and provides its `ToSi` conversion. No special additional interfaces are needed beyond the standard pattern. + +**Template**: + +```csharp +using Atmoos.Quantities.Dimensions; +using static Atmoos.Quantities.Extensions; + +namespace Atmoos.Quantities.Units.{System}.{Quantity}; + +// See: https://en.wikipedia.org/wiki/{UnitName} +public readonly struct {Name} : I{SystemMarker}, I{Dimension} +{ + public static Transformation ToSi(Transformation self) => {conversion expression}; + + public static String Representation => "{symbol}"; +} +``` + +For compound units, the conversion often involves `ValueOf()` to incorporate the conversion factor of a related unit. + +**Examples**: `Knot` (velocity: `self.DerivedFrom() / ValueOf()`) + +--- + +## Conversion Expressions + +The `ToSi` method defines how to convert from the new unit to the SI reference. Three key helpers are available: + +### `RootedIn()` + +Use when defining conversion **directly relative to the SI base/derived unit** for the dimension. The constraint is `TSi : ISiUnit`. This is a no-op that documents the SI anchor. + +```csharp +// Foot → Metre (SI base for length) +public static Transformation ToSi(Transformation self) => 3048 * self.RootedIn() / 1e4; + +// Celsius → Kelvin (SI base for temperature), with offset +public static Transformation ToSi(Transformation self) => self.RootedIn() + 273.15d; + +// Gram → Kilogram (SI base for mass) +public static Transformation ToSi(Transformation self) => self.RootedIn() / 1000; +``` + +### `DerivedFrom()` + +Use when defining conversion **relative to another non-SI unit**. This chains through `TBasis.ToSi()`, composing transformations. The constraint is `TBasis : IUnit, ITransform`. + +```csharp +// Mile derived from Foot: 1 mi = 5280 ft +public static Transformation ToSi(Transformation self) => 5280 * self.DerivedFrom(); + +// Inch derived from Foot: 1 ft = 12 in +public static Transformation ToSi(Transformation self) => self.DerivedFrom() / 12; + +// Day derived from Hour: 1 d = 24 h +public static Transformation ToSi(Transformation self) => 24 * self.DerivedFrom(); + +// Week derived from Day: 1 w = 7 d +public static Transformation ToSi(Transformation self) => 7 * self.DerivedFrom(); + +// Torr derived from StandardAtmosphere: 1 atm = 760 Torr +public static Transformation ToSi(Transformation self) => self.DerivedFrom() / 760; +``` + +### `ValueOf()` + +Use when a compound quantity unit needs the numeric conversion factor of another unit. Returns the evaluated polynomial at value 1.0, optionally raised to an exponent. + +```csharp +// Knot = NauticalMile per Hour +public static Transformation ToSi(Transformation self) => self.DerivedFrom() / ValueOf(); +``` + +### `FusedMultiplyAdd(multiplicand, addend)` + +Use for complex affine transformations that combine multiplication and addition efficiently: + +```csharp +// GasMark: [K] = [GM] × 125/9 + (5 × 218 + 9 × 273.15) / 9 +public static Transformation ToSi(Transformation self) => self.FusedMultiplyAdd(125, 5d * 218d + 9 * 273.15d) / 9; +``` + +### Conversion Guidelines + +1. **Pure scaling** (no offset): Use multiplication and division operators. + - `factor * self.RootedIn()` for scaling up from the new unit + - `self.RootedIn() / factor` for scaling down +2. **Offset conversions** (e.g., temperature): Use `+` and `-` operators after scaling. + - `self.RootedIn() + 273.15d` for Celsius +3. **Chained conversions**: Use `DerivedFrom()` to build on existing unit conversions. +4. **Keep conversions exact**: Use integer arithmetic or exact decimal fractions where possible. +5. **Internal constants**: Use `internal const` fields for conversion factors that may be reused. + +--- + +## Step-by-Step Procedure + +### Step 1: Determine the unit system and dimension + +Identify: +- **System**: Is it SI, Metric, Imperial, or NonStandard? +- **Dimension**: What physical quantity does it measure? Map to the existing dimension interface (e.g., `ILength`, `IMass`, `ITime`, `IPower`, `IArea`, `IVelocity`). +- **Special traits**: Is it an alias for a power-of dimension (`IPowerOf`)? Is it invertible (`IInvertible`)? Is it a compound quantity unit? + +### Step 2: Determine the conversion to SI + +Research the exact conversion factor or formula: +- Find the relationship to the SI reference unit for the dimension. +- Decide whether to use `RootedIn()` (direct SI reference) or `DerivedFrom()` (chained through another unit). +- For temperature-like units with offsets, include the offset in the transformation. + +### Step 3: Choose the correct project + +- **`Atmoos.Quantities` (core project)**: Only for the foundational units that the core library depends on. These are the SI base units (Metre, Second, Kilogram, Kelvin, Ampere, Candela, Mole), plus a small set of essential metric units (Hour, Minute, Litre, Celsius) and foundational imperial units (Foot, Inch, Mile, Pound, Fahrenheit, Pint). +- **`Atmoos.Quantities.Units`**: For all other units. This is where new units should almost always go. + +### Step 4: Create the unit struct + +Create the file in the correct location: + +| System | Path Pattern | +| ----------- | --------------------------------------------------------------------------------- | +| SI base | `source/Atmoos.Quantities/Units/Si/{Name}.cs` | +| SI derived | `source/Atmoos.Quantities.Units/Si/Derived/{Name}.cs` | +| Metric | `source/Atmoos.Quantities.Units/Si/Metric/{Name}.cs` | +| Imperial | `source/Atmoos.Quantities.Units/Imperial/{Quantity}/{Name}.cs` | +| NonStandard | `source/Atmoos.Quantities.Units/NonStandard/{Quantity}/{Name}.cs` | + +Use the appropriate template from the unit system categories above. Ensure: +- The struct is `public readonly struct`. +- It implements the correct system marker interface (`ISiUnit`, `IMetricUnit`, `IImperialUnit`, or `INonStandardUnit`). +- It implements the dimension interface (e.g., `ILength`, `IMass`). +- It provides a `static String Representation` property with the standard symbol. +- For non-SI units, it implements `ToSi(Transformation)` with the correct conversion. +- Add a Wikipedia link comment for reference. + +### Step 5: Verify + +Build the solution to ensure everything compiles: + +``` +dotnet build source/Atmoos.Quantities.sln +``` + +--- + +## Quick Reference: Existing Dimension-Unit Mappings + +| Dimension | SI Reference Unit | Dimension Interface | +| ---------------------- | --------------------- | ---------------------- | +| Length | Metre | `ILength` | +| Time | Second | `ITime` | +| Mass | Kilogram | `IMass` | +| Electric Current | Ampere | `IElectricCurrent` | +| Temperature | Kelvin | `ITemperature` | +| Amount of Substance | Mole | `IAmountOfSubstance` | +| Luminous Intensity | Candela | `ILuminousIntensity` | +| Area | Metre² (Square) | `IArea` | +| Volume | Metre³ (Cubic) | `IVolume` | +| Velocity | m/s | `IVelocity` | +| Acceleration | m/s² | `IAcceleration` | +| Force | Newton | `IForce` | +| Power | Watt | `IPower` | +| Energy | Joule | `IEnergy` | +| Frequency | Hertz | `IFrequency` | +| Pressure | Pascal | `IPressure` | +| Electric Potential | Volt | `IElectricPotential` | +| Electrical Resistance | Ohm | `IElectricalResistance`| +| Amount of Information | Bit (metric) | `IAmountOfInformation` | +| Information Rate | bit/s | `IInformationRate` | +| Mass Flow | kg/s | `IMassFlow` | + +--- + +## Important Conventions + +1. **`readonly struct`**: All units are `public readonly struct`. +2. **Single file per unit**: Each unit gets its own `.cs` file. +3. **Namespace matches directory**: The namespace must match the file's directory path. +4. **.NET type aliases**: Always use `Double`, `String`, `Int32`, etc. — never `double`, `string`, `int`. +5. **`Representation`**: Return the standard symbol string (e.g., `"m"`, `"kg"`, `"°C"`, `"ft"`). +6. **No constructor needed**: Units are marker structs — they carry no instance state. +7. **Wikipedia references**: Add `// See: https://en.wikipedia.org/wiki/{UnitArticle}` comments. +8. **Conversion accuracy**: Prefer exact integer/rational arithmetic in `ToSi` over floating-point approximations. +9. **`using` directives**: Always include `using Atmoos.Quantities.Dimensions;`. Add other `using` directives only as needed (e.g., `using static Atmoos.Quantities.Extensions;` when using `ValueOf()`). diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..369a082f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,540 @@ +# GitHub Copilot Instructions + +**When generating, editing, or suggesting code for this project, strictly adhere to the following coding conventions and style guidelines.** + +## Language and Locale + +- Use **en-GB** for all generated content (for example: spelling, grammar, and phrasing in code, comments, generated plans & agents, docs, commit messages, and explanations). + +--- + +# Code Style Guidelines + +This document describes the coding conventions and style guidelines for the Atmoos.Quantities project. + +## Language and Framework + +- **Language**: C# (using latest language features including extensions, file-scoped types) +- **Framework**: .NET (modern versions) +- **Style**: Explicit, strongly-typed, performance-conscious + +## AI Transparency + +It is made transparent which code is AI-generated by using file naming conventions and attributes to indicate AI-generated content. + +Annotations within a file are done with the `AiAttribute` from `AiAttribute.cs` to indicate which types and methods were generated by AI tooling. Set the attribute metadata to the model used for the prompt that produced the method. + +### AI-Generated Files + +Files that are **completely AI-generated** should use the `.ai.` infix in their filename to clearly indicate their origin: + +**Pattern**: `{name}.ai.{extension}` + +**Examples**: +- `NumericalStabilityProbe.ai.cs` - AI-generated C# test file +- `Documentation.ai.md` - AI-generated markdown documentation + +This convention makes it immediately obvious which files were created entirely by AI tooling (like GitHub Copilot) versus human-authored code. Use this pattern consistently for full-file AI generations. + +### AI-Generated Types + +Any **AI-generated type** (class, struct, record, interface) must be annotated with the `AiAttribute` from `AiAttribute.cs`. + +Example: + +```csharp +[Ai(Model = "Claude", Version = "4.6", Variant = "Opus")] +public readonly struct RevolutionsPerMinute : INonStandardUnit, IFrequency, IInvertible +{ + public static Transformation ToSi(Transformation self) => self / 60; + + static T ISystemInject.Inject(ISystems basis) => basis.Si(); + + public static String Representation => "rpm"; +} +``` + +### AI-Generated Public Methods + +Any **AI-generated `public` method** must be annotated with `AiAttribute` from `AiAttribute.cs`. + +*Exceptions* + +- The method is within an AI-generated type that is already annotated with `AiAttribute`. +- The method was only *changed* by AI tooling but was originally human-authored. In this case, the method should not be annotated to avoid confusion about its origin. + +Example: + +```csharp +[Ai(Model = "GPT", Version = "5.3", Variant = "Codex")] +public static Length Of(in Double value, in Scalar measure) + where TLength : ILength, IUnit => new(measure.Create(in value)); +``` + +Example for **test methods**: + +```csharp +[Fact] +[Ai(Model = "Claude", Version = "4.5", Variant = "Sonnet")] +public void RootByOtherDividesOuterExponent() +{ + Dimension self = Dim - + diff --git a/source/Atmoos.Quantities.Test.props b/source/Atmoos.Quantities.Test.props index d4b699cf..0a3603d3 100644 --- a/source/Atmoos.Quantities.Test.props +++ b/source/Atmoos.Quantities.Test.props @@ -1,9 +1,9 @@ - + false - $(MSBuildStartupDirectory)\.runsettings + $(SolutionDir)\.runsettings diff --git a/source/Atmoos.Quantities.Test/Common/IntrospectionTest.ai.cs b/source/Atmoos.Quantities.Test/Common/IntrospectionTest.ai.cs new file mode 100644 index 00000000..dd86ea57 --- /dev/null +++ b/source/Atmoos.Quantities.Test/Common/IntrospectionTest.ai.cs @@ -0,0 +1,86 @@ +using Atmoos.Quantities.Common; + +namespace Atmoos.Quantities.Test.Common; + +[Ai(Model = "GPT", Version = "5.3", Variant = "Codex")] +public sealed class IntrospectionTest +{ + [Fact] + public void MostDerivedOfReturnsDeepestInterface() + { + Type actual = typeof(MultiLevelImplementation).MostDerivedOf(typeof(ITopLevel)); + + Assert.Equal(typeof(IDeepest), actual); + } + + [Fact] + public void MostDerivedOfGenericOverloadReturnsDeepestInterface() + { + Type actual = typeof(MultiLevelImplementation).MostDerivedOf(); + + Assert.Equal(typeof(IDeepest), actual); + } + + [Fact] + public void InnerTypeReturnsOnlyGenericArgument() + { + Type actual = typeof(SingleGenericImplementation).InnerType(typeof(ISingleGeneric<>)); + + Assert.Equal(typeof(Int32), actual); + } + + [Fact] + public void InnerTypeThrowsWhenNoGenericMatchExists() + { + Assert.Throws(() => typeof(NoGenericImplementation).InnerType(typeof(ISingleGeneric<>))); + } + + [Fact] + public void InnerTypeThrowsWhenMultipleGenericMatchesExist() + { + Assert.Throws(() => typeof(MultiGenericImplementation).InnerType(typeof(ISingleGeneric<>))); + } + + [Fact] + public void InnerTypesReturnsArgumentsForInterfaceInputs() + { + Type[] actual = typeof(ISingleGeneric).InnerTypes(typeof(ISingleGeneric<>)); + + Assert.Equal([typeof(Int32)], actual); + } + + [Fact] + public void InnerTypesReturnsMultipleArgumentsForInterfaceInputs() + { + Type[] actual = typeof(MultiGenericImplementation).InnerTypes(typeof(ISingleGeneric<>)); + + Assert.Equal([typeof(Int32), typeof(Double)], actual); + } + + [Fact] + public void ImplementsAndImplementsGenericReturnExpectedValues() + { + Assert.True(typeof(MultiLevelImplementation).Implements(typeof(ITopLevel))); + Assert.False(typeof(MultiLevelImplementation).Implements(typeof(IDisposable))); + Assert.True(typeof(ISingleGeneric).ImplementsGeneric(typeof(ISingleGeneric<>))); + Assert.False(typeof(Int32).ImplementsGeneric(typeof(ISingleGeneric<>))); + } + + private interface ITopLevel; + + private interface IMiddle : ITopLevel; + + private interface IDeepest : IMiddle; + + private interface IAlternative : ITopLevel; + + private sealed class MultiLevelImplementation : IDeepest, IAlternative; + + private interface ISingleGeneric; + + private sealed class SingleGenericImplementation : ISingleGeneric; + + private sealed class NoGenericImplementation; + + private sealed class MultiGenericImplementation : ISingleGeneric, ISingleGeneric; +} diff --git a/source/Atmoos.Quantities.Test/Convenience.cs b/source/Atmoos.Quantities.Test/Convenience.cs index 378ca8f0..28ba4785 100644 --- a/source/Atmoos.Quantities.Test/Convenience.cs +++ b/source/Atmoos.Quantities.Test/Convenience.cs @@ -11,13 +11,7 @@ internal static Polynomial Poly(in Double nominator = 1, in Double denominator = internal static Dimension Copy(this Dimension d) => d.Pow(-1).Pow(-1); - public static TheoryData ToTheoryData(params TData[] data) - { - return data.Aggregate(new TheoryData(), Add); - static TheoryData Add(TheoryData td, TData item) - { - td.Add(item); - return td; - } - } + public static TheoryData ToTheoryData(this IEnumerable data) => [.. data]; + + public static TheoryData ToTheoryData(params TData[] data) => data.ToTheoryData(); } diff --git a/source/Atmoos.Quantities.Test/Core/ProductInjectorTest.ai.cs b/source/Atmoos.Quantities.Test/Core/ProductInjectorTest.ai.cs new file mode 100644 index 00000000..b8e7e1a1 --- /dev/null +++ b/source/Atmoos.Quantities.Test/Core/ProductInjectorTest.ai.cs @@ -0,0 +1,74 @@ +using Atmoos.Quantities.Core.Construction; +using Atmoos.Quantities.Core.Numerics; +using Atmoos.Quantities.Measures; +using Atmoos.Quantities.Serialization; +using Atmoos.Quantities.Units.Si.Metric; + +namespace Atmoos.Quantities.Test.Core; + +[Ai(Model = "GPT", Version = "5.3", Variant = "Codex")] +public sealed class ProductInjectorTest +{ + [Fact] + public void LeftTermBuildReturnsTheInjectedScalarMeasure() + { + Double value = 12.5; + IInject injector = new ProductInjector(1, 1); + IBuilder left = injector.Inject>(); + Quantity expected = Quantity.Of>(in value); + + Quantity actual = left.Build(in value); + + Assert.True(actual.EqualsExactly(expected)); + } + + [Fact] + public void PositivePositiveExponentsCreateDirectProduct() + { + AssertProductBuild(1, 1, static value => Quantity.Of, Metric>>(value)); + } + + [Fact] + public void PositiveNegativeExponentsCreateRightInverseProduct() + { + AssertProductBuild(1, -1, static value => Quantity.Of, Power, Negative>>>(value)); + } + + [Fact] + public void NegativePositiveExponentsCreateLeftInverseProduct() + { + AssertProductBuild(-1, 1, static value => Quantity.Of, Negative>, Metric>>(value)); + } + + [Fact] + public void NegativeNegativeExponentsCreateInverseOfProduct() + { + AssertProductBuild(-1, -1, static value => Quantity.Of, Metric>, Negative>>(value)); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(1, 0)] + [InlineData(0, 0)] + public void ZeroExponentCombinationsAreRejected(Int32 leftExponent, Int32 rightExponent) + { + IInject injector = new ProductInjector(leftExponent, rightExponent); + IBuilder left = injector.Inject>(); + IInject right = Assert.IsAssignableFrom>(left); + + Assert.Throws(() => right.Inject>()); + } + + private static void AssertProductBuild(Int32 leftExponent, Int32 rightExponent, Func expected) + { + Double value = 3.75; + IInject injector = new ProductInjector(leftExponent, rightExponent); + IBuilder left = injector.Inject>(); + IInject right = Assert.IsAssignableFrom>(left); + IBuilder product = right.Inject>(); + + Quantity actual = product.Build(in value); + + Assert.True(actual.EqualsExactly(expected(value))); + } +} diff --git a/source/Atmoos.Quantities.Test/Dimensions/ProductTest.cs b/source/Atmoos.Quantities.Test/Dimensions/ProductTest.cs index 9f171997..ae193e1c 100644 --- a/source/Atmoos.Quantities.Test/Dimensions/ProductTest.cs +++ b/source/Atmoos.Quantities.Test/Dimensions/ProductTest.cs @@ -101,6 +101,31 @@ public void RaisedToThePowerOfOneIsTheSameInstance() Assert.Same(someProduct, actual); } + [Fact] + [Ai(Model = "Claude", Version = "4.6", Variant = "Opus")] + public void RootByZeroThrows() + { + Assert.Throws(() => someProduct.Root(0)); + } + + [Fact] + [Ai(Model = "Claude", Version = "4.6", Variant = "Opus")] + public void RootByOneIsTheSameInstance() + { + var actual = someProduct.Root(1); + + Assert.Same(someProduct, actual); + } + + [Fact] + [Ai(Model = "Claude", Version = "4.6", Variant = "Opus")] + public void ProductByIdentityIsTheSameInstance() + { + var actual = someProduct * Unit.Identity; + + Assert.Same(someProduct, actual); + } + [Fact] public void ProductOfWithScalarIsOfTypeProduct() { @@ -113,6 +138,19 @@ public void ProductOfDifferingProductsIsOfTypeProduct() Assert.IsType(Dim