Algorithm-EventsPerSecond

 view release on metacpan or  search on metacpan

t/lib/AEPS_TestSuite.pm  view on Meta::CPAN

package AEPS_TestSuite;

# Shared behavior suite, run against whichever backend
# Algorithm::EventsPerSecond loads. Set ALGORITHM_EVENTSPERSECOND_PP=1
# before loading this module to force the pure Perl backend.

use strict;
use warnings;
use Test::More;

# Mock time() so bucket rotation and rate math are deterministic. The
# override must be installed before Algorithm::EventsPerSecond is compiled.
our $fake_time;

BEGIN {
	$fake_time          = 1_000_000;
	*CORE::GLOBAL::time = sub { $fake_time };
}

use Algorithm::EventsPerSecond;

sub advance { $fake_time += shift }

# deterministic PRNG so failures reproduce identically everywhere
my $lcg_state = 42;

sub _rnd {
	my ($limit) = @_;
	$lcg_state = ( $lcg_state * 1103515245 + 12345 ) % 2147483648;
	return $lcg_state % $limit;
}

sub run_suite {

	#
	# constructor
	#
	{
		my $meter = Algorithm::EventsPerSecond->new;
		isa_ok( $meter, 'Algorithm::EventsPerSecond', 'new with defaults' );
		is( $meter->window, 60, 'default window is 60 seconds' );

		$meter = Algorithm::EventsPerSecond->new( window => 10 );
		is( $meter->window, 10, 'window argument is honored' );

		ok( !eval { Algorithm::EventsPerSecond->new( window =>  0 );    1 }, 'window of 0 dies' );
		ok( !eval { Algorithm::EventsPerSecond->new( window => -5 );    1 }, 'negative window dies' );
		ok( !eval { Algorithm::EventsPerSecond->new( window => 'abc' ); 1 }, 'non-numeric window dies' );
		ok( !eval { Algorithm::EventsPerSecond->new( window => 1.5 );   1 }, 'fractional window dies' );
	}

	#
	# mark, count, total
	#
	{
		my $meter = Algorithm::EventsPerSecond->new( window => 10 );

		is( $meter->count, 0, 'count starts at 0' );
		is( $meter->total, 0, 'total starts at 0' );

		is( $meter->mark,  $meter, 'mark returns the meter object' );
		is( $meter->count, 1,      'count is 1 after one mark' );
		is( $meter->total, 1,      'total is 1 after one mark' );

		$meter->mark(5);
		is( $meter->count, 6, 'mark($count) records multiple events' );
		is( $meter->total, 6, 'total tracks multi-event marks' );

		$meter->mark->mark->mark;
		is( $meter->count, 9, 'mark calls chain' );
	}

	#
	# mark count validation: both backends must reject what they cannot
	# represent identically, so PP and XS never drift apart
	#
	{
		my $meter = Algorithm::EventsPerSecond->new( window => 10 );
		$meter->mark(2);

		ok( !eval { $meter->mark(-3);    1 }, 'negative count dies' );
		ok( !eval { $meter->mark(2.5);   1 }, 'fractional count dies' );
		ok( !eval { $meter->mark('abc'); 1 }, 'non-numeric count dies' );
		ok( !eval { $meter->mark('');    1 }, 'empty-string count dies' );

		is( $meter->count, 2,             'rejected marks do not change count' );
		is( $meter->total, 2,             'rejected marks do not change total' );
		is( $meter->count, $meter->total, 'count and total agree after rejected marks' );

		is( $meter->mark(0), $meter, 'mark(0) is accepted and returns the meter object' );
		is( $meter->count,   2,      'mark(0) is a no-op for count' );
		is( $meter->total,   2,      'mark(0) is a no-op for total' );
	}

	#
	# events spread across seconds, then falling out of the window
	#
	{
		my $meter = Algorithm::EventsPerSecond->new( window => 5 );

		$meter->mark(2);    # second 0
		advance(1);
		$meter->mark(3);    # second 1
		advance(1);
		$meter->mark(4);    # second 2

		is( $meter->count, 9, 'count sums events across seconds in the window' );
		is( $meter->total, 9, 'total matches while all events are in the window' );

		# now at second 2; window of 5 covers seconds -2..2, so nothing has
		# expired yet. Advance to second 5: window covers 1..5, dropping the
		# 2 events from second 0.
		advance(3);
		is( $meter->count, 7, 'events older than the window are excluded' );

		# advance well past the window: everything expires
		advance(10);
		is( $meter->count, 0, 'count is 0 once all events age out' );
		is( $meter->total, 9, 'total is unaffected by expiry' );
	}

	#
	# ring buffer reuse: marks a full window apart land in the same bucket
	# and must not be double counted
	#
	{
		my $meter = Algorithm::EventsPerSecond->new( window => 3 );

		$meter->mark(5);
		advance(3);    # same bucket index, new second
		$meter->mark(2);

		is( $meter->count, 2, 'stale bucket is cleared before reuse' );
		is( $meter->total, 7, 'total still counts events from cleared buckets' );
	}

	#
	# window of 1: smallest ring buffer
	#
	{
		my $meter = Algorithm::EventsPerSecond->new( window => 1 );

		$meter->mark(3)->mark(4);
		is( $meter->count, 7, 'window of 1 counts the current second' );
		advance(1);
		is( $meter->count, 0, 'window of 1 expires after one second' );
	}

	#
	# rate
	#
	{
		my $meter = Algorithm::EventsPerSecond->new( window => 10 );

		is( $meter->rate, 0, 'rate is 0 when no time has elapsed' );

		$meter->mark(10);
		advance(5);
		is( $meter->rate, 2, 'rate uses elapsed lifetime while younger than the window' );

		advance(15);    # lifetime 20s > window; only recent events count
		$meter->mark(30);
		is( $meter->rate, 3, 'rate averages over the full window once mature' );
	}

	#
	# reset
	#
	{
		my $meter = Algorithm::EventsPerSecond->new( window => 10 );

		$meter->mark(4);
		advance(2);
		$meter->mark(6);

		is( $meter->reset, $meter, 'reset returns the meter object' );
		is( $meter->count, 0,      'count is 0 after reset' );
		is( $meter->total, 0,      'total is 0 after reset' );
		is( $meter->rate,  0,      'rate is 0 after reset' );

		advance(4);
		$meter->mark(8);
		is( $meter->count, 8, 'meter is usable after reset' );
		is( $meter->rate,  2, 'rate clock restarts at reset' );
	}

	#
	# clock stepping backwards (an NTP step): must not crash, must not
	# produce negative readings, and must count correctly once the
	# clock recovers
	#
	{
		my $meter = Algorithm::EventsPerSecond->new( window => 5 );

		$meter->mark(3);
		advance(-11);       # -11 is not a multiple of the window, so this
		$meter->mark(2);    # mark lands in a different bucket than the one above

		ok( $meter->count >= 0, 'count is non-negative after the clock steps back' );
		is( $meter->total, 5, 'total is unaffected by the clock stepping back' );
		is( $meter->rate,  0, 'rate reads 0 while the clock is behind the start time' );

		advance(13);        # two seconds past where the clock originally was
		$meter->mark(4);
		is( $meter->count, 7, 'only in-window events counted once the clock recovers' );
		is( $meter->total, 9, 'total tracks marks made at every clock position' );

		advance(10);
		is( $meter->count, 0, 'everything ages out normally after recovery' );
		is( $meter->total, 9, 'total survives the aging out' );
	}

	#
	# randomized cross-check against an independent reference model.
	# Window sizes chosen to exercise the SIMD main loop, its scalar
	# tail, and tail-only runs (AVX2 does 4 buckets a step, SSE4.2 2);
	# 257 and 1024 add long main-loop runs on odd and even boundaries.
	#
	for my $window ( 1, 2, 3, 5, 8, 16, 33, 64, 257, 1024 ) {
		my $meter = Algorithm::EventsPerSecond->new( window => $window );
		my %events;    # second => count, the reference model
		my $total = 0;
		my $bad   = 0;

		for ( 1 .. 200 ) {
			advance( _rnd(4) );    # 0-3 seconds pass
			my $n = _rnd(5);       # 0-4 events
			if ($n) {
				$meter->mark($n);
				$events{$fake_time} += $n;
				$total += $n;
			}

			my $oldest   = $fake_time - $window + 1;
			my $expected = 0;
			for my $sec ( keys %events ) {
				$expected += $events{$sec} if $sec >= $oldest;
			}

			$bad++ if $meter->count != $expected;
			$bad++ if $meter->total != $total;
		} ## end for ( 1 .. 200 )

		is( $bad, 0, "randomized marks match reference model (window $window)" );
	} ## end for my $window ( 1, 2, 3, 5, 8, 16, 33, 64,...)

	return;
} ## end sub run_suite

1;



( run in 0.715 second using v1.01-cache-2.11-cpan-6aa56a78535 )