@@ -524,6 +524,94 @@ def test_component_base_midnight_utc_ignores_rewound_base(my_predbat):
524524 return False
525525
526526
527+ def test_component_base_minutes_now_follows_update_time (my_predbat ):
528+ """
529+ Test ComponentBase.minutes_now matches update_time()'s own value and ignores the faked one (GH#4804).
530+
531+ calculate_yesterday() (output.py) fakes base.minutes_now to 0 alongside its midnight_utc rewind.
532+ 0 is the nastier of the two, since it reads as a legitimate "just after midnight" rather than an
533+ obviously wrong date - it would tell octopus.py to keep every expired dispatch slot, and the
534+ AlphaESS/Deye/Sunsynk adapters that pick the live TOU slot by time of day to believe it is
535+ midnight while writing to a real inverter.
536+
537+ The derived value has to agree with update_time()'s (predbat.py), including its PREDICT_STEP
538+ flooring - so the first half of this test compares the two on the real base rather than
539+ restating the formula, and would catch the two drifting apart later.
540+ """
541+ print ("\n *** Test: ComponentBase.minutes_now follows update_time and ignores the faked value ***" )
542+
543+ # The fixture pins its own clock (unit_test.py's create_predbat) and the whole suite shares this
544+ # instance, so every field update_time() writes has to go back - not just the ones read here, or
545+ # a later test inherits this one's wall-clock state.
546+ saved = {name : getattr (my_predbat , name ) for name in ("now_utc" , "now_utc_real" , "midnight_utc" , "minutes_now" , "minutes_to_midnight" , "difference_minutes" , "local_tz" )}
547+ try :
548+ my_predbat .update_time (print = False )
549+ component = TestComponent (my_predbat )
550+ assert component .minutes_now == my_predbat .minutes_now , f"Derived { component .minutes_now } but update_time() computed { my_predbat .minutes_now } "
551+ assert component .minutes_now % 5 == 0 , f"Should stay on a PREDICT_STEP boundary, got { component .minutes_now } "
552+ finally :
553+ for name , value in saved .items ():
554+ setattr (my_predbat , name , value )
555+
556+ base = MockBase ()
557+ base .now_utc = datetime (2025 , 6 , 15 , 14 , 37 , 0 , tzinfo = timezone .utc )
558+ base .midnight_utc = base .now_utc .replace (hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
559+ base .minutes_now = 14 * 60 + 35
560+ component = TestComponent (base )
561+
562+ assert component .minutes_now == 14 * 60 + 35 , f"Expected 14:35 floored to a PREDICT_STEP boundary, got { component .minutes_now } "
563+
564+ # A concurrent calculate_yesterday() is mid-flight: the shared fields say midnight yesterday.
565+ base .minutes_now = 0
566+ base .midnight_utc = base .midnight_utc - timedelta (days = 1 )
567+ assert component .minutes_now == 14 * 60 + 35 , f"Faked base.minutes_now leaked into the component: { component .minutes_now } "
568+
569+ print ("PASS: minutes_now follows update_time's computation and ignores the faked value" )
570+ return False
571+
572+
573+ def test_component_base_minutes_now_snapshots_the_clock (my_predbat ):
574+ """
575+ Test minutes_now survives update_time() landing mid-read.
576+
577+ The value needs now_utc and the midnight derived from it, and self.midnight_utc goes back to
578+ base.now_utc for its own read - so a naive implementation reads the field twice. update_time()
579+ runs on the main thread while components run on theirs, so those two reads can straddle it: at
580+ a day boundary that subtracts the new day's midnight from the old day's timestamp and collapses
581+ 23:59 to 0, which reads as midnight rather than as an error.
582+
583+ A base whose now_utc advances a day between reads reproduces that deterministically, without
584+ needing two threads to interleave.
585+ """
586+ print ("\n *** Test: ComponentBase.minutes_now snapshots now_utc ***" )
587+
588+ class RollingClockBase (MockBase ):
589+ """A base whose clock rolls into the next day between one read of now_utc and the next."""
590+
591+ def __init__ (self ):
592+ """Start the day before, with a read counter."""
593+ super ().__init__ ()
594+ self .reads = 0
595+
596+ @property
597+ def now_utc (self ):
598+ """Return 23:59 on the first read and the next day's noon on every read after it."""
599+ self .reads += 1
600+ if self .reads == 1 :
601+ return datetime (2025 , 6 , 15 , 23 , 59 , 0 , tzinfo = timezone .utc )
602+ return datetime (2025 , 6 , 16 , 12 , 0 , 0 , tzinfo = timezone .utc )
603+
604+ base = RollingClockBase ()
605+ component = TestComponent (base )
606+
607+ minutes_now = component .minutes_now
608+ assert base .reads == 1 , f"now_utc should be read once per call, was read { base .reads } times"
609+ assert minutes_now == 23 * 60 + 55 , f"Expected the first read's 23:59 floored to 23:55, got { minutes_now } "
610+
611+ print ("PASS: minutes_now reads the clock once and stays on that reading" )
612+ return False
613+
614+
527615def test_component_base_all (my_predbat ):
528616 """Run all component_base tests"""
529617 tests = [
@@ -539,6 +627,8 @@ def test_component_base_all(my_predbat):
539627 ("set_arg_auto_keep" , test_component_base_set_arg_auto_keeps_user_setting , "set_arg_auto(overwrite=False) keeps an explicit apps.yaml setting" ),
540628 ("set_state_external" , test_component_base_set_state_external , "set_state_external forwards to the HA interface" ),
541629 ("midnight_utc_rewound" , test_component_base_midnight_utc_ignores_rewound_base , "midnight_utc ignores a rewound base.midnight_utc" ),
630+ ("minutes_now_derived" , test_component_base_minutes_now_follows_update_time , "minutes_now follows update_time and ignores the faked value" ),
631+ ("minutes_now_snapshot" , test_component_base_minutes_now_snapshots_the_clock , "minutes_now snapshots now_utc rather than reading it twice" ),
542632 ]
543633
544634 failed = []
0 commit comments