Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

test_variable_truncator.py 23KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. """
  2. Comprehensive unit tests for VariableTruncator class based on current implementation.
  3. This test suite covers all functionality of the current VariableTruncator including:
  4. - JSON size calculation for different data types
  5. - String, array, and object truncation logic
  6. - Segment-based truncation interface
  7. - Helper methods for budget-based truncation
  8. - Edge cases and error handling
  9. """
  10. import functools
  11. import json
  12. import uuid
  13. from typing import Any
  14. from uuid import uuid4
  15. import pytest
  16. from core.file.enums import FileTransferMethod, FileType
  17. from core.file.models import File
  18. from core.variables.segments import (
  19. ArrayFileSegment,
  20. ArraySegment,
  21. FileSegment,
  22. FloatSegment,
  23. IntegerSegment,
  24. NoneSegment,
  25. ObjectSegment,
  26. StringSegment,
  27. )
  28. from services.variable_truncator import (
  29. MaxDepthExceededError,
  30. TruncationResult,
  31. UnknownTypeError,
  32. VariableTruncator,
  33. )
  34. @pytest.fixture
  35. def file() -> File:
  36. return File(
  37. id=str(uuid4()), # Generate new UUID for File.id
  38. tenant_id=str(uuid.uuid4()),
  39. type=FileType.DOCUMENT,
  40. transfer_method=FileTransferMethod.LOCAL_FILE,
  41. related_id=str(uuid.uuid4()),
  42. filename="test_file.txt",
  43. extension=".txt",
  44. mime_type="text/plain",
  45. size=1024,
  46. storage_key="initial_key",
  47. )
  48. _compact_json_dumps = functools.partial(json.dumps, separators=(",", ":"))
  49. class TestCalculateJsonSize:
  50. """Test calculate_json_size method with different data types."""
  51. @pytest.fixture
  52. def truncator(self):
  53. return VariableTruncator()
  54. def test_string_size_calculation(self):
  55. """Test JSON size calculation for strings."""
  56. # Simple ASCII string
  57. assert VariableTruncator.calculate_json_size("hello") == 7 # "hello" + 2 quotes
  58. # Empty string
  59. assert VariableTruncator.calculate_json_size("") == 2 # Just quotes
  60. # Unicode string
  61. assert VariableTruncator.calculate_json_size("你好") == 4
  62. def test_number_size_calculation(self, truncator):
  63. """Test JSON size calculation for numbers."""
  64. assert truncator.calculate_json_size(123) == 3
  65. assert truncator.calculate_json_size(12.34) == 5
  66. assert truncator.calculate_json_size(-456) == 4
  67. assert truncator.calculate_json_size(0) == 1
  68. def test_boolean_size_calculation(self, truncator):
  69. """Test JSON size calculation for booleans."""
  70. assert truncator.calculate_json_size(True) == 4 # "true"
  71. assert truncator.calculate_json_size(False) == 5 # "false"
  72. def test_null_size_calculation(self, truncator):
  73. """Test JSON size calculation for None/null."""
  74. assert truncator.calculate_json_size(None) == 4 # "null"
  75. def test_array_size_calculation(self, truncator):
  76. """Test JSON size calculation for arrays."""
  77. # Empty array
  78. assert truncator.calculate_json_size([]) == 2 # "[]"
  79. # Simple array
  80. simple_array = [1, 2, 3]
  81. # [1,2,3] = 1 + 1 + 1 + 1 + 1 + 2 = 7 (numbers + commas + brackets)
  82. assert truncator.calculate_json_size(simple_array) == 7
  83. # Array with strings
  84. string_array = ["a", "b"]
  85. # ["a","b"] = 3 + 3 + 1 + 2 = 9 (quoted strings + comma + brackets)
  86. assert truncator.calculate_json_size(string_array) == 9
  87. def test_object_size_calculation(self, truncator):
  88. """Test JSON size calculation for objects."""
  89. # Empty object
  90. assert truncator.calculate_json_size({}) == 2 # "{}"
  91. # Simple object
  92. simple_obj = {"a": 1}
  93. # {"a":1} = 3 + 1 + 1 + 2 = 7 (key + colon + value + brackets)
  94. assert truncator.calculate_json_size(simple_obj) == 7
  95. # Multiple keys
  96. multi_obj = {"a": 1, "b": 2}
  97. # {"a":1,"b":2} = 3 + 1 + 1 + 1 + 3 + 1 + 1 + 2 = 13
  98. assert truncator.calculate_json_size(multi_obj) == 13
  99. def test_nested_structure_size_calculation(self, truncator):
  100. """Test JSON size calculation for nested structures."""
  101. nested = {"items": [1, 2, {"nested": "value"}]}
  102. size = truncator.calculate_json_size(nested)
  103. assert size > 0 # Should calculate without error
  104. # Verify it matches actual JSON length roughly
  105. actual_json = _compact_json_dumps(nested)
  106. # Should be close but not exact due to UTF-8 encoding considerations
  107. assert abs(size - len(actual_json.encode())) <= 5
  108. def test_calculate_json_size_max_depth_exceeded(self, truncator):
  109. """Test that calculate_json_size handles deep nesting gracefully."""
  110. # Create deeply nested structure
  111. nested: dict[str, Any] = {"level": 0}
  112. current = nested
  113. for i in range(105): # Create deep nesting
  114. current["next"] = {"level": i + 1}
  115. current = current["next"]
  116. # Should either raise an error or handle gracefully
  117. with pytest.raises(MaxDepthExceededError):
  118. truncator.calculate_json_size(nested)
  119. def test_calculate_json_size_unknown_type(self, truncator):
  120. """Test that calculate_json_size raises error for unknown types."""
  121. class CustomType:
  122. pass
  123. with pytest.raises(UnknownTypeError):
  124. truncator.calculate_json_size(CustomType())
  125. class TestStringTruncation:
  126. LENGTH_LIMIT = 10
  127. """Test string truncation functionality."""
  128. @pytest.fixture
  129. def small_truncator(self):
  130. return VariableTruncator(string_length_limit=10)
  131. def test_short_string_no_truncation(self, small_truncator):
  132. """Test that short strings are not truncated."""
  133. short_str = "hello"
  134. result = small_truncator._truncate_string(short_str, self.LENGTH_LIMIT)
  135. assert result.value == short_str
  136. assert result.truncated is False
  137. assert result.value_size == VariableTruncator.calculate_json_size(short_str)
  138. def test_long_string_truncation(self, small_truncator: VariableTruncator):
  139. """Test that long strings are truncated with ellipsis."""
  140. long_str = "this is a very long string that exceeds the limit"
  141. result = small_truncator._truncate_string(long_str, self.LENGTH_LIMIT)
  142. assert result.truncated is True
  143. assert result.value == long_str[:5] + "..."
  144. assert result.value_size == 10 # 10 chars + "..."
  145. def test_exact_limit_string(self, small_truncator: VariableTruncator):
  146. """Test string exactly at limit."""
  147. exact_str = "1234567890" # Exactly 10 chars
  148. result = small_truncator._truncate_string(exact_str, self.LENGTH_LIMIT)
  149. assert result.value == "12345..."
  150. assert result.truncated is True
  151. assert result.value_size == 10
  152. class TestArrayTruncation:
  153. """Test array truncation functionality."""
  154. @pytest.fixture
  155. def small_truncator(self):
  156. return VariableTruncator(array_element_limit=3, max_size_bytes=100)
  157. def test_small_array_no_truncation(self, small_truncator: VariableTruncator):
  158. """Test that small arrays are not truncated."""
  159. small_array = [1, 2]
  160. result = small_truncator._truncate_array(small_array, 1000)
  161. assert result.value == small_array
  162. assert result.truncated is False
  163. def test_array_element_limit_truncation(self, small_truncator: VariableTruncator):
  164. """Test that arrays over element limit are truncated."""
  165. large_array = [1, 2, 3, 4, 5, 6] # Exceeds limit of 3
  166. result = small_truncator._truncate_array(large_array, 1000)
  167. assert result.truncated is True
  168. assert result.value == [1, 2, 3]
  169. def test_array_size_budget_truncation(self, small_truncator: VariableTruncator):
  170. """Test array truncation due to size budget constraints."""
  171. # Create array with strings that will exceed size budget
  172. large_strings = ["very long string " * 5, "another long string " * 5]
  173. result = small_truncator._truncate_array(large_strings, 50)
  174. assert result.truncated is True
  175. # Should have truncated the strings within the array
  176. for item in result.value:
  177. assert isinstance(item, str)
  178. assert VariableTruncator.calculate_json_size(result.value) <= 50
  179. def test_array_with_nested_objects(self, small_truncator):
  180. """Test array truncation with nested objects."""
  181. nested_array = [
  182. {"name": "item1", "data": "some data"},
  183. {"name": "item2", "data": "more data"},
  184. {"name": "item3", "data": "even more data"},
  185. ]
  186. result = small_truncator._truncate_array(nested_array, 30)
  187. assert isinstance(result.value, list)
  188. assert len(result.value) <= 3
  189. for item in result.value:
  190. assert isinstance(item, dict)
  191. class TestObjectTruncation:
  192. """Test object truncation functionality."""
  193. @pytest.fixture
  194. def small_truncator(self):
  195. return VariableTruncator(max_size_bytes=100)
  196. def test_small_object_no_truncation(self, small_truncator):
  197. """Test that small objects are not truncated."""
  198. small_obj = {"a": 1, "b": 2}
  199. result = small_truncator._truncate_object(small_obj, 1000)
  200. assert result.value == small_obj
  201. assert result.truncated is False
  202. def test_empty_object_no_truncation(self, small_truncator):
  203. """Test that empty objects are not truncated."""
  204. empty_obj = {}
  205. result = small_truncator._truncate_object(empty_obj, 100)
  206. assert result.value == empty_obj
  207. assert result.truncated is False
  208. def test_object_value_truncation(self, small_truncator):
  209. """Test object truncation where values are truncated to fit budget."""
  210. obj_with_long_values = {
  211. "key1": "very long string " * 10,
  212. "key2": "another long string " * 10,
  213. "key3": "third long string " * 10,
  214. }
  215. result = small_truncator._truncate_object(obj_with_long_values, 80)
  216. assert result.truncated is True
  217. assert isinstance(result.value, dict)
  218. assert set(result.value.keys()).issubset(obj_with_long_values.keys())
  219. # Values should be truncated if they exist
  220. for key, value in result.value.items():
  221. if isinstance(value, str):
  222. original_value = obj_with_long_values[key]
  223. # Value should be same or smaller
  224. assert len(value) <= len(original_value)
  225. def test_object_key_dropping(self, small_truncator):
  226. """Test object truncation where keys are dropped due to size constraints."""
  227. large_obj = {f"key{i:02d}": f"value{i}" for i in range(20)}
  228. result = small_truncator._truncate_object(large_obj, 50)
  229. assert result.truncated is True
  230. assert len(result.value) < len(large_obj)
  231. # Should maintain sorted key order
  232. result_keys = list(result.value.keys())
  233. assert result_keys == sorted(result_keys)
  234. def test_object_with_nested_structures(self, small_truncator):
  235. """Test object truncation with nested arrays and objects."""
  236. nested_obj = {"simple": "value", "array": [1, 2, 3, 4, 5], "nested": {"inner": "data", "more": ["a", "b", "c"]}}
  237. result = small_truncator._truncate_object(nested_obj, 60)
  238. assert isinstance(result.value, dict)
  239. class TestSegmentBasedTruncation:
  240. """Test the main truncate method that works with Segments."""
  241. @pytest.fixture
  242. def truncator(self):
  243. return VariableTruncator()
  244. @pytest.fixture
  245. def small_truncator(self):
  246. return VariableTruncator(string_length_limit=20, array_element_limit=3, max_size_bytes=200)
  247. def test_integer_segment_no_truncation(self, truncator):
  248. """Test that integer segments are never truncated."""
  249. segment = IntegerSegment(value=12345)
  250. result = truncator.truncate(segment)
  251. assert isinstance(result, TruncationResult)
  252. assert result.truncated is False
  253. assert result.result == segment
  254. def test_boolean_as_integer_segment(self, truncator):
  255. """Test boolean values in IntegerSegment are converted to int."""
  256. segment = IntegerSegment(value=True)
  257. result = truncator.truncate(segment)
  258. assert isinstance(result, TruncationResult)
  259. assert result.truncated is False
  260. assert isinstance(result.result, IntegerSegment)
  261. assert result.result.value == 1 # True converted to 1
  262. def test_float_segment_no_truncation(self, truncator):
  263. """Test that float segments are never truncated."""
  264. segment = FloatSegment(value=123.456)
  265. result = truncator.truncate(segment)
  266. assert isinstance(result, TruncationResult)
  267. assert result.truncated is False
  268. assert result.result == segment
  269. def test_none_segment_no_truncation(self, truncator):
  270. """Test that None segments are never truncated."""
  271. segment = NoneSegment()
  272. result = truncator.truncate(segment)
  273. assert isinstance(result, TruncationResult)
  274. assert result.truncated is False
  275. assert result.result == segment
  276. def test_file_segment_no_truncation(self, truncator, file):
  277. """Test that file segments are never truncated."""
  278. file_segment = FileSegment(value=file)
  279. result = truncator.truncate(file_segment)
  280. assert result.result == file_segment
  281. assert result.truncated is False
  282. def test_array_file_segment_no_truncation(self, truncator, file):
  283. """Test that array file segments are never truncated."""
  284. array_file_segment = ArrayFileSegment(value=[file] * 20)
  285. result = truncator.truncate(array_file_segment)
  286. assert result.result == array_file_segment
  287. assert result.truncated is False
  288. def test_string_segment_small_no_truncation(self, truncator):
  289. """Test small string segments are not truncated."""
  290. segment = StringSegment(value="hello world")
  291. result = truncator.truncate(segment)
  292. assert isinstance(result, TruncationResult)
  293. assert result.truncated is False
  294. assert result.result == segment
  295. def test_string_segment_large_truncation(self, small_truncator):
  296. """Test large string segments are truncated."""
  297. long_text = "this is a very long string that will definitely exceed the limit"
  298. segment = StringSegment(value=long_text)
  299. result = small_truncator.truncate(segment)
  300. assert isinstance(result, TruncationResult)
  301. assert result.truncated is True
  302. assert isinstance(result.result, StringSegment)
  303. assert len(result.result.value) < len(long_text)
  304. assert result.result.value.endswith("...")
  305. def test_array_segment_small_no_truncation(self, truncator):
  306. """Test small array segments are not truncated."""
  307. from factories.variable_factory import build_segment
  308. segment = build_segment([1, 2, 3])
  309. result = truncator.truncate(segment)
  310. assert isinstance(result, TruncationResult)
  311. assert result.truncated is False
  312. assert result.result == segment
  313. def test_array_segment_large_truncation(self, small_truncator):
  314. """Test large array segments are truncated."""
  315. from factories.variable_factory import build_segment
  316. large_array = list(range(10)) # Exceeds element limit of 3
  317. segment = build_segment(large_array)
  318. result = small_truncator.truncate(segment)
  319. assert isinstance(result, TruncationResult)
  320. assert result.truncated is True
  321. assert isinstance(result.result, ArraySegment)
  322. assert len(result.result.value) <= 3
  323. def test_object_segment_small_no_truncation(self, truncator):
  324. """Test small object segments are not truncated."""
  325. segment = ObjectSegment(value={"key": "value"})
  326. result = truncator.truncate(segment)
  327. assert isinstance(result, TruncationResult)
  328. assert result.truncated is False
  329. assert result.result == segment
  330. def test_object_segment_large_truncation(self, small_truncator):
  331. """Test large object segments are truncated."""
  332. large_obj = {f"key{i}": f"very long value {i}" * 5 for i in range(5)}
  333. segment = ObjectSegment(value=large_obj)
  334. result = small_truncator.truncate(segment)
  335. assert isinstance(result, TruncationResult)
  336. assert result.truncated is True
  337. assert isinstance(result.result, ObjectSegment)
  338. # Object should be smaller or equal than original
  339. original_size = small_truncator.calculate_json_size(large_obj)
  340. result_size = small_truncator.calculate_json_size(result.result.value)
  341. assert result_size <= original_size
  342. def test_final_size_fallback_to_json_string(self, small_truncator):
  343. """Test final fallback when truncated result still exceeds size limit."""
  344. # Create data that will still be large after initial truncation
  345. large_nested_data = {"data": ["very long string " * 5] * 5, "more": {"nested": "content " * 20}}
  346. segment = ObjectSegment(value=large_nested_data)
  347. # Use very small limit to force JSON string fallback
  348. tiny_truncator = VariableTruncator(max_size_bytes=50)
  349. result = tiny_truncator.truncate(segment)
  350. assert isinstance(result, TruncationResult)
  351. assert result.truncated is True
  352. assert isinstance(result.result, StringSegment)
  353. # Should be JSON string with possible truncation
  354. assert len(result.result.value) <= 53 # 50 + "..." = 53
  355. def test_final_size_fallback_string_truncation(self, small_truncator):
  356. """Test final fallback for string that still exceeds limit."""
  357. # Create very long string that exceeds string length limit
  358. very_long_string = "x" * 6000 # Exceeds default string_length_limit of 5000
  359. segment = StringSegment(value=very_long_string)
  360. # Use small limit to test string fallback path
  361. tiny_truncator = VariableTruncator(string_length_limit=100, max_size_bytes=50)
  362. result = tiny_truncator.truncate(segment)
  363. assert isinstance(result, TruncationResult)
  364. assert result.truncated is True
  365. assert isinstance(result.result, StringSegment)
  366. # Should be truncated due to string limit or final size limit
  367. assert len(result.result.value) <= 1000 # Much smaller than original
  368. class TestEdgeCases:
  369. """Test edge cases and error conditions."""
  370. def test_empty_inputs(self):
  371. """Test truncator with empty inputs."""
  372. truncator = VariableTruncator()
  373. # Empty string
  374. result = truncator.truncate(StringSegment(value=""))
  375. assert not result.truncated
  376. assert result.result.value == ""
  377. # Empty array
  378. from factories.variable_factory import build_segment
  379. result = truncator.truncate(build_segment([]))
  380. assert not result.truncated
  381. assert result.result.value == []
  382. # Empty object
  383. result = truncator.truncate(ObjectSegment(value={}))
  384. assert not result.truncated
  385. assert result.result.value == {}
  386. def test_zero_and_negative_limits(self):
  387. """Test truncator behavior with zero or very small limits."""
  388. # Zero string limit
  389. with pytest.raises(ValueError):
  390. truncator = VariableTruncator(string_length_limit=3)
  391. with pytest.raises(ValueError):
  392. truncator = VariableTruncator(array_element_limit=0)
  393. with pytest.raises(ValueError):
  394. truncator = VariableTruncator(max_size_bytes=0)
  395. def test_unicode_and_special_characters(self):
  396. """Test truncator with unicode and special characters."""
  397. truncator = VariableTruncator(string_length_limit=10)
  398. # Unicode characters
  399. unicode_text = "🌍🚀🌍🚀🌍🚀🌍🚀🌍🚀" # Each emoji counts as 1 character
  400. result = truncator.truncate(StringSegment(value=unicode_text))
  401. if len(unicode_text) > 10:
  402. assert result.truncated is True
  403. # Special JSON characters
  404. special_chars = '{"key": "value with \\"quotes\\" and \\n newlines"}'
  405. result = truncator.truncate(StringSegment(value=special_chars))
  406. assert isinstance(result.result, StringSegment)
  407. class TestIntegrationScenarios:
  408. """Test realistic integration scenarios."""
  409. def test_workflow_output_scenario(self):
  410. """Test truncation of typical workflow output data."""
  411. truncator = VariableTruncator()
  412. workflow_data = {
  413. "result": "success",
  414. "data": {
  415. "users": [
  416. {"id": 1, "name": "Alice", "email": "alice@example.com"},
  417. {"id": 2, "name": "Bob", "email": "bob@example.com"},
  418. ]
  419. * 3, # Multiply to make it larger
  420. "metadata": {
  421. "count": 6,
  422. "processing_time": "1.23s",
  423. "details": "x" * 200, # Long string but not too long
  424. },
  425. },
  426. }
  427. segment = ObjectSegment(value=workflow_data)
  428. result = truncator.truncate(segment)
  429. assert isinstance(result, TruncationResult)
  430. assert isinstance(result.result, (ObjectSegment, StringSegment))
  431. # Should handle complex nested structure appropriately
  432. def test_large_text_processing_scenario(self):
  433. """Test truncation of large text data."""
  434. truncator = VariableTruncator(string_length_limit=100)
  435. large_text = "This is a very long text document. " * 20 # Make it larger than limit
  436. segment = StringSegment(value=large_text)
  437. result = truncator.truncate(segment)
  438. assert isinstance(result, TruncationResult)
  439. assert result.truncated is True
  440. assert isinstance(result.result, StringSegment)
  441. assert len(result.result.value) <= 103 # 100 + "..."
  442. assert result.result.value.endswith("...")
  443. def test_mixed_data_types_scenario(self):
  444. """Test truncation with mixed data types in complex structure."""
  445. truncator = VariableTruncator(string_length_limit=30, array_element_limit=3, max_size_bytes=300)
  446. mixed_data = {
  447. "strings": ["short", "medium length", "very long string " * 3],
  448. "numbers": [1, 2.5, 999999],
  449. "booleans": [True, False, True],
  450. "nested": {
  451. "more_strings": ["nested string " * 2],
  452. "more_numbers": list(range(5)),
  453. "deep": {"level": 3, "content": "deep content " * 3},
  454. },
  455. "nulls": [None, None],
  456. }
  457. segment = ObjectSegment(value=mixed_data)
  458. result = truncator.truncate(segment)
  459. assert isinstance(result, TruncationResult)
  460. # Should handle all data types appropriately
  461. if result.truncated:
  462. # Verify the result is smaller or equal than original
  463. original_size = truncator.calculate_json_size(mixed_data)
  464. if isinstance(result.result, ObjectSegment):
  465. result_size = truncator.calculate_json_size(result.result.value)
  466. assert result_size <= original_size