ucon 0.3.3rc2__py3-none-any.whl → 0.3.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tests/ucon/test_core.py CHANGED
@@ -1,101 +1,268 @@
1
+ # © 2025 The Radiativity Company
2
+ # Licensed under the Apache License, Version 2.0
3
+ # See the LICENSE file for details.
4
+
1
5
  import math
2
- from unittest import TestCase
6
+ import unittest
3
7
 
4
8
  from ucon import Number
5
9
  from ucon import Exponent
6
10
  from ucon import Ratio
7
11
  from ucon import Scale
8
12
  from ucon import Dimension
13
+ from ucon import Unit
9
14
  from ucon import units
10
- from ucon.unit import Unit
15
+ from ucon.algebra import Vector
16
+ from ucon.core import UnitFactor, UnitProduct, ScaleDescriptor
11
17
 
12
18
 
13
- class TestExponent(TestCase):
19
+ class TestDimension(unittest.TestCase):
14
20
 
15
- thousand = Exponent(10, 3)
16
- thousandth = Exponent(10, -3)
17
- kibibyte = Exponent(2, 10)
18
- mebibyte = Exponent(2, 20)
21
+ def test_basic_dimensions_are_unique(self):
22
+ seen = set()
23
+ for dim in Dimension:
24
+ self.assertNotIn(dim.value, seen, f'Duplicate vector found for {dim.name}')
25
+ seen.add(dim.value)
19
26
 
20
- def test___init__(self):
21
- with self.assertRaises(ValueError):
22
- Exponent(5, 3) # no support for base 5 logarithms
27
+ def test_multiplication_adds_exponents(self):
28
+ self.assertEqual(
29
+ Dimension.mass * Dimension.acceleration,
30
+ Dimension.force,
31
+ )
32
+ self.assertEqual(
33
+ Dimension.length * Dimension.length,
34
+ Dimension.area,
35
+ )
36
+ self.assertEqual(
37
+ Dimension.length * Dimension.length * Dimension.length,
38
+ Dimension.volume,
39
+ )
23
40
 
24
- def test_parts(self):
25
- self.assertEqual((10, 3), self.thousand.parts())
26
- self.assertEqual((10, -3), self.thousandth.parts())
41
+ def test_division_subtracts_exponents(self):
42
+ self.assertEqual(
43
+ Dimension.length / Dimension.time,
44
+ Dimension.velocity,
45
+ )
46
+ self.assertEqual(
47
+ Dimension.force / Dimension.area,
48
+ Dimension.pressure,
49
+ )
27
50
 
28
- def test_evaluated_property(self):
29
- self.assertEqual(1000, self.thousand.evaluated)
30
- self.assertAlmostEqual(0.001, self.thousandth.evaluated)
31
- self.assertEqual(1024, self.kibibyte.evaluated)
32
- self.assertEqual(1048576, self.mebibyte.evaluated)
51
+ # def test_none_dimension_behaves_neutrally(self):
52
+ # base = Dimension.mass
53
+ # self.assertEqual(base * Dimension.none, base)
54
+ # self.assertEqual(base / Dimension.none, base)
55
+ # self.assertEqual(Dimension.none * base, base)
56
+ # with self.assertRaises(ValueError) as exc:
57
+ # Dimension.none / base
58
+ # assert type(exc.exception) == ValueError
59
+ # assert str(exc.exception).endswith('is not a valid Dimension')
60
+
61
+ def test_hash_and_equality_consistency(self):
62
+ d1 = Dimension.mass
63
+ d2 = Dimension.mass
64
+ d3 = Dimension.length
65
+ self.assertEqual(d1, d2)
66
+ self.assertNotEqual(d1, d3)
67
+ self.assertEqual(hash(d1), hash(d2))
68
+ self.assertNotEqual(hash(d1), hash(d3))
69
+
70
+ def test_composite_quantities_examples(self):
71
+ # Energy = Force * Length
72
+ self.assertEqual(
73
+ Dimension.force * Dimension.length,
74
+ Dimension.energy,
75
+ )
76
+ # Power = Energy / Time
77
+ self.assertEqual(
78
+ Dimension.energy / Dimension.time,
79
+ Dimension.power,
80
+ )
81
+ # Pressure = Force / Area
82
+ self.assertEqual(
83
+ Dimension.force / Dimension.area,
84
+ Dimension.pressure,
85
+ )
86
+ # Charge = Current * Time
87
+ self.assertEqual(
88
+ Dimension.current * Dimension.time,
89
+ Dimension.charge,
90
+ )
33
91
 
34
- def test___truediv__(self):
35
- # same base returns a new Exponent
36
- ratio = self.thousand / self.thousandth
37
- self.assertIsInstance(ratio, Exponent)
38
- self.assertEqual(ratio.base, 10)
39
- self.assertEqual(ratio.power, 6)
40
- self.assertEqual(ratio.evaluated, 1_000_000)
41
-
42
- # different base returns numeric float
43
- val = self.thousand / self.kibibyte
44
- self.assertIsInstance(val, float)
45
- self.assertAlmostEqual(1000 / 1024, val)
92
+ def test_vector_equality_reflects_dimension_equality(self):
93
+ self.assertEqual(Dimension.mass.value, Dimension.mass.value)
94
+ self.assertNotEqual(Dimension.mass.value, Dimension.time.value)
95
+ self.assertEqual(Dimension.mass, Dimension.mass)
96
+ self.assertNotEqual(Dimension.mass, Dimension.time)
97
+
98
+ def test_pow_identity_and_zero(self):
99
+ self.assertIs(Dimension.length ** 1, Dimension.length)
100
+ self.assertIs(Dimension.mass ** 0, Dimension.none)
101
+
102
+ def test_pow_known_results(self):
103
+ self.assertEqual(Dimension.length ** 2, Dimension.area)
104
+ self.assertEqual(Dimension.time ** -1, Dimension.frequency)
105
+
106
+ def test_pow_returns_derived_dimension_for_unknown(self):
107
+ jerk = Dimension.length * (Dimension.time ** -3) # length / time^3
108
+ self.assertTrue(jerk.name.startswith("derived("))
109
+ self.assertNotIn(jerk.name, Dimension.__members__)
110
+
111
+ def test_resolve_known_vector_returns_enum_member(self):
112
+ dim = Dimension._resolve(Vector(0, 1, 0, 0, 0, 0, 0))
113
+ self.assertIs(dim, Dimension.length)
114
+
115
+ def test_resolve_unknown_vector_returns_dynamic_dimension(self):
116
+ vec = Vector(T=1, L=-1, M=0, I=0, Θ=0, J=0, N=0) # “speed per time”, not an enum member
117
+ dyn = Dimension._resolve(vec)
118
+ self.assertNotIn(dyn.name, Dimension.__members__)
119
+ self.assertEqual(dyn.value, vec)
120
+ self.assertEqual(dyn.name, f"derived({vec})")
121
+
122
+ def test_resolve_returns_same_dynamic_for_same_vector(self):
123
+ vec = Vector(T=2, L=-2, M=0, I=0, Θ=0, J=0, N=0)
124
+ first = Dimension._resolve(vec)
125
+ second = Dimension._resolve(vec)
126
+ self.assertEqual(first.value, second.value)
127
+ self.assertEqual(first.name, second.name)
128
+
129
+ def test_dynamic_dimensions_compare_by_vector(self):
130
+ v1 = Vector(T=2, L=-2, M=0, I=0, Θ=0, J=0, N=0)
131
+ v2 = Vector(T=2, L=-2, M=0, I=0, Θ=0, J=0, N=0)
132
+ d1 = Dimension._resolve(v1)
133
+ d2 = Dimension._resolve(v2)
134
+ self.assertEqual(d1.value, d2.value)
135
+ self.assertEqual(d1 == d2, True)
136
+ self.assertEqual(hash(d1), hash(d2))
137
+
138
+ def test_pow_zero_returns_none(self):
139
+ # Dimension ** 0 should always return Dimension.none
140
+ self.assertIs(Dimension.length ** 0, Dimension.none)
141
+
142
+ def test_pow_fractional(self):
143
+ # Fractional powers = derived dimensions not equal to any registered one
144
+ d = Dimension.length ** 0.5
145
+ self.assertIsInstance(d, Dimension)
146
+ self.assertNotIn(d, list(Dimension))
147
+
148
+ def test_invalid_operand_multiply(self):
149
+ with self.assertRaises(TypeError):
150
+ Dimension.length * 10
46
151
 
47
- def test___mul__(self):
48
- product = self.kibibyte * self.mebibyte
49
- self.assertIsInstance(product, Exponent)
50
- self.assertEqual(product.base, 2)
51
- self.assertEqual(product.power, 30)
52
- self.assertEqual(product.evaluated, 2**30)
53
-
54
- # cross-base multiplication returns numeric
55
- val = self.kibibyte * self.thousand
56
- self.assertIsInstance(val, float)
57
- self.assertAlmostEqual(1024 * 1000, val)
58
-
59
- def test___hash__(self):
60
- a = Exponent(10, 3)
61
- b = Exponent(10, 3)
62
- self.assertEqual(hash(a), hash(b))
63
- self.assertEqual(len({a, b}), 1) # both should hash to same value
64
-
65
- def test___float__(self):
66
- self.assertEqual(float(self.thousand), 1000.0)
67
-
68
- def test___int__(self):
69
- self.assertEqual(int(self.thousand), 1000)
70
-
71
- def test_comparisons(self):
72
- self.assertTrue(self.thousand > self.thousandth)
73
- self.assertTrue(self.thousandth < self.thousand)
74
- self.assertTrue(self.kibibyte < self.mebibyte)
75
- self.assertTrue(self.kibibyte == Exponent(2, 10))
152
+ def test_invalid_operand_divide(self):
153
+ with self.assertRaises(TypeError):
154
+ Dimension.time / "bad"
155
+
156
+
157
+ class TestDimensionResolve(unittest.TestCase):
158
+
159
+ def test_registered_multiplication(self):
160
+ # velocity = length / time
161
+ v = Dimension.length / Dimension.time
162
+ self.assertIs(v, Dimension.velocity)
163
+ self.assertEqual(v.value, Vector(-1, 1, 0, 0, 0, 0, 0))
164
+
165
+ def test_registered_power(self):
166
+ # area = length ** 2
167
+ a = Dimension.length ** 2
168
+ self.assertIs(a, Dimension.area)
169
+ self.assertEqual(a.value, Vector(0, 2, 0, 0, 0, 0, 0))
170
+
171
+ def test_unregistered_multiplication_creates_derived(self):
172
+ # L * M should yield derived(Vector(L=1, M=1))
173
+ d = Dimension.length * Dimension.mass
174
+ self.assertIsInstance(d, Dimension)
175
+ self.assertNotIn(d, list(Dimension))
176
+ self.assertIn("derived", d.name)
177
+ self.assertEqual(d.value, Vector(0, 1, 1, 0, 0, 0, 0))
178
+
179
+ def test_unregistered_division_creates_derived(self):
180
+ # M / T should yield derived(Vector(M=1, T=-1))
181
+ d = Dimension.mass / Dimension.time
182
+ self.assertIsInstance(d, Dimension)
183
+ self.assertNotIn(d, list(Dimension))
184
+ self.assertIn("derived", d.name)
185
+ self.assertEqual(d.value, Vector(-1, 0, 1, 0, 0, 0, 0))
186
+
187
+ def test_unregistered_power_creates_derived(self):
188
+ # (L * M)^2 → derived(Vector(L=2, M=2))
189
+ d1 = Dimension.length * Dimension.mass
190
+ d2 = d1 ** 2
191
+ self.assertIsInstance(d2, Dimension)
192
+ self.assertIn("derived", d2.name)
193
+ self.assertEqual(d2.value, Vector(0, 2, 2, 0, 0, 0, 0))
194
+
195
+ def test_registered_vs_derived_equality(self):
196
+ # Ensure derived dimensions only equal themselves
197
+ derived = Dimension.length * Dimension.mass
198
+ again = Dimension._resolve(Vector(0, 1, 1, 0, 0, 0, 0))
199
+ self.assertEqual(derived, again)
200
+ self.assertNotEqual(derived, Dimension.length)
201
+ self.assertNotEqual(derived, Dimension.mass)
202
+
203
+
204
+ class TestDimensionEdgeCases(unittest.TestCase):
205
+
206
+ def test_invalid_multiplication_type(self):
207
+ with self.assertRaises(TypeError):
208
+ Dimension.length * 5
209
+ with self.assertRaises(TypeError):
210
+ "mass" * Dimension.time
76
211
 
212
+ def test_invalid_division_type(self):
213
+ with self.assertRaises(TypeError):
214
+ Dimension.time / "length"
77
215
  with self.assertRaises(TypeError):
78
- _ = self.thousand == 1000 # comparison to non-Exponent
216
+ 5 / Dimension.mass
79
217
 
80
- def test___repr__(self):
81
- self.assertIn("Exponent", repr(Exponent(10, -3)))
218
+ def test_equality_with_non_dimension(self):
219
+ with self.assertRaises(TypeError):
220
+ Dimension.mass == "mass"
82
221
 
83
- def test___str__(self):
84
- self.assertEqual(str(self.thousand), '10^3')
85
- self.assertEqual(str(self.thousandth), '10^-3')
222
+ def test_enum_uniqueness_and_hash(self):
223
+ # Hashes should be unique per distinct dimension
224
+ hashes = {hash(d) for d in Dimension}
225
+ self.assertEqual(len(hashes), len(Dimension))
226
+ # All Dimension.value entries must be distinct Vectors
227
+ values = [d.value for d in Dimension]
228
+ self.assertEqual(len(values), len(set(values)))
86
229
 
87
- def test_to_base(self):
88
- e = Exponent(2, 10)
89
- converted = e.to_base(10)
90
- self.assertIsInstance(converted, Exponent)
91
- self.assertEqual(converted.base, 10)
92
- self.assertAlmostEqual(converted.power, math.log10(1024), places=10)
230
+ def test_combined_chained_operations(self):
231
+ # (mass * acceleration) / area = pressure
232
+ result = (Dimension.mass * Dimension.acceleration) / Dimension.area
233
+ self.assertEqual(result, Dimension.pressure)
93
234
 
94
- with self.assertRaises(ValueError):
95
- e.to_base(5)
235
+ def test_dimension_round_trip_equality(self):
236
+ # Multiplying and dividing by the same dimension returns self
237
+ d = Dimension.energy
238
+ self.assertEqual((d * Dimension.none) / Dimension.none, d)
239
+ self.assertEqual(d / Dimension.none, d)
240
+ self.assertEqual(Dimension.none * d, d)
96
241
 
242
+ def test_enum_is_hashable_and_iterable(self):
243
+ seen = {d for d in Dimension}
244
+ self.assertIn(Dimension.mass, seen)
245
+ self.assertEqual(len(seen), len(Dimension))
97
246
 
98
- class TestScale(TestCase):
247
+
248
+ class TestScaleDescriptor(unittest.TestCase):
249
+
250
+ def test_scale_descriptor_power_and_repr(self):
251
+ exp = Exponent(10, 3)
252
+ desc = ScaleDescriptor(exp, "k", "kilo")
253
+
254
+ # power property should reflect Exponent.power
255
+ assert desc.power == 3
256
+ assert desc.base == 10
257
+ assert math.isclose(desc.evaluated, 1e3)
258
+
259
+ # repr should include alias and power
260
+ r = repr(desc)
261
+ assert "kilo" in r or "k" in r
262
+ assert "10^3" in r
263
+
264
+
265
+ class TestScale(unittest.TestCase):
99
266
 
100
267
  def test___truediv__(self):
101
268
  self.assertEqual(Scale.deca, Scale.one / Scale.deci)
@@ -103,7 +270,6 @@ class TestScale(TestCase):
103
270
  self.assertEqual(Scale.kibi, Scale.mebi / Scale.kibi)
104
271
  self.assertEqual(Scale.milli, Scale.one / Scale.deca / Scale.deca / Scale.deca)
105
272
  self.assertEqual(Scale.deca, Scale.kilo / Scale.hecto)
106
- self.assertEqual(Scale._kibi, Scale.one / Scale.kibi)
107
273
  self.assertEqual(Scale.kibi, Scale.kibi / Scale.one)
108
274
  self.assertEqual(Scale.one, Scale.one / Scale.one)
109
275
  self.assertEqual(Scale.one, Scale.kibi / Scale.kibi)
@@ -126,11 +292,11 @@ class TestScale(TestCase):
126
292
 
127
293
  def test_all(self):
128
294
  for scale in Scale:
129
- self.assertTrue(isinstance(scale.value, Exponent))
295
+ self.assertTrue(isinstance(scale.value.exponent, Exponent))
130
296
  self.assertIsInstance(Scale.all(), dict)
131
297
 
132
298
 
133
- class TestScaleMultiplicationAdditional(TestCase):
299
+ class TestScaleMultiplicationAdditional(unittest.TestCase):
134
300
 
135
301
  def test_decimal_combinations(self):
136
302
  self.assertEqual(Scale.kilo * Scale.centi, Scale.deca)
@@ -166,8 +332,29 @@ class TestScaleMultiplicationAdditional(TestCase):
166
332
  self.assertIsInstance(result, Scale)
167
333
  self.assertEqual(result.value.base, 10)
168
334
 
335
+ @unittest.skip("TODO: revamp: Unit.scale is deprecated.")
336
+ def test_scale_multiplication_with_unit(self):
337
+ meter = UnitFactor('m', name='meter', dimension=Dimension.length)
338
+ kilometer = Scale.kilo * meter
339
+ self.assertIsInstance(kilometer, UnitFactor)
340
+ self.assertEqual(kilometer.scale, Scale.kilo)
341
+ self.assertEqual(kilometer.dimension, Dimension.length)
342
+ self.assertIn('meter', kilometer.name)
343
+
344
+ def test_scale_multiplication_with_unit_returns_not_implemented_for_invalid_type(self):
345
+ with self.assertRaises(TypeError):
346
+ Scale.kilo * 1
347
+
348
+ def test_scale_mul_with_unknown_exponent_hits_nearest(self):
349
+ # Construct two strange scales (base10^7 * base10^5 = base10^12 = tera)
350
+ s = Scale.nearest(10**7) * Scale.nearest(10**5)
351
+ self.assertIs(s, Scale.tera)
169
352
 
170
- class TestScaleDivisionAdditional(TestCase):
353
+ def test_scale_mul_non_unit_non_scale(self):
354
+ self.assertEqual(Scale.kilo.__mul__("nope"), NotImplemented)
355
+
356
+
357
+ class TestScaleDivisionAdditional(unittest.TestCase):
171
358
 
172
359
  def test_division_same_base_large_gap(self):
173
360
  # kilo / milli = mega
@@ -184,7 +371,6 @@ class TestScaleDivisionAdditional(TestCase):
184
371
 
185
372
  def test_division_binary_inverse_scales(self):
186
373
  self.assertEqual(Scale.kibi / Scale.kibi, Scale.one)
187
- self.assertEqual(Scale.kibi / Scale.mebi, Scale._kibi)
188
374
  self.assertEqual(Scale.mebi / Scale.kibi, Scale.kibi)
189
375
 
190
376
  def test_division_unmatched_returns_nearest(self):
@@ -198,8 +384,15 @@ class TestScaleDivisionAdditional(TestCase):
198
384
  with self.assertRaises(TypeError):
199
385
  Scale.kilo / 42
200
386
 
387
+ def test_scale_div_hits_nearest(self):
388
+ # giga / kilo = 10^(9-3) = 10^6 = mega
389
+ self.assertIs(Scale.giga / Scale.kilo, Scale.mega)
390
+
391
+ def test_scale_div_non_scale(self):
392
+ self.assertEqual(Scale.kilo.__truediv__("bad"), NotImplemented)
201
393
 
202
- class TestScaleNearestAdditional(TestCase):
394
+
395
+ class TestScaleNearestAdditional(unittest.TestCase):
203
396
 
204
397
  def test_nearest_handles_zero(self):
205
398
  self.assertEqual(Scale.nearest(0), Scale.one)
@@ -230,7 +423,7 @@ class TestScaleNearestAdditional(TestCase):
230
423
  self.assertEqual(Scale.nearest(10**-9), Scale.nano)
231
424
 
232
425
 
233
- class TestScaleInternals(TestCase):
426
+ class TestScaleInternals(unittest.TestCase):
234
427
 
235
428
  def test_decimal_and_binary_sets_are_disjoint(self):
236
429
  decimal_bases = {s.value.base for s in Scale._decimal_scales()}
@@ -253,175 +446,188 @@ class TestScaleInternals(TestCase):
253
446
  self.assertIs(Scale.by_value(), Scale.by_value())
254
447
 
255
448
 
256
- class TestNumber(TestCase):
257
-
258
- number = Number(unit=units.gram, quantity=1)
259
-
260
- def test_as_ratio(self):
261
- ratio = self.number.as_ratio()
262
- self.assertIsInstance(ratio, Ratio)
263
- self.assertEqual(ratio.numerator, self.number)
264
- self.assertEqual(ratio.denominator, Number())
265
-
266
- def test_simplify(self):
267
- ten_decagrams = Number(unit=units.gram, scale=Scale.deca, quantity=10)
268
- point_one_decagrams = Number(unit=units.gram, scale=Scale.deca, quantity=0.1)
269
- two_kibigrams = Number(unit=units.gram, scale=Scale.kibi, quantity=2)
270
-
271
- self.assertEqual(Number(unit=units.gram, quantity=100), ten_decagrams.simplify())
272
- self.assertEqual(Number(unit=units.gram, quantity=1), point_one_decagrams.simplify())
273
- self.assertEqual(Number(unit=units.gram, quantity=2048), two_kibigrams.simplify())
274
-
275
- def test_to(self):
276
- thousandth_of_a_kilogram = Number(unit=units.gram, scale=Scale.kilo, quantity=0.001)
277
- thousand_milligrams = Number(unit=units.gram, scale=Scale.milli, quantity=1000)
278
- kibigram_fraction = Number(unit=units.gram, scale=Scale.kibi, quantity=0.0009765625)
449
+ class TestUnit(unittest.TestCase):
279
450
 
280
- self.assertEqual(thousandth_of_a_kilogram, self.number.to(Scale.kilo))
281
- self.assertEqual(thousand_milligrams, self.number.to(Scale.milli))
282
- self.assertEqual(kibigram_fraction, self.number.to(Scale.kibi))
451
+ unit_name = 'second'
452
+ unit_type = 'time'
453
+ unit_aliases = ('seconds', 'secs', 's', 'S')
454
+ unit = Unit(*unit_aliases, name=unit_name, dimension=Dimension.time)
283
455
 
284
456
  def test___repr__(self):
285
- self.assertIn(str(self.number.quantity), str(self.number))
286
- self.assertIn(str(self.number.scale.value.evaluated), str(self.number))
287
- self.assertIn(self.number.unit.name, str(self.number))
288
-
289
- def test___truediv__(self):
290
- some_number = Number(unit=units.gram, scale=Scale.deca, quantity=10)
291
- another_number = Number(unit=units.gram, scale=Scale.milli, quantity=10)
292
- that_number = Number(unit=units.gram, scale=Scale.kibi, quantity=10)
293
-
294
- some_quotient = self.number / some_number
295
- another_quotient = self.number / another_number
296
- that_quotient = self.number / that_number
297
-
298
- self.assertEqual(some_quotient.value, 0.01)
299
- self.assertEqual(another_quotient.value, 100.0)
300
- self.assertEqual(that_quotient.value, 0.00009765625)
301
-
302
- def test___eq__(self):
303
- self.assertEqual(self.number, Ratio(self.number)) # 1 gram / 1
304
- with self.assertRaises(TypeError):
305
- self.number == 1
306
-
307
-
308
- class TestRatio(TestCase):
309
-
310
- point_five = Number(quantity=0.5)
311
- one = Number()
312
- two = Number(quantity=2)
313
- three = Number(quantity=3)
314
- four = Number(quantity=4)
315
-
316
- one_half = Ratio(numerator=one, denominator=two)
317
- three_fourths = Ratio(numerator=three, denominator=four)
318
- one_ratio = Ratio(numerator=one)
319
- three_halves = Ratio(numerator=three, denominator=two)
320
- two_ratio = Ratio(numerator=two, denominator=one)
321
-
322
- def test_evaluate(self):
323
- self.assertEqual(self.one_ratio.numerator, self.one)
324
- self.assertEqual(self.one_ratio.denominator, self.one)
325
- self.assertEqual(self.one_ratio.evaluate(), self.one)
326
- self.assertEqual(self.two_ratio.evaluate(), self.two)
327
-
328
- def test_reciprocal(self):
329
- self.assertEqual(self.two_ratio.reciprocal().numerator, self.one)
330
- self.assertEqual(self.two_ratio.reciprocal().denominator, self.two)
331
- self.assertEqual(self.two_ratio.reciprocal().evaluate(), self.point_five)
332
-
333
- def test___mul__commutivity(self):
334
- # Does commutivity hold?
335
- self.assertEqual(self.three_halves * self.one_half, self.three_fourths)
336
- self.assertEqual(self.one_half * self.three_halves, self.three_fourths)
337
-
338
- def test___mul__(self):
339
- n1 = Number(unit=units.gram, quantity=3.119)
340
- n2 = Number(unit=units.liter, scale=Scale.milli)
341
- bromine_density = Ratio(n1, n2)
342
-
343
- # How many grams of bromine are in 2 milliliters?
344
- two_milliliters_bromine = Number(unit=units.liter, scale=Scale.milli, quantity=2)
345
- ratio = two_milliliters_bromine.as_ratio() * bromine_density
346
- answer = ratio.evaluate()
347
- self.assertEqual(answer.unit.dimension, Dimension.mass)
348
- self.assertEqual(answer.value, 6.238) # Grams
349
-
350
- def test___truediv__(self):
351
- seconds_per_hour = Ratio(
352
- numerator=Number(unit=units.second, quantity=3600),
353
- denominator=Number(unit=units.hour, quantity=1)
354
- )
355
-
356
- # How many Wh from 20 kJ?
357
- twenty_kilojoules = Number(unit=units.joule, scale=Scale.kilo, quantity=20)
358
- ratio = twenty_kilojoules.as_ratio() / seconds_per_hour
359
- answer = ratio.evaluate()
360
- self.assertEqual(answer.unit.dimension, Dimension.energy)
361
- self.assertEqual(round(answer.value, 5), 5.55556) # Watt * hours
362
-
363
- def test___eq__(self):
364
- self.assertEqual(self.one_half, self.point_five)
365
- with self.assertRaises(ValueError):
366
- self.one_half == 1/2
367
-
368
- def test___repr__(self):
369
- self.assertEqual(str(self.one_ratio), '<1.0 >')
370
- self.assertEqual(str(self.two_ratio), '<2 > / <1.0 >')
371
- self.assertEqual(str(self.two_ratio.evaluate()), '<2.0 >')
372
-
373
-
374
- class TestExponentEdgeCases(TestCase):
375
-
376
- def test_extreme_powers(self):
377
- e = Exponent(10, 308)
378
- self.assertTrue(math.isfinite(e.evaluated))
379
- e_small = Exponent(10, -308)
380
- self.assertGreater(e.evaluated, e_small.evaluated)
381
-
382
- def test_precision_rounding_in_hash(self):
383
- a = Exponent(10, 6)
384
- b = Exponent(10, 6 + 1e-16)
385
- # rounding in hash avoids floating drift
386
- self.assertEqual(hash(a), hash(b))
387
-
388
- def test_negative_and_zero_power(self):
389
- e0 = Exponent(10, 0)
390
- e_neg = Exponent(10, -1)
391
- self.assertEqual(e0.evaluated, 1.0)
392
- self.assertEqual(e_neg.evaluated, 0.1)
393
- self.assertLess(e_neg, e0)
394
-
395
- def test_valid_exponent_evaluates_correctly(self):
396
- base, power = 10, 3
397
- e = Exponent(base, power)
398
- self.assertEqual(e.evaluated, 1000)
399
- self.assertEqual(e.parts(), (base, power))
400
- self.assertEqual(f'{base}^{power}', str(e))
401
- self.assertEqual(f'Exponent(base={base}, power={power})', repr(e))
402
-
403
- def test_invalid_base_raises_value_error(self):
404
- with self.assertRaises(ValueError):
405
- Exponent(5, 2)
406
-
407
- def test_exponent_comparisons(self):
408
- e1 = Exponent(10, 2)
409
- e2 = Exponent(10, 3)
410
- self.assertTrue(e1 < e2)
411
- self.assertTrue(e2 > e1)
412
- self.assertFalse(e1 == e2)
413
-
414
- def test_division_returns_exponent(self):
415
- e1 = Exponent(10, 3)
416
- e2 = Exponent(10, 2)
417
- self.assertEqual(e1 / e2, Exponent(10, 1))
418
-
419
- def test_equality_with_different_type(self):
420
- with self.assertRaises(TypeError):
421
- Exponent(10, 2) == "10^2"
422
-
423
-
424
- class TestScaleEdgeCases(TestCase):
457
+ self.assertEqual(f'<Unit {self.unit_aliases[0]}>', str(self.unit))
458
+
459
+ def test_unit_repr_has_dimension_when_no_shorthand(self):
460
+ u = Unit(name="", dimension=Dimension.force)
461
+ r = repr(u)
462
+ self.assertIn("force", r)
463
+ self.assertTrue(r.startswith("<Unit"))
464
+
465
+ def test_unit_equality_alias_normalization(self):
466
+ # ('',) should normalize to () under _norm
467
+ u1 = Unit("", name="x", dimension=Dimension.length)
468
+ u2 = Unit(name="x", dimension=Dimension.length)
469
+ self.assertEqual(u1, u2)
470
+
471
+ def test_unit_invalid_eq_type(self):
472
+ self.assertFalse(Unit("m", dimension=Dimension.length) == "meter")
473
+
474
+
475
+ class TestUnitProduct(unittest.TestCase):
476
+
477
+ mf = UnitFactor(unit=units.meter, scale=Scale.one)
478
+ sf = UnitFactor(unit=units.second, scale=Scale.one)
479
+ nf = UnitFactor(unit=units.none, scale=Scale.one)
480
+ velocity = UnitProduct({mf: 1, sf: -1})
481
+ acceleration = UnitProduct({mf: 1, sf: -2})
482
+
483
+ def test_composite_unit_collapses_to_unit(self):
484
+ cu = UnitProduct({self.mf: 1})
485
+ # should anneal to Unit
486
+ self.assertIsInstance(cu, UnitProduct)
487
+ self.assertEqual(cu.shorthand, self.mf.shorthand)
488
+
489
+ def test_merge_of_identical_units(self):
490
+ # Inner composite that already has m^1
491
+ inner = UnitProduct({self.mf: 1, self.sf: -1})
492
+ # Outer composite sees both `m:1` and `inner:1`
493
+ up = UnitProduct({self.mf: 1, inner: 1})
494
+ # merge_unit should accumulate the exponents → m^(1 + 1) = m^2
495
+ self.assertIn(self.mf, up.factors)
496
+ self.assertEqual(up.factors[self.mf], 2)
497
+
498
+ def test_merge_of_nested_composite_units(self):
499
+ # expect m*s^-2
500
+ self.assertEqual(self.acceleration.factors[self.mf], 1)
501
+ self.assertEqual(self.acceleration.factors[self.sf], -2)
502
+
503
+ def test_drop_dimensionless_component(self):
504
+ up = UnitProduct({self.mf: 2, self.nf: 1})
505
+ self.assertIn(self.mf, up.factors)
506
+ self.assertNotIn(self.nf, up.factors)
507
+
508
+ def test_unitproduct_can_behave_like_single_unit(self):
509
+ """
510
+ A UnitProduct with only one factor should seem like that factor.
511
+ """
512
+ up = UnitProduct({self.mf: 1})
513
+ self.assertEqual(up.shorthand, self.mf.shorthand)
514
+ self.assertEqual(up.dimension, self.mf.dimension)
515
+
516
+ def test_composite_mul_with_scale(self):
517
+ up = UnitProduct({self.mf: 1, self.sf: -1})
518
+ result = Scale.kilo * up
519
+ # equivalent to scale multiplication on RMUL path
520
+ self.assertIsNotNone(result)
521
+ self.assertIsNotNone(result.shorthand, "km/s")
522
+
523
+ def test_composite_div_dimensionless(self):
524
+ up = UnitProduct({self.mf: 2})
525
+ out = up / UnitProduct({})
526
+ self.assertEqual(out.factors[self.mf], 2)
527
+
528
+ def test_truediv_composite_by_composite(self):
529
+ jerk = self.acceleration / self.velocity
530
+ # jerk = m^1 s^-2 / m^1 s^-1 = s^-1
531
+ self.assertEqual(list(jerk.factors.values()), [-1])
532
+
533
+
534
+ class TestUnitEdgeCases(unittest.TestCase):
535
+
536
+ # --- Initialization & representation -----------------------------------
537
+
538
+ def test_default_unit_is_dimensionless(self):
539
+ u = Unit()
540
+ self.assertEqual(u.dimension, Dimension.none)
541
+ self.assertEqual(u.name, '')
542
+ self.assertEqual(u.aliases, ())
543
+ self.assertEqual(u.shorthand, '')
544
+ self.assertEqual(repr(u), '<Unit>')
545
+
546
+ def test_unit_with_aliases_and_name(self):
547
+ u = Unit('m', 'M', name='meter', dimension=Dimension.length)
548
+ self.assertEqual(u.shorthand, 'm')
549
+ self.assertIn('m', u.aliases)
550
+ self.assertIn('M', u.aliases)
551
+ self.assertIn('length', u.dimension.name)
552
+ self.assertIn('meter', u.name)
553
+ self.assertIn('<Unit m>', repr(u))
554
+
555
+ def test_hash_and_equality_consistency(self):
556
+ u1 = Unit('m', name='meter', dimension=Dimension.length)
557
+ u2 = Unit('m', name='meter', dimension=Dimension.length)
558
+ u3 = Unit('s', name='second', dimension=Dimension.time)
559
+ self.assertEqual(u1, u2)
560
+ self.assertEqual(hash(u1), hash(u2))
561
+ self.assertNotEqual(u1, u3)
562
+ self.assertNotEqual(hash(u1), hash(u3))
563
+
564
+ def test_units_with_same_name_but_different_dimension_not_equal(self):
565
+ u1 = Unit(name='amp', dimension=Dimension.current)
566
+ u2 = Unit(name='amp', dimension=Dimension.time)
567
+ self.assertNotEqual(u1, u2)
568
+
569
+ # --- arithmetic behavior ----------------------------------------------
570
+
571
+ def test_multiplication_produces_composite_unit(self):
572
+ m = Unit('m', name='meter', dimension=Dimension.length)
573
+ s = Unit('s', name='second', dimension=Dimension.time)
574
+ v = m / s
575
+ self.assertIsInstance(v, UnitProduct)
576
+ self.assertEqual(v.dimension, Dimension.velocity)
577
+ self.assertIn('/', repr(v))
578
+
579
+ def test_division_with_dimensionless_denominator_returns_self(self):
580
+ m = Unit('m', name='meter', dimension=Dimension.length)
581
+ none = Unit(name='none', dimension=Dimension.none)
582
+ result = m / none
583
+ self.assertEqual(result, m)
584
+
585
+ def test_division_of_identical_units_returns_dimensionless(self):
586
+ m1 = Unit('m', name='meter', dimension=Dimension.length)
587
+ m2 = Unit('m', name='meter', dimension=Dimension.length)
588
+ result = m1 / m2
589
+ self.assertEqual(result.dimension, Dimension.none)
590
+ self.assertEqual(result.name, '')
591
+
592
+ def test_multiplying_with_dimensionless_returns_self(self):
593
+ m = Unit('m', name='meter', dimension=Dimension.length)
594
+ none = Unit(name='none', dimension=Dimension.none)
595
+ result = m * none
596
+ self.assertEqual(result.dimension, Dimension.length)
597
+ self.assertEqual('m', result.shorthand)
598
+
599
+ def test_invalid_dimension_combinations_raise_value_error(self):
600
+ m = Unit('m', name='meter', dimension=Dimension.length)
601
+ c = Unit('C', name='coulomb', dimension=Dimension.charge)
602
+ # The result of combination gives CompositeUnit
603
+ self.assertIsInstance(m / c, UnitProduct)
604
+ self.assertIsInstance(m * c, UnitProduct)
605
+
606
+ # --- equality, hashing, immutability ----------------------------------
607
+
608
+ def test_equality_with_non_unit(self):
609
+ self.assertFalse(Unit('m', name='meter', dimension=Dimension.length) == 'meter')
610
+
611
+ def test_hash_stability_in_collections(self):
612
+ m1 = Unit('m', name='meter', dimension=Dimension.length)
613
+ s = set([m1])
614
+ self.assertIn(Unit('m', name='meter', dimension=Dimension.length), s)
615
+
616
+ def test_operations_do_not_mutate_operands(self):
617
+ m = Unit('m', name='meter', dimension=Dimension.length)
618
+ s = Unit('s', name='second', dimension=Dimension.time)
619
+ _ = m / s
620
+ self.assertEqual(m.dimension, Dimension.length)
621
+ self.assertEqual(s.dimension, Dimension.time)
622
+
623
+ # --- operator edge cases ----------------------------------------------
624
+
625
+ def test_repr_contains_dimension_name_even_without_name(self):
626
+ u = Unit(dimension=Dimension.force)
627
+ self.assertIn('force', repr(u))
628
+
629
+
630
+ class TestScaleEdgeCases(unittest.TestCase):
425
631
 
426
632
  def test_nearest_prefers_decimal_by_default(self):
427
633
  self.assertEqual(Scale.nearest(1024), Scale.kilo)
@@ -429,7 +635,6 @@ class TestScaleEdgeCases(TestCase):
429
635
  self.assertEqual(Scale.nearest(1/1024), Scale.milli)
430
636
 
431
637
  def test_nearest_includes_binary_when_opted_in(self):
432
- self.assertEqual(Scale.nearest(1/1024, include_binary=True), Scale._kibi)
433
638
  self.assertEqual(Scale.nearest(1024, include_binary=True), Scale.kibi)
434
639
  self.assertEqual(Scale.nearest(50_000, include_binary=True), Scale.kibi)
435
640
  self.assertEqual(Scale.nearest(2**20, include_binary=True), Scale.mebi)
@@ -466,118 +671,158 @@ class TestScaleEdgeCases(TestCase):
466
671
  by_val = Scale.by_value()
467
672
  self.assertTrue(all((val in by_val.values()) for _, val in all_map.items()))
468
673
 
674
+ def test_descriptor_property(self):
675
+ self.assertIsInstance(Scale.kilo.descriptor, ScaleDescriptor)
676
+ self.assertEqual(Scale.kilo.descriptor, Scale.kilo.value)
469
677
 
470
- class TestNumberEdgeCases(TestCase):
471
-
472
- def test_default_number_is_dimensionless_one(self):
473
- n = Number()
474
- self.assertEqual(n.unit, units.none)
475
- self.assertEqual(n.scale, Scale.one)
476
- self.assertEqual(n.quantity, 1)
477
- self.assertAlmostEqual(n.value, 1.0)
478
- self.assertIn("1", repr(n))
479
-
480
- def test_to_new_scale_changes_value(self):
481
- n = Number(quantity=1000, scale=Scale.kilo)
482
- converted = n.to(Scale.one)
483
- self.assertNotEqual(n.value, converted.value)
484
- self.assertAlmostEqual(converted.value, 1000)
485
-
486
- def test_simplify_uses_value_as_quantity(self):
487
- n = Number(quantity=2, scale=Scale.kilo)
488
- simplified = n.simplify()
489
- self.assertEqual(simplified.quantity, n.value)
490
- self.assertEqual(simplified.unit, n.unit)
491
-
492
- def test_multiplication_combines_units_and_quantities(self):
493
- n1 = Number(unit=units.joule, quantity=2)
494
- n2 = Number(unit=units.second, quantity=3)
495
- result = n1 * n2
496
- self.assertEqual(result.quantity, 6)
497
- self.assertEqual(result.unit.dimension, Dimension.energy * Dimension.time)
498
-
499
- def test_division_combines_units_scales_and_quantities(self):
500
- n1 = Number(unit=units.meter, scale=Scale.kilo, quantity=1000)
501
- n2 = Number(unit=units.second, scale=Scale.one, quantity=2)
502
- result = n1 / n2
503
- self.assertEqual(result.scale, Scale.kilo / Scale.one)
504
- self.assertEqual(result.unit.dimension, Dimension.velocity)
505
- self.assertAlmostEqual(result.quantity, 500)
506
-
507
- def test_equality_with_non_number_raises_value_error(self):
508
- n = Number()
509
- with self.assertRaises(TypeError):
510
- n == '5'
511
-
512
- def test_equality_between_numbers_and_ratios(self):
513
- n1 = Number(quantity=10)
514
- n2 = Number(quantity=10)
515
- r = Ratio(n1, n2)
516
- self.assertTrue(r == Number())
517
-
518
- def test_repr_includes_scale_and_unit(self):
519
- n = Number(unit=units.volt, scale=Scale.kilo, quantity=5)
520
- rep = repr(n)
521
- self.assertIn("kilo", rep)
522
- self.assertIn("volt", rep)
523
-
524
-
525
- class TestRatioEdgeCases(TestCase):
526
-
527
- def test_default_ratio_is_dimensionless_one(self):
528
- r = Ratio()
529
- self.assertEqual(r.numerator.unit, units.none)
530
- self.assertEqual(r.denominator.unit, units.none)
531
- self.assertAlmostEqual(r.evaluate().value, 1.0)
532
-
533
- def test_reciprocal_swaps_numerator_and_denominator(self):
534
- n1 = Number(quantity=10)
535
- n2 = Number(quantity=2)
536
- r = Ratio(n1, n2)
537
- reciprocal = r.reciprocal()
538
- self.assertEqual(reciprocal.numerator, r.denominator)
539
- self.assertEqual(reciprocal.denominator, r.numerator)
540
-
541
- def test_evaluate_returns_number_division_result(self):
542
- r = Ratio(Number(unit=units.meter), Number(unit=units.second))
543
- result = r.evaluate()
544
- self.assertIsInstance(result, Number)
545
- self.assertEqual(result.unit.dimension, Dimension.velocity)
546
-
547
- def test_multiplication_between_compatible_ratios(self):
548
- r1 = Ratio(Number(unit=units.meter), Number(unit=units.second))
549
- r2 = Ratio(Number(unit=units.second), Number(unit=units.meter))
550
- product = r1 * r2
551
- self.assertIsInstance(product, Ratio)
552
- self.assertEqual(product.evaluate().unit.dimension, Dimension.none)
553
-
554
- def test_multiplication_with_incompatible_units_fallback(self):
555
- r1 = Ratio(Number(unit=units.meter), Number(unit=units.ampere))
556
- r2 = Ratio(Number(unit=units.ampere), Number(unit=units.meter))
557
- result = r1 * r2
558
- self.assertIsInstance(result, Ratio)
559
-
560
- def test_division_between_ratios_yields_new_ratio(self):
561
- r1 = Ratio(Number(quantity=2), Number(quantity=1))
562
- r2 = Ratio(Number(quantity=4), Number(quantity=2))
563
- result = r1 / r2
564
- self.assertIsInstance(result, Ratio)
565
- self.assertAlmostEqual(result.evaluate().value, 1.0)
566
-
567
- def test_equality_with_non_ratio_raises_value_error(self):
568
- r = Ratio()
569
- with self.assertRaises(ValueError):
570
- _ = (r == "not_a_ratio")
571
-
572
- def test_repr_handles_equal_numerator_denominator(self):
573
- r = Ratio()
574
- self.assertEqual(str(r.evaluate().value), "1.0")
575
- rep = repr(r)
576
- self.assertTrue(rep.startswith("<1"))
577
-
578
- def test_repr_of_non_equal_ratio_includes_slash(self):
579
- n1 = Number(quantity=2)
580
- n2 = Number(quantity=1)
581
- r = Ratio(n1, n2)
582
- rep = repr(r)
583
- self.assertIn("/", rep)
678
+ def test_alias_property(self):
679
+ self.assertEqual(Scale.kilo.alias, "kilo")
680
+ self.assertEqual(Scale.one.alias, "")
681
+
682
+ def test_scale_descriptor_parts(self):
683
+ self.assertEqual(Scale.kilo.value.parts(), (10, 3))
684
+ self.assertEqual(Scale.kibi.value.parts(), (2, 10))
685
+
686
+ def test_scale_hash_used_in_sets(self):
687
+ s = {Scale.kilo, Scale.milli}
688
+ self.assertIn(Scale.kilo, s)
689
+ self.assertNotIn(Scale.one, s)
690
+
691
+ def test_scale_mul_nonmatching_falls_to_nearest(self):
692
+ # kilo * kibi → no exact match, falls through to Scale.nearest
693
+ result = Scale.kilo * Scale.kibi
694
+ self.assertIsInstance(result, Scale)
695
+
696
+ def test_scale_pow(self):
697
+ result = Scale.kilo ** 2
698
+ self.assertEqual(result, Scale.mega)
699
+
700
+ def test_scale_pow_binary(self):
701
+ result = Scale.kibi ** 2
702
+ self.assertEqual(result, Scale.mebi)
703
+
704
+ def test_scale_pow_nonmatching_falls_to_nearest(self):
705
+ result = Scale.kilo ** 0.5
706
+ self.assertIsInstance(result, Scale)
707
+
708
+
709
+ class TestUnitAlgebra(unittest.TestCase):
710
+
711
+ def test_unit_mul_unitproduct(self):
712
+ m = units.meter
713
+ velocity = UnitProduct({m: 1, units.second: -1})
714
+ result = m * velocity
715
+ self.assertIsInstance(result, UnitProduct)
716
+ # m * (m/s) = m²/s
717
+ self.assertEqual(result.dimension, Dimension.area / Dimension.time)
718
+
719
+ def test_unit_mul_non_unit_returns_not_implemented(self):
720
+ result = units.meter.__mul__("not a unit")
721
+ self.assertIs(result, NotImplemented)
722
+
723
+ def test_unit_truediv_non_unit_returns_not_implemented(self):
724
+ result = units.meter.__truediv__("not a unit")
725
+ self.assertIs(result, NotImplemented)
726
+
727
+ def test_unit_pow(self):
728
+ m = units.meter
729
+ result = m ** 2
730
+ self.assertIsInstance(result, UnitProduct)
731
+ self.assertEqual(result.dimension, Dimension.area)
732
+
733
+ def test_unit_pow_3(self):
734
+ m = units.meter
735
+ result = m ** 3
736
+ self.assertEqual(result.dimension, Dimension.volume)
737
+
738
+
739
+ class TestUnitFactorCoverage(unittest.TestCase):
740
+
741
+ def test_shorthand_name_fallback(self):
742
+ # UnitFactor where unit has no aliases but has a name
743
+ u = Unit(name='gram', dimension=Dimension.mass)
744
+ fu = UnitFactor(unit=u, scale=Scale.milli)
745
+ self.assertEqual(fu.shorthand, 'mgram')
746
+
747
+ def test_repr(self):
748
+ fu = UnitFactor(unit=units.meter, scale=Scale.kilo)
749
+ self.assertIn('UnitFactor', repr(fu))
750
+ self.assertIn('kilo', repr(fu))
751
+
752
+ def test_eq_non_unit_returns_not_implemented(self):
753
+ fu = UnitFactor(unit=units.meter, scale=Scale.one)
754
+ self.assertIs(fu.__eq__("string"), NotImplemented)
755
+
756
+
757
+ class TestUnitProductAlgebra(unittest.TestCase):
758
+
759
+ def test_mul_unitproduct_by_unitproduct(self):
760
+ velocity = UnitProduct({units.meter: 1, units.second: -1})
761
+ time_sq = UnitProduct({units.second: 2})
762
+ result = velocity * time_sq
763
+ self.assertIsInstance(result, UnitProduct)
764
+ # (m/s) * s² = m·s
765
+ self.assertEqual(result.dimension, Dimension.length * Dimension.time)
766
+
767
+ def test_mul_unitproduct_by_scale_returns_not_implemented(self):
768
+ velocity = UnitProduct({units.meter: 1, units.second: -1})
769
+ result = velocity.__mul__(Scale.kilo)
770
+ self.assertIs(result, NotImplemented)
771
+
772
+ def test_mul_unitproduct_by_non_unit_returns_not_implemented(self):
773
+ velocity = UnitProduct({units.meter: 1, units.second: -1})
774
+ result = velocity.__mul__("string")
775
+ self.assertIs(result, NotImplemented)
776
+
777
+ def test_truediv_unitproduct_by_unitproduct(self):
778
+ acceleration = UnitProduct({units.meter: 1, units.second: -2})
779
+ velocity = UnitProduct({units.meter: 1, units.second: -1})
780
+ result = acceleration / velocity
781
+ self.assertIsInstance(result, UnitProduct)
782
+ # (m/s²) / (m/s) = 1/s
783
+ self.assertEqual(result.dimension, Dimension.frequency)
784
+
785
+ def test_rmul_unit_times_unitproduct(self):
786
+ velocity = UnitProduct({units.meter: 1, units.second: -1})
787
+ result = units.meter * velocity
788
+ self.assertIsInstance(result, UnitProduct)
789
+
790
+ def test_rmul_scale_on_empty_unitproduct(self):
791
+ empty = UnitProduct({})
792
+ result = Scale.kilo * empty
793
+ self.assertIs(result, empty)
794
+
795
+ def test_rmul_scale_applies_to_sink_unit(self):
796
+ velocity = UnitProduct({units.meter: 1, units.second: -1})
797
+ result = Scale.kilo * velocity
798
+ self.assertIsInstance(result, UnitProduct)
799
+ self.assertIn('km', result.shorthand)
800
+
801
+ def test_rmul_scale_combines_with_existing_scale(self):
802
+ km_per_s = Scale.kilo * UnitProduct({units.meter: 1, units.second: -1})
803
+ # Apply another scale on top → should combine scales
804
+ result = Scale.milli * km_per_s
805
+ self.assertIsInstance(result, UnitProduct)
806
+
807
+ def test_rmul_non_unit_returns_not_implemented(self):
808
+ velocity = UnitProduct({units.meter: 1, units.second: -1})
809
+ result = velocity.__rmul__("string")
810
+ self.assertIs(result, NotImplemented)
811
+
812
+ def test_append_dimensionless_skipped(self):
813
+ # UnitProduct with only dimensionless factor → empty shorthand
814
+ up = UnitProduct({})
815
+ self.assertEqual(up.shorthand, "")
816
+
817
+ def test_shorthand_with_negative_non_unit_exponent(self):
818
+ # e.g. m/s² should show superscript on denominator
819
+ accel = UnitProduct({units.meter: 1, units.second: -2})
820
+ sh = accel.shorthand
821
+ self.assertIn('m', sh)
822
+ self.assertIn('s', sh)
823
+
824
+ def test_shorthand_numerator_exponent(self):
825
+ area = UnitProduct({units.meter: 2})
826
+ self.assertIn('m', area.shorthand)
827
+ # Should contain superscript 2
828
+ self.assertIn('²', area.shorthand)