@@ -301,6 +301,58 @@ async def run_test():
301301 return asyncio .run (run_test ())
302302
303303
304+ def test_component_base_first_cleared_when_run_presets_api_started (my_predbat ):
305+ """Regression: a component that sets api_started itself must still leave the startup path.
306+
307+ The gateway's MQTT background loop sets self.api_started = True before run(first=True)
308+ returns. If start() only clears the `first` flag inside `if not self.api_started`, the
309+ flag stays True forever and start() keeps re-running the first=True startup path on
310+ backoff, never reaching the steady-state (first=False) housekeeping that publishes the
311+ plan. This verifies start() transitions to first=False regardless of who set api_started.
312+ """
313+ print ("\n *** Test: ComponentBase clears first when run() pre-sets api_started ***" )
314+
315+ class PresetComponent (ComponentBase ):
316+ def __init__ (self , base ):
317+ self .first_flags = []
318+ super ().__init__ (base )
319+
320+ def initialize (self , ** kwargs ):
321+ pass
322+
323+ async def run (self , seconds , first ):
324+ self .first_flags .append (first )
325+ # Mimic a background task marking the component started before run() returns.
326+ self .api_started = True
327+ return True
328+
329+ async def run_test ():
330+ with patch ("asyncio.sleep" , side_effect = fast_sleep ):
331+ base = MockBase ()
332+ component = PresetComponent (base )
333+
334+ task = asyncio .create_task (component .start ())
335+
336+ # Wait long enough (sped up 100x by fast_sleep → ~2s real) for the component
337+ # loop to advance past simulated seconds=60 so a steady-state run can occur.
338+ await asyncio .sleep (200 )
339+
340+ assert component .api_started , "Component should be started"
341+
342+ await component .stop ()
343+ await task
344+
345+ assert component .first_flags , "run() should have been called"
346+ assert component .first_flags [0 ] is True , "First run should be first=True"
347+ assert any (f is False for f in component .first_flags ), "Component must reach steady-state housekeeping (first=False); got first flags: {}" .format (component .first_flags )
348+ assert component .first_flags .count (True ) == 1 , "Startup run() should happen exactly once, got {}" .format (component .first_flags )
349+
350+ print (f"PASS: first cleared despite self-set api_started (flags={ component .first_flags } )" )
351+ return False # False = test passed
352+
353+ return asyncio .run (run_test ())
354+
355+
304356def test_component_base_all (my_predbat ):
305357 """Run all component_base tests"""
306358 tests = [
@@ -310,6 +362,7 @@ def test_component_base_all(my_predbat):
310362 ("normal_operation" , test_component_base_normal_operation_after_start , "Component runs every 60s after start" ),
311363 ("exception_handling" , test_component_base_exception_handling , "Component handles exceptions with backoff" ),
312364 ("run_timeout" , test_component_base_run_timeout , "Hung run() triggers timeout, stack trace, and error count" ),
365+ ("first_cleared_preset" , test_component_base_first_cleared_when_run_presets_api_started , "first flag clears even when run() pre-sets api_started" ),
313366 ]
314367
315368 failed = []
0 commit comments