From a63eb49b6f38334df4e06d4a909f5c8303c9a925 Mon Sep 17 00:00:00 2001 From: Tim Alenus Date: Thu, 23 Jul 2026 16:18:26 +0200 Subject: [PATCH] feat: energy density --- CHANGELOG.md | 6 + README.md | 5 + lib/energy_density.dart | 10 + lib/quantify.dart | 4 + .../units/energy_density/energy_density.dart | 139 ++++++++++ .../energy_density_extensions.dart | 40 +++ .../energy_density_factors.dart | 16 ++ .../energy_density/energy_density_unit.dart | 78 ++++++ test/units/energy_density_parsing_test.dart | 119 +++++++++ test/units/energy_density_test.dart | 241 ++++++++++++++++++ 10 files changed, 658 insertions(+) create mode 100644 lib/energy_density.dart create mode 100644 lib/src/units/energy_density/energy_density.dart create mode 100644 lib/src/units/energy_density/energy_density_extensions.dart create mode 100644 lib/src/units/energy_density/energy_density_factors.dart create mode 100644 lib/src/units/energy_density/energy_density_unit.dart create mode 100644 test/units/energy_density_parsing_test.dart create mode 100644 test/units/energy_density_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 170d640..e173985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the `quantify` package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +* Added `EnergyDensity`, a new linear quantity for energy per unit volume (dimensionally equivalent to `Pressure`), supporting `J/m³`, `J/L`, and `Wh/L`, with `EnergyDensity.from(Energy, Volume)`, `energyOf(Volume)`, and `volumeFor(Energy)` dimensional-analysis helpers, plus a granular `energy_density.dart` entry point. + ## [0.20.1] ### Fixed diff --git a/README.md b/README.md index 758f200..991dc99 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,7 @@ The library supports a comprehensive range of physical quantities, including all | **Torque** | ✅ | **`N·m`** (newton-meter), `mN·m`, `kN·m`, `MN·m`, `lbf·ft`, `lbf·in`, `kgf·m`, `ozf·in`, `dyn·cm` | Derived SI: N·m. Dimensionally = Energy but type-distinct. | | **Power** | ✅ | **`W`** (Watt), `mW`, `kW`, `MW`, `GW`, `TW`, `μW`, `nW`, `hp`, `PS` (metric hp), `Btu/h`, `erg/s` | Derived SI: J/s | | **Density** | ✅ | **`kg/m³`** (kilogram per cubic meter), `g/cm³`, `g/mL` | Derived SI: kg/m³ | +| **Energy Density** | ✅ | **`J/m³`** (joule per cubic meter), `J/L`, `Wh/L` | Derived SI: J/m³. Dimensionally = Pressure but type-distinct. | | **Specific Energy** | ✅ | **`J/kg`** (joule per kilogram), `kJ/kg`, `Wh/kg`, `kWh/kg` | Derived SI: J/kg | | **Information** | ✅ | **`bit`**, `B` (byte); SI bits: `kbit`…`Ybit`; SI bytes: `kB`…`YB`; IEC binary: `KiB`…`YiB` | IEC 80000-13. Three tracks: SI bit, SI byte, IEC binary. | | **Voltage** | ✅ | **`V`** (volt), `nV`, `µV`, `mV`, `kV`, `MV`, `GV`, `statV`, `abV` | Derived SI: W/A. Also: `ElectricPotential` type alias. | @@ -420,6 +421,10 @@ final angle = av.totalAngleOver(2.minutes); // 2.0 revolutions final density = Density.from(13.5.g, 1.0.cm3); // 13.5 g/cm³ ← result in g/cm³ final mass = density.massOf(10.cm3); // 135.0 g ← result in g +// EnergyDensity = Energy / Volume ↔ energy/volume retrieval +final energyDensity = EnergyDensity.from(2.Wh, 1.0.L); // 2.0 Wh/L ← result in Wh/L +final storedEnergy = energyDensity.energyOf(5.L); // 10.0 Wh ← result in Wh + // SpecificEnergy = Energy / Mass ↔ energy retrieval final specific = SpecificEnergy.from(1.kJ, 0.5.kg); // 2.0 kJ/kg ← result in kJ/kg final stored = specific.energyIn(10.kg); // 20.0 kJ ← result in kJ diff --git a/lib/energy_density.dart b/lib/energy_density.dart new file mode 100644 index 0000000..206e3a4 --- /dev/null +++ b/lib/energy_density.dart @@ -0,0 +1,10 @@ +/// Exports the 'EnergyDensity' quantity, its units, and convenience extensions. +/// +/// This library provides all necessary components to work with energy density +/// conversions and calculations. +library; + +export 'src/units/energy_density/energy_density.dart'; +export 'src/units/energy_density/energy_density_extensions.dart'; +export 'src/units/energy_density/energy_density_factors.dart'; +export 'src/units/energy_density/energy_density_unit.dart'; diff --git a/lib/quantify.dart b/lib/quantify.dart index 2fd825b..a0985ae 100644 --- a/lib/quantify.dart +++ b/lib/quantify.dart @@ -44,6 +44,10 @@ export 'src/units/electric_charge/electric_charge_unit.dart'; export 'src/units/energy/energy.dart'; export 'src/units/energy/energy_extensions.dart'; export 'src/units/energy/energy_unit.dart'; +// Exporting units and their extensions for energy density +export 'src/units/energy_density/energy_density.dart'; +export 'src/units/energy_density/energy_density_extensions.dart'; +export 'src/units/energy_density/energy_density_unit.dart'; // Exporting units and their extensions for force export 'src/units/force/force.dart'; export 'src/units/force/force_extensions.dart'; diff --git a/lib/src/units/energy_density/energy_density.dart b/lib/src/units/energy_density/energy_density.dart new file mode 100644 index 0000000..9de75c4 --- /dev/null +++ b/lib/src/units/energy_density/energy_density.dart @@ -0,0 +1,139 @@ +import 'package:meta/meta.dart'; + +import '../../core/linear_quantity.dart'; +import '../../core/quantity_format.dart'; +import '../../core/quantity_parser.dart'; +import '../energy/energy.dart'; +import '../energy/energy_extensions.dart'; +import '../energy/energy_unit.dart'; +import '../volume/volume.dart'; +import '../volume/volume_extensions.dart'; +import '../volume/volume_unit.dart'; +import 'energy_density_unit.dart'; + +/// Represents a quantity of energy density. +/// +/// Energy density is a physical quantity representing the amount of energy +/// stored per unit volume. It is dimensionally identical to Pressure +/// (J/m³ ≡ Pa), but is modeled as a distinct type to preserve semantic +/// meaning. The SI derived unit is Joule per Cubic Meter (J/m³). +@immutable +final class EnergyDensity extends LinearQuantity { + /// Creates a new `EnergyDensity` with a given [value] and [unit]. + const EnergyDensity(super._value, super._unit); + + /// Creates an `EnergyDensity` from [energy] and [volume] (u = E / V). + /// + /// If the combination of [energy]'s unit and [volume]'s unit matches a + /// standard energy density unit (J + m³ → J/m³, J + L → J/L, Wh + L → Wh/L), + /// the result uses that unit. Otherwise the result is in + /// [EnergyDensityUnit.joulePerCubicMeter]. + /// If [volume] is zero, the result follows IEEE 754 semantics. + /// + /// ```dart + /// EnergyDensity.from(1000.J, 2.m3); // 500.0 J/m³ + /// EnergyDensity.from(500.J, 1.0.L); // 500.0 J/L + /// EnergyDensity.from(1.Wh, 1.0.L); // 1.0 Wh/L + /// ``` + factory EnergyDensity.from(Energy energy, Volume volume) { + final target = _correspondingEnergyDensityUnit(energy.unit, volume.unit); + if (target != null) return EnergyDensity(energy.value / volume.value, target); + return EnergyDensity( + energy.inJoules / volume.inCubicMeters, + EnergyDensityUnit.joulePerCubicMeter, + ); + } + + /// Maps an [EnergyUnit] × [VolumeUnit] pair to its natural [EnergyDensityUnit]. + static EnergyDensityUnit? _correspondingEnergyDensityUnit(EnergyUnit e, VolumeUnit v) => + switch ((e, v)) { + (EnergyUnit.joule, VolumeUnit.cubicMeter) => EnergyDensityUnit.joulePerCubicMeter, + (EnergyUnit.joule, VolumeUnit.litre) => EnergyDensityUnit.joulePerLiter, + (EnergyUnit.wattHour, VolumeUnit.litre) => EnergyDensityUnit.wattHourPerLiter, + _ => null, + }; + + @override + @protected + EnergyDensity create(double value, EnergyDensityUnit unit) => EnergyDensity(value, unit); + + /// The parser instance used to convert strings into [EnergyDensity] objects. + /// + /// The parser supports both strict symbol aliases and case-insensitive name + /// aliases configured in [EnergyDensityUnit]. + static final QuantityParser parser = + QuantityParser( + symbolAliases: EnergyDensityUnit.symbolAliases, + nameAliases: EnergyDensityUnit.nameAliases, + factory: EnergyDensity.new, + ); + + /// Parses a string representation of energy density into an [EnergyDensity] object. + /// + /// The [formats] list controls how the numeric portion is interpreted. + static EnergyDensity parse( + String input, { + List formats = const [QuantityFormat.invariant], + }) { + return parser.parse(input, formats: formats); + } + + /// Parses a string representation of energy density into an [EnergyDensity] object, + /// returning `null` when parsing fails. + /// + /// See [parse] for formatting and matching behavior. + static EnergyDensity? tryParse( + String input, { + List formats = const [QuantityFormat.invariant], + }) { + return parser.tryParse(input, formats: formats); + } + + // --- Dimensional Analysis --- + + /// Calculates the [Energy] stored in a given [Volume] at this energy density. + /// + /// The result's unit matches the energy component of this energy density's + /// unit: `J/m³` → joules, `J/L` → joules, `Wh/L` → watt-hours. + /// + /// ```dart + /// final density = 500.joulesPerLiter; + /// density.energyOf(2.0.L); // 1000.0 J + /// ``` + Energy energyOf(Volume volume) { + final energyUnit = _correspondingEnergyUnit(unit); + final volUnit = _correspondingVolumeUnit(unit); + return Energy(value * volume.getValue(volUnit), energyUnit); + } + + /// Calculates the [Volume] required to store a given amount of [Energy] + /// at this energy density. + /// + /// The result's unit matches the volume component of this energy density's + /// unit: `J/m³` → m³, `J/L` → L, `Wh/L` → L. + /// If the energy density is zero, the result follows IEEE 754 semantics. + /// + /// ```dart + /// final density = 500.joulesPerLiter; + /// density.volumeFor(1000.J); // 2.0 L + /// ``` + Volume volumeFor(Energy energy) { + final energyUnit = _correspondingEnergyUnit(unit); + final volUnit = _correspondingVolumeUnit(unit); + return Volume(energy.getValue(energyUnit) / value, volUnit); + } + + /// Maps an [EnergyDensityUnit] to its energy component unit. + static EnergyUnit _correspondingEnergyUnit(EnergyDensityUnit u) => switch (u) { + EnergyDensityUnit.joulePerCubicMeter => EnergyUnit.joule, + EnergyDensityUnit.joulePerLiter => EnergyUnit.joule, + EnergyDensityUnit.wattHourPerLiter => EnergyUnit.wattHour, + }; + + /// Maps an [EnergyDensityUnit] to its volume component unit. + static VolumeUnit _correspondingVolumeUnit(EnergyDensityUnit u) => switch (u) { + EnergyDensityUnit.joulePerCubicMeter => VolumeUnit.cubicMeter, + EnergyDensityUnit.joulePerLiter => VolumeUnit.litre, + EnergyDensityUnit.wattHourPerLiter => VolumeUnit.litre, + }; +} diff --git a/lib/src/units/energy_density/energy_density_extensions.dart b/lib/src/units/energy_density/energy_density_extensions.dart new file mode 100644 index 0000000..bc155e4 --- /dev/null +++ b/lib/src/units/energy_density/energy_density_extensions.dart @@ -0,0 +1,40 @@ +import 'energy_density.dart'; +import 'energy_density_unit.dart'; + +/// Provides convenient access to [EnergyDensity] values in specific units. +extension EnergyDensityValueGetters on EnergyDensity { + /// Returns the energy density value in Joules per Cubic Meter (J/m³). + double get inJoulesPerCubicMeter => getValue(EnergyDensityUnit.joulePerCubicMeter); + + /// Returns the energy density value in Joules per Liter (J/L). + double get inJoulesPerLiter => getValue(EnergyDensityUnit.joulePerLiter); + + /// Returns the energy density value in Watt-hours per Liter (Wh/L). + double get inWattHoursPerLiter => getValue(EnergyDensityUnit.wattHourPerLiter); + + /// Returns a new [EnergyDensity] object representing this energy density in + /// Joules per Cubic Meter (J/m³). + EnergyDensity get asJoulesPerCubicMeter => convertTo(EnergyDensityUnit.joulePerCubicMeter); + + /// Returns a new [EnergyDensity] object representing this energy density in + /// Joules per Liter (J/L). + EnergyDensity get asJoulesPerLiter => convertTo(EnergyDensityUnit.joulePerLiter); + + /// Returns a new [EnergyDensity] object representing this energy density in + /// Watt-hours per Liter (Wh/L). + EnergyDensity get asWattHoursPerLiter => convertTo(EnergyDensityUnit.wattHourPerLiter); +} + +/// Provides convenient factory methods for creating [EnergyDensity] instances from [num]. +extension EnergyDensityCreation on num { + /// Creates an [EnergyDensity] instance from this value in Joules per Cubic Meter (J/m³). + EnergyDensity get joulesPerCubicMeter => + EnergyDensity(toDouble(), EnergyDensityUnit.joulePerCubicMeter); + + /// Creates an [EnergyDensity] instance from this value in Joules per Liter (J/L). + EnergyDensity get joulesPerLiter => EnergyDensity(toDouble(), EnergyDensityUnit.joulePerLiter); + + /// Creates an [EnergyDensity] instance from this value in Watt-hours per Liter (Wh/L). + EnergyDensity get wattHoursPerLiter => + EnergyDensity(toDouble(), EnergyDensityUnit.wattHourPerLiter); +} diff --git a/lib/src/units/energy_density/energy_density_factors.dart b/lib/src/units/energy_density/energy_density_factors.dart new file mode 100644 index 0000000..e04f197 --- /dev/null +++ b/lib/src/units/energy_density/energy_density_factors.dart @@ -0,0 +1,16 @@ +/// Defines base conversion factors for various energy density units relative to +/// the Joule per Cubic Meter (J/m³), which is the SI derived unit for energy +/// density. +/// +/// These constants represent: `1 [Unit] = Z [Joules Per Cubic Meter]`. +class EnergyDensityFactors { + /// Joules per Cubic Meter: 1 J/m³ = 1.0 J/m³. + static const double joulePerCubicMeter = 1; + + /// Joules per Liter: 1 J/L = 1000 J/m³ (since 1 L = 0.001 m³). + static const double joulePerLiter = 1000; + + /// Watt-hours per Liter: 1 Wh/L = 3,600,000 J/m³ + /// (since 1 Wh = 3600 J and 1 L = 0.001 m³, so 1 Wh/L = 3600 J / 0.001 m³). + static const double wattHourPerLiter = 3600000; +} diff --git a/lib/src/units/energy_density/energy_density_unit.dart b/lib/src/units/energy_density/energy_density_unit.dart new file mode 100644 index 0000000..38dfb4d --- /dev/null +++ b/lib/src/units/energy_density/energy_density_unit.dart @@ -0,0 +1,78 @@ +import 'package:meta/meta.dart'; + +import '../../core/unit.dart'; +import 'energy_density_factors.dart'; + +/// Represents units of energy density. +/// +/// This enum implements the [LinearUnit] interface to provide conversion capabilities +/// and a display [symbol] for each energy density unit. +/// All conversion factors are pre-calculated in the constructor relative to +/// Joule per Cubic Meter (J/m³). +enum EnergyDensityUnit implements LinearUnit { + /// Joule per Cubic Meter (J/m³), the SI derived unit of energy density. + joulePerCubicMeter(EnergyDensityFactors.joulePerCubicMeter, 'J/m³'), + + /// Joule per Liter (J/L), a common unit for energy density of fuels and batteries. + joulePerLiter(EnergyDensityFactors.joulePerLiter, 'J/L'), + + /// Watt-hour per Liter (Wh/L), commonly used for battery energy density. + wattHourPerLiter(EnergyDensityFactors.wattHourPerLiter, 'Wh/L'); + + /// Constant constructor for enum members. + const EnergyDensityUnit(double toJPerM3Factor, this.symbol) + : _toJPerM3Factor = toJPerM3Factor, + _factorToJoulePerCubicMeter = toJPerM3Factor / 1.0, + _factorToJoulePerLiter = toJPerM3Factor / EnergyDensityFactors.joulePerLiter, + _factorToWattHourPerLiter = toJPerM3Factor / EnergyDensityFactors.wattHourPerLiter; + + /// SI and unit symbols matched **strictly case-sensitive**. + static const Map symbolAliases = { + 'J/m³': joulePerCubicMeter, + 'J/L': joulePerLiter, + 'Wh/L': wattHourPerLiter, + }; + + /// Full word-form names and non-SI abbreviations matched **case-insensitively**. + static const Map nameAliases = { + 'joule per cubic meter': joulePerCubicMeter, + 'joule per cubic metre': joulePerCubicMeter, + 'joulepercubicmeter': joulePerCubicMeter, + 'j per m3': joulePerCubicMeter, + 'jperm3': joulePerCubicMeter, + 'joule per liter': joulePerLiter, + 'joule per litre': joulePerLiter, + 'jouleperliter': joulePerLiter, + 'j per l': joulePerLiter, + 'jperl': joulePerLiter, + 'watt hour per liter': wattHourPerLiter, + 'watt hour per litre': wattHourPerLiter, + 'watthourperliter': wattHourPerLiter, + 'wh per l': wattHourPerLiter, + 'whperl': wattHourPerLiter, + }; + + // ignore: unused_field // Used to store the conversion factor to J/m³. + final double _toJPerM3Factor; + + @override + final String symbol; + + // --- Pre-calculated direct conversion factors --- + final double _factorToJoulePerCubicMeter; + final double _factorToJoulePerLiter; + final double _factorToWattHourPerLiter; + + @override + @internal + double factorTo(EnergyDensityUnit targetUnit) { + switch (targetUnit) { + case EnergyDensityUnit.joulePerCubicMeter: + return _factorToJoulePerCubicMeter; + case EnergyDensityUnit.joulePerLiter: + return _factorToJoulePerLiter; + case EnergyDensityUnit.wattHourPerLiter: + return _factorToWattHourPerLiter; + } + } +} diff --git a/test/units/energy_density_parsing_test.dart b/test/units/energy_density_parsing_test.dart new file mode 100644 index 0000000..96e39a0 --- /dev/null +++ b/test/units/energy_density_parsing_test.dart @@ -0,0 +1,119 @@ +import 'package:quantify/quantify.dart'; +import 'package:test/test.dart'; + +void main() { + group('EnergyDensity parsing —', () { + group('Basic parsing (invariant format)', () { + test('parses joule per cubic meter with symbol', () { + expect( + EnergyDensity.parse('1000 J/m³'), + const EnergyDensity(1000, EnergyDensityUnit.joulePerCubicMeter), + ); + }); + + test('parses joule per liter', () { + expect(EnergyDensity.parse('1 J/L'), const EnergyDensity(1, EnergyDensityUnit.joulePerLiter)); + }); + + test('parses watt-hour per liter', () { + expect( + EnergyDensity.parse('0.92 Wh/L'), + const EnergyDensity(0.92, EnergyDensityUnit.wattHourPerLiter), + ); + }); + }); + + group('Spacing tolerance', () { + test('parses with no space', () { + expect( + EnergyDensity.parse('1000J/m³'), + const EnergyDensity(1000, EnergyDensityUnit.joulePerCubicMeter), + ); + }); + + test('parses with multiple spaces', () { + expect( + EnergyDensity.parse('1 J/L'), + const EnergyDensity(1, EnergyDensityUnit.joulePerLiter), + ); + }); + + test('parses with leading/trailing whitespace', () { + expect( + EnergyDensity.parse(' 0.92 Wh/L '), + const EnergyDensity(0.92, EnergyDensityUnit.wattHourPerLiter), + ); + }); + }); + + group('Case sensitivity', () { + test('full names are case-insensitive', () { + expect( + EnergyDensity.parse('1000 JOULE PER CUBIC METER'), + const EnergyDensity(1000, EnergyDensityUnit.joulePerCubicMeter), + ); + expect( + EnergyDensity.parse('1 Joule Per Liter'), + const EnergyDensity(1, EnergyDensityUnit.joulePerLiter), + ); + expect( + EnergyDensity.parse('0.92 watt hour per liter'), + const EnergyDensity(0.92, EnergyDensityUnit.wattHourPerLiter), + ); + }); + + test('abbreviated names are case-insensitive', () { + expect( + EnergyDensity.parse('1000 J PER M3'), + const EnergyDensity(1000, EnergyDensityUnit.joulePerCubicMeter), + ); + expect( + EnergyDensity.parse('1 J PER L'), + const EnergyDensity(1, EnergyDensityUnit.joulePerLiter), + ); + expect( + EnergyDensity.parse('0.92 WH PER L'), + const EnergyDensity(0.92, EnergyDensityUnit.wattHourPerLiter), + ); + }); + }); + + group('Error handling', () { + test('throws on invalid number', () { + expect(() => EnergyDensity.parse('abc J/m³'), throwsA(isA())); + }); + + test('throws on missing unit', () { + expect(() => EnergyDensity.parse('1000'), throwsA(isA())); + }); + + test('throws on unknown unit', () { + expect(() => EnergyDensity.parse('1000 xyz'), throwsA(isA())); + }); + + test('returns null with tryParse on invalid input', () { + expect(EnergyDensity.tryParse('invalid'), isNull); + }); + + test('returns null with tryParse on missing unit', () { + expect(EnergyDensity.tryParse('1000'), isNull); + }); + }); + + group('Round-trip', () { + test('parse(toString()) preserves value and unit', () { + const original = EnergyDensity(1, EnergyDensityUnit.joulePerLiter); + final roundTrip = EnergyDensity.parse(original.toString()); + expect(roundTrip, equals(original)); + }); + + test('parse(toString()) works for all units', () { + for (final unit in EnergyDensityUnit.values) { + final original = EnergyDensity(1, unit); + final roundTrip = EnergyDensity.parse(original.toString()); + expect(roundTrip, equals(original)); + } + }); + }); + }); +} diff --git a/test/units/energy_density_test.dart b/test/units/energy_density_test.dart new file mode 100644 index 0000000..3901b9b --- /dev/null +++ b/test/units/energy_density_test.dart @@ -0,0 +1,241 @@ +import 'package:quantify/energy.dart'; +import 'package:quantify/energy_density.dart'; +import 'package:quantify/volume.dart'; +import 'package:test/test.dart'; + +void main() { + group('EnergyDensity', () { + const tolerance = 1e-9; + + group('Constructors and Getters', () { + test('should create from num extensions and retrieve values', () { + final density = 500.joulesPerCubicMeter; + expect(density.value, 500.0); + expect(density.unit, EnergyDensityUnit.joulePerCubicMeter); + expect(density.inJoulesPerCubicMeter, closeTo(500.0, tolerance)); + + final wh = 1.5.wattHoursPerLiter; + expect(wh.inWattHoursPerLiter, closeTo(1.5, tolerance)); + }); + }); + + group('Conversions', () { + test('J/m³ to J/L', () { + // 1000 J/m³ = 1 J/L + final d = 1000.joulesPerCubicMeter; + expect(d.inJoulesPerLiter, closeTo(1.0, tolerance)); + }); + + test('J/L to J/m³', () { + final d = 1.joulesPerLiter; + expect(d.inJoulesPerCubicMeter, closeTo(1000.0, tolerance)); + }); + + test('Wh/L to J/m³', () { + // 1 Wh/L = 3,600,000 J/m³ + final d = 1.wattHoursPerLiter; + expect(d.inJoulesPerCubicMeter, closeTo(3600000.0, tolerance)); + }); + + test('Wh/L to J/L', () { + // 1 Wh/L = 3600 J/L + final d = 1.wattHoursPerLiter; + expect(d.inJoulesPerLiter, closeTo(3600.0, tolerance)); + }); + }); + + group('convertTo', () { + test('convertTo same unit returns identical instance', () { + final d = 1000.joulesPerCubicMeter; + expect(identical(d, d.convertTo(EnergyDensityUnit.joulePerCubicMeter)), isTrue); + }); + + test('convertTo different unit returns correct value and unit', () { + final d = 1000.joulesPerCubicMeter; + final converted = d.convertTo(EnergyDensityUnit.joulePerLiter); + expect(converted.unit, EnergyDensityUnit.joulePerLiter); + expect(converted.value, closeTo(1.0, tolerance)); + }); + }); + + group('Comparison', () { + test('should correctly compare different units', () { + final d1 = 1000.joulesPerCubicMeter; + final d2 = 1.joulesPerLiter; + expect(d1.compareTo(d2), 0); + expect(d1.compareTo(0.5.joulesPerLiter), greaterThan(0)); + }); + }); + + group('Arithmetic', () { + test('operator + with same unit', () { + final sum = 1.joulesPerLiter + 1000.joulesPerCubicMeter; // 1 + 1 (in J/L) + expect(sum.inJoulesPerLiter, closeTo(2.0, tolerance)); + }); + + test('operator - subtracts energy density (same unit)', () { + final diff = 2.joulesPerLiter - 1.joulesPerLiter; + expect(diff.inJoulesPerLiter, closeTo(1.0, tolerance)); + expect(diff.unit, EnergyDensityUnit.joulePerLiter); + }); + + test('operator - subtracts energy density (mixed units, result in lhs unit)', () { + // 2000 J/m³ − 1 J/L = 2000 − 1000 = 1000 J/m³ + final diff = 2000.joulesPerCubicMeter - 1.joulesPerLiter; + expect(diff.inJoulesPerCubicMeter, closeTo(1000.0, tolerance)); + expect(diff.unit, EnergyDensityUnit.joulePerCubicMeter); + }); + + test('operator * scales energy density by a scalar', () { + final scaled = 500.joulesPerCubicMeter * 3.0; + expect(scaled.inJoulesPerCubicMeter, closeTo(1500.0, tolerance)); + expect(scaled.unit, EnergyDensityUnit.joulePerCubicMeter); + }); + + test('operator / scales energy density by a scalar', () { + final scaled = 4.joulesPerLiter / 2.0; + expect(scaled.inJoulesPerLiter, closeTo(2.0, tolerance)); + expect(scaled.unit, EnergyDensityUnit.joulePerLiter); + }); + + test('operator / by zero returns infinity', () { + expect((1000.joulesPerCubicMeter / 0.0).value, double.infinity); + }); + }); + + group('Equality and HashCode', () { + test('same value and unit are equal', () { + final d1 = 1000.joulesPerCubicMeter; + final d2 = 1000.joulesPerCubicMeter; + expect(d1, equals(d2)); + expect(d1.hashCode, equals(d2.hashCode)); + }); + + test('different units are not equal even if equivalent', () { + final d1 = 1000.joulesPerCubicMeter; + final d2 = 1.joulesPerLiter; + expect(d1, isNot(equals(d2))); + expect(d1.compareTo(d2), 0); + }); + }); + + group('toString', () { + test('displays value with symbol', () { + expect(1000.joulesPerCubicMeter.toString(), '1000.0\u00A0J/m³'); + expect(1.joulesPerLiter.toString(), '1.0\u00A0J/L'); + expect(1.wattHoursPerLiter.toString(), '1.0\u00A0Wh/L'); + }); + }); + + group('Round Trip Conversions', () { + for (final unit in EnergyDensityUnit.values) { + test('Round trip ${unit.symbol} <-> J/m³', () { + const initialValue = 1234.5; + final d = EnergyDensity(initialValue, unit); + final roundTrip = d.asJoulesPerCubicMeter.convertTo(unit); + expect(roundTrip.value, closeTo(initialValue, 1e-6)); + }); + } + }); + + group('Comprehensive Extension Coverage', () { + test('all creation extensions', () { + expect(1.wattHoursPerLiter.unit, EnergyDensityUnit.wattHourPerLiter); + expect(1.wattHoursPerLiter.inWattHoursPerLiter, closeTo(1.0, tolerance)); + }); + + test('all as* conversion getters', () { + final d = 1000.joulesPerCubicMeter; + + final asJm3 = d.asJoulesPerCubicMeter; + expect(asJm3.unit, EnergyDensityUnit.joulePerCubicMeter); + expect(asJm3.value, closeTo(1000.0, tolerance)); + + final asJl = d.asJoulesPerLiter; + expect(asJl.unit, EnergyDensityUnit.joulePerLiter); + expect(asJl.value, closeTo(1.0, tolerance)); + + final asWhl = d.asWattHoursPerLiter; + expect(asWhl.unit, EnergyDensityUnit.wattHourPerLiter); + expect(asWhl.value, closeTo(1000.0 / 3600000.0, tolerance)); + }); + }); + + group('Dimensional Analysis', () { + test('EnergyDensity = Energy / Volume', () { + final energy = 1000.J; + final volume = 2.m3; + final density = EnergyDensity.from(energy, volume); + expect(density.inJoulesPerCubicMeter, closeTo(500.0, tolerance)); + + expect(EnergyDensity.from(10.J, 0.m3).inJoulesPerCubicMeter, double.infinity); + expect(EnergyDensity.from(0.J, 0.m3).inJoulesPerCubicMeter, isNaN); + }); + + test('Energy = EnergyDensity * Volume', () { + final density = 500.joulesPerCubicMeter; + final volume = 5.m3; + final energy = density.energyOf(volume); + expect(energy.inJoules, closeTo(2500.0, tolerance)); + }); + + test('Energy / EnergyDensity = Volume', () { + final density = 500.joulesPerCubicMeter; + final energy = 2500.J; + final volume = density.volumeFor(energy); + expect(volume, isA()); + expect(volume.inCubicMeters, closeTo(5.0, tolerance)); + + expect(0.joulesPerCubicMeter.volumeFor(10.J).inCubicMeters, double.infinity); + expect(0.joulesPerCubicMeter.volumeFor(0.J).inCubicMeters, isNaN); + }); + + // --- Unit-preserving behaviour --- + test('EnergyDensity.from: J + m³ → J/m³', () { + final d = EnergyDensity.from(1000.J, 2.m3); + expect(d.unit, EnergyDensityUnit.joulePerCubicMeter); + expect(d.value, closeTo(500.0, tolerance)); + }); + + test('EnergyDensity.from: J + L → J/L', () { + final d = EnergyDensity.from(500.J, 1.0.L); + expect(d.unit, EnergyDensityUnit.joulePerLiter); + expect(d.value, closeTo(500.0, tolerance)); + }); + + test('EnergyDensity.from: Wh + L → Wh/L', () { + final d = EnergyDensity.from(2.Wh, 1.0.L); + expect(d.unit, EnergyDensityUnit.wattHourPerLiter); + expect(d.value, closeTo(2.0, tolerance)); + }); + + test('EnergyDensity.from: unmatched → SI fallback', () { + expect(EnergyDensity.from(1.Wh, 1.m3).unit, EnergyDensityUnit.joulePerCubicMeter); + }); + + test('energyOf: J/L → result in joules', () { + final d = 500.joulesPerLiter; + final e = d.energyOf(2.L); + expect(e.unit, EnergyUnit.joule); + expect(e.value, closeTo(1000.0, tolerance)); + }); + + test('energyOf: Wh/L → result in watt-hours', () { + final d = 2.wattHoursPerLiter; + final e = d.energyOf(3.L); + expect(e.unit, EnergyUnit.wattHour); + expect(e.value, closeTo(6.0, tolerance)); + }); + + test('volumeFor: J/L → result in liters', () { + final v = 500.joulesPerLiter.volumeFor(1000.J); + expect(v.unit, VolumeUnit.litre); + expect(v.value, closeTo(2.0, tolerance)); + }); + + test('physical correctness: energyOf round-trip', () { + expect(2.wattHoursPerLiter.energyOf(3.L).inWattHours, closeTo(6.0, tolerance)); + }); + }); + }); +}