lifx-emulator-core 3.1.0__tar.gz → 3.2.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.
Files changed (74) hide show
  1. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/CHANGELOG.md +21 -0
  2. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/PKG-INFO +1 -1
  3. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/pyproject.toml +1 -1
  4. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/device.py +19 -0
  5. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/multizone_handlers.py +19 -15
  6. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/tile_handlers.py +59 -52
  7. lifx_emulator_core-3.2.0/tests/test_partial_responses.py +301 -0
  8. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_scenario_manager.py +27 -14
  9. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_server.py +2 -2
  10. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/.gitignore +0 -0
  11. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/README.md +0 -0
  12. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/__init__.py +0 -0
  13. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/constants.py +0 -0
  14. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/__init__.py +0 -0
  15. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/manager.py +0 -0
  16. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/observers.py +0 -0
  17. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/persistence.py +0 -0
  18. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/state_restorer.py +0 -0
  19. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/state_serializer.py +0 -0
  20. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/states.py +0 -0
  21. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/__init__.py +0 -0
  22. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/builder.py +0 -0
  23. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/default_config.py +0 -0
  24. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/factory.py +0 -0
  25. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/firmware_config.py +0 -0
  26. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/serial_generator.py +0 -0
  27. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/__init__.py +0 -0
  28. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/base.py +0 -0
  29. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/device_handlers.py +0 -0
  30. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/light_handlers.py +0 -0
  31. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/registry.py +0 -0
  32. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/products/__init__.py +0 -0
  33. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/products/generator.py +0 -0
  34. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/products/registry.py +0 -0
  35. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/products/specs.py +0 -0
  36. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/products/specs.yml +0 -0
  37. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/__init__.py +0 -0
  38. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/base.py +0 -0
  39. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/const.py +0 -0
  40. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/generator.py +0 -0
  41. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/header.py +0 -0
  42. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/packets.py +0 -0
  43. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/protocol_types.py +0 -0
  44. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/serializer.py +0 -0
  45. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/repositories/__init__.py +0 -0
  46. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/repositories/device_repository.py +0 -0
  47. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/repositories/storage_backend.py +0 -0
  48. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/scenarios/__init__.py +0 -0
  49. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/scenarios/manager.py +0 -0
  50. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/scenarios/models.py +0 -0
  51. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/scenarios/persistence.py +0 -0
  52. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/server.py +0 -0
  53. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/conftest.py +0 -0
  54. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_async_storage.py +0 -0
  55. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_backwards_compatibility.py +0 -0
  56. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_device.py +0 -0
  57. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_device_edge_cases.py +0 -0
  58. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_device_handlers_extended.py +0 -0
  59. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_device_manager.py +0 -0
  60. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_handler_registry.py +0 -0
  61. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_integration.py +0 -0
  62. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_light_handlers_extended.py +0 -0
  63. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_multizone_handlers_extended.py +0 -0
  64. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_observers.py +0 -0
  65. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_products_generator.py +0 -0
  66. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_products_specs.py +0 -0
  67. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_protocol_generator.py +0 -0
  68. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_protocol_types_coverage.py +0 -0
  69. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_repositories.py +0 -0
  70. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_scenario_persistence.py +0 -0
  71. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_serializer.py +0 -0
  72. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_state_restorer.py +0 -0
  73. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_switch_devices.py +0 -0
  74. {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_tile_handlers_extended.py +0 -0
@@ -2,6 +2,27 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v3.2.0 (2026-02-02)
6
+
7
+ ### Bug Fixes
8
+
9
+ - **tests**: Replace flaky probabilistic drop rate tests with deterministic mocks
10
+ ([`5070d6e`](https://github.com/Djelibeybi/lifx-emulator/commit/5070d6e88495dfeffaffb8f54f6eb5e6098d0f43))
11
+
12
+ ### Features
13
+
14
+ - **core**: Wire partial_responses scenario through packet handlers
15
+ ([`a799dba`](https://github.com/Djelibeybi/lifx-emulator/commit/a799dba3f2d04501dd5b0694359e3e71e2ed5bbb))
16
+
17
+
18
+ ## v3.1.1 (2026-02-01)
19
+
20
+ ### Bug Fixes
21
+
22
+ - **tests**: Relax timing threshold in test_no_delay_by_default
23
+ ([`dd1b416`](https://github.com/Djelibeybi/lifx-emulator/commit/dd1b4162e7b6111a13818aad0450f15367c7c9ac))
24
+
25
+
5
26
  ## v3.1.0 (2026-01-11)
6
27
 
7
28
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-emulator-core
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: Core LIFX Emulator library for testing LIFX LAN protocol libraries
5
5
  Author-email: Avi Miller <me@dje.li>
6
6
  Maintainer-email: Avi Miller <me@dje.li>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lifx-emulator-core"
3
- version = "3.1.0"
3
+ version = "3.2.0"
4
4
  description = "Core LIFX Emulator library for testing LIFX LAN protocol libraries"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import copy
7
7
  import logging
8
+ import random
8
9
  import time
9
10
  from typing import Any
10
11
 
@@ -304,6 +305,24 @@ class EmulatedLifxDevice:
304
305
 
305
306
  # Handle specific packet types - handlers always return list
306
307
  response_packets = self._handle_packet_type(header, packet)
308
+
309
+ # Apply partial_responses: truncate multi-packet responses to random subset
310
+ if len(response_packets) > 1:
311
+ first_pkt = response_packets[0]
312
+ if (
313
+ hasattr(first_pkt, "PKT_TYPE")
314
+ and first_pkt.PKT_TYPE in scenario.partial_responses
315
+ ):
316
+ original_count = len(response_packets)
317
+ partial_count = random.randint(1, original_count - 1) # nosec
318
+ response_packets = response_packets[:partial_count]
319
+ logger.info(
320
+ "Sending partial response for packet type %s (%d of %d packets)",
321
+ first_pkt.PKT_TYPE,
322
+ partial_count,
323
+ original_count,
324
+ )
325
+
307
326
  # Handlers now always return list (empty if no response)
308
327
  for resp_packet in response_packets:
309
328
  # Cache packed payload to avoid double packing (performance optimization)
@@ -124,22 +124,26 @@ class ExtendedGetColorZonesHandler(PacketHandler):
124
124
  if not device_state.has_multizone:
125
125
  return []
126
126
 
127
- colors_count = min(82, len(device_state.zone_colors))
128
- colors = []
129
- for i in range(colors_count):
130
- colors.append(device_state.zone_colors[i])
131
- # Pad to 82 colors
132
- while len(colors) < 82:
133
- colors.append(LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500))
134
-
135
- return [
136
- MultiZone.ExtendedStateMultiZone(
137
- count=device_state.zone_count,
138
- index=0,
139
- colors_count=colors_count,
140
- colors=colors,
127
+ responses = []
128
+ index = 0
129
+ while index < device_state.zone_count:
130
+ end = min(index + 82, device_state.zone_count)
131
+ colors_count = end - index
132
+ colors = list(device_state.zone_colors[index:end])
133
+ # Pad to 82 colors
134
+ while len(colors) < 82:
135
+ colors.append(LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500))
136
+ responses.append(
137
+ MultiZone.ExtendedStateMultiZone(
138
+ count=device_state.zone_count,
139
+ index=index,
140
+ colors_count=colors_count,
141
+ colors=colors,
142
+ )
141
143
  )
142
- ]
144
+ index += 82
145
+
146
+ return responses
143
147
 
144
148
 
145
149
  class ExtendedSetColorZonesHandler(PacketHandler):
@@ -128,64 +128,71 @@ class Get64Handler(PacketHandler):
128
128
  if not device_state.has_matrix or not packet:
129
129
  return []
130
130
 
131
- tile_index = packet.tile_index
132
131
  rect = packet.rect
132
+ length = max(1, packet.length)
133
133
 
134
- if tile_index >= len(device_state.tile_devices):
135
- return []
136
-
137
- tile = device_state.tile_devices[tile_index]
138
- tile_width = tile["width"]
139
- tile_height = tile["height"]
140
-
141
- # Get64 always returns framebuffer 0 (the visible buffer)
142
- # regardless of which fb_index is in the request
143
- tile_colors = tile["colors"]
144
-
145
- # Calculate how many rows fit in 64 zones
146
- rows_to_return = 64 // rect.width if rect.width > 0 else 1
147
- rows_to_return = min(rows_to_return, tile_height - rect.y)
148
-
149
- # Extract colors from the requested rectangle
150
- colors = []
151
- zones_extracted = 0
152
-
153
- for row in range(rows_to_return):
154
- y = rect.y + row
155
- if y >= tile_height:
134
+ responses = []
135
+ for i in range(length):
136
+ idx = packet.tile_index + i
137
+ if idx >= len(device_state.tile_devices):
156
138
  break
157
139
 
158
- for col in range(rect.width):
159
- x = rect.x + col
160
- if x >= tile_width or zones_extracted >= 64:
161
- colors.append(
162
- LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
163
- )
140
+ tile = device_state.tile_devices[idx]
141
+ tile_width = tile["width"]
142
+ tile_height = tile["height"]
143
+
144
+ # Get64 always returns framebuffer 0 (the visible buffer)
145
+ # regardless of which fb_index is in the request
146
+ tile_colors = tile["colors"]
147
+
148
+ # Calculate how many rows fit in 64 zones
149
+ rows_to_return = 64 // rect.width if rect.width > 0 else 1
150
+ rows_to_return = min(rows_to_return, tile_height - rect.y)
151
+
152
+ # Extract colors from the requested rectangle
153
+ colors = []
154
+ zones_extracted = 0
155
+
156
+ for row in range(rows_to_return):
157
+ y = rect.y + row
158
+ if y >= tile_height:
159
+ break
160
+
161
+ for col in range(rect.width):
162
+ x = rect.x + col
163
+ if x >= tile_width or zones_extracted >= 64:
164
+ colors.append(
165
+ LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
166
+ )
167
+ zones_extracted += 1
168
+ continue
169
+
170
+ # Calculate zone index in flat color array
171
+ zone_idx = y * tile_width + x
172
+ if zone_idx < len(tile_colors):
173
+ colors.append(tile_colors[zone_idx])
174
+ else:
175
+ colors.append(
176
+ LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
177
+ )
164
178
  zones_extracted += 1
165
- continue
166
179
 
167
- # Calculate zone index in flat color array
168
- zone_idx = y * tile_width + x
169
- if zone_idx < len(tile_colors):
170
- colors.append(tile_colors[zone_idx])
171
- else:
172
- colors.append(
173
- LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
174
- )
175
- zones_extracted += 1
176
-
177
- # Pad to exactly 64 colors
178
- while len(colors) < 64:
179
- colors.append(LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500))
180
-
181
- # Return with fb_index forced to 0 (visible buffer)
182
- return_rect = TileBufferRect(
183
- fb_index=0, # Always return FB0
184
- x=rect.x,
185
- y=rect.y,
186
- width=rect.width,
187
- )
188
- return [Tile.State64(tile_index=tile_index, rect=return_rect, colors=colors)]
180
+ # Pad to exactly 64 colors
181
+ while len(colors) < 64:
182
+ colors.append(LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500))
183
+
184
+ # Return with fb_index forced to 0 (visible buffer)
185
+ return_rect = TileBufferRect(
186
+ fb_index=0, # Always return FB0
187
+ x=rect.x,
188
+ y=rect.y,
189
+ width=rect.width,
190
+ )
191
+ responses.append(
192
+ Tile.State64(tile_index=idx, rect=return_rect, colors=colors)
193
+ )
194
+
195
+ return responses
189
196
 
190
197
 
191
198
  class Set64Handler(PacketHandler):
@@ -0,0 +1,301 @@
1
+ """Tests for partial_responses, extended multizone >82 zones, and Get64 length."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from lifx_emulator.factories import create_multizone_light, create_tile_device
6
+ from lifx_emulator.protocol.header import LifxHeader
7
+ from lifx_emulator.protocol.packets import MultiZone, Tile
8
+ from lifx_emulator.protocol.protocol_types import TileBufferRect
9
+ from lifx_emulator.scenarios import HierarchicalScenarioManager, ScenarioConfig
10
+
11
+ # --- Helpers ---
12
+
13
+
14
+ def _make_header(device, pkt_type, res_required=True):
15
+ return LifxHeader(
16
+ source=1,
17
+ target=device.state.get_target_bytes(),
18
+ sequence=1,
19
+ pkt_type=pkt_type,
20
+ res_required=res_required,
21
+ )
22
+
23
+
24
+ def _responses_of_type(responses, pkt_type):
25
+ return [r for r in responses if r[0].pkt_type == pkt_type]
26
+
27
+
28
+ # --- Extended multizone fix tests ---
29
+
30
+
31
+ class TestExtendedMultizoneMultiplePackets:
32
+ """ExtendedGetColorZonesHandler should return multiple packets for >82 zones."""
33
+
34
+ def test_120_zones_returns_two_packets(self):
35
+ device = create_multizone_light(
36
+ "d073d5100001", zone_count=120, extended_multizone=True
37
+ )
38
+ header = _make_header(device, 511)
39
+ responses = device.process_packet(header, None)
40
+
41
+ ext_responses = _responses_of_type(responses, 512)
42
+ assert len(ext_responses) == 2
43
+
44
+ _, pkt0 = ext_responses[0]
45
+ assert pkt0.index == 0
46
+ assert pkt0.colors_count == 82
47
+ assert pkt0.count == 120
48
+
49
+ _, pkt1 = ext_responses[1]
50
+ assert pkt1.index == 82
51
+ assert pkt1.colors_count == 38
52
+ assert pkt1.count == 120
53
+
54
+ def test_60_zones_returns_one_packet(self):
55
+ device = create_multizone_light(
56
+ "d073d5100002", zone_count=60, extended_multizone=True
57
+ )
58
+ header = _make_header(device, 511)
59
+ responses = device.process_packet(header, None)
60
+
61
+ ext_responses = _responses_of_type(responses, 512)
62
+ assert len(ext_responses) == 1
63
+ assert ext_responses[0][1].colors_count == 60
64
+
65
+ def test_82_zones_returns_one_packet(self):
66
+ device = create_multizone_light(
67
+ "d073d5100003", zone_count=82, extended_multizone=True
68
+ )
69
+ header = _make_header(device, 511)
70
+ responses = device.process_packet(header, None)
71
+
72
+ ext_responses = _responses_of_type(responses, 512)
73
+ assert len(ext_responses) == 1
74
+ assert ext_responses[0][1].colors_count == 82
75
+
76
+
77
+ # --- Get64 length fix tests ---
78
+
79
+
80
+ class TestGet64Length:
81
+ """Get64Handler should respect the length field."""
82
+
83
+ def test_length_1(self):
84
+ device = create_tile_device("d073d5200001", tile_count=5)
85
+ packet = Tile.Get64(
86
+ tile_index=0,
87
+ length=1,
88
+ rect=TileBufferRect(fb_index=0, x=0, y=0, width=8),
89
+ )
90
+ header = _make_header(device, 707)
91
+ responses = device.process_packet(header, packet)
92
+
93
+ tile_responses = _responses_of_type(responses, 711)
94
+ assert len(tile_responses) == 1
95
+ assert tile_responses[0][1].tile_index == 0
96
+
97
+ def test_length_3(self):
98
+ device = create_tile_device("d073d5200002", tile_count=5)
99
+ packet = Tile.Get64(
100
+ tile_index=0,
101
+ length=3,
102
+ rect=TileBufferRect(fb_index=0, x=0, y=0, width=8),
103
+ )
104
+ header = _make_header(device, 707)
105
+ responses = device.process_packet(header, packet)
106
+
107
+ tile_responses = _responses_of_type(responses, 711)
108
+ assert len(tile_responses) == 3
109
+ assert [r[1].tile_index for r in tile_responses] == [0, 1, 2]
110
+
111
+ def test_length_5_full_chain(self):
112
+ device = create_tile_device("d073d5200003", tile_count=5)
113
+ packet = Tile.Get64(
114
+ tile_index=0,
115
+ length=5,
116
+ rect=TileBufferRect(fb_index=0, x=0, y=0, width=8),
117
+ )
118
+ header = _make_header(device, 707)
119
+ responses = device.process_packet(header, packet)
120
+
121
+ tile_responses = _responses_of_type(responses, 711)
122
+ assert len(tile_responses) == 5
123
+ assert [r[1].tile_index for r in tile_responses] == [0, 1, 2, 3, 4]
124
+
125
+ def test_length_exceeds_chain(self):
126
+ device = create_tile_device("d073d5200004", tile_count=5)
127
+ packet = Tile.Get64(
128
+ tile_index=3,
129
+ length=5,
130
+ rect=TileBufferRect(fb_index=0, x=0, y=0, width=8),
131
+ )
132
+ header = _make_header(device, 707)
133
+ responses = device.process_packet(header, packet)
134
+
135
+ tile_responses = _responses_of_type(responses, 711)
136
+ assert len(tile_responses) == 2
137
+ assert [r[1].tile_index for r in tile_responses] == [3, 4]
138
+
139
+
140
+ # --- Partial response tests ---
141
+
142
+
143
+ class TestPartialResponsesMultizone:
144
+ """partial_responses should truncate multi-packet multizone responses."""
145
+
146
+ def test_standard_multizone_partial(self):
147
+ """120 zones = 15 StateMultiZone packets; partial should return 1..14."""
148
+ device = create_multizone_light("d073d5300001", zone_count=120)
149
+ scenario_manager = HierarchicalScenarioManager()
150
+ scenario_manager.set_device_scenario(
151
+ "d073d5300001", ScenarioConfig(partial_responses=[506])
152
+ )
153
+ device.scenario_manager = scenario_manager
154
+ device.invalidate_scenario_cache()
155
+
156
+ packet = MultiZone.GetColorZones(start_index=0, end_index=119)
157
+ header = _make_header(device, 502)
158
+ responses = device.process_packet(header, packet)
159
+
160
+ mz_responses = _responses_of_type(responses, 506)
161
+ assert 1 <= len(mz_responses) < 15
162
+
163
+ def test_standard_multizone_no_partial(self):
164
+ """Without partial_responses, all 15 packets should be returned."""
165
+ device = create_multizone_light("d073d5300002", zone_count=120)
166
+ packet = MultiZone.GetColorZones(start_index=0, end_index=119)
167
+ header = _make_header(device, 502)
168
+ responses = device.process_packet(header, packet)
169
+
170
+ mz_responses = _responses_of_type(responses, 506)
171
+ assert len(mz_responses) == 15
172
+
173
+ def test_extended_multizone_partial(self):
174
+ """120 zones = 2 ExtendedStateMultiZone; partial should return 1."""
175
+ device = create_multizone_light(
176
+ "d073d5300003", zone_count=120, extended_multizone=True
177
+ )
178
+ scenario_manager = HierarchicalScenarioManager()
179
+ scenario_manager.set_device_scenario(
180
+ "d073d5300003", ScenarioConfig(partial_responses=[512])
181
+ )
182
+ device.scenario_manager = scenario_manager
183
+ device.invalidate_scenario_cache()
184
+
185
+ header = _make_header(device, 511)
186
+ responses = device.process_packet(header, None)
187
+
188
+ ext_responses = _responses_of_type(responses, 512)
189
+ # 2 packets, partial -> exactly 1 (randint(1,1) = 1)
190
+ assert len(ext_responses) == 1
191
+
192
+ def test_extended_multizone_not_affected_by_standard_partial(self):
193
+ """partial_responses=[506] should not affect ExtendedStateMultiZone (512)."""
194
+ device = create_multizone_light(
195
+ "d073d5300004", zone_count=120, extended_multizone=True
196
+ )
197
+ scenario_manager = HierarchicalScenarioManager()
198
+ scenario_manager.set_device_scenario(
199
+ "d073d5300004", ScenarioConfig(partial_responses=[506])
200
+ )
201
+ device.scenario_manager = scenario_manager
202
+ device.invalidate_scenario_cache()
203
+
204
+ header = _make_header(device, 511)
205
+ responses = device.process_packet(header, None)
206
+
207
+ ext_responses = _responses_of_type(responses, 512)
208
+ assert len(ext_responses) == 2
209
+
210
+
211
+ class TestPartialResponsesTile:
212
+ """partial_responses should truncate multi-packet tile responses."""
213
+
214
+ def test_tile_partial(self):
215
+ """5 tiles = 5 State64 packets; partial should return 1..4."""
216
+ device = create_tile_device("d073d5400001", tile_count=5)
217
+ scenario_manager = HierarchicalScenarioManager()
218
+ scenario_manager.set_device_scenario(
219
+ "d073d5400001", ScenarioConfig(partial_responses=[711])
220
+ )
221
+ device.scenario_manager = scenario_manager
222
+ device.invalidate_scenario_cache()
223
+
224
+ packet = Tile.Get64(
225
+ tile_index=0,
226
+ length=5,
227
+ rect=TileBufferRect(fb_index=0, x=0, y=0, width=8),
228
+ )
229
+ header = _make_header(device, 707)
230
+ responses = device.process_packet(header, packet)
231
+
232
+ tile_responses = _responses_of_type(responses, 711)
233
+ assert 1 <= len(tile_responses) < 5
234
+
235
+
236
+ class TestPartialResponsesDeterministic:
237
+ """Verify partial truncation with mocked randomness."""
238
+
239
+ def test_deterministic_truncation(self):
240
+ """With randint mocked to return 3, should get exactly 3 packets."""
241
+ device = create_multizone_light("d073d5500001", zone_count=120)
242
+ scenario_manager = HierarchicalScenarioManager()
243
+ scenario_manager.set_device_scenario(
244
+ "d073d5500001", ScenarioConfig(partial_responses=[506])
245
+ )
246
+ device.scenario_manager = scenario_manager
247
+ device.invalidate_scenario_cache()
248
+
249
+ packet = MultiZone.GetColorZones(start_index=0, end_index=119)
250
+ header = _make_header(device, 502)
251
+
252
+ with patch("lifx_emulator.devices.device.random.randint", return_value=3):
253
+ responses = device.process_packet(header, packet)
254
+
255
+ mz_responses = _responses_of_type(responses, 506)
256
+ assert len(mz_responses) == 3
257
+
258
+ def test_partial_coexists_with_other_scenarios(self):
259
+ """partial_responses should work alongside response_delays."""
260
+ device = create_multizone_light("d073d5500002", zone_count=120)
261
+ scenario_manager = HierarchicalScenarioManager()
262
+ scenario_manager.set_device_scenario(
263
+ "d073d5500002",
264
+ ScenarioConfig(
265
+ partial_responses=[506],
266
+ response_delays={506: 0.01},
267
+ ),
268
+ )
269
+ device.scenario_manager = scenario_manager
270
+ device.invalidate_scenario_cache()
271
+
272
+ packet = MultiZone.GetColorZones(start_index=0, end_index=119)
273
+ header = _make_header(device, 502)
274
+
275
+ with patch("lifx_emulator.devices.device.random.randint", return_value=5):
276
+ responses = device.process_packet(header, packet)
277
+
278
+ mz_responses = _responses_of_type(responses, 506)
279
+ assert len(mz_responses) == 5
280
+
281
+ def test_randomness_varies(self):
282
+ """Without mocking, repeated runs should not all return the same count."""
283
+ device = create_multizone_light("d073d5500003", zone_count=120)
284
+ scenario_manager = HierarchicalScenarioManager()
285
+ scenario_manager.set_device_scenario(
286
+ "d073d5500003", ScenarioConfig(partial_responses=[506])
287
+ )
288
+ device.scenario_manager = scenario_manager
289
+ device.invalidate_scenario_cache()
290
+
291
+ packet = MultiZone.GetColorZones(start_index=0, end_index=119)
292
+ header = _make_header(device, 502)
293
+
294
+ counts = set()
295
+ for _ in range(20):
296
+ responses = device.process_packet(header, packet)
297
+ mz_responses = _responses_of_type(responses, 506)
298
+ counts.add(len(mz_responses))
299
+
300
+ # With 14 possible values (1..14), 20 runs should produce >1 unique count
301
+ assert len(counts) > 1
@@ -1,5 +1,7 @@
1
1
  """Tests for hierarchical scenario manager."""
2
2
 
3
+ from unittest.mock import patch
4
+
3
5
  from lifx_emulator.factories import (
4
6
  create_color_light,
5
7
  create_multizone_light,
@@ -293,29 +295,40 @@ class TestScenarioManagerMethods:
293
295
  assert manager.should_respond(102, scenario) is False
294
296
  assert manager.should_respond(103, scenario) is True
295
297
 
296
- def test_should_respond_probabilistic(self):
297
- """Test probabilistic packet dropping."""
298
+ def test_should_respond_drops_below_threshold(self):
299
+ """Test that a random roll below the drop rate causes a drop."""
298
300
  manager = HierarchicalScenarioManager()
299
301
  scenario = ScenarioConfig(drop_packets={101: 0.5})
300
302
 
301
- # With drop_rate=0.5, approximately 50% should be dropped
302
- responses = [manager.should_respond(101, scenario) for _ in range(100)]
303
- dropped = responses.count(False)
303
+ # random() returns 0.3, which is < 0.5 drop_rate → should drop
304
+ with patch("lifx_emulator.scenarios.manager.random.random", return_value=0.3):
305
+ assert manager.should_respond(101, scenario) is False
304
306
 
305
- # Should be roughly 50%, allowing 30-70% range for randomness
306
- assert 30 <= dropped <= 70
307
+ def test_should_respond_keeps_at_or_above_threshold(self):
308
+ """Test that a random roll at or above the drop rate keeps the packet."""
309
+ manager = HierarchicalScenarioManager()
310
+ scenario = ScenarioConfig(drop_packets={101: 0.5})
307
311
 
308
- def test_should_respond_never_drop(self):
309
- """Test with very low drop rate."""
312
+ # random() returns 0.5, which is >= 0.5 drop_rate → should keep
313
+ with patch("lifx_emulator.scenarios.manager.random.random", return_value=0.5):
314
+ assert manager.should_respond(101, scenario) is True
315
+
316
+ # random() returns 0.9, which is >= 0.5 drop_rate → should keep
317
+ with patch("lifx_emulator.scenarios.manager.random.random", return_value=0.9):
318
+ assert manager.should_respond(101, scenario) is True
319
+
320
+ def test_should_respond_boundary_with_low_drop_rate(self):
321
+ """Test boundary behavior with a low drop rate."""
310
322
  manager = HierarchicalScenarioManager()
311
323
  scenario = ScenarioConfig(drop_packets={101: 0.1})
312
324
 
313
- # With drop_rate=0.1, approximately 10% should be dropped
314
- responses = [manager.should_respond(101, scenario) for _ in range(100)]
315
- dropped = responses.count(False)
325
+ # random() returns 0.09, which is < 0.1 → drop
326
+ with patch("lifx_emulator.scenarios.manager.random.random", return_value=0.09):
327
+ assert manager.should_respond(101, scenario) is False
316
328
 
317
- # Should be roughly 10%, allowing 0-20% range for randomness
318
- assert 0 <= dropped <= 20
329
+ # random() returns 0.1, which is >= 0.1 keep
330
+ with patch("lifx_emulator.scenarios.manager.random.random", return_value=0.1):
331
+ assert manager.should_respond(101, scenario) is True
319
332
 
320
333
  def test_get_response_delay(self):
321
334
  """Test response delay retrieval."""
@@ -298,8 +298,8 @@ class TestResponseDelays:
298
298
  await server.handle_packet(packet_data, addr)
299
299
  elapsed = time.time() - start_time
300
300
 
301
- # Should be very fast (< 10ms)
302
- assert elapsed < 0.01
301
+ # Should be very fast (< 100ms, generous for CI runners)
302
+ assert elapsed < 0.1
303
303
 
304
304
 
305
305
  class TestServerLifecycle: