1use crate::layer::Layer;
37use crate::runtime::Never;
38use crate::{Effect, Exit, ServiceContext, TestClock};
39use std::cell::{Cell, RefCell};
40use std::fmt::Debug;
41
42thread_local! {
43 static LEAKED_FIBERS: Cell<usize> = const { Cell::new(0) };
44 static UNCLOSED_SCOPES: Cell<usize> = const { Cell::new(0) };
45 static ACTIVE_TEST_CLOCK: RefCell<Option<TestClock>> = const { RefCell::new(None) };
46}
47
48struct TestClockScope {
49 previous: Option<TestClock>,
50}
51
52impl Drop for TestClockScope {
53 fn drop(&mut self) {
54 let previous = self.previous.clone();
55 ACTIVE_TEST_CLOCK.with(|clock| {
56 *clock.borrow_mut() = previous;
57 });
58 }
59}
60
61fn install_test_clock(clock: TestClock) -> TestClockScope {
62 let previous = ACTIVE_TEST_CLOCK.with(|active| active.borrow_mut().replace(clock));
63 TestClockScope { previous }
64}
65
66pub(crate) fn current_test_clock() -> Option<TestClock> {
67 ACTIVE_TEST_CLOCK.with(|clock| clock.borrow().clone())
68}
69
70fn reset_counters() {
71 LEAKED_FIBERS.with(|c| c.set(0));
72 UNCLOSED_SCOPES.with(|c| c.set(0));
73}
74
75fn assert_hygiene_counters() {
76 let fiber_leaks = LEAKED_FIBERS.with(|c| c.get());
77 assert_eq!(
78 fiber_leaks, 0,
79 "deterministic test harness detected leaked fibers: {fiber_leaks}"
80 );
81
82 let scope_leaks = UNCLOSED_SCOPES.with(|c| c.get());
83 assert_eq!(
84 scope_leaks, 0,
85 "deterministic test harness detected unclosed scopes: {scope_leaks}"
86 );
87}
88
89pub fn record_leaked_fiber() {
91 LEAKED_FIBERS.with(|c| c.set(c.get().saturating_add(1)));
92}
93
94pub fn record_unclosed_scope() {
96 UNCLOSED_SCOPES.with(|c| c.set(c.get().saturating_add(1)));
97}
98
99pub fn assert_no_leaked_fibers() -> Effect<(), Never, ()> {
101 Effect::new(move |_env| {
102 let leaks = LEAKED_FIBERS.with(|c| c.get());
103 assert_eq!(
104 leaks, 0,
105 "deterministic test harness detected leaked fibers: {leaks}"
106 );
107 Ok(())
108 })
109}
110
111pub fn assert_no_unclosed_scopes() -> Effect<(), Never, ()> {
113 Effect::new(move |_env| {
114 let leaks = UNCLOSED_SCOPES.with(|c| c.get());
115 assert_eq!(
116 leaks, 0,
117 "deterministic test harness detected unclosed scopes: {leaks}"
118 );
119 Ok(())
120 })
121}
122
123pub struct TestRuntime<R, F = fn() -> R>
129where
130 R: 'static,
131 F: FnOnce() -> R,
132{
133 make_env: F,
134}
135
136impl<R> Default for TestRuntime<R>
137where
138 R: Default + 'static,
139{
140 fn default() -> Self {
141 Self {
142 make_env: R::default,
143 }
144 }
145}
146
147impl<R, F> TestRuntime<R, F>
148where
149 R: 'static,
150 F: FnOnce() -> R,
151{
152 #[inline]
154 pub fn with_env(make_env: F) -> Self {
155 Self { make_env }
156 }
157
158 #[inline]
160 pub async fn run<A, E>(self, effect: Effect<A, E, R>) -> Result<A, E>
161 where
162 A: 'static,
163 E: 'static,
164 {
165 let env = (self.make_env)();
166 run_effect_test_with_env(effect, env).await
167 }
168
169 #[inline]
171 pub async fn expect<A, E>(self, effect: Effect<A, E, R>) -> A
172 where
173 A: 'static,
174 E: Debug + 'static,
175 {
176 expect_effect_test_with_env(effect, (self.make_env)()).await
177 }
178}
179
180#[inline]
182pub async fn run_effect_test<A, E, R>(effect: Effect<A, E, R>) -> Result<A, E>
183where
184 A: 'static,
185 E: 'static,
186 R: Default + 'static,
187{
188 run_effect_test_with_env(effect, R::default()).await
189}
190
191#[inline]
193pub async fn run_effect_test_with_env<A, E, R>(effect: Effect<A, E, R>, mut env: R) -> Result<A, E>
194where
195 A: 'static,
196 E: 'static,
197 R: 'static,
198{
199 reset_counters();
200 let result = effect.run(&mut env).await;
201 assert_hygiene_counters();
202 result
203}
204
205#[inline]
207pub async fn expect_effect_test<A, E, R>(effect: Effect<A, E, R>) -> A
208where
209 A: 'static,
210 E: Debug + 'static,
211 R: Default + 'static,
212{
213 expect_effect_test_with_env(effect, R::default()).await
214}
215
216#[inline]
218pub async fn expect_effect_test_with_env<A, E, R>(effect: Effect<A, E, R>, env: R) -> A
219where
220 A: 'static,
221 E: Debug + 'static,
222 R: 'static,
223{
224 match run_effect_test_with_env(effect, env).await {
225 Ok(value) => value,
226 Err(error) => panic!("effectful test failed: {error:?}"),
227 }
228}
229
230#[inline]
232pub async fn expect_effect_test_with_layer<A, E, ROut>(
233 effect: Effect<A, E, ServiceContext>,
234 layer: Layer<ROut, E, ()>,
235) -> A
236where
237 A: 'static,
238 E: Debug + 'static,
239 ROut: 'static,
240{
241 expect_effect_test(effect.provide(layer)).await
242}
243
244#[inline]
246pub fn run_test<A, E, R>(effect: Effect<A, E, R>, env: R) -> Exit<A, E>
247where
248 A: 'static,
249 E: 'static,
250 R: 'static,
251{
252 reset_counters();
253 let result = crate::runtime::run_blocking(effect, env);
254 assert_hygiene_counters();
255 match result {
256 Ok(value) => Exit::succeed(value),
257 Err(error) => Exit::fail(error),
258 }
259}
260
261#[inline]
263pub fn run_test_with_clock<A, E, R>(effect: Effect<A, E, R>, env: R, clock: TestClock) -> Exit<A, E>
264where
265 A: 'static,
266 E: 'static,
267 R: 'static,
268{
269 let _scope = install_test_clock(clock);
270 run_test(effect, env)
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::scheduling::duration::duration;
277 use crate::{
278 Metric, MissingService, Schedule, ScheduleInput, fail, retry, retry_with_clock, succeed,
279 };
280 use rstest::rstest;
281 use std::sync::Arc;
282 use std::sync::Mutex;
283 use std::sync::atomic::{AtomicUsize, Ordering};
284
285 #[derive(Clone, Debug, PartialEq, effectful::Service)]
286 struct TestService {
287 value: u32,
288 }
289
290 struct TestFailure {
291 code: u32,
292 }
293
294 impl std::fmt::Debug for TestFailure {
295 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296 f.debug_struct("TestFailure")
297 .field("code", &self.code)
298 .finish()
299 }
300 }
301
302 fn service_context() -> ServiceContext {
303 TestService { value: 9 }.to_context()
304 }
305
306 fn service_layer() -> Layer<TestService, MissingService, ()> {
307 Layer::succeed(TestService { value: 11 })
308 }
309
310 mod run_test {
311 use super::*;
312
313 #[test]
314 fn run_test_with_success_effect_returns_success_exit() {
315 let exit = run_test(succeed::<u32, (), ()>(7), ());
316 assert_eq!(exit, Exit::succeed(7));
317 }
318
319 #[test]
320 fn run_test_with_failure_effect_returns_failure_exit() {
321 let exit = run_test(fail::<(), &'static str, ()>("boom"), ());
322 assert_eq!(exit, Exit::fail("boom"));
323 }
324
325 #[rstest]
326 #[case::zero(0u8)]
327 #[case::positive(9u8)]
328 fn run_test_with_clock_matches_run_test_semantics_for_successful_effect(#[case] value: u8) {
329 let effect = succeed::<u8, (), ()>(value);
330 let clock = TestClock::new(std::time::Instant::now());
331 let exit = run_test_with_clock(effect, (), clock);
332 assert_eq!(exit, Exit::succeed(value));
333 }
334
335 #[test]
336 fn run_test_with_clock_drives_retry_schedule_sleep_without_wall_clock_wait() {
337 let start = std::time::Instant::now();
338 let clock = TestClock::new(start);
339 let attempts = Arc::new(AtomicUsize::new(0));
340 let attempts_c = Arc::clone(&attempts);
341 let effect = retry(
342 move || {
343 let attempt = attempts_c.fetch_add(1, Ordering::SeqCst);
344 if attempt == 0 {
345 fail::<usize, &'static str, ()>("boom")
346 } else {
347 succeed::<usize, &'static str, ()>(attempt + 1)
348 }
349 },
350 Schedule::spaced(duration::millis(50)).compose(Schedule::recurs(1)),
351 );
352
353 let before = std::time::Instant::now();
354 let exit = run_test_with_clock(effect, (), clock.clone());
355 let elapsed = before.elapsed();
356
357 assert_eq!(exit, Exit::succeed(2));
358 assert!(
359 elapsed < duration::millis(25),
360 "retry waited on wall clock for {elapsed:?}"
361 );
362 assert_eq!(clock.pending_sleeps(), vec![start + duration::millis(50)]);
363 }
364
365 #[test]
366 fn run_test_with_clock_retry_composed_schedule_uses_attempt_inputs_and_test_clock() {
367 let start = std::time::Instant::now();
368 let clock = TestClock::new(start);
369 let counter = Metric::counter("retry_composed_attempts", []);
370
371 let predicate_attempts = Arc::new(Mutex::new(Vec::new()));
372 let contramap_attempts = Arc::new(Mutex::new(Vec::new()));
373
374 let predicate_attempts_c = Arc::clone(&predicate_attempts);
375 let contramap_attempts_c = Arc::clone(&contramap_attempts);
376
377 let attempts = Arc::new(AtomicUsize::new(0));
378 let attempts_c = Arc::clone(&attempts);
379
380 let effect = retry_with_clock(
381 move || {
382 let n = attempts_c.fetch_add(1, Ordering::SeqCst);
383 if n < 2 {
384 fail::<usize, &'static str, ()>("boom")
385 } else {
386 succeed::<usize, &'static str, ()>(n + 1)
387 }
388 },
389 Schedule::spaced(duration::millis(50))
390 .compose(Schedule::recurs_while({
391 let attempts = Arc::clone(&predicate_attempts_c);
392 Box::new(move |i: &ScheduleInput| {
393 attempts.lock().expect("mutex poisoned").push(i.attempt);
394 i.attempt < 2
395 })
396 }))
397 .compose(
398 Schedule::recurs_until(Box::new(|i: &ScheduleInput| i.attempt >= 12)).contramap({
399 let attempts = Arc::clone(&contramap_attempts_c);
400 move |mut i: ScheduleInput| {
401 attempts.lock().expect("mutex poisoned").push(i.attempt);
402 i.attempt = i.attempt.saturating_add(10);
403 i
404 }
405 }),
406 ),
407 TestClock::new(start),
408 Some(counter.clone()),
409 );
410
411 let before = std::time::Instant::now();
412 let exit = run_test_with_clock(effect, (), clock.clone());
413 let elapsed = before.elapsed();
414
415 assert_eq!(exit, Exit::succeed(3));
416 assert_eq!(attempts.load(Ordering::SeqCst), 3);
417 assert_eq!(counter.snapshot_count(), 3);
418
419 let pred_seen = predicate_attempts.lock().expect("mutex poisoned");
420 assert_eq!(*pred_seen, vec![0, 1]);
421
422 let contra_seen = contramap_attempts.lock().expect("mutex poisoned");
423 assert_eq!(*contra_seen, vec![0, 1]);
424
425 assert_eq!(
426 clock.pending_sleeps(),
427 vec![start + duration::millis(50), start + duration::millis(50)]
428 );
429
430 assert!(
431 elapsed < duration::millis(25),
432 "retry waited on wall clock for {elapsed:?}"
433 );
434 }
435 }
436
437 mod effect_test_attribute {
438 use super::*;
439
440 #[crate::effect_test]
441 fn effect_returning_test_with_unit_environment_passes() -> Effect<(), &'static str, ()> {
442 Effect::new(|_| Ok(()))
443 }
444
445 #[crate::effect_test(env = "service_context")]
446 fn effect_returning_test_with_provided_context_passes()
447 -> Effect<(), MissingService, ServiceContext> {
448 Effect::<TestService, MissingService, ServiceContext>::service::<TestService>()
449 .map(|service| assert_eq!(service.value, 9))
450 }
451
452 #[crate::effect_test(layer = "service_layer")]
453 fn effect_returning_test_with_provided_layer_passes()
454 -> Effect<(), MissingService, ServiceContext> {
455 Effect::<TestService, MissingService, ServiceContext>::service::<TestService>()
456 .map(|service| assert_eq!(service.value, 11))
457 }
458 }
459
460 mod async_harness {
461 use super::*;
462
463 #[tokio::test]
464 async fn run_effect_test_with_env_returns_success_value() {
465 let effect = Effect::<u32, MissingService, ServiceContext>::service::<TestService>()
466 .map(|service| service.value);
467
468 let result = run_effect_test_with_env(effect, service_context()).await;
469
470 assert_eq!(result, Ok(9));
471 }
472
473 #[tokio::test]
474 async fn test_runtime_with_env_returns_success_value() {
475 let effect = Effect::<u32, MissingService, ServiceContext>::service::<TestService>()
476 .map(|service| service.value);
477
478 let result = TestRuntime::with_env(service_context).run(effect).await;
479
480 assert_eq!(result, Ok(9));
481 }
482
483 #[tokio::test]
484 #[should_panic(expected = "effectful test failed: TestFailure { code: 7 }")]
485 async fn expect_effect_test_with_failure_formats_debug_error() {
486 expect_effect_test(fail::<(), TestFailure, ()>(TestFailure { code: 7 })).await;
487 }
488 }
489
490 mod assertions {
491 use super::*;
492
493 #[test]
494 #[should_panic(expected = "deterministic test harness detected leaked fibers")]
495 fn assert_no_leaked_fibers_when_leaked_fiber_recorded_panics() {
496 record_leaked_fiber();
497 let _ = crate::runtime::run_blocking(assert_no_leaked_fibers(), ());
498 }
499
500 #[test]
501 #[should_panic(expected = "deterministic test harness detected unclosed scopes")]
502 fn assert_no_unclosed_scopes_when_unclosed_scope_recorded_panics() {
503 record_unclosed_scope();
504 let _ = crate::runtime::run_blocking(assert_no_unclosed_scopes(), ());
505 }
506 }
507}