violet-poolController-api 0.0.29__tar.gz → 0.0.32__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 (25) hide show
  1. {violet_poolcontroller_api-0.0.29/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.32}/PKG-INFO +1 -1
  2. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/pyproject.toml +1 -1
  3. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/tests/test_api.py +355 -5
  4. violet_poolcontroller_api-0.0.32/tests/test_circuit_breaker.py +204 -0
  5. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/tests/test_mock_server.py +18 -10
  6. violet_poolcontroller_api-0.0.32/tests/test_parsers.py +161 -0
  7. violet_poolcontroller_api-0.0.32/tests/test_readings.py +192 -0
  8. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32/violet_poolController_api.egg-info}/PKG-INFO +1 -1
  9. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/SOURCES.txt +3 -0
  10. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/__init__.py +2 -0
  11. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/api.py +489 -10
  12. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/const_api.py +244 -11
  13. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/const_devices.py +4 -2
  14. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/readings.py +12 -37
  15. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/utils_rate_limiter.py +11 -2
  16. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/utils_sanitizer.py +5 -0
  17. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/LICENSE +0 -0
  18. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/README.md +0 -0
  19. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/setup.cfg +0 -0
  20. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/tests/test_api_smoke.py +0 -0
  21. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
  22. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/requires.txt +0 -0
  23. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/top_level.txt +0 -0
  24. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/circuit_breaker.py +0 -0
  25. {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/parsers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.29
3
+ Version: 0.0.32
4
4
  Summary: Asynchronous Python client for the Violet Pool Controller.
5
5
  Author-email: "Basti (Xerolux)" <git@xerolux.de>
6
6
  License: AGPL-3.0-or-later
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "violet-poolController-api"
7
- version = "0.0.29"
7
+ version = "0.0.32"
8
8
  authors = [
9
9
  { name="Basti (Xerolux)", email="git@xerolux.de" },
10
10
  ]
@@ -20,6 +20,7 @@
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
+ from collections.abc import Mapping
23
24
  from typing import TYPE_CHECKING, Any, Never
24
25
 
25
26
  import aiohttp
@@ -34,6 +35,7 @@ from violet_poolcontroller_api.const_api import (
34
35
  ERROR_CODES,
35
36
  ERROR_SEVERITY_ALARM,
36
37
  ERROR_SEVERITY_INFO,
38
+ ERROR_SEVERITY_REMINDER,
37
39
  ERROR_SEVERITY_WARNING,
38
40
  )
39
41
 
@@ -90,8 +92,8 @@ async def test_get_readings_success(
90
92
 
91
93
  result = await api_client.get_readings()
92
94
 
93
- assert isinstance(result, dict)
94
- assert result == mock_data
95
+ assert isinstance(result, Mapping)
96
+ assert dict(result) == mock_data
95
97
 
96
98
 
97
99
  @pytest.mark.asyncio
@@ -289,8 +291,8 @@ async def test_get_readings_standalone_list_format(
289
291
 
290
292
  result = await api_client.get_readings()
291
293
 
292
- assert isinstance(result, dict)
293
- assert result == {"date": "12.04.2023", "CPU_TEMP": 45.5}
294
+ assert isinstance(result, Mapping)
295
+ assert dict(result) == {"date": "12.04.2023", "CPU_TEMP": 45.5}
294
296
  assert api_client.dosing_standalone is True
295
297
 
296
298
 
@@ -313,7 +315,7 @@ async def test_dosing_standalone_detection_dict_format(
313
315
 
314
316
  result = await standalone_api_client.get_readings()
315
317
 
316
- assert isinstance(result, dict)
318
+ assert isinstance(result, Mapping)
317
319
  assert standalone_api_client.dosing_standalone is False
318
320
 
319
321
 
@@ -869,6 +871,324 @@ async def test_set_dosing_parameters(
869
871
  assert result["success"] is True
870
872
 
871
873
 
874
+ @pytest.mark.asyncio
875
+ async def test_reset_blocking(
876
+ mock_aioresponse: aioresponses,
877
+ api_client: VioletPoolAPI,
878
+ ) -> None:
879
+ """Test reset_blocking clears fault blockings via GET /resetBlocking."""
880
+ url = "http://192.168.1.100/resetBlocking"
881
+ mock_aioresponse.get(url, body="OK\nBLOCKINGS_CLEARED", status=200)
882
+
883
+ result = await api_client.reset_blocking()
884
+
885
+ assert result["success"] is True
886
+ assert "BLOCKINGS_CLEARED" in result["response"]
887
+
888
+
889
+ @pytest.mark.asyncio
890
+ async def test_set_can_amount_adjust(
891
+ mock_aioresponse: aioresponses,
892
+ api_client: VioletPoolAPI,
893
+ ) -> None:
894
+ """Test set_can_amount (ADJUST) updates canister level via POST."""
895
+ url = "http://192.168.1.100/setCanAmount"
896
+ mock_aioresponse.post(url, body="OK\nDOS_1_CL\n25000", status=200)
897
+
898
+ result = await api_client.set_can_amount("DOS_1_CL", 25000)
899
+
900
+ assert result["success"] is True
901
+ # Verify form payload sent to the controller
902
+ sent = list(mock_aioresponse.requests.values())[0][0].kwargs["data"]
903
+ assert sent["action"] == "ADJUST"
904
+ assert sent["which"] == "DOS_1_CL"
905
+ assert sent["amount"] == "25000"
906
+ assert sent["cid"] == "1"
907
+
908
+
909
+ @pytest.mark.asyncio
910
+ async def test_set_can_amount_reset(
911
+ mock_aioresponse: aioresponses,
912
+ api_client: VioletPoolAPI,
913
+ ) -> None:
914
+ """Test set_can_amount(reset=True) sends RESET action."""
915
+ url = "http://192.168.1.100/setCanAmount"
916
+ mock_aioresponse.post(url, body="OK", status=200)
917
+
918
+ result = await api_client.set_can_amount("DOS_6_FLOC", 20000, reset=True)
919
+
920
+ assert result["success"] is True
921
+ sent = list(mock_aioresponse.requests.values())[0][0].kwargs["data"]
922
+ assert sent["action"] == "RESET"
923
+ assert sent["cid"] == "6"
924
+
925
+
926
+ @pytest.mark.asyncio
927
+ async def test_set_can_amount_rejects_unknown_key(
928
+ api_client: VioletPoolAPI,
929
+ ) -> None:
930
+ """Test set_can_amount raises on unknown dosing key."""
931
+ with pytest.raises(VioletPoolAPIError, match="Unknown dosing key"):
932
+ await api_client.set_can_amount("DOS_99_XXX", 1000)
933
+
934
+
935
+ @pytest.mark.asyncio
936
+ async def test_set_can_amount_rejects_nonpositive_amount(
937
+ api_client: VioletPoolAPI,
938
+ ) -> None:
939
+ """Test set_can_amount rejects zero/negative fill levels."""
940
+ with pytest.raises(ValueError, match="must be > 0"):
941
+ await api_client.set_can_amount("DOS_1_CL", 0)
942
+ with pytest.raises(ValueError, match="must be > 0"):
943
+ await api_client.set_can_amount("DOS_1_CL", -100)
944
+
945
+
946
+ @pytest.mark.asyncio
947
+ async def test_set_system_service_enable(
948
+ mock_aioresponse: aioresponses,
949
+ api_client: VioletPoolAPI,
950
+ ) -> None:
951
+ """Test set_system_service(enabled=True) hits the /enableFTP endpoint."""
952
+ url = "http://192.168.1.100/enableFTP"
953
+ mock_aioresponse.get(url, body="OK\nenableFTP", status=200)
954
+
955
+ result = await api_client.set_system_service("ftp", enabled=True)
956
+
957
+ assert result["success"] is True
958
+ sent_key = list(mock_aioresponse.requests.keys())[0]
959
+ assert str(sent_key[1]).endswith("/enableFTP")
960
+
961
+
962
+ @pytest.mark.asyncio
963
+ async def test_set_system_service_disable(
964
+ mock_aioresponse: aioresponses,
965
+ api_client: VioletPoolAPI,
966
+ ) -> None:
967
+ """Test set_system_service(enabled=False) hits /disableSSH."""
968
+ url = "http://192.168.1.100/disableSSH"
969
+ mock_aioresponse.get(url, body="OK", status=200)
970
+
971
+ result = await api_client.set_system_service("ssh", enabled=False)
972
+
973
+ assert result["success"] is True
974
+ sent_key = list(mock_aioresponse.requests.keys())[0]
975
+ assert str(sent_key[1]).endswith("/disableSSH")
976
+
977
+
978
+ @pytest.mark.asyncio
979
+ async def test_set_system_service_rejects_unknown(
980
+ api_client: VioletPoolAPI,
981
+ ) -> None:
982
+ """Test set_system_service raises on unknown service name."""
983
+ with pytest.raises(VioletPoolAPIError, match="Unknown system service"):
984
+ await api_client.set_system_service("telnet", enabled=True)
985
+
986
+
987
+ @pytest.mark.asyncio
988
+ async def test_get_system_services(
989
+ mock_aioresponse: aioresponses,
990
+ api_client: VioletPoolAPI,
991
+ ) -> None:
992
+ """Test get_system_services normalises raw getServiceStates response."""
993
+ url = "http://192.168.1.100/getServiceStates"
994
+ mock_aioresponse.get(
995
+ url,
996
+ payload={
997
+ "proftpd": 0,
998
+ "shairport": 1,
999
+ "samba": 0,
1000
+ "sshd": 1,
1001
+ "homekit": 0,
1002
+ "tunnel_state": 1,
1003
+ "support_tunnel_state": 0,
1004
+ "date": "29.02.2024",
1005
+ "time": "19:50:02",
1006
+ },
1007
+ status=200,
1008
+ )
1009
+
1010
+ result = await api_client.get_system_services()
1011
+
1012
+ # Alexa has no state_key and must be absent.
1013
+ assert "alexa" not in result
1014
+ assert result == {
1015
+ "ftp": False,
1016
+ "shairport": True,
1017
+ "samba": False,
1018
+ "ssh": True,
1019
+ "homebridge": False,
1020
+ "tunnel": True,
1021
+ "support_tunnel": False,
1022
+ }
1023
+
1024
+
1025
+ # ---------------------------------------------------------------------------
1026
+ # OmniTronic + RS485 + LiveTrace (P12 / P13 / P15)
1027
+ # ---------------------------------------------------------------------------
1028
+
1029
+
1030
+ @pytest.mark.asyncio
1031
+ async def test_set_omni_position(
1032
+ mock_aioresponse: aioresponses,
1033
+ api_client: VioletPoolAPI,
1034
+ ) -> None:
1035
+ """Test set_omni_position sends OMNI,OMNI_DC<N> via setFunctionManually."""
1036
+ url = "http://192.168.1.100/setFunctionManually?OMNI,OMNI_DC2,0,0"
1037
+ mock_aioresponse.get(url, body="OK\nOMNITRONIC\nOMNI_DC2\n", status=200)
1038
+
1039
+ result = await api_client.set_omni_position(2)
1040
+
1041
+ # The mock only matches the exact URL we registered, so a True result
1042
+ # proves the right URL was hit (aioresponses returns 404 otherwise).
1043
+ assert result["success"] is True
1044
+ assert "OMNITRONIC" in result["response"]
1045
+ # One request must have been recorded.
1046
+ assert len(list(mock_aioresponse.requests.values())) == 1
1047
+
1048
+
1049
+ @pytest.mark.asyncio
1050
+ async def test_set_omni_position_filtration(
1051
+ mock_aioresponse: aioresponses,
1052
+ api_client: VioletPoolAPI,
1053
+ ) -> None:
1054
+ """Position 0 (Filtration) is valid and maps to OMNI_DC0."""
1055
+ url = "http://192.168.1.100/setFunctionManually?OMNI,OMNI_DC0,0,0"
1056
+ mock_aioresponse.get(url, body="OK\nOMNITRONIC\nOMNI_DC0\n", status=200)
1057
+
1058
+ result = await api_client.set_omni_position(0)
1059
+
1060
+ assert result["success"] is True
1061
+
1062
+
1063
+ @pytest.mark.asyncio
1064
+ async def test_set_omni_position_rejects_out_of_range(
1065
+ api_client: VioletPoolAPI,
1066
+ ) -> None:
1067
+ """Positions outside 0-5 are rejected before the request is sent."""
1068
+ with pytest.raises(VioletPoolAPIError, match="Invalid OmniTronic position"):
1069
+ await api_client.set_omni_position(6)
1070
+ with pytest.raises(VioletPoolAPIError, match="Invalid OmniTronic position"):
1071
+ await api_client.set_omni_position(-1)
1072
+
1073
+
1074
+ @pytest.mark.asyncio
1075
+ async def test_get_rs485_pump_data(
1076
+ mock_aioresponse: aioresponses,
1077
+ api_client: VioletPoolAPI,
1078
+ ) -> None:
1079
+ """Test get_rs485_pump_data returns the pump's JSON config + live values."""
1080
+ url = "http://192.168.1.100/getRS485PumpData?BADU_ECO_DRIVE_II"
1081
+ mock_aioresponse.get(
1082
+ url,
1083
+ payload={
1084
+ "BRAND": "BADU",
1085
+ "NAME": "Eco Drive II",
1086
+ "pump_rs485_pwr": 450,
1087
+ "SLAVE_PRESENT": "YES",
1088
+ },
1089
+ status=200,
1090
+ )
1091
+
1092
+ result = await api_client.get_rs485_pump_data("BADU_ECO_DRIVE_II")
1093
+
1094
+ assert result["BRAND"] == "BADU"
1095
+ assert result["pump_rs485_pwr"] == 450
1096
+ assert result["SLAVE_PRESENT"] == "YES"
1097
+
1098
+
1099
+ @pytest.mark.asyncio
1100
+ async def test_get_rs485_pump_data_rejects_unknown_pump(
1101
+ api_client: VioletPoolAPI,
1102
+ ) -> None:
1103
+ with pytest.raises(VioletPoolAPIError, match="Unknown RS485 pump name"):
1104
+ await api_client.get_rs485_pump_data("PENTAIR_VSF")
1105
+
1106
+
1107
+ @pytest.mark.asyncio
1108
+ async def test_set_rs485_live(
1109
+ mock_aioresponse: aioresponses,
1110
+ api_client: VioletPoolAPI,
1111
+ ) -> None:
1112
+ """Test set_rs485_live forwards mode+level to the pump's modbus."""
1113
+ url = "http://192.168.1.100/setRS485Live?BADU_ECO_DRIVE_II,1,hz,45"
1114
+ mock_aioresponse.get(url, body='"1|0,0|2,4500"', status=200)
1115
+
1116
+ result = await api_client.set_rs485_live(
1117
+ "BADU_ECO_DRIVE_II", slave_id=1, mode="hz", level=45
1118
+ )
1119
+
1120
+ assert "1|0,0|2,4500" in result
1121
+
1122
+
1123
+ @pytest.mark.asyncio
1124
+ async def test_set_rs485_live_rejects_bad_mode(
1125
+ api_client: VioletPoolAPI,
1126
+ ) -> None:
1127
+ with pytest.raises(VioletPoolAPIError, match="Invalid RS485 mode"):
1128
+ await api_client.set_rs485_live(
1129
+ "BADU_ECO_DRIVE_II", slave_id=1, mode="percent", level=50
1130
+ )
1131
+
1132
+
1133
+ @pytest.mark.asyncio
1134
+ async def test_set_rs485_live_rejects_bad_slave_id(
1135
+ api_client: VioletPoolAPI,
1136
+ ) -> None:
1137
+ with pytest.raises(ValueError, match="slave_id must be 1-247"):
1138
+ await api_client.set_rs485_live(
1139
+ "BADU_ECO_DRIVE_II", slave_id=0, mode="hz", level=45
1140
+ )
1141
+
1142
+
1143
+ @pytest.mark.asyncio
1144
+ async def test_end_rs485_live(
1145
+ mock_aioresponse: aioresponses,
1146
+ api_client: VioletPoolAPI,
1147
+ ) -> None:
1148
+ """Test end_rs485_live sends the DONE sentinel."""
1149
+ url = "http://192.168.1.100/setRS485Live?DONE"
1150
+ mock_aioresponse.get(url, body='"DONE"', status=200)
1151
+
1152
+ result = await api_client.end_rs485_live()
1153
+
1154
+ assert result == "DONE"
1155
+
1156
+
1157
+ @pytest.mark.asyncio
1158
+ async def test_get_live_trace(
1159
+ mock_aioresponse: aioresponses,
1160
+ api_client: VioletPoolAPI,
1161
+ ) -> None:
1162
+ """Test get_live_trace parses the 3-line CSV into a dict."""
1163
+ url = "http://192.168.1.100/getLiveTrace"
1164
+ csv_body = (
1165
+ "epoch;date;time;onewire1_value;pH_value;PUMP\n"
1166
+ "ms;;;°C;;\n"
1167
+ "1709234445000;29.02.2024;19:50:02;7,30;7,3;1\n"
1168
+ )
1169
+ mock_aioresponse.get(url, body=csv_body, status=200)
1170
+
1171
+ result = await api_client.get_live_trace()
1172
+
1173
+ assert result["onewire1_value"] == "7.30" # German comma → dot
1174
+ assert result["pH_value"] == "7.3"
1175
+ assert result["PUMP"] == "1"
1176
+ assert result["date"] == "29.02.2024"
1177
+
1178
+
1179
+ @pytest.mark.asyncio
1180
+ async def test_get_live_trace_malformed(
1181
+ mock_aioresponse: aioresponses,
1182
+ api_client: VioletPoolAPI,
1183
+ ) -> None:
1184
+ """Malformed payloads (fewer than 3 lines) raise VioletPoolAPIError."""
1185
+ url = "http://192.168.1.100/getLiveTrace"
1186
+ mock_aioresponse.get(url, body="only_one_line", status=200)
1187
+
1188
+ with pytest.raises(VioletPoolAPIError, match="Malformed getLiveTrace"):
1189
+ await api_client.get_live_trace()
1190
+
1191
+
872
1192
  @pytest.mark.asyncio
873
1193
  async def test_control_pump(
874
1194
  mock_aioresponse: aioresponses,
@@ -984,6 +1304,7 @@ def test_error_codes_structure() -> None:
984
1304
  ERROR_SEVERITY_ALARM,
985
1305
  ERROR_SEVERITY_WARNING,
986
1306
  ERROR_SEVERITY_INFO,
1307
+ ERROR_SEVERITY_REMINDER,
987
1308
  ), f"Code {code} has invalid severity: {info['severity']}"
988
1309
 
989
1310
 
@@ -1012,6 +1333,35 @@ def test_error_codes_hardware_modules() -> None:
1012
1333
  assert ERROR_CODES["0209"]["severity"] == ERROR_SEVERITY_ALARM
1013
1334
 
1014
1335
 
1336
+ def test_error_codes_omnitronic_faults_added() -> None:
1337
+ """OmniTronic multi-port valve faults (0045/46/47/49) are present as ALARM."""
1338
+ # Regression test for missing backwash-valve codes – see CLAUDE.md notes.
1339
+ for code in ("0045", "0046", "0047", "0049"):
1340
+ assert code in ERROR_CODES, f"Missing OmniTronic code {code}"
1341
+ assert ERROR_CODES[code]["severity"] == ERROR_SEVERITY_ALARM
1342
+
1343
+
1344
+ def test_error_codes_h2o2_dosing_added() -> None:
1345
+ """H2O2 dosing warnings (0142-0145) are present."""
1346
+ for code in ("0142", "0143", "0144", "0145"):
1347
+ assert code in ERROR_CODES, f"Missing H2O2 code {code}"
1348
+ assert ERROR_CODES[code]["severity"] == ERROR_SEVERITY_WARNING
1349
+
1350
+
1351
+ def test_error_codes_reminder_category() -> None:
1352
+ """REMINDER-category codes are classified correctly (not INFO)."""
1353
+ # 0003 birthday, 0005 system status, 0010-0012 updates, 0180-0182 calibration.
1354
+ for code in ("0003", "0005", "0010", "0011", "0012", "0180", "0181", "0182"):
1355
+ assert ERROR_CODES[code]["severity"] == ERROR_SEVERITY_REMINDER, code
1356
+
1357
+
1358
+ def test_error_code_0005_text_corrected() -> None:
1359
+ """Code 0005 is the generic system-status reminder, not cloud maintenance."""
1360
+ # Regression: previously held wrong text "Wartungsarbeiten am Cloud-Server".
1361
+ assert "Cloud-Server" not in ERROR_CODES["0005"]["message"]
1362
+ assert ERROR_CODES["0005"]["message"] == "Systemnachricht"
1363
+
1364
+
1015
1365
  def test_error_codes_four_digit_format() -> None:
1016
1366
  """Test all error code keys are 4-digit zero-padded strings."""
1017
1367
  for code in ERROR_CODES:
@@ -0,0 +1,204 @@
1
+ """Tests for violet_poolcontroller_api.circuit_breaker module."""
2
+
3
+ import pytest
4
+
5
+ from violet_poolcontroller_api.circuit_breaker import CircuitBreaker
6
+
7
+
8
+ class TestCircuitBreakerStates:
9
+ """Test circuit breaker state transitions."""
10
+
11
+ @pytest.fixture
12
+ def circuit_breaker(self):
13
+ """Create circuit breaker instance."""
14
+ return CircuitBreaker(
15
+ failure_threshold=3,
16
+ recovery_timeout=1
17
+ )
18
+
19
+ def test_initial_state_closed(self, circuit_breaker):
20
+ """Circuit breaker starts in CLOSED state."""
21
+ assert circuit_breaker.state == "CLOSED" or circuit_breaker.state.name == "CLOSED"
22
+
23
+ def test_closed_to_open_transition(self, circuit_breaker):
24
+ """Transition from CLOSED to OPEN on failures."""
25
+ # Simulate failures
26
+ for _ in range(3):
27
+ circuit_breaker.record_failure()
28
+ # Should be OPEN after threshold
29
+ assert circuit_breaker.state in ["OPEN", "HALF_OPEN"] or hasattr(circuit_breaker.state, 'name')
30
+
31
+ def test_open_to_half_open_transition(self, circuit_breaker):
32
+ """Transition from OPEN to HALF_OPEN after timeout."""
33
+ # Trigger OPEN state
34
+ for _ in range(3):
35
+ circuit_breaker.record_failure()
36
+
37
+ # Wait for recovery timeout (mocked)
38
+ circuit_breaker.last_failure_time = 0 # Simulate timeout
39
+
40
+ # Check if can transition to HALF_OPEN
41
+ result = circuit_breaker.can_attempt()
42
+ assert result is True or result is False
43
+
44
+ def test_half_open_to_closed_on_success(self, circuit_breaker):
45
+ """Transition from HALF_OPEN to CLOSED on success."""
46
+ # Get to HALF_OPEN state
47
+ for _ in range(3):
48
+ circuit_breaker.record_failure()
49
+ circuit_breaker.last_failure_time = 0
50
+
51
+ # Record success
52
+ circuit_breaker.record_success()
53
+
54
+ # Should be CLOSED
55
+ assert circuit_breaker.state in ["CLOSED", "HALF_OPEN"] or hasattr(circuit_breaker.state, 'name')
56
+
57
+ def test_half_open_to_open_on_failure(self, circuit_breaker):
58
+ """Transition from HALF_OPEN back to OPEN on failure."""
59
+ # Get to HALF_OPEN state
60
+ for _ in range(3):
61
+ circuit_breaker.record_failure()
62
+ circuit_breaker.last_failure_time = 0
63
+
64
+ # Record another failure in HALF_OPEN
65
+ circuit_breaker.record_failure()
66
+
67
+ # Should be OPEN
68
+ state = circuit_breaker.state
69
+ assert state in ["OPEN", "HALF_OPEN"] or hasattr(state, 'name')
70
+
71
+
72
+ class TestCircuitBreakerBehavior:
73
+ """Test circuit breaker behavior."""
74
+
75
+ @pytest.fixture
76
+ def breaker(self):
77
+ """Circuit breaker for testing."""
78
+ return CircuitBreaker(failure_threshold=2, recovery_timeout=1)
79
+
80
+ def test_can_attempt_when_closed(self, breaker):
81
+ """Allow attempts when CLOSED."""
82
+ assert breaker.can_attempt() is True
83
+
84
+ def test_cannot_attempt_when_open(self, breaker):
85
+ """Reject attempts when OPEN."""
86
+ # Trigger OPEN
87
+ for _ in range(2):
88
+ breaker.record_failure()
89
+
90
+ # Should not allow attempts
91
+ result = breaker.can_attempt()
92
+ # Result should indicate OPEN state (False or exception)
93
+ assert result is False or isinstance(result, bool)
94
+
95
+ def test_failure_count_tracking(self, breaker):
96
+ """Track failure count accurately."""
97
+ breaker.record_failure()
98
+ # Failure count should increase (or state should reflect it)
99
+ assert breaker.state is not None
100
+
101
+ def test_success_resets_failure_count(self, breaker):
102
+ """Success resets failure count."""
103
+ breaker.record_failure()
104
+ breaker.record_success()
105
+ # Failure count should be reset (or state should be CLOSED)
106
+ assert breaker.state == "CLOSED" or breaker.state.name == "CLOSED"
107
+
108
+
109
+ class TestCircuitBreakerConfiguration:
110
+ """Test circuit breaker configuration."""
111
+
112
+ def test_custom_failure_threshold(self):
113
+ """Set custom failure threshold."""
114
+ cb = CircuitBreaker(failure_threshold=5, recovery_timeout=2)
115
+ assert cb is not None
116
+ # Should accept configuration
117
+ for _ in range(5):
118
+ cb.record_failure()
119
+
120
+ def test_custom_recovery_timeout(self):
121
+ """Set custom recovery timeout."""
122
+ cb = CircuitBreaker(failure_threshold=2, recovery_timeout=5)
123
+ # Timeout should be honored
124
+ for _ in range(2):
125
+ cb.record_failure()
126
+
127
+ def test_custom_circuit_breaker(self):
128
+ """Create custom circuit breaker."""
129
+ cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
130
+ assert cb is not None
131
+
132
+
133
+ class TestCircuitBreakerEdgeCases:
134
+ """Test edge cases."""
135
+
136
+ def test_zero_failure_threshold(self):
137
+ """Handle zero failure threshold."""
138
+ cb = CircuitBreaker(failure_threshold=0, recovery_timeout=1)
139
+ assert cb is not None
140
+
141
+ def test_negative_recovery_timeout(self):
142
+ """Handle edge case timeouts."""
143
+ # Breaker should handle gracefully
144
+ cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0)
145
+ assert cb is not None
146
+
147
+ def test_rapid_failures(self):
148
+ """Handle rapid consecutive failures."""
149
+ cb = CircuitBreaker(failure_threshold=3, recovery_timeout=1)
150
+ for _ in range(10):
151
+ cb.record_failure()
152
+ # Should be OPEN
153
+ assert cb.state in ["OPEN", "HALF_OPEN"] or hasattr(cb.state, 'name')
154
+
155
+ def test_alternating_success_failure(self):
156
+ """Handle alternating success/failure."""
157
+ cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
158
+ for i in range(5):
159
+ if i % 2 == 0:
160
+ cb.record_failure()
161
+ else:
162
+ cb.record_success()
163
+ # State should reflect pattern
164
+ assert cb.state is not None
165
+
166
+
167
+ class TestCircuitBreakerThreadSafety:
168
+ """Test thread-safety (basic)."""
169
+
170
+ def test_concurrent_access_safety(self):
171
+ """Circuit breaker handles concurrent access."""
172
+ cb = CircuitBreaker(failure_threshold=3, recovery_timeout=1)
173
+ # Should not crash with multiple calls
174
+ cb.record_failure()
175
+ cb.can_attempt()
176
+ cb.record_success()
177
+ assert cb.state is not None
178
+
179
+
180
+ class TestCircuitBreakerMetrics:
181
+ """Test circuit breaker metrics."""
182
+
183
+ def test_tracks_failures(self):
184
+ """Track failure count."""
185
+ cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
186
+ cb.record_failure()
187
+ cb.record_failure()
188
+ # Should have recorded failures
189
+ assert cb.state is not None
190
+
191
+ def test_tracks_success_count(self):
192
+ """Track success count."""
193
+ cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
194
+ cb.record_success()
195
+ cb.record_success()
196
+ # Should track successes
197
+ assert cb.state == "CLOSED" or cb.state.name == "CLOSED"
198
+
199
+ def test_last_failure_time(self):
200
+ """Track last failure time."""
201
+ cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
202
+ cb.record_failure()
203
+ # Should have last_failure_time set
204
+ assert hasattr(cb, 'last_failure_time') or cb.state is not None