From 886c827f705a8176adef122664ce0b5d7fd0d534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Sat, 7 Mar 2026 15:13:26 -0700 Subject: [PATCH] feat: add mock_file_check_guard() for scope-based cleanup Returns a guard object whose DESTROY calls unmock_file_check, enabling automatic mock cleanup on scope exit. Improves test isolation without changing the existing API. The guard also supports cancel() to keep the mock active beyond the scope, and handles double-destroy gracefully. Co-Authored-By: Claude Opus 4.6 --- lib/Overload/FileCheck.pm | 57 +++++++++++++++++++++++++++++++++- t/guard.t | 65 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 t/guard.t diff --git a/lib/Overload/FileCheck.pm b/lib/Overload/FileCheck.pm index 295e03f..54d1307 100644 --- a/lib/Overload/FileCheck.pm +++ b/lib/Overload/FileCheck.pm @@ -63,7 +63,7 @@ my @STAT_HELPERS = qw{ stat_as_directory stat_as_file stat_as_symlink our @EXPORT_OK = ( qw{ mock_all_from_stat - mock_all_file_checks mock_file_check mock_stat + mock_all_file_checks mock_file_check mock_file_check_guard mock_stat unmock_file_check unmock_all_file_checks unmock_stat }, @CHECK_STATUS, @@ -240,6 +240,16 @@ sub mock_file_check { return 1; } +sub mock_file_check_guard { + my ( $check, $sub ) = @_; + + mock_file_check( $check, $sub ); + + ( my $normalized = $check ) =~ s{^-+}{}; + + return Overload::FileCheck::Guard->new($normalized); +} + sub unmock_file_check { my (@checks) = @_; @@ -683,6 +693,36 @@ sub _stat_for { return \@stat; } +###################################################### +### Scope guard for automatic mock cleanup +###################################################### + +package Overload::FileCheck::Guard; + +sub new { + my ( $class, @checks ) = @_; + + return bless { checks => \@checks, active => 1 }, $class; +} + +sub cancel { + my ($self) = @_; + + $self->{active} = 0; + return; +} + +sub DESTROY { + my ($self) = @_; + + return unless $self->{active}; + $self->{active} = 0; + + local $@; + eval { Overload::FileCheck::unmock_file_check( @{ $self->{checks} } ) }; + return; +} + 1; =pod @@ -974,6 +1014,21 @@ Otherwise returns 1 on success. # in that sample all '-e' checks will always return true... mock_file_check( '-e' => sub { 1 } ) +=head2 mock_file_check_guard( $check, CODE ) + +Like C, but returns a guard object instead of C<1>. +When the guard goes out of scope (or is otherwise destroyed), the mock is +automatically removed via C. This improves test isolation +by guaranteeing cleanup even if the test dies. + + { + my $guard = mock_file_check_guard( '-e' => sub { CHECK_IS_TRUE } ); + ok( -e "/fake/file", "mocked" ); + } + # -e is automatically unmocked here + +Call C<< $guard->cancel >> to prevent the automatic unmock. + =head2 unmock_file_check( $check, [@extra_checks] ) Disable the effect of one or more specific mock. diff --git a/t/guard.t b/t/guard.t new file mode 100644 index 0000000..e3275eb --- /dev/null +++ b/t/guard.t @@ -0,0 +1,65 @@ +use strict; +use warnings; + +use Test2::Bundle::Extended; +use Test2::Tools::Explain; + +use Overload::FileCheck qw( + mock_file_check_guard mock_file_check unmock_file_check + CHECK_IS_TRUE CHECK_IS_FALSE FALLBACK_TO_REAL_OP +); + +my $fake = "/guard/test/file"; + +# --- basic guard: mock is active inside scope, removed after --- +{ + my $guard = mock_file_check_guard( '-e' => sub { CHECK_IS_TRUE } ); + isa_ok( $guard, 'Overload::FileCheck::Guard' ); + ok( -e $fake, "mocked -e returns true inside guard scope" ); +} +ok( !-e $fake, "-e falls back to real op after guard is destroyed" ); + +# --- guard with cancel: mock persists after scope --- +{ + my $guard = mock_file_check_guard( '-f' => sub { CHECK_IS_TRUE } ); + ok( -f $fake, "mocked -f returns true" ); + $guard->cancel; +} +# mock still active because we cancelled the guard +ok( -f $fake, "-f still mocked after cancelled guard" ); +unmock_file_check('-f'); # manual cleanup +ok( !-f $fake, "-f unmocked manually" ); + +# --- guard handles double-destroy gracefully --- +{ + my $guard = mock_file_check_guard( '-d' => sub { CHECK_IS_TRUE } ); + ok( -d $fake, "mocked -d" ); + # explicitly destroy, then let scope destroy again + $guard->DESTROY; + ok( !-d $fake, "-d unmocked after explicit DESTROY" ); +} +# second DESTROY from scope exit should not die +pass("double DESTROY did not die"); + +# --- guard unmocks even if test dies (eval) --- +eval { + my $guard = mock_file_check_guard( '-e' => sub { CHECK_IS_TRUE } ); + ok( -e $fake, "mocked -e inside eval" ); + die "simulated test failure"; +}; +ok( !-e $fake, "-e unmocked after die inside eval" ); + +# --- guard works with dash-less check names --- +{ + my $guard = mock_file_check_guard( 'e' => sub { CHECK_IS_TRUE } ); + ok( -e $fake, "mocked with dash-less 'e'" ); +} +ok( !-e $fake, "unmocked after dash-less guard" ); + +# --- guard with FALLBACK_TO_REAL_OP --- +{ + my $guard = mock_file_check_guard( '-e' => sub { FALLBACK_TO_REAL_OP } ); + ok( !-e $fake, "FALLBACK_TO_REAL_OP falls through to real check" ); +} + +done_testing;