juham-watermeter 0.0.6__tar.gz → 0.0.8__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.
Files changed (24) hide show
  1. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/CHANGELOG.rst +13 -0
  2. {juham_watermeter-0.0.6/juham_watermeter.egg-info → juham_watermeter-0.0.8}/PKG-INFO +5 -27
  3. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/juham_watermeter/__init__.py +2 -2
  4. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/juham_watermeter/webcamera.py +2 -6
  5. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8/juham_watermeter.egg-info}/PKG-INFO +5 -27
  6. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/juham_watermeter.egg-info/SOURCES.txt +3 -1
  7. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/juham_watermeter.egg-info/requires.txt +1 -1
  8. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/pyproject.toml +3 -4
  9. juham_watermeter-0.0.8/tests/test_leakdetector.py +124 -0
  10. juham_watermeter-0.0.8/tests/test_webcamera.py +100 -0
  11. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/LICENSE.rst +0 -0
  12. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/MANIFEST.in +0 -0
  13. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/README.rst +0 -0
  14. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/juham_watermeter/leakdetector.py +0 -0
  15. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/juham_watermeter/py.typed +0 -0
  16. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/juham_watermeter/watermeter_imgdiff.py +0 -0
  17. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/juham_watermeter/watermeter_ocr.py +0 -0
  18. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/juham_watermeter/watermeter_ts.py +0 -0
  19. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/juham_watermeter.egg-info/dependency_links.txt +0 -0
  20. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/juham_watermeter.egg-info/entry_points.txt +0 -0
  21. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/juham_watermeter.egg-info/top_level.txt +0 -0
  22. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/setup.cfg +0 -0
  23. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/tests/__init__.py +0 -0
  24. {juham_watermeter-0.0.6 → juham_watermeter-0.0.8}/tests/test_watermeter.py +0 -0
@@ -1,6 +1,19 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
+
5
+ [0.0.8] - April 18, 2025
6
+ ------------------------
7
+
8
+ - Updated pyproject.toml to comply with the new SPDX expression for packaging standards
9
+
10
+
11
+ [0.0.7] - March 09, 2025
12
+ ------------------------
13
+
14
+ - ``pyproject.tml`` dependencies updated, version elevated to 0.0.7
15
+
16
+
4
17
  [0.0.3] - Februrary 23, 2025
5
18
  ----------------------------
6
19
 
@@ -1,32 +1,10 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: juham-watermeter
3
- Version: 0.0.6
3
+ Version: 0.0.8
4
4
  Summary: Web-camera based watermeter for Juham
5
5
  Author-email: J Meskanen <juham.api@gmail.com>
6
6
  Maintainer-email: "J. Meskanen" <juham.api@gmail.com>
7
- License: MIT License
8
- ===========
9
-
10
- Copyright (c) 2024, Juha Meskanen
11
-
12
- Permission is hereby granted, free of charge, to any person obtaining a copy
13
- of this software and associated documentation files (the "Software"), to deal
14
- in the Software without restriction, including without limitation the rights
15
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
- copies of the Software, and to permit persons to whom the Software is
17
- furnished to do so, subject to the following conditions:
18
-
19
- The above copyright notice and this permission notice shall be included in all
20
- copies or substantial portions of the Software.
21
-
22
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
-
29
-
7
+ License-Expression: MIT
30
8
  Project-URL: Homepage, https://gitlab.com/juham/juham
31
9
  Project-URL: Bug Reports, https://gitlab.com/juham/juham
32
10
  Project-URL: Funding, https://meskanen.com
@@ -36,18 +14,18 @@ Keywords: object-oriented,plugin,framework,watermeter,home automation
36
14
  Classifier: Development Status :: 2 - Pre-Alpha
37
15
  Classifier: Intended Audience :: Developers
38
16
  Classifier: Topic :: Software Development
39
- Classifier: License :: OSI Approved :: MIT License
40
17
  Classifier: Programming Language :: Python :: 3.8
41
18
  Requires-Python: >=3.8
42
19
  Description-Content-Type: text/markdown
43
20
  License-File: LICENSE.rst
44
- Requires-Dist: juham-core>=0.1.1
21
+ Requires-Dist: juham-core>=0.1.5
45
22
  Requires-Dist: numpy
46
23
  Requires-Dist: Pillow>=10.4.0
47
24
  Requires-Dist: opencv-python-headless>=4.10.0
48
25
  Requires-Dist: pytesseract>=0.3.13
49
26
  Provides-Extra: dev
50
27
  Requires-Dist: check-manifest; extra == "dev"
28
+ Dynamic: license-file
51
29
 
52
30
  Watermeter plugin for Juham™
53
31
  =============================
@@ -7,14 +7,14 @@ Web-camera based watermeter classes with leak detector
7
7
  """
8
8
 
9
9
  from .webcamera import WebCamera
10
- #from .watermeter_ocr import WaterMeterOCR
10
+ from .watermeter_ocr import WaterMeterOCR
11
11
  from .watermeter_imgdiff import WaterMeterImgDiff
12
12
  from .watermeter_ts import WaterMeterTs
13
13
  from .leakdetector import LeakDetector
14
14
 
15
15
  __all__ = [
16
16
  "WebCamera",
17
- # "WaterMeterOCR",
17
+ "WaterMeterOCR",
18
18
  "WaterMeterImgDiff",
19
19
  "WaterMeterTs",
20
20
  "LeakDetector",
@@ -1,6 +1,4 @@
1
- """Web camera with basic image processing features.
2
-
3
- """
1
+ """Web camera with basic image processing features."""
4
2
 
5
3
  import cv2
6
4
  import numpy as np
@@ -64,7 +62,6 @@ class WebCameraThread(MasterPieceThread):
64
62
 
65
63
  # Check if the webcam is opened correctly
66
64
  if not cap.isOpened():
67
- print("CANNOT ACCESS THE CAMERA")
68
65
  self.error(f"Could not access the camera {self._camera}.")
69
66
  return np.zeros(
70
67
  (1, 1, 3), dtype=np.uint8
@@ -74,7 +71,6 @@ class WebCameraThread(MasterPieceThread):
74
71
  try:
75
72
  ret, frame = cap.read()
76
73
  if not ret:
77
- print("WTF, Camera failed")
78
74
  self.error("Could not capture image.")
79
75
  frame = np.zeros(
80
76
  (1, 1, 3), dtype=np.uint8
@@ -142,7 +138,7 @@ class WebCamera(JuhamThread):
142
138
  _WEBCAMERA_ATTRS: list[str] = ["location", "camera"]
143
139
 
144
140
  _workerThreadId: str = WebCameraThread.get_class_id()
145
- update_interval: float = 60
141
+ update_interval: float = 5 * 60
146
142
  location = "home"
147
143
  camera: int = 0
148
144
 
@@ -1,32 +1,10 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: juham-watermeter
3
- Version: 0.0.6
3
+ Version: 0.0.8
4
4
  Summary: Web-camera based watermeter for Juham
5
5
  Author-email: J Meskanen <juham.api@gmail.com>
6
6
  Maintainer-email: "J. Meskanen" <juham.api@gmail.com>
7
- License: MIT License
8
- ===========
9
-
10
- Copyright (c) 2024, Juha Meskanen
11
-
12
- Permission is hereby granted, free of charge, to any person obtaining a copy
13
- of this software and associated documentation files (the "Software"), to deal
14
- in the Software without restriction, including without limitation the rights
15
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
- copies of the Software, and to permit persons to whom the Software is
17
- furnished to do so, subject to the following conditions:
18
-
19
- The above copyright notice and this permission notice shall be included in all
20
- copies or substantial portions of the Software.
21
-
22
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
-
29
-
7
+ License-Expression: MIT
30
8
  Project-URL: Homepage, https://gitlab.com/juham/juham
31
9
  Project-URL: Bug Reports, https://gitlab.com/juham/juham
32
10
  Project-URL: Funding, https://meskanen.com
@@ -36,18 +14,18 @@ Keywords: object-oriented,plugin,framework,watermeter,home automation
36
14
  Classifier: Development Status :: 2 - Pre-Alpha
37
15
  Classifier: Intended Audience :: Developers
38
16
  Classifier: Topic :: Software Development
39
- Classifier: License :: OSI Approved :: MIT License
40
17
  Classifier: Programming Language :: Python :: 3.8
41
18
  Requires-Python: >=3.8
42
19
  Description-Content-Type: text/markdown
43
20
  License-File: LICENSE.rst
44
- Requires-Dist: juham-core>=0.1.1
21
+ Requires-Dist: juham-core>=0.1.5
45
22
  Requires-Dist: numpy
46
23
  Requires-Dist: Pillow>=10.4.0
47
24
  Requires-Dist: opencv-python-headless>=4.10.0
48
25
  Requires-Dist: pytesseract>=0.3.13
49
26
  Provides-Extra: dev
50
27
  Requires-Dist: check-manifest; extra == "dev"
28
+ Dynamic: license-file
51
29
 
52
30
  Watermeter plugin for Juham™
53
31
  =============================
@@ -17,4 +17,6 @@ juham_watermeter.egg-info/entry_points.txt
17
17
  juham_watermeter.egg-info/requires.txt
18
18
  juham_watermeter.egg-info/top_level.txt
19
19
  tests/__init__.py
20
- tests/test_watermeter.py
20
+ tests/test_leakdetector.py
21
+ tests/test_watermeter.py
22
+ tests/test_webcamera.py
@@ -1,4 +1,4 @@
1
- juham-core>=0.1.1
1
+ juham-core>=0.1.5
2
2
  numpy
3
3
  Pillow>=10.4.0
4
4
  opencv-python-headless>=4.10.0
@@ -10,11 +10,11 @@ packages = ["juham_watermeter"]
10
10
 
11
11
  [project]
12
12
  name = "juham-watermeter"
13
- version = "0.0.6"
13
+ version = "0.0.8"
14
14
  description = "Web-camera based watermeter for Juham"
15
15
  readme = {file = "README.rst", content-type = "text/markdown"}
16
16
  requires-python = ">=3.8"
17
- license = {file = "LICENSE.rst", content-type = "text/markdown"}
17
+ license = "MIT"
18
18
  keywords = ["object-oriented", "plugin", "framework", "watermeter", "home automation"]
19
19
  authors = [
20
20
  {name = "J Meskanen", email = "juham.api@gmail.com" }
@@ -27,12 +27,11 @@ classifiers = [
27
27
  "Development Status :: 2 - Pre-Alpha",
28
28
  "Intended Audience :: Developers",
29
29
  "Topic :: Software Development",
30
- "License :: OSI Approved :: MIT License",
31
30
  "Programming Language :: Python :: 3.8",
32
31
  ]
33
32
 
34
33
  dependencies = [
35
- "juham-core >= 0.1.1",
34
+ "juham-core >= 0.1.5",
36
35
  "numpy",
37
36
  "Pillow >= 10.4.0",
38
37
  "opencv-python-headless >= 4.10.0",
@@ -0,0 +1,124 @@
1
+ import unittest
2
+ from unittest.mock import patch, MagicMock
3
+ from typing import Any
4
+ from juham_watermeter.leakdetector import LeakDetector
5
+ from masterpiece.mqtt import MqttMsg
6
+
7
+
8
+ class MqttTestMsg:
9
+ def __init__(self, topic: str, payload: bytes):
10
+ self.topic = topic
11
+ self.payload = payload
12
+
13
+
14
+ class TestLeakDetector(unittest.TestCase):
15
+
16
+ def setUp(self) -> None:
17
+ self.leak_detector = LeakDetector()
18
+ self.leak_detector.activity_timeout = 300.0 # Set timeout to 5 minutes
19
+
20
+ def test_initialization(self) -> None:
21
+ self.assertFalse(self.leak_detector.leak_detected)
22
+ self.assertEqual(self.leak_detector.zero_usage_periods_count, 0)
23
+ self.assertTrue(self.leak_detector.watermeter_full_topic.endswith("watermeter"))
24
+ self.assertTrue(self.leak_detector.motion_full_topic.endswith("motion"))
25
+
26
+ def test_to_from_dict(self) -> None:
27
+ state = self.leak_detector.to_dict()
28
+ new_instance = LeakDetector()
29
+ new_instance.from_dict(state)
30
+ self.assertEqual(new_instance.motion_topic, self.leak_detector.motion_topic)
31
+
32
+ @patch.object(LeakDetector, "subscribe")
33
+ def test_on_connect_success(self, mock_subscribe: MagicMock) -> None:
34
+ self.leak_detector.on_connect(None, None, 0, 0)
35
+ self.assertEqual(mock_subscribe.call_count, 2)
36
+
37
+ @patch.object(LeakDetector, "process_water_meter_data")
38
+ @patch.object(LeakDetector, "process_motion_data")
39
+ def test_on_message_routing(
40
+ self, mock_motion: MagicMock, mock_water: MagicMock
41
+ ) -> None:
42
+ water_payload = MqttTestMsg(
43
+ topic=self.leak_detector.watermeter_full_topic,
44
+ payload=b'{"active_lpm": 1.0, "ts": 123}',
45
+ )
46
+
47
+ motion_payload = MqttTestMsg(
48
+ topic=self.leak_detector.motion_full_topic,
49
+ payload=b'{"motion": true, "ts": 123}',
50
+ )
51
+
52
+ self.leak_detector.on_message(None, None, water_payload)
53
+ mock_water.assert_called_once()
54
+
55
+ self.leak_detector.on_message(None, None, motion_payload)
56
+ mock_motion.assert_called_once()
57
+
58
+ @patch("juham_watermeter.leakdetector.timestamp", return_value=1000.0)
59
+ def test_detect_activity_true(self, _: MagicMock) -> None:
60
+ self.leak_detector.motion_last_detected_ts = 500000.0
61
+ self.assertTrue(self.leak_detector.detect_activity(1000.0))
62
+
63
+ def test_detect_activity_false(self) -> None:
64
+ self.leak_detector.motion_last_detected_ts = 0.0
65
+ self.assertFalse(self.leak_detector.detect_activity(500000.0))
66
+
67
+ @patch.object(LeakDetector, "publish")
68
+ @patch.object(LeakDetector, "warning")
69
+ def test_process_water_leak_detected(
70
+ self, mock_warning: MagicMock, mock_publish: MagicMock
71
+ ) -> None:
72
+ self.leak_detector.motion_last_detected_ts = 0.0 # No motion
73
+ data = {"active_lpm": 1.2, "ts": 1000000.0}
74
+
75
+ # Process water meter data
76
+ self.leak_detector.process_water_meter_data(data)
77
+
78
+ # Assert leak is detected
79
+ self.assertTrue(self.leak_detector.leak_detected)
80
+ self.assertEqual(self.leak_detector.zero_usage_periods_count, 0)
81
+ mock_warning.assert_called_once()
82
+ mock_publish.assert_called_once()
83
+
84
+ @patch.object(LeakDetector, "publish")
85
+ def test_process_water_leak_reset(self, mock_publish: MagicMock) -> None:
86
+ self.leak_detector.leak_detected = True
87
+ self.leak_detector.zero_usage_periods_count = 61
88
+ data = {"active_lpm": 0.0, "ts": 2000.0}
89
+
90
+ # Process water meter data
91
+ self.leak_detector.process_water_meter_data(data)
92
+
93
+ # Assert leak is reset
94
+ self.assertFalse(self.leak_detector.leak_detected)
95
+ self.assertEqual(self.leak_detector.motion_last_detected_ts, 2000.0)
96
+ mock_publish.assert_called_once()
97
+
98
+ @patch.object(LeakDetector, "publish")
99
+ def test_process_water_normal_usage(self, mock_publish: MagicMock) -> None:
100
+ self.leak_detector.motion_last_detected_ts = 900.0
101
+ self.leak_detector.detect_activity = MagicMock(return_value=True)
102
+ data = {"active_lpm": 2.0, "ts": 1000.0}
103
+
104
+ # Process water meter data
105
+ self.leak_detector.process_water_meter_data(data)
106
+
107
+ # Assert no leak detected and no warning
108
+ self.assertFalse(self.leak_detector.leak_detected)
109
+ self.assertEqual(self.leak_detector.zero_usage_periods_count, 0)
110
+ mock_publish.assert_called_once()
111
+
112
+ def test_process_motion_data(self) -> None:
113
+ self.leak_detector.motion_last_detected_ts = 0.0
114
+ data = {"motion": True, "ts": 1337.0}
115
+
116
+ # Process motion data
117
+ self.leak_detector.process_motion_data(data)
118
+
119
+ # Assert last motion timestamp updated
120
+ self.assertEqual(self.leak_detector.motion_last_detected_ts, 1337.0)
121
+
122
+
123
+ if __name__ == "__main__":
124
+ unittest.main()
@@ -0,0 +1,100 @@
1
+ import unittest
2
+ from unittest.mock import patch, MagicMock
3
+ import numpy as np
4
+ import cv2
5
+
6
+ from typing import Any
7
+ from juham_watermeter.webcamera import WebCameraThread
8
+
9
+
10
+ class TestWebCameraThread(unittest.TestCase):
11
+ def setUp(self) -> None:
12
+ self.cam = WebCameraThread()
13
+
14
+ def test_init_sets_defaults(self) -> None:
15
+ self.assertEqual(self.cam._interval, 60)
16
+ self.assertEqual(self.cam._location, "unknown")
17
+ self.assertEqual(self.cam._camera, 0)
18
+ self.assertEqual(self.cam.image.shape, (1, 1, 3))
19
+ self.assertEqual(self.cam.image_timestamp, 0.0)
20
+
21
+ def test_init_method_sets_values(self) -> None:
22
+ self.cam.init(30.0, "office", 1)
23
+ self.assertEqual(self.cam._interval, 30.0)
24
+ self.assertEqual(self.cam._location, "office")
25
+ self.assertEqual(self.cam._camera, 1)
26
+
27
+ @patch("cv2.VideoCapture")
28
+ def test_capture_image_successful(self, mock_VideoCapture: MagicMock) -> None:
29
+ mock_cap = MagicMock()
30
+ mock_frame = np.ones((480, 640, 3), dtype=np.uint8)
31
+ mock_cap.isOpened.return_value = True
32
+ mock_cap.read.return_value = (True, mock_frame)
33
+ mock_VideoCapture.return_value = mock_cap
34
+
35
+ result = self.cam.capture_image()
36
+ self.assertEqual(result.shape, mock_frame.shape)
37
+
38
+ @patch("cv2.VideoCapture")
39
+ def test_capture_image_camera_unavailable(
40
+ self, mock_VideoCapture: MagicMock
41
+ ) -> None:
42
+ mock_cap = MagicMock()
43
+ mock_cap.isOpened.return_value = False
44
+ mock_VideoCapture.return_value = mock_cap
45
+
46
+ result = self.cam.capture_image()
47
+ self.assertTrue(np.array_equal(result, np.zeros((1, 1, 3), dtype=np.uint8)))
48
+
49
+ @patch("cv2.VideoCapture")
50
+ def test_capture_image_read_failure(self, mock_VideoCapture: MagicMock) -> None:
51
+ mock_cap = MagicMock()
52
+ mock_cap.isOpened.return_value = True
53
+ mock_cap.read.return_value = (False, None)
54
+ mock_VideoCapture.return_value = mock_cap
55
+
56
+ result = self.cam.capture_image()
57
+ self.assertTrue(np.array_equal(result, np.zeros((1, 1, 3), dtype=np.uint8)))
58
+
59
+ def test_process_image_with_empty_input(self) -> None:
60
+ result = self.cam.process_image(np.zeros((1, 1, 3), dtype=np.uint8))
61
+ self.assertEqual(result.shape, (1, 1, 3))
62
+
63
+ def test_process_image_grayscale_conversion(self) -> None:
64
+ dummy_img = np.ones((480, 640, 3), dtype=np.uint8) * 255
65
+ self.cam._expected_image_size = dummy_img.size
66
+ result = self.cam.process_image(dummy_img)
67
+ self.assertEqual(result.ndim, 2) # grayscale image
68
+
69
+ def test_enhance_contrast_on_invalid_input(self) -> None:
70
+ # Input is not grayscale
71
+ dummy_img = np.ones((480, 640, 3), dtype=np.uint8)
72
+ result = self.cam.enhance_contrast(dummy_img)
73
+ self.assertTrue((result == dummy_img).all())
74
+
75
+ def test_enhance_contrast_output_shape(self) -> None:
76
+ gray_img = np.ones((480, 640), dtype=np.uint8) * 100
77
+ result = self.cam.enhance_contrast(gray_img)
78
+ self.assertEqual(result.shape, gray_img.shape)
79
+ self.assertEqual(result.dtype, gray_img.dtype)
80
+
81
+ def test_update_interval_returns_correct_value(self) -> None:
82
+ self.cam._interval = 42.0
83
+ self.assertEqual(self.cam.update_interval(), 42.0)
84
+
85
+ @patch("juham_watermeter.webcamera.timestamp", return_value=123.456)
86
+ @patch.object(
87
+ WebCameraThread,
88
+ "capture_image",
89
+ return_value=np.ones((480, 640, 3), dtype=np.uint8),
90
+ )
91
+ def test_update_method(self, mock_capture, mock_timestamp):
92
+ self.cam._expected_image_size = 480 * 640 * 3
93
+ result = self.cam.update()
94
+ self.assertTrue(result)
95
+ self.assertEqual(self.cam.image.shape, (480, 640, 3))
96
+ self.assertEqual(self.cam.image_timestamp, 123.456)
97
+
98
+
99
+ if __name__ == "__main__":
100
+ unittest.main()