File Coverage

lib/Test/Approx.pm
Criterion Covered Total %
statement 90 91 98.9
branch 53 74 71.6
condition 10 13 76.9
subroutine 12 12 100.0
pod 4 5 80.0
total 169 195 86.6


line stmt bran cond sub pod time code
1             =head1 NAME
2              
3             Test::Approx - compare two things for approximate equality
4              
5             =head1 SYNOPSIS
6              
7             use Test::Approx 'no_plan';
8              
9             is_approx( 'abcd', 'abcd', 'equal strings' );
10             is_approx( 1234, 1234, 'equal integers' );
11             is_approx( 1.234, 1.234, 'equal decimal numbers' );
12             is_approx( '1.234000', '1.234', 'equal decimal numbers, extra zeros' );
13             is_approx( 1.0, 1, 'equal decimal number & integer' );
14              
15             is_approx( 'abcdefgh', 'abcdefg', 'approx strings' );
16             is_approx( 1, 1.001, 'approx given decimal number & integer' );
17             is_approx( 51.60334, 51.603335, 'approx decimal numbers' );
18              
19             # default Levenshtein edit tolerance is 5% of avg string length:
20             is_approx( 'abcdefg', 'abcgfe', 'str tolerance' ); # fail
21              
22             # default difference tolerance is 5% of first number:
23             is_approx( 1, 1.04, 'num tolerance' ); # fail
24             is_approx( 1, 1.05, 'num tolerance' ); # fail
25              
26             # default difference tolerance is 5% of first integer, or 1:
27             is_approx( 1, 2, 'int tolerance' ); # pass
28             is_approx( 100, 105, 'int tolerance' ); # pass
29             is_approx( 100, 106, 'int tolerance' ); # fail
30              
31             # you can set the tolerance yourself:
32             is_approx( 'abcdefg', 'abcgfe', 'diff strings', '50%' ); # pass
33              
34             # you can set tolerance as a number too:
35             is_approx( 'abcdefg', 'abcgfe', 'diff strings', 6 );
36              
37             # you can force compare as string, number, or integer:
38             is_approx_str( '1.001', '1.901', 'pass as string' );
39             is_approx_num( '1.001', '1.901', 'fail as num' );
40             is_approx_int( '1.001', '1.901', 'pass as int' ); # not rounded!
41              
42             =cut
43              
44             package Test::Approx;
45              
46 1     1   1975 use strict;
  1         2  
  1         44  
47 1     1   6 use warnings;
  1         2  
  1         37  
48              
49 1     1   3171 use POSIX qw( strtod strtol );
  1         34874  
  1         10  
50 1     1   2341 use Text::LevenshteinXS qw(distance);
  1         4337  
  1         77  
51 1     1   8 use Test::Builder;
  1         2  
  1         29  
52              
53 1     1   5 use base 'Exporter';
  1         3  
  1         1240  
54             our @EXPORT = qw( is_approx is_approx_str is_approx_num is_approx_int );
55              
56             our $VERSION = 0.03;
57             our %DEFAULT_TOLERANCE = (
58             str => '5%',
59             num => '5%',
60             int => '5%',
61             );
62             our $Test = Test::Builder->new;
63              
64             sub import {
65 1     1   8 my $self = shift;
66 1         3 my $pack = caller;
67              
68 1         7 $Test->exported_to($pack);
69 1 50       11 $Test->plan(@_) if (@_);
70              
71 1         140 $self->export_to_level(1, $self, @EXPORT);
72             }
73              
74             sub check_type {
75 32     32 0 40 my $arg = shift;
76              
77 32         89 local $! = 0;
78 32         186 my ($num, $unparsed) = strtod( $arg );
79 32 100 66     262 return 'str' if (($arg eq '') || ($unparsed != 0) || $!);
      66        
80 22 100       141 return 'num' if $num =~ /\.\d*\z/;
81              
82 14         41 return 'int';
83             }
84              
85             sub is_approx {
86 16     16 1 21179 my ($arg1, $arg2, $msg, $tolerance) = @_;
87              
88 16         29 local $Test::Builder::Level = $Test::Builder::Level + 1;
89              
90             # clean input & avoid warnings
91 16 50       39 $arg1 = '' unless defined $arg1;
92 16 50       35 $arg2 = '' unless defined $arg2;
93              
94             # check inputs types and call appropriate sub
95 16         36 my $arg1_type = check_type( $arg1 );
96 16         34 my $arg2_type = check_type( $arg2 );
97              
98 16 100       63 if ($arg1_type eq 'int') {
    100          
99 7 100       31 return is_approx_int( @_ ) if $arg2_type eq 'int';
100 1 50       7 return is_approx_num( @_ ) if $arg2_type eq 'num';
101             } elsif ($arg1_type eq 'num') {
102 4 50 66     42 return is_approx_num( @_ )
103             if ($arg2_type eq 'int') or ($arg2_type eq 'num');
104             }
105              
106             # default behaviour, compare as strings:
107 5         12 return is_approx_str( @_ );
108             }
109              
110             sub is_approx_str {
111 7     7 1 1643 my ($str1, $str2, $msg, $tolerance) = @_;
112              
113             # clean input & avoid warnings
114 7 50       20 $str1 = '' unless defined $str1;
115 7 50       16 $str2 = '' unless defined $str2;
116 7 100       22 $tolerance = $DEFAULT_TOLERANCE{str} unless defined( $tolerance );
117              
118             # build some diagnostics info
119 7 100       42 my $short1 = length($str1) > 8 ? substr($str1, 0, 5) . '...' : $str1;
120 7 100       16 my $short2 = length($str2) > 8 ? substr($str2, 0, 5) . '...' : $str2;
121 7         19 my $msg2 = "'$short1' =~ '$short2'";
122              
123             # set default message
124 7 50       16 $msg = $msg2 unless defined($msg);
125              
126             # figure out what to use as the threshold
127 7         9 my $threshold;
128 7 50       33 if ($tolerance =~ /^(.+)%$/) {
129             # tolerance is a percentage
130 7         21 my $percent = $1 / 100;
131             # calculate threshold from a percentage:
132             # x% of average string length, or 1
133 7   100     37 $threshold = int(( (length($str1)+length($str2))/2 )*$percent) || 1;
134             } else {
135             # tolerance is already a threshold
136 0         0 $threshold = $tolerance;
137             }
138              
139              
140             # we've got a threshold, now do the test:
141 7         64305 my $dist = distance($str1, $str2);
142 7 100       36 unless ($Test->ok($dist <= $threshold, $msg)) {
143 3 50       1617 $Test->diag(" test: $msg2") if ($msg ne $msg2);
144 3         207 $Test->diag(" error: edit distance ($dist) was greater than threshold ($threshold)");
145             }
146             }
147              
148             sub is_approx_num {
149 13     13 1 12417 my ($num1, $num2, $msg, $tolerance) = @_;
150              
151             # clean input & avoid warnings
152 13 50       270 $num1 = strtod( defined $num1 ? $num1 : '' ); # ignore any errors
153 13 50       76 $num2 = strtod( defined $num2 ? $num2 : '' ); # ignore any errors
154 13 100       45 $tolerance = $DEFAULT_TOLERANCE{num} unless defined( $tolerance );
155              
156             # build some diagnostics info
157 13 50       254 my $short1 = length($num1) > 8 ? substr($num1, 0, 5) . '...' : $num1;
158 13 100       51 my $short2 = length($num2) > 8 ? substr($num2, 0, 5) . '...' : $num2;
159 13         221 my $msg2 = "'$short1' =~ '$short2'";
160              
161             # set default message
162 13 50       31 $msg = $msg2 unless defined($msg);
163              
164             # figure out what to use as the threshold
165 13         16 my $threshold;
166 13 100       227 if ($tolerance =~ /^(.+)%$/) {
167             # tolerance is a percentage
168 10         28 my $percent = $1 / 100;
169             # calculate threshold from a percentage: x% of num1
170             # strtod() to get around weird bug:
171             # $dist = 0.05; $threshold = 0.05; $dist <= $threshold; # false ??!?
172 10         227 $threshold = strtod( abs( $num1 * $percent ) );
173             } else {
174             # tolerance is already a threshold
175 3         5 $threshold = $tolerance;
176             }
177              
178             # we've got a threshold, now do the test:
179             # strtod() to get around weird bug:
180             # $dist = 0.05; $threshold = 0.05; $dist <= $threshold; # false ??!?
181 13         71 my $dist = strtod( abs($num2 - $num1) );
182 13 100       223 unless ($Test->ok($dist <= $threshold, $msg)) {
183 3 50       1719 $Test->diag(" test: $msg2") if ($msg ne $msg2);
184 3         237 $Test->diag(" error: distance ($dist) was greater than threshold ($threshold)");
185             }
186             }
187              
188             sub is_approx_int {
189 14     14 1 3094 my ($int1, $int2, $msg, $tolerance) = @_;
190              
191             # clean input & avoid warnings
192 14 50       9474 $int1 = strtol( defined $int1 ? $int1 : '' ); # ignore any errors
193 14 50       67 $int2 = strtol( defined $int2 ? $int2 : '' ); # ignore any errors
194 14 100       51 $tolerance = $DEFAULT_TOLERANCE{int} unless defined( $tolerance );
195              
196             # build some diagnostics info
197 14 50       40 my $short1 = length($int1) > 8 ? substr($int1, 0, 5) . '...' : $int1;
198 14 50       29 my $short2 = length($int2) > 8 ? substr($int2, 0, 5) . '...' : $int2;
199 14         34 my $msg2 = "'$short1' =~ '$short2'";
200              
201             # set default message
202 14 50       32 $msg = $msg2 unless defined($msg);
203              
204             # figure out what to use as the threshold
205 14         15 my $threshold;
206 14 100       74 if ($tolerance =~ /^(.+)%$/) {
207             # tolerance is a percentage
208 13         37 my $percent = $1 / 100;
209             # calculate threshold from a percentage: x% of num1 || 1
210             # strtod() to get around weird bug:
211             # $dist = 0.05; $threshold = 0.05; $dist <= $threshold; # false ??!?
212 13   100     107 $threshold = strtod( abs( int( $int1 * $percent ) ) ) || 1;
213             } else {
214             # tolerance is already a threshold
215 1         5 $threshold = $tolerance;
216             }
217              
218             # we've got a threshold, now do the test:
219             # strtod() to get around weird bug:
220             # $dist = 0.05; $threshold = 0.05; $dist <= $threshold; # false ??!?
221 14         59 my $dist = strtod( abs($int2 - $int1) );
222 14 100       74 unless ($Test->ok($dist <= $threshold, $msg)) {
223 3 50       1570 $Test->diag(" test: $msg2") if ($msg ne $msg2);
224 3         209 $Test->diag(" error: distance ($dist) was greater than threshold ($threshold)");
225             }
226             }
227              
228              
229             1;
230              
231             __END__