TER1 (Statement): 98.39%
TER2 (Branch): 95.45%
TER3 (LCSAJ): 100.0% (23/23)
Approximate LCSAJ segments: 45
โ Covered โ this LCSAJ path was executed during testing.
โ Not covered โ this LCSAJ path was never executed. These are the paths to focus on.
Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:
start โ end โ jump
Uncovered paths show [NOT COVERED] in the tooltip.
1: package TimeZone::TimeZoneDB; 2: 3: use strict; 4: use warnings; 5: use autodie qw(:all); 6: 7: use Carp; 8: use CHI; 9: use JSON::MaybeXS; 10: use LWP::UserAgent; 11: use Object::Configure; 12: use Params::Get 0.13; 13: use Params::Validate::Strict 0.10; 14: use Readonly; 15: use Return::Set; 16: use Scalar::Util; 17: use Time::HiRes; 18: use URI; 19: 20: =head1 NAME 21: 22: TimeZone::TimeZoneDB - Interface to L<https://timezonedb.com> for looking up Timezone data 23: 24: =head1 VERSION 25: 26: Version 0.05 27: 28: =cut 29: 30: our $VERSION = '0.05'; 31: 32: # --------------------------------------------------------------------------- 33: # Compile-time constants for the timezonedb.com REST API. 34: # These never vary and are inlined by the compiler. 35: # --------------------------------------------------------------------------- 36: Readonly::Scalar my $API_FORMAT => 'json'; 37: Readonly::Scalar my $API_BY => 'position'; # query by geographic position 38: 39: # printf-style format for the cache key; 6 dp normalises 0.1 and 0.1000000 40: Readonly::Scalar my $CACHE_KEY_FMT => 'tz:%.6f:%.6f'; 41: 42: # Valid coordinate ranges as defined by the WGS-84 standard 43: Readonly::Scalar my $LAT_MIN => -90; 44: Readonly::Scalar my $LAT_MAX => 90; 45: Readonly::Scalar my $LNG_MIN => -180; 46: Readonly::Scalar my $LNG_MAX => 180; 47: 48: # --------------------------------------------------------------------------- 49: # Runtime defaults. Every key may be overridden via Object::Configure, 50: # which reads from a per-class configuration file or environment variables. 51: # --------------------------------------------------------------------------- 52: my %config = ( 53: host => 'api.timezonedb.com', # remote API hostname 54: api_version => 'v2.1', # path component for the API version 55: api_endpoint => 'get-time-zone', # path component for the lookup method 56: cache_expires => '1 day', # CHI expiry string for cached responses 57: min_interval => 0, # minimum seconds between outbound requests 58: ); 59: 60: =head1 SYNOPSIS 61: 62: use TimeZone::TimeZoneDB; 63: 64: my $tzdb = TimeZone::TimeZoneDB->new(key => 'XXXXXXXX'); 65: my $tz = $tzdb->get_time_zone({ latitude => 0.1, longitude => 0.2 }); 66: 67: =head1 DESCRIPTION 68: 69: The C<TimeZone::TimeZoneDB> Perl module provides an interface to the 70: L<https://timezonedb.com> API, enabling users to retrieve timezone data 71: based on geographic coordinates. 72: It supports configurable HTTP user agents, allowing for proxy settings 73: and request throttling. 74: The module includes robust error handling, ensuring proper validation of 75: input parameters and secure API interactions. 76: JSON responses are safely parsed with error handling to prevent crashes. 77: Designed for flexibility, it allows users to override default configurations 78: while maintaining a lightweight and efficient structure for querying timezone 79: information. 80: 81: =over 4 82: 83: =item * Caching 84: 85: Identical requests are cached (using L<CHI> or a user-supplied caching object), 86: reducing the number of HTTP requests to the API and speeding up repeated queries. 87: 88: A cache key is constructed from the normalised coordinates (6 decimal places) 89: so that C<0.1> and C<0.1000000> share the same cache entry. 90: 91: =item * Rate-Limiting 92: 93: A minimum interval between successive API calls can be enforced to ensure that 94: the API is not overwhelmed and to comply with any request throttling requirements. 95: 96: Rate-limiting is implemented using L<Time::HiRes>. 97: A minimum interval between API calls can be specified via the C<min_interval> 98: parameter in the constructor. 99: Before making an API call, the module checks how much time has elapsed since 100: the last request and, if necessary, sleeps for the remaining time. 101: 102: =back 103: 104: =head1 METHODS 105: 106: =head2 new 107: 108: my $tzdb = TimeZone::TimeZoneDB->new(key => 'XXXXX'); 109: 110: # With a throttled user-agent that respects free-tier rate limits 111: use LWP::UserAgent::Throttled; 112: my $ua = LWP::UserAgent::Throttled->new(); 113: $ua->env_proxy(1); 114: $tzdb = TimeZone::TimeZoneDB->new(ua => $ua, key => 'XXXXX'); 115: 116: # Retrieve the timezone for Ramsgate, UK 117: my $tz = $tzdb->get_time_zone({ latitude => 51.34, longitude => 1.42 })->{'zoneName'}; 118: print "Ramsgate timezone: $tz\n"; 119: 120: Creates and returns a new C<TimeZone::TimeZoneDB> instance. 121: When invoked on an existing object rather than a class name, it returns a 122: shallow clone of that object with any supplied parameters merged in. 123: Passing C<ua =E<gt> undef> in a clone call is silently ignored so that the 124: original user-agent is inherited unchanged. 125: 126: =head3 ARGUMENTS 127: 128: =over 4 129: 130: =item C<key> (required) 131: 132: API key for timezonedb.com. Free keys are available at 133: L<https://timezonedb.com/register>. 134: 135: =item C<ua> (optional) 136: 137: An HTTP user-agent object. Must respond to C<get()>. Defaults to a plain 138: L<LWP::UserAgent> with C<gzip,deflate> accepted. 139: 140: =item C<host> (optional) 141: 142: Override the API hostname. Defaults to C<api.timezonedb.com>. 143: 144: =item C<cache> (optional) 145: 146: A L<CHI>-compatible caching object. Defaults to a private in-memory cache 147: with a one-day expiry. 148: 149: =item C<min_interval> (optional) 150: 151: Minimum number of seconds to wait between successive API calls. 152: Defaults to C<0> (no enforced delay). 153: 154: =back 155: 156: =head3 RETURNS 157: 158: A blessed C<TimeZone::TimeZoneDB> reference. 159: Croaks if C<key> is absent. 160: 161: =head3 SIDE EFFECTS 162: 163: None. 164: 165: =head3 NOTES 166: 167: An optional C<logger> key may be passed; if present it must be an object 168: implementing C<warn()> and C<error()> (e.g. L<Log::Log4perl>). 169: 170: =head3 API SPECIFICATION 171: 172: =head4 INPUT 173: 174: { 175: 'key' => { type => 'string' }, 176: 'ua' => { type => 'object', can => 'get', optional => 1 }, 177: 'host' => { type => 'string', optional => 1 }, 178: 'cache' => { type => 'object', optional => 1 }, 179: 'min_interval' => { type => 'number', min => 0, optional => 1 }, 180: } 181: 182: =head4 OUTPUT 183: 184: { type => 'object' } # a blessed TimeZone::TimeZoneDB reference 185: 186: =cut 187: 188: sub new 189: { โ190 โ 195 โ 213โ190 โ 195 โ 0 190: my $class = shift; 191: # Normalise both positional and named calling conventions 192: my $params = Params::Get::get_params(undef, \@_) || {}; 193: 194: # Support function-style call: TimeZone::TimeZoneDB::new() without -> 195: if(!defined($class)) {196: $class = __PACKAGE__; 197: } elsif(Scalar::Util::blessed($class)) { 198: # If $class is an object, clone it with new arguments 199: # Clone path: merge new params over the existing object's fields. 200: if(exists($params->{ua})) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_195_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes201: if(!defined($params->{ua})) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_200_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes202: # ua=>undef means "keep the original" -- silently drop it 203: delete $params->{ua}; 204: } elsif(!Scalar::Util::blessed($params->{ua}) || !$params->{ua}->can('get')) { 205: # A defined ua must be a proper object with a get() method 206: Carp::croak("'ua' argument must be an object with a get() method"); 207: } 208: } 209: return bless { %{$class}, %{$params} }, ref($class); 210: } 211: 212: # Merge any file- or environment-based configuration into $params โ213 โ 220 โ 226โ213 โ 220 โ 0 213: $params = Object::Configure::configure($class, $params); 214: 215: # The API key is the only mandatory argument 216: my $key = $params->{'key'} or Carp::croak("'key' argument is required"); 217: 218: # Build a default user-agent if the caller did not supply one 219: my $ua = $params->{ua}; 220: if(!defined($ua)) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_201_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
221: $ua = LWP::UserAgent->new(agent => __PACKAGE__ . "/$VERSION"); 222: $ua->default_header(accept_encoding => 'gzip,deflate'); 223: } 224: 225: # Prefer an explicit host override, then the package-level default โ226 โ 238 โ 0 226: my $host = $params->{host} || $config{host}; 227: 228: # Fall back to a private in-memory cache if none was supplied 229: my $cache = $params->{cache} || CHI->new( 230: driver => 'Memory', 231: global => 0, 232: expires_in => $config{cache_expires}, 233: ); 234: 235: # Use // so that an explicit 0 (disabled) is not replaced by the default 236: my $min_interval = $params->{min_interval} // $config{min_interval}; 237: 238: return bless { 239: key => $key, 240: min_interval => $min_interval, 241: last_request => 0, # epoch zero: no request has been made yet 242: %{$params}, # pass any extra keys (e.g. logger) through 243: cache => $cache, # computed values override %{$params} copies 244: host => $host, 245: ua => $ua, 246: }, $class; 247: } 248: 249: =head2 get_time_zone 250: 251: my $result = $tzdb->get_time_zone({ latitude => 51.34, longitude => 1.42 }); 252: print $result->{'zoneName'}, "\n"; 253: 254: # Also accepts a Geo::Location::Point-compatible object 255: use Geo::Location::Point; 256: my $ramsgate = Geo::Location::Point->new({ latitude => 51.34, longitude => 1.42 }); 257: my $tz = $tzdb->get_time_zone($ramsgate)->{'zoneName'}; 258: 259: Queries the timezonedb.com API for the IANA timezone name and associated 260: metadata at the supplied geographic coordinates. 261: Identical queries are served from cache without making a network request. 262: 263: =head3 ARGUMENTS 264: 265: =over 4 266: 267: =item C<latitude> (required) 268: 269: Decimal degrees, range C<-90> to C<+90>. 270: 271: =item C<longitude> (required) 272: 273: Decimal degrees, range C<-180> to C<+180>. 274: 275: Alternatively, a single L<Geo::Location::Point>-compatible object (any 276: object implementing C<latitude()> and C<longitude()> methods) may be passed 277: instead of a hash or hashref. 278: 279: =back 280: 281: =head3 RETURNS 282: 283: A hashref containing at least C<zoneName> on success. 284: Returns C<undef> when the API responds with a non-C<OK> status. 285: Croaks on HTTP errors or invalid arguments. 286: 287: =head3 SIDE EFFECTS 288: 289: Updates the internal response cache and the C<last_request> timestamp. 290: 291: =head3 NOTES 292: 293: The API key is transmitted as a URL query parameter because the 294: timezonedb.com API does not support an C<Authorization> header. 295: The key is redacted from all error and warning messages to prevent 296: accidental secret leakage into log aggregators or crash reporters. 297: 298: =head3 API SPECIFICATION 299: 300: =head4 INPUT 301: 302: { 303: 'latitude' => { type => 'number', min => -90, max => 90 }, 304: 'longitude' => { type => 'number', min => -180, max => 180 }, 305: } 306: 307: =head4 OUTPUT 308: 309: Argument error : croak 310: HTTP error : croak 311: Non-OK status : undef 312: Success : { type => 'hashref', min => 1 } 313: 314: =cut 315: 316: sub get_time_zone 317: { โ318 โ 322 โ 331โ318 โ 322 โ 0 318: my $self = shift; 319: my $params; 320: 321: # Accept a Geo::Location::Point-compatible object or a plain hash/hashref 322: if((@_ == 1) && Scalar::Util::blessed($_[0]) && $_[0]->can('latitude')) {
Mutants (Total: 2, Killed: 2, Survived: 0)
323: my $location = $_[0]; 324: $params->{latitude} = $location->latitude(); 325: $params->{longitude} = $location->longitude(); 326: } else { 327: $params = Params::Get::get_params(undef, \@_); 328: } 329: 330: # Validate coordinate ranges; croaks on out-of-range or missing values โ331 โ 366 โ 371โ331 โ 366 โ 0 331: $params = Params::Validate::Strict::validate_strict( 332: args => $params, 333: schema => { 334: 'latitude' => { type => 'number', min => $LAT_MIN, max => $LAT_MAX }, 335: 'longitude' => { type => 'number', min => $LNG_MIN, max => $LNG_MAX }, 336: } 337: ); 338: 339: my $latitude = $params->{latitude}; 340: my $longitude = $params->{longitude}; 341: 342: # Params::Validate::Strict silently skips type/range checks for undef values, 343: # so guard explicitly to avoid sprintf warnings and silent URL corruption 344: Carp::croak("Required parameter 'latitude' must be defined") unless defined($latitude); 345: Carp::croak("Required parameter 'longitude' must be defined") unless defined($longitude); 346: 347: # Build the full API URL; key must go in the query string (API requirement) 348: my $uri = URI->new( 349: sprintf('https://%s/%s/%s', 350: $self->{host}, 351: $config{api_version}, 352: $config{api_endpoint} 353: ) 354: ); 355: $uri->query_form( 356: by => $API_BY, 357: lat => $latitude, 358: lng => $longitude, 359: format => $API_FORMAT, 360: key => $self->{'key'}, 361: ); 362: my $url = $uri->as_string(); 363: 364: # Normalise to 6 dp so that 0.1 and 0.1000000 share the same cache slot 365: my $cache_key = sprintf($CACHE_KEY_FMT, $latitude, $longitude); 366: if(my $cached = $self->{cache}->get($cache_key)) {
Mutants (Total: 1, Killed: 1, Survived: 0)
367: return $cached;
368: } 369: 370: # Sleep if needed to honour the caller's minimum inter-request interval โ371 โ 373 โ 378โ371 โ 373 โ 0 371: my $now = time(); 372: my $elapsed = $now - $self->{last_request}; 373: if($elapsed < $self->{min_interval}) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_367_3: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes๐งช Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_367_3: Replace return expression with undef
LOW: Mutation survived, but impact may be minor๐งช Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );Mutants (Total: 4, Killed: 4, Survived: 0)
374: Time::HiRes::sleep($self->{min_interval} - $elapsed); 375: } 376: 377: # Perform the HTTP GET; all transport details are the UA's responsibility โ378 โ 384 โ 393โ378 โ 384 โ 0 378: my $res = $self->{ua}->get($url); 379: 380: # Stamp the request time before any early return so rate-limiting is correct 381: $self->{last_request} = time(); 382: 383: # Redact the API key before including the URL in any error message 384: if($res->is_error()) {
Mutants (Total: 1, Killed: 1, Survived: 0)
385: (my $safe_url = $url) =~ s/key=[^&]*/key=REDACTED/; 386: if(my $logger = $self->{logger}) {
387: $logger->error($safe_url . ' API returned error: ' . $res->status_line()); 388: } 389: Carp::croak($safe_url . ' API returned error: ' . $res->status_line()); 390: } 391: 392: # Safely decode the JSON body; a malformed response is a soft failure โ393 โ 395 โ 404โ393 โ 395 โ 0 393: my $rc; 394: eval { $rc = JSON::MaybeXS->new()->utf8()->decode($res->decoded_content()) }; 395: if($@) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_386_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
396: if(my $logger = $self->{logger}) {
397: $logger->warn("Failed to parse JSON response: $@"); 398: } 399: Carp::carp("Failed to parse JSON response: $@"); 400: return; 401: } 402: 403: # Cache the decoded response before returning so the next caller is served โ404 โ 407 โ 416โ404 โ 407 โ 0 404: $self->{'cache'}->set($cache_key, $rc); 405: 406: # A non-OK API status means the coordinates returned no result 407: if($rc && defined($rc->{'status'}) && ($rc->{'status'} ne 'OK')) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_396_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
408: if(my $logger = $self->{'logger'}) {
409: (my $safe_url = $url) =~ s/key=[^&]*/key=REDACTED/; 410: $logger->warn(__PACKAGE__ . ": $safe_url returns $rc->{status}"); 411: } 412: return; 413: } 414: 415: # Assert the output contract: a non-empty hashref โ416 โ 416 โ 0 416: return Return::Set::set_return($rc, { 'type' => 'hashref', 'min' => 1 }); 417: } 418: 419: =head2 ua 420: 421: # Getter: retrieve the current user-agent 422: my $ua = $tzdb->ua(); 423: $ua->env_proxy(1); 424: 425: # Setter: swap in a throttled agent (returns the new agent for compatibility) 426: use LWP::UserAgent::Throttled; 427: my $new_ua = LWP::UserAgent::Throttled->new(); 428: $new_ua->throttle('timezonedb.com' => 1); 429: $tzdb->ua($new_ua); 430: 431: Gets or sets the HTTP user-agent object used for API requests. 432: The return value is always the current user-agent (after any update), 433: consistent with the convention used by L<LWP::UserAgent> and related 434: packages that expose a C<ua()> accessor. 435: 436: =head3 ARGUMENTS 437: 438: =over 4 439: 440: =item C<ua> (optional) 441: 442: Replacement user-agent object. Must implement a C<get($url)> method. 443: Omit to use this method as a getter. 444: 445: =back 446: 447: =head3 RETURNS 448: 449: The user-agent object stored on the instance -- the supplied value when 450: called as a setter, the existing value when called as a getter. 451: Croaks if a defined but invalid object (no C<get()> method) is supplied, 452: or if C<undef> is explicitly passed. 453: 454: =head3 SIDE EFFECTS 455: 456: When used as a setter, all subsequent API calls on this object use the new 457: user-agent. 458: 459: =head3 NOTES 460: 461: Free timezonedb.com accounts are rate-limited to one request per second. 462: Use L<LWP::UserAgent::Throttled> to enforce this transparently. 463: 464: The accessor always returns the user-agent rather than C<$self> so that 465: callers can do C<$tzdb-E<gt>ua()-E<gt>env_proxy(1)> in a single expression 466: without ambiguity about what was returned. 467: 468: =head3 API SPECIFICATION 469: 470: =head4 INPUT 471: 472: # Getter (no argument) 473: {} 474: 475: # Setter 476: { 'ua' => { type => 'object', can => 'get' } } 477: 478: =head4 OUTPUT 479: 480: { type => 'object' } # the stored user-agent (getter or setter) 481: 482: =cut 483: 484: sub ua { โ485 โ 495 โ 502โ485 โ 495 โ 0 485: my $self = shift; 486: 487: # Getter path: no arguments, return the stored agent immediately 488: return $self->{ua} unless @_;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_408_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes489: 490: # Params::Get::get_params('ua', \@_) mis-routes the named form ua(ua => $ref) 491: # into { ua => [$key, $ref] } because its $array_ref path fires before the 492: # even-count hash path when the value is a reference (Params::Get line 258). 493: # Detect and fix the named-pair form manually before passing to validate_strict. 494: my $args; 495: if(@_ == 2 && defined($_[0]) && !ref($_[0]) && $_[0] eq 'ua') {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_488_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes๐งช Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_488_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor๐งช Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );496: $args = { ua => $_[1] }; # named: ua(ua => $obj) 497: } else { 498: $args = Params::Get::get_params('ua', \@_); # positional: ua($obj) 499: } 500: 501: # Validate that the supplied object implements the interface we depend on โ502 โ 514 โ 522โ502 โ 514 โ 0 502: my $params = Params::Validate::Strict::validate_strict( 503: args => $args, 504: schema => { 505: ua => { 506: type => 'object', 507: can => 'get', 508: } 509: } 510: ); 511: 512: # Params::Validate::Strict skips the type check for undef 'object' params, 513: # so we must guard explicitly to prevent silent corruption of $self->{ua} 514: if(!defined($params->{ua})) {Mutants (Total: 2, Killed: 0, Survived: 2)
- NUM_BOUNDARY_495_8_!=: Numeric boundary flip == to !=
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- COND_INV_495_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes515: if(my $logger = $self->{'logger'}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_514_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes516: $logger->error('ua() requires a defined value'); 517: } 518: Carp::croak('ua() requires a defined value'); 519: } 520: 521: # Store the new agent and return it, consistent with LWP::UserAgent convention โ522 โ 523 โ 0 522: $self->{ua} = $params->{ua}; 523: return $self->{ua};Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_515_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes524: } 525: 526: =head1 AUTHOR 527: 528: Nigel Horne, C<< <njh@nigelhorne.com> >> 529: 530: This library is free software; you can redistribute it and/or modify 531: it under the same terms as Perl itself. 532: 533: Lots of thanks to the folks at L<https://timezonedb.com>. 534: 535: =head1 BUGS 536: 537: This module is provided as-is without any warranty. 538: 539: Please report any bugs or feature requests to C<bug-timezone-timezonedb at rt.cpan.org>, 540: or through the web interface at 541: L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=TimeZone-TimeZoneDB>. 542: I will be notified, and then you'll automatically be notified of progress 543: on your bug as I make changes. 544: 545: =head1 SEE ALSO 546: 547: =over 4 548: 549: =item * TimezoneDB API: L<https://timezonedb.com/api> 550: 551: =item * L<Test Dashboard|https://nigelhorne.github.io/TimeZone-TimeZoneDB/coverage/> 552: 553: =back 554: 555: =encoding utf-8 556: 557: =head2 FORMAL SPECIFICATION 558: 559: =head3 new 560: 561: TimeZoneDB-State ::= [ 562: key : STRING ; 563: ua : USERAGENT ; 564: host : STRING ; 565: cache : CACHE ; 566: min_interval : â ; 567: last_request : â 568: ] 569: 570: Init 571: key? : STRING 572: ua? : USERAGENT ⪠{â¥} 573: host? : STRING ⪠{â¥} 574: cache? : CACHE ⪠{â¥} 575: min_interval? : â ⪠{â¥} 576: result! : TimeZoneDB-State 577: ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 578: key? â "" â§ 579: result!.key = key? â§ 580: result!.ua = (if ua? â ⥠then ua? else DefaultUA) â§ 581: result!.host = (if host? â ⥠then host? else config.host) â§ 582: result!.cache = (if cache? â ⥠then cache? else NewCache) â§ 583: result!.min_interval = (if min_interval? â ⥠then min_interval? else 0) â§ 584: result!.last_request = 0 585: 586: =head3 get_time_zone 587: 588: GetTimeZone 589: Î TimeZoneDB-State (writes cache and last_request) 590: lat? : {n : â | -90 ⤠n ⤠90} 591: lng? : {n : â | -180 ⤠n ⤠180} 592: result! : HASHREF ⪠{â¥} 593: ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 594: let k == sprintf(CACHE_KEY_FMT, lat?, lng?) 595: â§ cache.has(k) â 596: result! = cache.get(k) 597: â§ last_request' = last_request 598: â§ cache' = cache 599: ⧠¬cache.has(k) â 600: let r == ua.get(ApiUrl(lat?, lng?, key)) 601: ⧠¬r.ok â ⥠602: â§ r.ok â§ r.json.status = "OK" â 603: result! = r.json 604: â§ cache' = cache â {k ⦠r.json} 605: â§ last_request' = now 606: â§ r.ok â§ r.json.status â "OK" â 607: result! = ⥠608: â§ cache' = cache 609: â§ last_request' = now 610: 611: =head2 ua 612: 613: UA 614: Delta TimeZoneDB-State 615: ua? : USERAGENT ⪠{â¥} (⥠= not supplied) 616: ua! : USERAGENT 617: ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 618: (ua? = ⥠⧠ua' = ua) ⨠619: (ua? â ⥠⧠defined(ua?) â§ ua? can 'get' 620: â§ ua' = ua? 621: â§ â x : {key, host, cache, min_interval, last_request} ⢠x' = x) 622: â§ ua! = ua' 623: 624: =head1 LICENSE AND COPYRIGHT 625: 626: Copyright 2023-2026 Nigel Horne. 627: 628: Usage is subject to the GPL2 licence terms. 629: If you use it, 630: please let me know. 631: 632: =cut 633: 634: 1;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_523_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes๐งช Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_523_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor๐งช Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );