ucon 0.2.2rc1__tar.gz → 0.3.0__tar.gz

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.
@@ -17,8 +17,11 @@ jobs:
17
17
  with:
18
18
  python-version: 3.9
19
19
 
20
- - name: Provision virtual environment and run tests
21
- run: pip install nox && nox -s ucon
20
+ - name: Install dependencies
21
+ run: pip install -r requirements.txt
22
+
23
+ - name: Run tests
24
+ run: nox -s test
22
25
 
23
26
 
24
27
  publish:
@@ -36,10 +39,10 @@ jobs:
36
39
  python-version: 3.9
37
40
 
38
41
  - name: Install dependencies
39
- run: pip install nox
42
+ run: pip install -r requirements.txt
40
43
 
41
44
  - name: Build distribution 📦
42
- run: PYTHONWARNINGS=ignore LOCAL_VERSION_SCHEME=true nox -s build-ucon
45
+ run: PYTHONWARNINGS=ignore LOCAL_VERSION_SCHEME=true nox -s build
43
46
 
44
47
  - name: Publish distribution 📦 to Test PyPI
45
48
  uses: pypa/gh-action-pypi-publish@master
@@ -4,31 +4,31 @@ on: [push]
4
4
 
5
5
  jobs:
6
6
  check:
7
- runs-on: ubuntu-latest
7
+ runs-on: ubuntu-22.04
8
8
  strategy:
9
9
  max-parallel: 3
10
10
  matrix:
11
- python-version: [3.7, 3.8, 3.9, '3.10']
11
+ python-version: ['3.7', '3.8', '3.9', '3.10']
12
12
 
13
13
  steps:
14
- - uses: actions/checkout@v1
14
+ - uses: actions/checkout@v5
15
15
 
16
16
  - name: Set up Python ${{ matrix.python-version }}
17
- uses: actions/setup-python@v1
17
+ uses: actions/setup-python@v6
18
18
  with:
19
19
  python-version: ${{ matrix.python-version }}
20
20
 
21
- - name: Install nox
22
- run: pip install nox
21
+ - name: Install dependencies
22
+ run: pip install -r requirements.txt
23
23
 
24
- - name: Provision virtual environment and run tests
25
- run: nox -s ucon
24
+ - name: Run tests
25
+ run: nox -s test
26
26
 
27
27
  - name: Upload coverage
28
28
  run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }}
29
29
 
30
30
  - name: Archive coverage report
31
- uses: actions/upload-artifact@v2
31
+ uses: actions/upload-artifact@v4
32
32
  with:
33
- name: code-coverage-report
33
+ name: python-${{ matrix.python-version }}-code-coverage-report-${{ github.run_id }}-${{ github.run_attempt }}
34
34
  path: ${{ github.workspace }}/coverage.xml
@@ -1,12 +1,12 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: ucon
3
- Version: 0.2.2rc1
3
+ Version: 0.3.0
4
4
  Summary: a tool for dimensional analysis: a "Unit CONverter"
5
5
  Home-page: https://github.com/withtwoemms/ucon
6
+ Author: Emmanuel I. Obi
6
7
  Maintainer: Emmanuel I. Obi
7
8
  Maintainer-email: withtwoemms@gmail.com
8
9
  License: MIT
9
- Platform: UNKNOWN
10
10
  Classifier: Development Status :: 3 - Alpha
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Intended Audience :: Education
@@ -19,13 +19,24 @@ Classifier: Programming Language :: Python :: 3.9
19
19
  Classifier: Programming Language :: Python :: 3.10
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
+ Dynamic: author
23
+ Dynamic: classifier
24
+ Dynamic: description
25
+ Dynamic: description-content-type
26
+ Dynamic: home-page
27
+ Dynamic: license
28
+ Dynamic: license-file
29
+ Dynamic: maintainer
30
+ Dynamic: maintainer-email
31
+ Dynamic: summary
32
+
33
+ <img src="https://gist.githubusercontent.com/withtwoemms/0cb9e6bc8df08f326771a89eeb790f8e/raw/dde6c7d3b8a7d79eb1006ace03fb834e044cdebc/ucon-logo.png" align="left" width="450" />
22
34
 
23
35
  # ucon
24
36
 
25
37
  > Pronounced: _yoo · cahn_
26
38
 
27
- [![tests](https://github.com/withtwoemms/ucon/workflows/tests/badge.svg)](https://github.com/withtwoemms/ucon/actions?query=workflow%3Atests)
28
- [![publish](https://github.com/withtwoemms/ucon/workflows/publish/badge.svg)](https://github.com/withtwoemms/ucon/actions?query=workflow%3Apublish)
39
+ [![tests](https://github.com/withtwoemms/ucon/workflows/tests/badge.svg)](https://github.com/withtwoemms/ucon/actions?query=workflow%3Atests) [![codecov](https://codecov.io/gh/withtwoemms/ucon/graph/badge.svg?token=BNONQTRJWG)](https://codecov.io/gh/withtwoemms/ucon) [![publish](https://github.com/withtwoemms/ucon/workflows/publish/badge.svg)](https://github.com/withtwoemms/ucon/actions?query=workflow%3Apublish)
29
40
 
30
41
  # Background
31
42
 
@@ -55,9 +66,9 @@ To best answer this question, we turn to an age-old technique ([dimensional anal
55
66
 
56
67
  Ensure `nox` is installed.
57
68
  ```
58
- pip install nox
69
+ pip install -r requirements.txt
59
70
  ```
60
- Run `nox -s ucon` to install `ucon` and run tests.
71
+ Run `nox -s install` to install `ucon` and `nox -s test` to run tests.
61
72
 
62
73
  # Usage
63
74
 
@@ -77,5 +88,3 @@ answer = two_milliliters_bromine * bromine_density #=> <6.238 gram>
77
88
  answer.to(Scale.milli) #=> <6238.0 milligram>
78
89
  answer.to(Scale.kibi) #=> <0.006091796875 kibigram>
79
90
  ```
80
-
81
-
@@ -1,9 +1,10 @@
1
+ <img src="https://gist.githubusercontent.com/withtwoemms/0cb9e6bc8df08f326771a89eeb790f8e/raw/dde6c7d3b8a7d79eb1006ace03fb834e044cdebc/ucon-logo.png" align="left" width="450" />
2
+
1
3
  # ucon
2
4
 
3
5
  > Pronounced: _yoo · cahn_
4
6
 
5
- [![tests](https://github.com/withtwoemms/ucon/workflows/tests/badge.svg)](https://github.com/withtwoemms/ucon/actions?query=workflow%3Atests)
6
- [![publish](https://github.com/withtwoemms/ucon/workflows/publish/badge.svg)](https://github.com/withtwoemms/ucon/actions?query=workflow%3Apublish)
7
+ [![tests](https://github.com/withtwoemms/ucon/workflows/tests/badge.svg)](https://github.com/withtwoemms/ucon/actions?query=workflow%3Atests) [![codecov](https://codecov.io/gh/withtwoemms/ucon/graph/badge.svg?token=BNONQTRJWG)](https://codecov.io/gh/withtwoemms/ucon) [![publish](https://github.com/withtwoemms/ucon/workflows/publish/badge.svg)](https://github.com/withtwoemms/ucon/actions?query=workflow%3Apublish)
7
8
 
8
9
  # Background
9
10
 
@@ -33,9 +34,9 @@ To best answer this question, we turn to an age-old technique ([dimensional anal
33
34
 
34
35
  Ensure `nox` is installed.
35
36
  ```
36
- pip install nox
37
+ pip install -r requirements.txt
37
38
  ```
38
- Run `nox -s ucon` to install `ucon` and run tests.
39
+ Run `nox -s install` to install `ucon` and `nox -s test` to run tests.
39
40
 
40
41
  # Usage
41
42
 
ucon-0.3.0/noxfile.py ADDED
@@ -0,0 +1,114 @@
1
+ import nox
2
+ import re
3
+
4
+ from distutils.util import strtobool
5
+ from subprocess import check_output
6
+ from typing import List
7
+ from os import environ as envvar
8
+
9
+
10
+ PROJECT_NAME = 'ucon'
11
+ VENV = f'{PROJECT_NAME}-venv'
12
+ USEVENV = envvar.get('USEVENV', False)
13
+
14
+ if USEVENV:
15
+ assert USEVENV in ('none', 'virtualenv', 'conda', 'mamba', 'venv')
16
+
17
+ OFFICIAL = bool(strtobool(envvar.get('OFFICIAL', 'False')))
18
+ COVERAGE = bool(strtobool(envvar.get('COVERAGE', 'True')))
19
+ TESTDIR = f'tests/'
20
+ TESTNAME = envvar.get('TESTNAME', '')
21
+
22
+
23
+ external = False if USEVENV else True
24
+ supported_python_versions = [
25
+ '3.6',
26
+ '3.7',
27
+ '3.8',
28
+ '3.9',
29
+ '3.10',
30
+ ]
31
+
32
+ nox.options.default_venv_backend = 'none' if not USEVENV else USEVENV
33
+
34
+ def session_name(suffix: str):
35
+ return f'{VENV}-{suffix}' if USEVENV else suffix
36
+
37
+
38
+ def semver(version: str):
39
+ unofficial_semver = r'^([0-9]|[1-9][0-9]*)\.([0-9]|[1-9][0-9]*)\.([0-9]|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?(.+)$'
40
+ official_semver = r'^([0-9]|[1-9][0-9]*)\.([0-9]|[1-9][0-9]*)\.([0-9]|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?$'
41
+ _semver = re.search(official_semver, version) or re.search(unofficial_semver, version)
42
+ if _semver:
43
+ return [val for val in _semver.groups() if val]
44
+
45
+
46
+ def is_official(semver: List[str]):
47
+ """
48
+ TODO (withtwoemms) -- create SemVer type to replace List[str]
49
+ """
50
+ if len(semver) > 3:
51
+ return False
52
+ else:
53
+ return True
54
+
55
+
56
+ def latest_version(official: bool = False):
57
+ output = check_output('git for-each-ref --sort=creatordate --format %(refname) refs/tags'.split())
58
+ all_versions = (
59
+ version.lstrip('refs/tags/')
60
+ for version in reversed(output.decode().strip('\n').split('\n'))
61
+ )
62
+
63
+ if not official:
64
+ return next(all_versions)
65
+
66
+ for version in all_versions:
67
+ if official and is_official(semver(version)):
68
+ return version
69
+
70
+
71
+ @nox.session(name=session_name('version'), python=supported_python_versions)
72
+ def version(session):
73
+ print(latest_version(official=OFFICIAL))
74
+
75
+
76
+ @nox.session(name=session_name('install'), python=supported_python_versions)
77
+ def install(session):
78
+ session.run(
79
+ 'python', '-m',
80
+ 'pip', '--disable-pip-version-check', 'install', '.',
81
+ external=external
82
+ )
83
+
84
+
85
+ @nox.session(name=session_name('test'), python=supported_python_versions)
86
+ def test(session):
87
+ if USEVENV:
88
+ install(session)
89
+
90
+ if COVERAGE:
91
+ session.run(
92
+ 'python', '-m',
93
+ 'coverage', 'run', '--source', '.', '--branch',
94
+ '--omit', '**tests/*,**/site-packages/*.py,noxfile.py,setup.py',
95
+ '-m', 'unittest', TESTNAME if TESTNAME else f'discover',
96
+ '--start-directory', TESTDIR,
97
+ '--top-level-directory', '.',
98
+ external=external
99
+ )
100
+ session.run('coverage', 'report', '-m', external=external)
101
+ session.run('coverage', 'xml', external=external)
102
+ else:
103
+ session.run(
104
+ 'python', '-m',
105
+ 'unittest', TESTNAME if TESTNAME else f'discover',
106
+ '--start-directory', TESTDIR,
107
+ '--top-level-directory', '.',
108
+ external=external
109
+ )
110
+
111
+
112
+ @nox.session(name=f'build')
113
+ def build(session):
114
+ session.run('python', 'setup.py', 'sdist')
@@ -0,0 +1,2 @@
1
+ coverage==5.5
2
+ nox==2021.10.1
@@ -1,6 +1,6 @@
1
1
  from os import environ as envvars
2
2
  from pathlib import Path
3
- from setuptools import setup
3
+ from setuptools import find_packages, setup
4
4
 
5
5
 
6
6
  setup(
@@ -13,7 +13,8 @@ setup(
13
13
  setup_requires=[
14
14
  'setuptools_scm==6.3.2'
15
15
  ],
16
- py_modules=['ucon'],
16
+ packages=find_packages(exclude=['tests']),
17
+ author='Emmanuel I. Obi',
17
18
  maintainer='Emmanuel I. Obi',
18
19
  maintainer_email='withtwoemms@gmail.com',
19
20
  url='https://github.com/withtwoemms/ucon',
File without changes
File without changes
@@ -0,0 +1,355 @@
1
+ from unittest import TestCase
2
+
3
+ from ucon import Number
4
+ from ucon import Exponent
5
+ from ucon import Ratio
6
+ from ucon import Scale
7
+ from ucon import Dimension
8
+ from ucon import units
9
+ from ucon.unit import Unit
10
+
11
+
12
+ class TestExponent(TestCase):
13
+
14
+ thousand = Exponent(10, 3)
15
+ thousandth = Exponent(10, -3)
16
+
17
+ def test___init__(self):
18
+ with self.assertRaises(ValueError):
19
+ Exponent(5, 3) # no support for base 5 logarithms
20
+
21
+ def test_parts(self):
22
+ self.assertEqual((10, 3), self.thousand.parts())
23
+ self.assertEqual((10, -3), self.thousandth.parts())
24
+
25
+ def test___truediv__(self):
26
+ self.assertEqual(1000, self.thousand.evaluated)
27
+ self.assertEqual(float(1/1000), self.thousandth.evaluated)
28
+ self.assertEqual(float(1000000), (self.thousand / self.thousandth))
29
+
30
+ def test___lt__(self):
31
+ self.assertLess(self.thousandth, self.thousand)
32
+
33
+ def test___gt__(self):
34
+ self.assertGreater(self.thousand, self.thousandth)
35
+
36
+ def test___repr__(self):
37
+ self.assertEqual(str(self.thousand), '<10^3>')
38
+ self.assertEqual(str(self.thousandth), '<10^-3>')
39
+
40
+
41
+ class TestScale(TestCase):
42
+
43
+ def test___truediv__(self):
44
+ self.assertEqual(Scale.deca, Scale.one / Scale.deci)
45
+ self.assertEqual(Scale.deci, Scale.one / Scale.deca)
46
+ self.assertEqual(Scale.kibi, Scale.mebi / Scale.kibi)
47
+ self.assertEqual(Scale.milli, Scale.one / Scale.deca / Scale.deca / Scale.deca)
48
+ self.assertEqual(Scale.deca, Scale.kilo / Scale.hecto)
49
+ self.assertEqual(Scale._kibi, Scale.one / Scale.kibi)
50
+ self.assertEqual(Scale.kibi, Scale.kibi / Scale.one)
51
+ self.assertEqual(Scale.one, Scale.kibi / Scale.kibi)
52
+ self.assertEqual(Scale.one, Scale.kibi / Scale.kilo)
53
+
54
+ def test___lt__(self):
55
+ self.assertLess(Scale.one, Scale.kilo)
56
+
57
+ def test___gt__(self):
58
+ self.assertGreater(Scale.kilo, Scale.one)
59
+
60
+ def test_all(self):
61
+ for scale in Scale:
62
+ self.assertTrue(isinstance(scale.value, Exponent))
63
+ self.assertIsInstance(Scale.all(), dict)
64
+
65
+
66
+ class TestNumber(TestCase):
67
+
68
+ number = Number(unit=units.gram, quantity=1)
69
+
70
+ def test_as_ratio(self):
71
+ ratio = self.number.as_ratio()
72
+ self.assertIsInstance(ratio, Ratio)
73
+ self.assertEqual(ratio.numerator, self.number)
74
+ self.assertEqual(ratio.denominator, Number())
75
+
76
+ def test_simplify(self):
77
+ ten_decagrams = Number(unit=units.gram, scale=Scale.deca, quantity=10)
78
+ point_one_decagrams = Number(unit=units.gram, scale=Scale.deca, quantity=0.1)
79
+ two_kibigrams = Number(unit=units.gram, scale=Scale.kibi, quantity=2)
80
+
81
+ self.assertEqual(Number(unit=units.gram, quantity=100), ten_decagrams.simplify())
82
+ self.assertEqual(Number(unit=units.gram, quantity=1), point_one_decagrams.simplify())
83
+ self.assertEqual(Number(unit=units.gram, quantity=2048), two_kibigrams.simplify())
84
+
85
+ def test_to(self):
86
+ thousandth_of_a_kilogram = Number(unit=units.gram, scale=Scale.kilo, quantity=0.001)
87
+ thousand_milligrams = Number(unit=units.gram, scale=Scale.milli, quantity=1000)
88
+ kibigram_fraction = Number(unit=units.gram, scale=Scale.kibi, quantity=0.0009765625)
89
+
90
+ self.assertEqual(thousandth_of_a_kilogram, self.number.to(Scale.kilo))
91
+ self.assertEqual(thousand_milligrams, self.number.to(Scale.milli))
92
+ self.assertEqual(kibigram_fraction, self.number.to(Scale.kibi))
93
+
94
+ def test___repr__(self):
95
+ self.assertIn(str(self.number.quantity), str(self.number))
96
+ self.assertIn(str(self.number.scale.value.evaluated), str(self.number))
97
+ self.assertIn(self.number.unit.name, str(self.number))
98
+
99
+ def test___truediv__(self):
100
+ some_number = Number(unit=units.gram, scale=Scale.deca, quantity=10)
101
+ another_number = Number(unit=units.gram, scale=Scale.milli, quantity=10)
102
+ that_number = Number(unit=units.gram, scale=Scale.kibi, quantity=10)
103
+
104
+ some_quotient = self.number / some_number
105
+ another_quotient = self.number / another_number
106
+ that_quotient = self.number / that_number
107
+
108
+ self.assertEqual(some_quotient.value, 0.01)
109
+ self.assertEqual(another_quotient.value, 100.0)
110
+ self.assertEqual(that_quotient.value, 0.00009765625)
111
+
112
+ def test___eq__(self):
113
+ self.assertEqual(self.number, Ratio(self.number)) # 1 gram / 1
114
+ with self.assertRaises(ValueError):
115
+ self.number == 1
116
+
117
+
118
+ class TestRatio(TestCase):
119
+
120
+ point_five = Number(quantity=0.5)
121
+ one = Number()
122
+ two = Number(quantity=2)
123
+ three = Number(quantity=3)
124
+ four = Number(quantity=4)
125
+
126
+ one_half = Ratio(numerator=one, denominator=two)
127
+ three_fourths = Ratio(numerator=three, denominator=four)
128
+ one_ratio = Ratio(numerator=one)
129
+ three_halves = Ratio(numerator=three, denominator=two)
130
+ two_ratio = Ratio(numerator=two, denominator=one)
131
+
132
+ def test_evaluate(self):
133
+ self.assertEqual(self.one_ratio.numerator, self.one)
134
+ self.assertEqual(self.one_ratio.denominator, self.one)
135
+ self.assertEqual(self.one_ratio.evaluate(), self.one)
136
+ self.assertEqual(self.two_ratio.evaluate(), self.two)
137
+
138
+ def test_reciprocal(self):
139
+ self.assertEqual(self.two_ratio.reciprocal().numerator, self.one)
140
+ self.assertEqual(self.two_ratio.reciprocal().denominator, self.two)
141
+ self.assertEqual(self.two_ratio.reciprocal().evaluate(), self.point_five)
142
+
143
+ def test___mul__commutivity(self):
144
+ # Does commutivity hold?
145
+ self.assertEqual(self.three_halves * self.one_half, self.three_fourths)
146
+ self.assertEqual(self.one_half * self.three_halves, self.three_fourths)
147
+
148
+ def test___mul__(self):
149
+ bromine_density = Ratio(Number(units.gram, quantity=3.119), Number(units.liter, Scale.milli))
150
+
151
+ # How many grams of bromine are in 2 milliliters?
152
+ two_milliliters_bromine = Number(units.liter, Scale.milli, 2)
153
+ ratio = two_milliliters_bromine.as_ratio() * bromine_density
154
+ answer = ratio.evaluate()
155
+ self.assertEqual(answer.unit.dimension, Dimension.mass)
156
+ self.assertEqual(answer.value, 6.238) # Grams
157
+
158
+ def test___truediv__(self):
159
+ seconds_per_hour = Ratio(
160
+ numerator=Number(unit=units.second, quantity=3600),
161
+ denominator=Number(unit=units.hour, quantity=1)
162
+ )
163
+
164
+ # How many Wh from 20 kJ?
165
+ twenty_kilojoules = Number(unit=units.joule, scale=Scale.kilo, quantity=20)
166
+ ratio = twenty_kilojoules.as_ratio() / seconds_per_hour
167
+ answer = ratio.evaluate()
168
+ self.assertEqual(answer.unit.dimension, Dimension.energy)
169
+ self.assertEqual(round(answer.value, 5), 5.55556) # Watt * hours
170
+
171
+ def test___eq__(self):
172
+ self.assertEqual(self.one_half, self.point_five)
173
+ with self.assertRaises(ValueError):
174
+ self.one_half == 1/2
175
+
176
+ def test___repr__(self):
177
+ self.assertEqual(str(self.one_ratio), '<1.0 >')
178
+ self.assertEqual(str(self.two_ratio), '<2 > / <1 >')
179
+ self.assertEqual(str(self.two_ratio.evaluate()), '<2.0 >')
180
+
181
+
182
+ class TestExponentEdgeCases(TestCase):
183
+
184
+ def test_valid_exponent_evaluates_correctly(self):
185
+ e = Exponent(10, 3)
186
+ self.assertEqual(e.evaluated, 1000)
187
+ self.assertEqual(e.parts(), (10, 3))
188
+ self.assertIn("^", repr(e))
189
+
190
+ def test_invalid_base_raises_value_error(self):
191
+ with self.assertRaises(ValueError):
192
+ Exponent(5, 2)
193
+
194
+ def test_exponent_comparisons(self):
195
+ e1 = Exponent(10, 2)
196
+ e2 = Exponent(10, 3)
197
+ self.assertTrue(e1 < e2)
198
+ self.assertTrue(e2 > e1)
199
+ self.assertFalse(e1 == e2)
200
+
201
+ def test_division_returns_float_ratio(self):
202
+ e1 = Exponent(10, 3)
203
+ e2 = Exponent(10, 2)
204
+ self.assertEqual(e1 / e2, 10.0)
205
+
206
+ def test_equality_with_different_type(self):
207
+ with self.assertRaises(TypeError):
208
+ Exponent(10, 2) == "10^2"
209
+
210
+
211
+ class TestScaleEdgeCases(TestCase):
212
+
213
+ def test_division_same_base_scales(self):
214
+ result = Scale.kilo / Scale.milli
215
+ self.assertIsInstance(result, Scale)
216
+ self.assertEqual(result.value.evaluated, 10 ** 6)
217
+
218
+ def test_division_same_scale_returns_one(self):
219
+ self.assertEqual(Scale.kilo / Scale.kilo, Scale.one)
220
+
221
+ def test_division_different_bases_returns_valid_scale(self):
222
+ result = Scale.kibi / Scale.kilo
223
+ self.assertIsInstance(result, Scale)
224
+ self.assertIn(result, Scale)
225
+
226
+ def test_division_with_one(self):
227
+ result = Scale.one / Scale.kilo
228
+ self.assertIsInstance(result, Scale)
229
+ self.assertTrue(hasattr(result, "value"))
230
+
231
+ def test_comparisons_and_equality(self):
232
+ self.assertTrue(Scale.kilo > Scale.deci)
233
+ self.assertTrue(Scale.milli < Scale.one)
234
+ self.assertTrue(Scale.kilo == Scale.kilo)
235
+
236
+ def test_all_and_by_value_cover_all_enum_members(self):
237
+ all_map = Scale.all()
238
+ by_val = Scale.by_value()
239
+ self.assertTrue(all((val in by_val.values()) for _, val in all_map.items()))
240
+
241
+
242
+ class TestNumberEdgeCases(TestCase):
243
+
244
+ def test_default_number_is_dimensionless_one(self):
245
+ n = Number()
246
+ self.assertEqual(n.unit, units.none)
247
+ self.assertEqual(n.scale, Scale.one)
248
+ self.assertEqual(n.quantity, 1)
249
+ self.assertAlmostEqual(n.value, 1.0)
250
+ self.assertIn("1", repr(n))
251
+
252
+ def test_to_new_scale_changes_value(self):
253
+ n = Number(quantity=1000, scale=Scale.kilo)
254
+ converted = n.to(Scale.one)
255
+ self.assertNotEqual(n.value, converted.value)
256
+ self.assertAlmostEqual(converted.value, 1000)
257
+
258
+ def test_simplify_uses_value_as_quantity(self):
259
+ n = Number(quantity=2, scale=Scale.kilo)
260
+ simplified = n.simplify()
261
+ self.assertEqual(simplified.quantity, n.value)
262
+ self.assertEqual(simplified.unit, n.unit)
263
+
264
+ def test_multiplication_combines_units_and_quantities(self):
265
+ n1 = Number(unit=units.joule, quantity=2)
266
+ n2 = Number(unit=units.second, quantity=3)
267
+ result = n1 * n2
268
+ self.assertEqual(result.quantity, 6)
269
+ self.assertEqual(result.unit.dimension, Dimension.energy * Dimension.time)
270
+
271
+ def test_division_combines_units_scales_and_quantities(self):
272
+ n1 = Number(unit=units.meter, scale=Scale.kilo, quantity=1000)
273
+ n2 = Number(unit=units.second, scale=Scale.one, quantity=2)
274
+ result = n1 / n2
275
+ self.assertEqual(result.scale, Scale.kilo / Scale.one)
276
+ self.assertEqual(result.unit.dimension, Dimension.velocity)
277
+ self.assertAlmostEqual(result.quantity, 500)
278
+
279
+ def test_equality_with_non_number_raises_value_error(self):
280
+ n = Number()
281
+ with self.assertRaises(ValueError):
282
+ _ = (n == "5")
283
+
284
+ def test_equality_between_numbers_and_ratios(self):
285
+ n1 = Number(quantity=10)
286
+ n2 = Number(quantity=10)
287
+ r = Ratio(n1, n2)
288
+ self.assertTrue(r == Number())
289
+
290
+ def test_repr_includes_scale_and_unit(self):
291
+ n = Number(unit=units.volt, scale=Scale.kilo, quantity=5)
292
+ rep = repr(n)
293
+ self.assertIn("kilo", rep)
294
+ self.assertIn("volt", rep)
295
+
296
+
297
+ class TestRatioEdgeCases(TestCase):
298
+
299
+ def test_default_ratio_is_dimensionless_one(self):
300
+ r = Ratio()
301
+ self.assertEqual(r.numerator.unit, units.none)
302
+ self.assertEqual(r.denominator.unit, units.none)
303
+ self.assertAlmostEqual(r.evaluate().value, 1.0)
304
+
305
+ def test_reciprocal_swaps_numerator_and_denominator(self):
306
+ n1 = Number(quantity=10)
307
+ n2 = Number(quantity=2)
308
+ r = Ratio(n1, n2)
309
+ reciprocal = r.reciprocal()
310
+ self.assertEqual(reciprocal.numerator, r.denominator)
311
+ self.assertEqual(reciprocal.denominator, r.numerator)
312
+
313
+ def test_evaluate_returns_number_division_result(self):
314
+ r = Ratio(Number(unit=units.meter), Number(unit=units.second))
315
+ result = r.evaluate()
316
+ self.assertIsInstance(result, Number)
317
+ self.assertEqual(result.unit.dimension, Dimension.velocity)
318
+
319
+ def test_multiplication_between_compatible_ratios(self):
320
+ r1 = Ratio(Number(unit=units.meter), Number(unit=units.second))
321
+ r2 = Ratio(Number(unit=units.second), Number(unit=units.meter))
322
+ product = r1 * r2
323
+ self.assertIsInstance(product, Ratio)
324
+ self.assertEqual(product.evaluate().unit.dimension, Dimension.none)
325
+
326
+ def test_multiplication_with_incompatible_units_fallback(self):
327
+ r1 = Ratio(Number(unit=units.meter), Number(unit=units.ampere))
328
+ r2 = Ratio(Number(unit=units.ampere), Number(unit=units.meter))
329
+ result = r1 * r2
330
+ self.assertIsInstance(result, Ratio)
331
+
332
+ def test_division_between_ratios_yields_new_ratio(self):
333
+ r1 = Ratio(Number(quantity=2), Number(quantity=1))
334
+ r2 = Ratio(Number(quantity=4), Number(quantity=2))
335
+ result = r1 / r2
336
+ self.assertIsInstance(result, Ratio)
337
+ self.assertAlmostEqual(result.evaluate().value, 1.0)
338
+
339
+ def test_equality_with_non_ratio_raises_value_error(self):
340
+ r = Ratio()
341
+ with self.assertRaises(ValueError):
342
+ _ = (r == "not_a_ratio")
343
+
344
+ def test_repr_handles_equal_numerator_denominator(self):
345
+ r = Ratio()
346
+ self.assertEqual(str(r.evaluate().value), "1.0")
347
+ rep = repr(r)
348
+ self.assertTrue(rep.startswith("<1"))
349
+
350
+ def test_repr_of_non_equal_ratio_includes_slash(self):
351
+ n1 = Number(quantity=2)
352
+ n2 = Number(quantity=1)
353
+ r = Ratio(n1, n2)
354
+ rep = repr(r)
355
+ self.assertIn("/", rep)