
    <-ib                       d Z ddlmZ ddlZddlmc mZ ddl	Z	ddl
Z
ddlZddlZddlZddlmZmZmZ ddlmZ ddlmZ ddlmZmZmZ ddlZdZeej6                  vrej6                  j9                  de       dd	lmZmZm Z m!Z!m"Z"m#Z# d
Z$d_dZ%d Z&d`dZ'd_dadZ(dbdZ) G d d      Z* G d d      Z+ G d d      Z, G d d      Z- G d d      Z. G d d      Z/ G d d      Z0 G d d      Z1d  Z2d! Z3d" Z4d# Z5d$ Z6e7d%k(  rddl8Z8dcd&Z9g d' e*       jt                  fd( e*       jv                  fd) e*       jx                  fd* e*       jz                  fd+ e+       j|                  fd,d- fd.d/ fd0d1 fd2d3 fd4d5 fd6d7 fd8 e-       j~                  fd9 e.       j                  fd: e.       j                  fd; e.       j                  fd<d= fd> e/       j                  fd?d@ fdA e0       j                  fdBdC fdDdE fdFdG fdHdI fdJdK fdLdM fdNdO fdPe2fdQe3fdRe4fdSdT fdUdV fZEddlZdZF eGeE      ZHeED ]  \  ZIZJ	  eJ         eKdWeI        eFdXz  ZF  eKd[eF d\eH d]       eFeHk(  r	 eKd^       y ej                  dX       yy# eL$ r)ZM eKdYeI dZeM         e8j                          Y dZM[MzdZM[Mww xY w)du3  
tests/track_b/test_story_9_01.py

Story 9.01: EpochScheduler — APScheduler Cron Trigger

Black Box Tests (BB):
    BB1  force_trigger() calls run_epoch_safe() immediately
    BB2  get_next_run() returns a future Sunday datetime (or mock equivalent)
    BB3  Start + stop + restart cycle — no duplicate jobs, no exceptions
    BB4  stop() before start() → no crash (safe no-op)

White Box Tests (WB):
    WB1  Cron is configured hour=16 (UTC), not hour=2 (AEST)
    WB2  run_epoch_safe (lock version) is called, not run_epoch directly
    WB3  Job ID is 'nightly_epoch'
    WB4  events.jsonl entries written on start and stop

ALL external I/O and APScheduler are mocked — zero live scheduler, zero live I/O.

Patching strategy:
    Because AsyncIOScheduler is a lazy import inside EpochScheduler.start(),
    we patch at its original location:
        apscheduler.schedulers.asyncio.AsyncIOScheduler
    rather than at the module level.

    EVENTS_LOG_PATH redirection is done via the constructor parameter
    ``events_log_path`` — no module-level patch needed.
    )annotationsN)datetimetimezone	timedelta)Path)Optional)	AsyncMock	MagicMockpatchz/mnt/e/genesis-system)EpochSchedulerEVENTS_LOG_PATH_JOB_ID_CRON_DAY_OF_WEEK
_CRON_HOUR_CRON_MINUTEz/apscheduler.schedulers.asyncio.AsyncIOSchedulerc                <    t               }t        |       |_        |S )z6Build a mock EpochRunner with an async run_epoch_safe.return_value)r
   r	   run_epoch_safe)epoch_resultrunners     6/mnt/e/genesis-system/tests/track_b/test_story_9_01.py_make_runnerr   H   s    [F%<@FM    c                H    t        j                         j                  |       S )z.Run an async coroutine synchronously in tests.)asyncioget_event_looprun_until_complete)coros    r   _runr    O   s    !!#66t<<r   c                     t        j                  t        j                        } d| j	                         z
  dz  }|dk(  r| j
                  dk\  rd}| t        |      z   }|j                  dddd      S )z.Compute the next Sunday at 16:00 UTC from now.      r      )days)hourminutesecondmicrosecond)r   nowr   utcweekdayr&   r   replace)r*   days_until_sundaynext_suns      r   _next_sunday_utcr0   T   sl    
,,x||
$CS[[]*a/A#((b.Y$566HAaQGGr   c                    t               }| xs
 t               |_        t               }||j                  _        d|j
                  _        d|j                  _        ||j                  _        ||fS )z
    Return a (mock_instance, mock_job) pair that stands in for
    an APScheduler AsyncIOScheduler instance.

    ``mock_instance.get_job()`` returns a mock job whose
    ``next_run_time`` equals *next_run_time*.
    N)r
   r0   next_run_timeget_jobr   startshutdownadd_job)r2   mock_jobmock_schs      r   _make_mock_schedulerr9   ^   sd     {H*@.>.@H{H$,H!"&HNN%)H"$,H!Xr   c                    | 
t               } |rt        |dz        nd}t        | |      }t        |      \  }}t	        |      }||||fS )zv
    Build an EpochScheduler with a mocked APScheduler.

    Returns (EpochScheduler, mock_apscheduler_instance).
    events.jsonlz/tmp/test_events.jsonlevents_log_pathr2   r   )r   strr   r9   r
   )r   tmp_pathr2   events_path	schedulerr8   r7   mock_clss           r   _make_schedulerrD   r   sX     ~4<#h/0BZKv{CI-MJHhh/Hh(22r   c                  (    e Zd ZdZd Zd Zd Zd Zy)TestBB1_ForceTriggerz8BB1: force_trigger() calls run_epoch_safe() immediately.c                    t               }t        |      }t        |j                                |j                  j                          y Nr   r   r    force_triggerr   assert_called_onceselfr   rB   s      r   'test_force_trigger_calls_run_epoch_safez<TestBB1_ForceTrigger.test_force_trigger_calls_run_epoch_safe   s6    "6*	Y$$&'002r   c                    t               }t        |      }t        |j                                |j                  j                          y)z=force_trigger() does not require start() to have been called.NrI   rL   s      r   &test_force_trigger_works_without_startz;TestBB1_ForceTrigger.test_force_trigger_works_without_start   s8    "6*	 	Y$$&'002r   c                   t               }t        |      }t        j                  }|j                  } ||      }|s t        j                  d      dz   dt        j                         v st        j                  t              rt        j                  t              ndt        j                  |      dt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      t        j                  |      dz  }t        t        j                  |            dx}x}}y)z5force_trigger() is declared async and can be awaited.z%force_trigger must be an async methodzz
>assert %(py7)s
{%(py7)s = %(py2)s
{%(py2)s = %(py0)s.iscoroutinefunction
}(%(py5)s
{%(py5)s = %(py3)s.force_trigger
})
}inspectrB   )py0py2py3py5py7N)r   r   rR   iscoroutinefunctionrJ   
@pytest_ar_format_assertmsg@py_builtinslocals_should_repr_global_name	_safereprAssertionError_format_explanation)rM   r   rB   @py_assert1@py_assert4@py_assert6@py_format8s          r   (test_force_trigger_is_coroutine_functionz=TestBB1_ForceTrigger.test_force_trigger_is_coroutine_function   s   "6*	** 	
9+B+B 	
*+BC 	
C 	
  4	
 	
	6	
 	
   	
 	
 		  	
 	
 		 + 	
 	
	6	
 	
  ,5 	
 	
 		 ,5 	
 	
 		 ,C 	
 	
 		 D 	
 	
 	
 	
 	
 	
r   c                   t               }t        |      }t        |j                                t        |j                                t        |j                                |j                  }|j
                  }d}||k(  }|st        j                  d|fd||f      dt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      t        j                  |      t        j                  |      dz  }t        j                  d|j                  j
                         dz   d|iz  }t        t        j                  |            d x}x}x}}y )	N   ==)zV%(py4)s
{%(py4)s = %(py2)s
{%(py2)s = %(py0)s.run_epoch_safe
}.call_count
} == %(py7)sr   )rS   rT   py4rW   z(Expected 3 calls to run_epoch_safe, got z
>assert %(py9)spy9)r   r   r    rJ   r   
call_countrY   _call_reprcomparer[   r\   r]   r^   rZ   r_   r`   )	rM   r   rB   ra   @py_assert3rc   @py_assert5rd   @py_format10s	            r   @test_force_trigger_multiple_times_calls_run_epoch_safe_each_timezUTestBB1_ForceTrigger.test_force_trigger_multiple_times_calls_run_epoch_safe_each_time   s?   "6*	Y$$&'Y$$&'Y$$&'$$ 	
$// 	
1 	
/14 	
 	
/1 	
 	
	6	
 	
   	
 	
 		  	
 	
 		 % 	
 	
 		 0 	
 	
 		 45 	
 	
  7v7L7L7W7W6XY	
 	
 	
 	
 	
 	
r   N)__name__
__module____qualname____doc__rN   rP   re   rq    r   r   rF   rF      s    B33


r   rF   c                  (    e Zd ZdZd Zd Zd Zd Zy)TestBB2_GetNextRunzHBB2: get_next_run() returns a future Sunday datetime or mock equivalent.c                   t               }t        |      }|j                         }d }||u }|st        j                  d|fd||f      dt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      dz  }t        j                  d|      dz   d|iz  }t        t        j                  |            d x}}y )Nisz%(py0)s is %(py3)sresultrS   rU   z7get_next_run() should return None before start(), got: 
>assert %(py5)srV   )r   r   get_next_runrY   rm   r[   r\   r]   r^   rZ   r_   r`   )rM   r   rB   r}   @py_assert2ra   @py_format4@py_format6s           r   +test_get_next_run_returns_none_before_startz>TestBB2_GetNextRun.test_get_next_run_returns_none_before_start   s    "6*	'') 	
v~ 	
 	
v 	
 	
	6	
 	
   	
 	
 		  	
 	
 		  	
 	
  FfZP	
 	
 	
 	
 	
r   c                   t        |      \  }}}}|j                  }t        t        |      5  |j	                          d d d        |j                         }d }||u}	|	st        j                  d|	fd||f      dt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      dz  }
t        j                  d      dz   d|
iz  }t        t        j                  |            d x}	}t        |t               }|s-t        j                  d	t#        |             d
z   dt        j                         v st        j                  t              rt        j                  t              nddt        j                         v st        j                  |      rt        j                  |      nddt        j                         v st        j                  t               rt        j                  t               ndt        j                  |      dz  }t        t        j                  |            d }y # 1 sw Y   xY w)Nr@   is not)z%(py0)s is not %(py3)sr}   r~   z5get_next_run() should return a datetime after start()r   rV   z-get_next_run() should return a datetime, got 7
>assert %(py4)s
{%(py4)s = %(py0)s(%(py1)s, %(py2)s)
}
isinstancer   rS   py1rT   rj   )rD   r2   r   _APSCHEDULER_PATCHr4   r   rY   rm   r[   r\   r]   r^   rZ   r_   r`   r   r   type)rM   r@   rB   rC   r8   r7   expected_nextr}   r   ra   r   r   rn   @py_format5s                 r   .test_get_next_run_returns_datetime_after_startzATestBB2_GetNextRun.test_get_next_run_returns_datetime_after_start   s   2A82T/	8Xx ..%x0 	OO	 '')!ZvT!ZZZvTZZZZZZvZZZvZZZTZZZ#ZZZZZZZ&(+ 	
+ 	
  <DL>J	
 	
	6	
 	
   	
 	
 		  	
 	
	6	
 	
  ! 	
 	
 		 ! 	
 	
	6	
 	
  #+ 	
 	
 		 #+ 	
 	
 		 , 	
 	
 	
 	
 	
	 	s   IIc                   t               }t        ||      \  }}}}t        t        |      5  |j	                          d d d        |j                         }||k(  }|st        j                  d|fd||f      dt        j                         v st        j                  |      rt        j                  |      nddt        j                         v st        j                  |      rt        j                  |      nddz  }	t        j                  d|d|      d	z   d
|	iz  }
t        t        j                  |
            d }y # 1 sw Y   xY w)N)r@   r2   rh   )z%(py0)s == %(py2)sr}   r   rS   rT   z9get_next_run() should return job.next_run_time; expected=z, got=
>assert %(py4)srj   )r0   rD   r   r   r4   r   rY   rm   r[   r\   r]   r^   rZ   r_   r`   )rM   r@   r   rB   rC   r8   r7   r}   ra   @py_format3r   s              r   (test_get_next_run_returns_value_from_jobz;TestBB2_GetNextRun.test_get_next_run_returns_value_from_job   s-   (*2A]3
/	8Xx %x0 	OO	 '')& 	
 	
v 	
 	
 
6	
 	
   	
 	
 
	  	
 	
 
6	
 	
  ' 	
 	
 
	 ' 	
 	
 %(vj:	
 	
 	
 	
 	
	 	s   EEc                   t               }t        |dz        }t        ||      }t               }d |j                  _        t        |      }t        t        |      5  |j                          d d d        |j                         }d }||u }	|	st        j                  d|	fd||f      dt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      dz  }
t        j                   d|      d	z   d
|
iz  }t#        t        j$                  |            d x}	}y # 1 sw Y   xY w)Nr;   r<   r   rz   r|   r}   r~   zBget_next_run() should return None when the job is not found, got: r   rV   )r   r?   r   r
   r3   r   r   r   r4   r   rY   rm   r[   r\   r]   r^   rZ   r_   r`   )rM   r@   r   rA   rB   r8   rC   r}   r   ra   r   r   s               r   /test_get_next_run_returns_none_when_job_missingzBTestBB2_GetNextRun.test_get_next_run_returns_none_when_job_missing   s!   (^34"6;G	;(,%(3%x0 	OO	 '') 	
v~ 	
 	
v 	
 	
	6	
 	
   	
 	
 		  	
 	
 		  	
 	
  QQWPZ[	
 	
 	
 	
 	
	 	s   EEN)rr   rs   rt   ru   r   r   r   r   rv   r   r   rx   rx      s    R


 
r   rx   c                  "    e Zd ZdZd Zd Zd Zy)TestBB3_StartStopRestartuG   BB3: Start + stop + restart cycle — no duplicate jobs, no exceptions.c                    t        |      \  }}}}t        t        |      5  |j                          d d d        |j                  j	                          y # 1 sw Y   $xY w)Nr   )rD   r   r   r4   rK   rM   r@   rB   rC   r8   _s         r   test_start_does_not_raisez2TestBB3_StartStopRestart.test_start_does_not_raise   sQ    +:H+M(	8Xq%x0 	OO	 	))+	 	s   AAc                    t        |      \  }}}}t        t        |      5  |j                          |j	                          d d d        |j
                  j                  d       y # 1 sw Y   &xY w)Nr   F)wait)rD   r   r   r4   stopr5   assert_called_once_withr   s         r   test_stop_does_not_raisez1TestBB3_StartStopRestart.test_stop_does_not_raise   sb    +:H+M(	8Xq%x0 	OONN	 	11u1=		 	s   !A((A1c           	     V   t               }t        |dz        }t        ||      }t               }t        t	                     |j
                  _        t        |      }t        t        |      5  |j                          |j                          ddd       t               }t        t	                     |j
                  _        t        |      }t        t        |      5  |j                          |j                          ddd       d|fd|ffD ]z  \  }	}
|
j                  j                  }t        |      }d}||k(  }|st        j                  d	|fd
||f      dt!        j"                         v st        j$                  t              rt        j&                  t              nddt!        j"                         v st        j$                  |      rt        j&                  |      ndt        j&                  |      t        j&                  |      dz  }t        j(                  d|	 dt        |             dz   d|iz  }t+        t        j,                  |            dx}x}}|d   j.                  }|j0                  }d} ||      }d}||u }|st        j                  d|fd||f      dt!        j"                         v st        j$                  |      rt        j&                  |      ndt        j&                  |      t        j&                  |      t        j&                  |      t        j&                  |      dz  }t        j(                  d|	 d|       dz   d|iz  }t+        t        j,                  |            dx}x}x}x}}} y# 1 sw Y   xY w# 1 sw Y   xY w)z:Each start() must call add_job with replace_existing=True.r;   r<   r>   r   Nfirstr(      rh   z0%(py3)s
{%(py3)s = %(py0)s(%(py1)s)
} == %(py6)slencallsrS   r   rU   py6z#Expected exactly 1 add_job call on z start, got z
>assert %(py8)spy8r   replace_existingTrz   )zI%(py6)s
{%(py6)s = %(py2)s
{%(py2)s = %(py0)s.get
}(%(py4)s)
} is %(py9)skwrS   rT   rj   r   rk   zadd_job on z0 start must have replace_existing=True; kwargs: 
>assert %(py11)spy11)r   r?   r   r
   r0   r3   r   r   r   r4   r   r6   call_args_listr   rY   rm   r[   r\   r]   r^   rZ   r_   r`   kwargsget)rM   r@   r   rA   rB   	mock_sch1	mock_cls1	mock_sch2	mock_cls2labelr8   r   r   ro   rb   @py_format7@py_format9r   ra   rn   @py_assert8@py_assert7rp   @py_format12s                           r   'test_restart_uses_replace_existing_truez@TestBB3_StartStopRestart.test_restart_uses_replace_existing_true  s7   (^34"6;G	 K	)2AQAS)T	&95	%y1 	OONN	
 K	)2AQAS)T	&95	%y1 	OONN	
 ")) 4x6KL 	OE8$$33Eu:  :?   :  v     I   v     I   I   I "#    6eWLUU     qB66 , 6,-  -5  -  v     I   I   I -  I .  I 26    eW$TUWTXY     		 		 	s   /!N!!NNN(N)rr   rs   rt   ru   r   r   r   rv   r   r   r   r      s    Q,>!r   r   c                      e Zd ZdZd Zy)TestBB4_StopBeforeStartu5   BB4: stop() before start() → no crash (safe no-op).c                N    t               }t        |      }|j                          y rH   )r   r   r   rL   s      r   test_stop_before_start_is_no_opz7TestBB4_StopBeforeStart.test_stop_before_start_is_no_op-  s    "6*	 	r   N)rr   rs   rt   ru   r   rv   r   r   r   r   *  s
    ?r   r   c                  (    e Zd ZdZd Zd Zd Zd Zy)TestWB1_CronIsUTCHour16z+WB1: Cron hour=16 (UTC), not hour=2 (AEST).c                   d}t         |k(  }|st        j                  d|fdt         |f      dt        j                         v st        j
                  t               rt        j                  t               ndt        j                  |      dz  }t        j                  dt          d      dz   d	|iz  }t        t        j                  |            d x}}y )
Nr$   rh   z%(py0)s == %(py3)sr   r~   z!_CRON_HOUR must be 16 (UTC), got z. UTC 16:00 = AEST 02:00 Mon.r   rV   )
r   rY   rm   r[   r\   r]   r^   rZ   r_   r`   rM   r   ra   r   r   s        r   test_cron_hour_constant_is_16z5TestWB1_CronIsUTCHour16.test_cron_hour_constant_is_16=  s     	
zR 	
 	
zR 	
 	
 
6	
 	
   	
 	
 
	  	
 	
 
	   	
 	
  0
| <* *	
 	
 	
 	
 	
r   c                   d}t         |k(  }|st        j                  d|fdt         |f      dt        j                         v st        j
                  t               rt        j                  t               ndt        j                  |      dz  }t        j                  dt                dz   d|iz  }t        t        j                  |            d x}}y )	Nr   rh   r   r   r~   z_CRON_MINUTE must be 0, got r   rV   )
r   rY   rm   r[   r\   r]   r^   rZ   r_   r`   r   s        r   test_cron_minute_constant_is_0z6TestWB1_CronIsUTCHour16.test_cron_minute_constant_is_0C  sk     O|q OOO|qOOOOOO|OOO|OOOqOOO$@"OOOOOOOr   c                   d}t         |k(  }|st        j                  d|fdt         |f      dt        j                         v st        j
                  t               rt        j                  t               ndt        j                  |      dz  }t        j                  dt               dz   d|iz  }t        t        j                  |            d x}}y )	Nsunrh   r   r   r~   z%_CRON_DAY_OF_WEEK must be 'sun', got r   rV   )
r   rY   rm   r[   r\   r]   r^   rZ   r_   r`   r   s        r   test_cron_day_of_week_is_sundayz7TestWB1_CronIsUTCHour16.test_cron_day_of_week_is_sundayF  s    $) 	
 E) 	
 	
 E 	
 	
	6	
 	
  ! 	
 	
 		 ! 	
 	
 		 %* 	
 	
  44E3HI	
 	
 	
 	
 	
r   c                	   t        |      \  }}}}t        t        |      5  |j                          d d d        |j                  j
                  }t        |      }d}||k(  }	|	st        j                  d|	fd||f      dt        j                         v st        j                  t              rt        j                  t              nddt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      t        j                  |      dz  }
dd	|
iz  }t        t        j                  |            d x}x}	}|d
   j                  }|j                   }d} ||      }d}||k(  }|st        j                  d|fd||f      dt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      t        j                  |      t        j                  |      t        j                  |      dz  }t        j"                  d|j!                  d            dz   d|iz  }t        t        j                  |            d x}x}x}x}}|j                   }d} ||      }d
}||k(  }|st        j                  d|fd||f      dt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      t        j                  |      t        j                  |      t        j                  |      dz  }t        j"                  d|j!                  d            dz   d|iz  }t        t        j                  |            d x}x}x}x}}|j                   }d} ||      }d}||k(  }|st        j                  d|fd||f      dt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      t        j                  |      t        j                  |      t        j                  |      dz  }t        j"                  d|j!                  d            dz   d|iz  }t        t        j                  |            d x}x}x}x}}y # 1 sw Y   xY w)Nr   r   rh   r   r   r   r   assert %(py8)sr   r   r&   r$   zI%(py6)s
{%(py6)s = %(py2)s
{%(py2)s = %(py0)s.get
}(%(py4)s)
} == %(py9)sr   r   z#add_job hour must be 16 (UTC), got r   r   r'   zadd_job minute must be 0, got day_of_weekr   z'add_job day_of_week must be 'sun', got )rD   r   r   r4   r6   r   r   rY   rm   r[   r\   r]   r^   r_   r`   r   r   rZ   )rM   r@   rB   rC   r8   r   r   r   ro   rb   r   r   r   ra   rn   r   r   rp   r   s                      r   %test_add_job_uses_correct_cron_paramsz=TestWB1_CronIsUTCHour16.test_add_job_uses_correct_cron_paramsK  s   +:H+M(	8Xq%x0 	OO	   //5zQzQzQss55zQ1X__vv 	
f 	
vf~ 	
 	
~# 	
 	
~ 	
 	
	6	
 	
   	
 	
 		  	
 	
 		  	
 	
 		  	
 	
 		  	
 	
 		 "$ 	
 	
  2"&&.1CD	
 	
 	
 	
 	
 	
 vv 	
h 	
vh 	
1 	
1$ 	
 	
1 	
 	
	6	
 	
   	
 	
 		  	
 	
 		  	
 	
 		  	
 	
 		   	
 	
 		 $% 	
 	
  -RVVH-=,@A	
 	
 	
 	
 	
 	
 vv 	
m 	
vm$ 	
 	
$- 	
 	
$ 	
 	
	6	
 	
   	
 	
 		  	
 	
 		  	
 	
 		 $ 	
 	
 		 % 	
 	
 		 ). 	
 	
  6bff]6K5NO	
 	
 	
 	
 	
 	
	 	s   S##S-N)rr   rs   rt   ru   r   r   r   r   rv   r   r   r   r   :  s    5
P


r   r   c                      e Zd ZdZd Zd Zy)TestWB2_RunEpochSafeCalledzEWB2: run_epoch_safe (lock version) is called, not run_epoch directly.c                    t               }t        d       |_        t        |      }t	        |j                                |j                  j                          |j                  j                          y )Nr   )	r   r	   	run_epochr   r    rJ   r   rK   assert_not_calledrL   s      r   5test_force_trigger_calls_run_epoch_safe_not_run_epochzPTestWB2_RunEpochSafeCalled.test_force_trigger_calls_run_epoch_safe_not_run_epochc  sU    $$7"6*	Y$$&'002**,r   c                *   t               }t        ||      \  }}}}t        t        |      5  |j	                          d d d        |j
                  j                  }t        |      }d}	||	k(  }
|
st        j                  d|
fd||	f      dt        j                         v st        j                  t              rt        j                  t              nddt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      t        j                  |	      dz  }dd	|iz  }t        t        j                  |            d x}x}
}	|d
   j                   d
   }|j"                  }||u }|st        j                  d|fd||f      dt        j                         v st        j                  |      rt        j                  |      nddt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      dz  }t        j$                  d|      dz   d|iz  }t        t        j                  |            d x}}y # 1 sw Y   LxY w)N)r   r@   r   rh   r   r   r   r   r   r   r   rz   )z6%(py0)s is %(py4)s
{%(py4)s = %(py2)s.run_epoch_safe
}job_fnr   rS   rT   rj   z8Scheduler job target must be runner.run_epoch_safe, got z
>assert %(py6)sr   )r   rD   r   r   r4   r6   r   r   rY   rm   r[   r\   r]   r^   r_   r`   argsr   rZ   )rM   r@   r   rB   rC   r8   r   r   r   ro   rb   r   r   r   rn   ra   r   s                    r   +test_scheduler_job_target_is_run_epoch_safezFTestWB2_RunEpochSafeCalled.test_scheduler_job_target_is_run_epoch_safen  s   +:&S[+\(	8Xq%x0 	OO	   //5zQzQzQss55zQ qq!.. 	
v.. 	
 	
v. 	
 	
	6	
 	
   	
 	
 		  	
 	
	6	
 	
    	
 	
 		   	
 	
 		 / 	
 	
  GvjQ	
 	
 	
 	
 	
	 	s   JJN)rr   rs   rt   ru   r   r   rv   r   r   r   r   `  s    O	-
r   r   c                  "    e Zd ZdZd Zd Zd Zy)TestWB3_JobIDzWB3: Job ID is 'nightly_epoch'.c                   d}t         |k(  }|st        j                  d|fdt         |f      dt        j                         v st        j
                  t               rt        j                  t               ndt        j                  |      dz  }t        j                  dt               dz   d|iz  }t        t        j                  |            d x}}y )	Nnightly_epochrh   r   r   r~   z%_JOB_ID must be 'nightly_epoch', got r   rV   )
r   rY   rm   r[   r\   r]   r^   rZ   r_   r`   r   s        r   test_job_id_constantz"TestWB3_JobID.test_job_id_constant  s    ) 	
w/) 	
 	
w/ 	
 	
	6	
 	
   	
 	
 		  	
 	
 		 * 	
 	
  4G;?	
 	
 	
 	
 	
r   c                $   t        |      \  }}}}t        t        |      5  |j                          d d d        |j                  j
                  d   j                  }|j                  }d} ||      }	d}
|	|
k(  }|st        j                  d|fd|	|
f      dt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      t        j                  |      t        j                  |	      t        j                  |
      dz  }t        j                  d	|j                  d            d
z   d|iz  }t        t        j                   |            d x}x}x}	x}}
y # 1 sw Y   TxY w)Nr   r   idr   rh   r   r   r   z(add_job id must be 'nightly_epoch', got r   r   )rD   r   r   r4   r6   r   r   r   rY   rm   r[   r\   r]   r^   rZ   r_   r`   )rM   r@   rB   rC   r8   r   r   ra   rn   ro   r   r   rp   r   s                 r   "test_add_job_uses_nightly_epoch_idz0TestWB3_JobID.test_add_job_uses_nightly_epoch_id  sf   +:H+M(	8Xq%x0 	OO	 ,,Q/66vv 	
d 	
vd| 	
 	
|. 	
 	
| 	
 	
	6	
 	
   	
 	
 		  	
 	
 		  	
 	
 		  	
 	
 		  	
 	
 		  / 	
 	
  7rvvd|6FG	
 	
 	
 	
 	
 	
		 	s   FFc                    t        |      \  }}}}t        t        |      5  |j                          d d d        |j	                          |j
                  j                  d       y # 1 sw Y   5xY w)Nr   r   )rD   r   r   r4   r   r3   assert_called_withr   s         r   (test_get_job_queries_by_nightly_epoch_idz6TestWB3_JobID.test_get_job_queries_by_nightly_epoch_id  sa    +:H+M(	8Xq%x0 	OO	 	 ++O<	 	s   A''A0N)rr   rs   rt   ru   r   r   r   rv   r   r   r   r     s    )

	
=r   r   c                  .    e Zd ZdZd Zd Zd Zd Zd Zy)TestWB4_EventsJSONLz4WB4: events.jsonl entries written on start and stop.c                   t        |      \  }}}}t        t        |      5  |j                          d d d        t	        |j
                        }|j                  } |       }|st        j                  d      dz   dt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      t        j                  |      dz  }	t        t        j                  |	            d x}}|j                         j!                         D 
cg c]#  }
|
j#                         s|
j#                         % }}
t%        |      }d}||k\  }|st        j&                  d|fd||f      d	t        j                         v st        j                  t$              rt        j                  t$              nd	d
t        j                         v st        j                  |      rt        j                  |      nd
t        j                  |      t        j                  |      dz  }dd|iz  }t        t        j                  |            d x}x}}|D 
cg c]  }
t)        j*                  |
      d    }}
d}||v }|st        j&                  d|fd||f      t        j                  |      dt        j                         v st        j                  |      rt        j                  |      nddz  }t        j                  d|       dz   d|iz  }t        t        j                  |            d x}}y # 1 sw Y   xY wc c}
w c c}
w )Nr   z)events.jsonl should be created on start()zC
>assert %(py4)s
{%(py4)s = %(py2)s
{%(py2)s = %(py0)s.exists
}()
}rA   r   r   )>=)z0%(py3)s
{%(py3)s = %(py0)s(%(py1)s)
} >= %(py6)sr   linesr   r   r   
event_typeepoch_scheduler_startedinz%(py1)s in %(py3)sevent_typesr   rU   z)'epoch_scheduler_started' not in events: r   rV   )rD   r   r   r4   r   r=   existsrY   rZ   r[   r\   r]   r^   r_   r`   	read_text
splitlinesstripr   rm   jsonloads)rM   r@   rB   rC   r8   r   rA   ra   rn   r   lr   r   ro   rb   r   r   r   @py_assert0r   r   s                        r   )test_start_writes_epoch_scheduler_startedz=TestWB4_EventsJSONL.test_start_writes_epoch_scheduler_started  sH   +:H+M(	8Xq%x0 	OO	 9445!!P!#P#PP%PPPPPPP{PPP{PPP!PPP#PPPPPP$/$9$9$;$F$F$HVqAGGIVV5zQzQzQss55zQ<ABqtzz!}\2BB( 	
(K7 	
 	
(K 	
 	
 		 ) 	
 	
	6	
 	
  -8 	
 	
 		 -8 	
 	
  8}E	
 	
 	
 	
 	
	 	 W Cs   M
,MM*M
Mc                J   t        |      \  }}}}t        t        |      5  |j                          |j	                          d d d        t        |j                        }|j                         j                         D cg c]#  }|j                         s|j                         % }}|D cg c]  }t        j                  |      d    }	}d}
|
|	v }|st        j                  d|fd|
|	f      t        j                  |
      dt        j                          v st        j"                  |	      rt        j                  |	      nddz  }t        j$                  d|	       d	z   d
|iz  }t'        t        j(                  |            d x}
}y # 1 sw Y   MxY wc c}w c c}w )Nr   r   epoch_scheduler_stoppedr   r   r   r   z)'epoch_scheduler_stopped' not in events: r   rV   )rD   r   r   r4   r   r   r=   r   r   r   r   r   rY   rm   r^   r[   r\   r]   rZ   r_   r`   )rM   r@   rB   rC   r8   r   rA   r   r   r   r   r   r   r   s                 r   (test_stop_writes_epoch_scheduler_stoppedz<TestWB4_EventsJSONL.test_stop_writes_epoch_scheduler_stopped  sZ   +:H+M(	8Xq%x0 	OONN	 9445$/$9$9$;$F$F$HVqAGGIVV<ABqtzz!}\2BB( 	
(K7 	
 	
(K 	
 	
 		 ) 	
 	
	6	
 	
  -8 	
 	
 		 -8 	
 	
  8}E	
 	
 	
 	
 	
	 	
 WBs   !F FF.F Fc                   t        |      \  }}}}t        t        |      5  |j                          |j	                          d d d        t        |j                        }|j                         j                         D cg c]#  }|j                         s|j                         % }}|D ]  }	t        j                  |	      }
|
j                  dd      }|s~t        j                  d|
       dz   ddt        j                          v st        j"                  |      rt        j$                  |      ndiz  }t'        t        j(                  |            t+        j,                  |j/                  dd	            }|j0                  }d }||u}|st        j2                  d
|fd||f      dt        j                          v st        j"                  |      rt        j$                  |      ndt        j$                  |      t        j$                  |      dz  }t        j                  d|      dz   d|iz  }t'        t        j(                  |            d x}x}} y # 1 sw Y   xY wc c}w )Nr   	timestamp zEvent entry missing timestamp: z
>assert %(py0)srS   tsZz+00:00r   )z2%(py2)s
{%(py2)s = %(py0)s.tzinfo
} is not %(py5)sdtrS   rT   rV   z(Event timestamp must be timezone-aware: 
>assert %(py7)srW   )rD   r   r   r4   r   r   r=   r   r   r   r   r   r   rY   rZ   r[   r\   r]   r^   r_   r`   r   fromisoformatr-   tzinform   )rM   r@   rB   rC   r8   r   rA   r   r   lineentryr  @py_format1r  ra   rb   rn   r   rd   s                      r   test_events_have_iso_timestampsz3TestWB4_EventsJSONL.test_events_have_iso_timestamps  s   +:H+M(	8Xq%x0 	OONN	 9445$/$9$9$;$F$F$HVqAGGIVV 	\DJJt$E;+B@@8@@@@@@@2@@@2@@@@@''

3(ABB99[D[9D([[[9D[[[[[[2[[[2[[[9[[[D[[[,TUWTZ*[[[[[[[[	\	 	
 Ws   !I  I-I- I*c                   t        |dz        }t               }t        ||      }|j                          t	        |      }|j
                  } |       }| }|s t        j                  d      dz   dt        j                         v st        j                  t              rt        j                  t              nddt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      t        j                  |      t        j                  |      dz  }	t        t        j                  |	            dx}x}x}}y)	z0stop() before start() must not write any events.r;   r<   zGevents.jsonl should NOT be created when stop() is called before start()ze
>assert not %(py7)s
{%(py7)s = %(py5)s
{%(py5)s = %(py3)s
{%(py3)s = %(py0)s(%(py1)s)
}.exists
}()
}r   rA   )rS   r   rU   rV   rW   N)r?   r   r   r   r   r   rY   rZ   r[   r\   r]   r^   r_   r`   )
rM   r@   rA   r   rB   r   rb   rc   r   r   s
             r   *test_no_event_written_on_stop_before_startz>TestWB4_EventsJSONL.test_no_event_written_on_stop_before_start  s;   (^34"6;G	$ 	
$++ 	
+- 	
-- 	
- 	
  V	
 	
	6	
 	
   	
 	
 		  	
 	
	6	
 	
  $ 	
 	
 		 $ 	
 	
 		 % 	
 	
 		 , 	
 	
 		 . 	
 	
 	
 	
 	
 	
r   c                    t        |dz        }t               }t        ||      }t        dt	        d            5  |j                  d       ddd       y# 1 sw Y   yxY w)z3I/O errors writing events.jsonl must not propagate.r;   r<   z&core.epoch.epoch_scheduler.os.makedirszPermission deniedside_effectr   N)r?   r   r   r   OSError
_log_event)rM   r@   rA   r   rB   s        r   .test_oserror_on_event_write_does_not_propagatezBTestWB4_EventsJSONL.test_oserror_on_event_write_does_not_propagate  s[    (^34"6;G	 ;QdIef 	<  !:;	< 	< 	<s   AA N)	rr   rs   rt   ru   r   r  r  r  r  rv   r   r   r   r     s    >
"
\ 

	<r   r   c                 L   d} | t         v }|st        j                  d|fd| t         f      t        j                  |       dt	        j
                         v st        j                  t               rt        j                  t               nddz  }t        j                  dt               dz   d|iz  }t        t        j                  |            d	x} }d
} | t         v }|st        j                  d|fd| t         f      t        j                  |       dt	        j
                         v st        j                  t               rt        j                  t               nddz  }t        j                  dt               dz   d|iz  }t        t        j                  |            d	x} }y	)zFEVENTS_LOG_PATH constant must reference the observability events file.r;   r   r   r   r   4EVENTS_LOG_PATH should contain 'events.jsonl', got: r   rV   Nobservabilityz:EVENTS_LOG_PATH should be under data/observability/, got: )
r   rY   rm   r^   r[   r\   r]   rZ   r_   r`   )r   r   r   r   s       r   $test_events_log_path_module_constantr    sE    >_,  >_          -    -    ?>QR      ?o-  ?o          .    .    E_DWX    r   c                    ddl m} m} | t        u }|st        j                  d|fd| t        f      dt        j                         v st        j                  |       rt        j                  |       nddt        j                         v st        j                  t              rt        j                  t              nddz  }t        j                  d      d	z   d
|iz  }t        t        j                  |            d}t        |t              }|s!t        j                  d      dz   dt        j                         v st        j                  t              rt        j                  t              nddt        j                         v st        j                  |      rt        j                  |      nddt        j                         v st        j                  t              rt        j                  t              ndt        j                  |      dz  }t        t        j                  |            d}d}||v }|st        j                  d|fd||f      t        j                  |      dt        j                         v st        j                  |      rt        j                  |      nddz  }t        j                  d|      dz   d|iz  }	t        t        j                  |	            dx}}y)zCcore.epoch __init__ must export EpochScheduler and EVENTS_LOG_PATH.r   )r   r   rz   )z%(py0)s is %(py2)sESr   r   z.EpochScheduler not re-exported from core.epochr   rj   Nz"EVENTS_LOG_PATH should be a stringr   r   ELPr?   r   r;   r   r   r   r  r   rV   )
core.epochr   r   rY   rm   r[   r\   r]   r^   rZ   r_   r`   r   r?   )
r  r  ra   r   r   rn   r   r   r   r   s
             r   )test_package_init_exports_epoch_schedulerr     s   GQQQ2QQQQQQ2QQQ2QQQQQQQQQQQQQ!QQQQQQQc3EEE!EEEEEEE:EEE:EEEEEEcEEEcEEEEEE3EEE3EEEEEEEEE >S   >S          !    !    ?sgF    r   c                    t               } t        |       }|j                  }d}||u }|st        j                  d|fd||f      dt        j                         v st        j                  |      rt        j                  |      ndt        j                  |      t        j                  |      dz  }t        j                  d|j                        dz   d|iz  }t        t        j                  |            dx}x}}y)	z3_scheduler must be None immediately after __init__.Nrz   )z2%(py2)s
{%(py2)s = %(py0)s._scheduler
} is %(py5)srB   r  z._scheduler should be None before start(); got r	  rW   )r   r   
_schedulerrY   rm   r[   r\   r]   r^   rZ   r_   r`   )r   rB   ra   rb   rn   r   rd   s          r   %test_scheduler_not_started_after_initr#    s    ^Fv&I 4 4'  4                   $(    99M9M8PQ     r   c                   t               }t        | dz        }t        ||      }t               }d|j                  _        t        |      }t        t        |      5  |j                          |j                          ddd       |j                         }d}||u }|st        j                  d|fd||f      dt        j                         v st        j                  |      rt        j                   |      ndt        j                   |      dz  }	t        j"                  d	|      d
z   d|	iz  }
t%        t        j&                  |
            dx}}y# 1 sw Y   xY w)uF   After stop(), if get_job returns None → get_next_run() returns None.r;   r<   Nr   rz   r|   r}   r~   z9get_next_run() should return None when job is gone, got: r   rV   )r   r?   r   r
   r3   r   r   r   r4   r   r   rY   rm   r[   r\   r]   r^   rZ   r_   r`   )r@   r   rA   rB   r8   rC   r}   r   ra   r   r   s              r   %test_get_next_run_after_stop_job_goner%    s&   ^Fh/0Kv{CI{H$(H!h/H	!8	,  ##%F 6T>  6T                  DF:N     s   !EEc                  	 t               }t        | dz        }t        ||      }d		fd}t        t        |      5  |j                          |j                          |j                          |j                          ddd       d}	|k(  }|st        j                  d|fd		|f      d
t        j                         v st        j                  	      rt        j                  	      nd
t        j                  |      dz  }t        j                  d	       dz   d|iz  }t        t        j                  |            dx}}y# 1 sw Y   xY w)zCEach call to start() creates a brand-new AsyncIOScheduler instance.r;   r<   r   c                 l    dz  t               }t        t                     |j                  _        |S )Nr   r>   )r
   r0   r3   r   )r   r   r8   rl   s      r   make_mock_schzAtest_start_creates_new_scheduler_each_call.<locals>.make_mock_sch'  s/    a
;(1@P@R(S%r   r  N   rh   r   rl   r~   zDAsyncIOScheduler should be instantiated twice (once per start); got r   rV   )r   r?   r   r   r   r4   r   rY   rm   r[   r\   r]   r^   rZ   r_   r`   )
r@   r   rA   rB   r(  r   ra   r   r   rl   s
            @r   *test_start_creates_new_scheduler_each_callr*    s   ^Fh/0Kv{CIJ 
!}	= 	  :?  :                  Ozl[     s   AEE__main__c                 <    t        t        j                               S rH   )r   tempfilemkdtemprv   r   r   _tmpr/  @  s    H$$&''r   z'BB1: force_trigger calls run_epoch_safez&BB1: force_trigger works without startzBB1: force_trigger is asynczBB1: force_trigger x3 = 3 callsz#BB2: get_next_run None before startz&BB2: get_next_run datetime after startc                 D    t               j                  t                     S rH   )rx   r   r/  rv   r   r   <lambda>r1  K  s$    ;M;O;~;~  @D  @F  <G r   z(BB2: get_next_run from job.next_run_timec                 D    t               j                  t                     S rH   )rx   r   r/  rv   r   r   r1  r1  L  s     =O=Q=z=z{  |B  >C r   z'BB2: get_next_run None when job missingc                 D    t               j                  t                     S rH   )rx   r   r/  rv   r   r   r1  r1  M  s*    <N<P  =A  =A  BF  BH  =I r   zBB3: start does not raisec                 D    t               j                  t                     S rH   )r   r   r/  rv   r   r   r1  r1  O  s    .F.H.b.bcgci.j r   zBB3: stop does not raisec                 D    t               j                  t                     S rH   )r   r   r/  rv   r   r   r1  r1  P  s    -E-G-`-`aeag-h r   u&   BB3: restart — replace_existing=Truec                 D    t               j                  t                     S rH   )r   r   r/  rv   r   r   r1  r1  Q  s$    ;S;U;};}  C  E  <F r   zBB4: stop before start is no-opzWB1: _CRON_HOUR is 16zWB1: _CRON_MINUTE is 0zWB1: _CRON_DAY_OF_WEEK is 'sun'z%WB1: add_job uses correct cron paramsc                 D    t               j                  t                     S rH   )r   r   r/  rv   r   r   r1  r1  X  s     :Q:S:y:yz~  {A  ;B r   z&WB2: force_trigger calls safe, not rawz+WB2: scheduler job target is run_epoch_safec                 D    t               j                  t                     S rH   )r   r   r/  rv   r   r   r1  r1  [  s-    @Z@\  AI  AI  JN  JP  AQ r   z'WB3: job ID constant is 'nightly_epoch'z$WB3: add_job uses 'nightly_epoch' idc                 D    t               j                  t                     S rH   )r   r   r/  rv   r   r   r1  r1  ^  s    9k9klplr9s r   z'WB3: get_job queries by 'nightly_epoch'c                 D    t               j                  t                     S rH   )r   r   r/  rv   r   r   r1  r1  _  s    MO<t<tuyu{<| r   z)WB4: start writes scheduler_started eventc                 D    t               j                  t                     S rH   )r   r   r/  rv   r   r   r1  r1  a  s$    >Q>S>}>}  C  E  ?F r   z(WB4: stop writes scheduler_stopped eventc                 D    t               j                  t                     S rH   )r   r  r/  rv   r   r   r1  r1  b  s$    =P=R={={  }A  }C  >D r   zWB4: events have ISO timestampsc                 D    t               j                  t                     S rH   )r   r  r/  rv   r   r   r1  r1  c  s    4G4I4i4ijnjp4q r   z*WB4: no event written on stop-before-startc                 D    t               j                  t                     S rH   )r   r  r/  rv   r   r   r1  r1  d  s%    ?R?T??  AE  AG  @H r   zWB4: OSError does not propagatec                 D    t               j                  t                     S rH   )r   r  r/  rv   r   r   r1  r1  e  s    4G4I4x4xy}y  5A r   z%EDGE: EVENTS_LOG_PATH module constantz6EDGE: package exports EpochScheduler + EVENTS_LOG_PATHz EDGE: _scheduler None after initz"EDGE: get_next_run None after stopc                 (    t        t                     S rH   )r%  r/  rv   r   r   r1  r1  j  s    7\]a]c7d r   z+EDGE: start creates new scheduler each callc                 (    t        t                     S rH   )r*  r/  rv   r   r   r1  r1  k  s    @jkokq@r r   z	  [PASS] r   z	  [FAIL] z: 
/z tests passedz(ALL TESTS PASSED -- Story 9.01 (Track B)rH   )returnr   )r2   zOptional[datetime])NNN)rD  r   )Pru   
__future__r   builtinsr[   _pytest.assertion.rewrite	assertionrewriterY   r   rR   r   sysr-  r   r   r   pathlibr   typingr   unittest.mockr	   r
   r   pytestGENESIS_ROOTpathinsertcore.epoch.epoch_schedulerr   r   r   r   r   r   r   r   r    r0   r9   rD   rF   rx   r   r   r   r   r   r   r  r   r#  r%  r*  rr   	tracebackr/  rN   rP   re   rq   r   r   r   r   r   r   r   test_fnspassedr   totalnamefnprint	Exceptionexc	print_excexitrv   r   r   <module>r^     s~  : #      
  2 2   5 5  'sxxHHOOA|$  G =
H(3.(
 (
V;
 ;
|5 5p  #
 #
L
 
>= =<G< G<^(< z()	24H4J4r4rs) 
23G3I3p3pq) 
'(<(>(g(gh	)
 
+,@,B  -D  -D  	E) 
/0B0D0p0pq) 
2  4G  	H) 
4  6C  	D) 
3  5I  	J) 
%&jk) 
$%hi) 
2  4F  	G)  
+,C,E,e,ef!)$ 
!"9";"Y"YZ%)& 
"#:#<#[#[\')( 
+,C,E,e,ef))* 
1  3B  	C+). 
23M3O  4F  4F  	G/)0 
7  9Q  	R1)4 
3MO4X4XY5)6 
01st7)8 
34|}9)< 
5  7F  	G=)> 
4  6D  	E?)@ 
+,qrA)B 
6  8H  	IC)D 
+  -A  	BE)H 
12VWI)J 
BClmK)L 
,-RSM)N 
./deO)P 
78rsQ)HV FME "b	"DIdV$%aKF	" 
Bvhawm
,-89C r  	"IdV2cU+,I!!	"s   J""K'KK