diff --git a/macros/math/nondecimal_base.pl b/macros/math/nondecimal_base.pl new file mode 100644 index 0000000000..8fa46a3b35 --- /dev/null +++ b/macros/math/nondecimal_base.pl @@ -0,0 +1,147 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +=head1 NAME + +nondecimal_base.pl - Handles conversions to and from non-decimal bases. + +=head1 DESCRIPTION + +The subroutine C converts nubmers to and from bases up to hexadecimal. + + $x = 47; + $x2 = convertBase($x, to => 2); + + $y16 = convertBase('2EF9', from => 16); + +This can be used in problems with base conversion. + + $x = random(200,500); + $x_16 = Real(convertBase($x, to => 16)); + + BEGIN_PGML + Convert the number [$x] (in decimal) to base-16 + + [$x] = [___]{$x_16} [`_{16}`] + END_PGML + +=cut + +sub _nondecimal_base_init { } + +=head2 convertBase + +Convert positive integers to and from non-decimal bases. + + convertBase($x, %opts) + +where C<$x> is an integer or string representation of a string. If the base (in either the +C or C options is greater than 1, the standard digits 'ABCDEF' are used for the next +6 digits.) + +If instead, you wish to use other characters, the option C is an arrayref of digits. +See an example below. + +The subroutine checks to ensure that the input number contain only provided digits (in the given +base), that the C and C options are only between 2 and 16 and that the C +option is an array ref. + +=head3 Options + +=over + +=item * + +C the base to convert the integer to. C should be an integer between 2 and 16. The +default is 10. + +=item * + +C the base that the number C<$x> is in. C should be an integer between 2 and 16. +The default is 10. + +=item * + +C is an arrayref of digits to use. The default is C<[0..9, 'A'..'F']> + +=back + +=head3 Examples + +=over + +=item * + +C 2)> returns C<1010111> + +=item * + +C 16)> returns C<40936> + +=item * + +The standard digits up to hexadecimal is 0..9,A,B,..,F. You can use non-standard digits +with the C option. The following uses 'T' and 'E' for the digit ten and eleven in +base 12. + + convertBase(56, to => 12, digits = [0..9,'T','E']) + +=back + +=cut + +sub convertBase { + my ($value, %opts) = @_; + $from = $opts{from} // 10; + $to = $opts{to} // 10; + + return Value::Error('The option digits must be an array ref of length at least the larger of to/from.') + if defined($opts{digits}) + && (ref($opts{digits}) ne 'ARRAY' || scalar(@{ $opts{digits} }) < $to || scalar(@{ $opts{digits} }) < $from); + my @digits = + defined($opts{digits}) && ref($opts{digits}) eq 'ARRAY' ? @{ $opts{digits} } : ('0' .. '9', 'A' .. 'F'); + + return Value::Error('The base of conversion must be between 2 and 16') + unless $from >= 2 && $from <= 16 && $to >= 2 && $to <= 16; + + # regular expression only of the digits up to from. + my $digre = '^[' . join('', @digits[ 0 .. ($from - 1) ]) . ']+$'; + return Value::Error('The input number must consist only of the digits') unless $value =~ qr/$digre/; + + # The value in base 10 + my $val_10 = 0; + # convert $value to base 10 if not in that base + if ($from != 10) { + my $from_b = 1; + for my $ch (reverse($value =~ m/./g)) { + # convert + my $v = (grep { $digits[$_] eq $ch } (0 .. $#digits))[0]; + $val_10 += $v * $from_b; + $from_b *= $from; + } + } else { + $val_10 = $value; + } + return $val_10 if $to == 10; + + # Convert to the $to base + my $val_b = ''; + do { + my $dig = $val_10 % $to; + $val_10 = int($val_10 / $to); + $val_b = "$digits[$dig]$val_b"; + } while ($val_10 > 0); + return $val_b; +} diff --git a/t/macros_math/nondecimal_base.t b/t/macros_math/nondecimal_base.t new file mode 100644 index 0000000000..32a4aae041 --- /dev/null +++ b/t/macros_math/nondecimal_base.t @@ -0,0 +1,114 @@ +#!/usr/bin/env perl + +=head1 nondecimal_base + +Tests conversion of integers to non-decimal bases. + +=cut + +use Test2::V0 '!E', { E => 'EXISTS' }; + +die "PG_ROOT not found in environment.\n" unless $ENV{PG_ROOT}; +do "$ENV{PG_ROOT}/t/build_PG_envir.pl"; + +loadMacros('nondecimal_base.pl'); + +subtest 'conversion from a non-decimal base to base 10' => sub { + is convertBase('101010', from => 2), 42, 'convert from base 2'; + is convertBase('44011', from => 5), 3006, 'convert from base 5'; + is convertBase('5073', from => 8), 2619, 'convert from base 8'; + is convertBase('98A', from => 12), 1402, 'convert from base 12'; + is convertBase('98T', from => 12, digits => [ '0' .. '9', 'T', 'E' ]), 1402, + 'convert from base 12 with non-standard digits'; + is convertBase('9FE8', from => 16), 40936, 'convert from base 16'; +}; + +subtest 'Convert from decimal to non-decimal bases' => sub { + is convertBase(12, to => 2), '1100', 'convert to base 2'; + is convertBase(47, to => 2), '101111', 'convert to base 2'; + + is convertBase(98, to => 5), '343', 'convert to base 5'; + is convertBase(761, to => 5), '11021', 'convert to base 5'; + + is convertBase(519, to => 8), '1007', 'convert to base 8'; + is convertBase(2023, to => 8), '3747', 'convert to base 8'; + + is convertBase(853, to => 12), '5B1', 'convert to base 12'; + is convertBase(2023, to => 12), '1207', 'convert to base 12'; + is convertBase(1678, to => 12, digits => [ '0' .. '9', 'T', 'E' ]), 'E7T', + 'convert to base 12 using non-standard digits'; + + is convertBase(5752, to => 16), '1678', 'convert to base 16'; + is convertBase(41446, to => 16), 'A1E6', 'convert to base 16'; +}; + +subtest 'Check that errors are returned for illegal arguments' => sub { + like( + dies { convertBase('10E3', to => 16) }, + qr/The input number must consist only of the digits/, + 'The input number (base 10) doesn\'t consist of the given digits' + ); + like( + dies { convertBase('10201', from => 2) }, + qr/The input number must consist only of the digits/, + 'The input number (base 2) doesn\'t consist of the given digits' + ); + like( + dies { convertBase('807', from => 8) }, + qr/The input number must consist only of the digits/, + 'The input number (base 8) doesn\'t consist of the given digits' + ); + like( + dies { convertBase('930C', from => 12) }, + qr/The input number must consist only of the digits/, + 'The input number (base 12) doesn\'t consist of the given digits' + ); + like( + dies { convertBase('930A', from => 12, digits => [ 0 .. 9, 'T', 'E' ]) }, + qr/The input number must consist only of the digits/, + 'The input number (base 12) doesn\'t consist of the given digits (provided)' + ); +}; + +subtest 'Check that errors are returned for illegal options' => sub { + + like( + dies { convertBase(87, to => 14, digits => [ 0 .. 9, 'T' ]) }, + qr/The option digits must be an array ref/, + 'The digits option must have enough digits.' + ); + like( + dies { convertBase(87, from => 12, digits => [ 0 .. 9, 'T' ]) }, + qr/The option digits must be an array ref/, + 'The digits option must have enough digits.' + ); + like( + dies { convertBase(87, to => 8, digits => (0 .. 7)) }, + qr/The option digits must be an array ref/, + 'The digits option must be an array ref.' + ); + like( + dies { convertBase(87, to => 1) }, + qr/The base of conversion must be between 2 and 16/, + 'The to option must be between 2 and 16.' + ); + like( + dies { convertBase(87, to => 24) }, + qr/The base of conversion must be between 2 and 16/, + 'The to option must be between 2 and 16.' + ); + + like( + dies { convertBase('0110101', from => 1) }, + qr/The base of conversion must be between 2 and 16/, + 'The from option must be between 2 and 16.' + ); + like( + dies { convertBase(87, from => 24) }, + qr/The base of conversion must be between 2 and 16/, + 'The from option must be between 2 and 16.' + ); + +}; + +done_testing;