From 026279bb6a6aafcad461b60f1e3eda9ce5dc03dc Mon Sep 17 00:00:00 2001 From: gargaml Date: Wed, 9 Aug 2017 10:59:17 +0200 Subject: [PATCH] Roman numerals (improvements) (#4402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * First shot at improving roman numeral IA. Added a new converter. Added new default settings. Added some tests. * Added un upper bound on arabic numbers. * Remove some typo… * Added some new triggers. Allowed uncorrect roman number to load the UI anyway. Fixed a test. * Added new triggers. Fixed regular expressions involved in the UI configuration. Added new tests. --- lib/DDG/Goodie/Roman.pm | 81 ++++++++--- share/goodie/roman/roman.css | 29 ++++ share/goodie/roman/roman.handlebars | 11 ++ share/goodie/roman/roman.js | 218 ++++++++++++++++++++++++++++ t/Roman.t | 48 +++--- 5 files changed, 353 insertions(+), 34 deletions(-) create mode 100644 share/goodie/roman/roman.css create mode 100644 share/goodie/roman/roman.handlebars create mode 100644 share/goodie/roman/roman.js diff --git a/lib/DDG/Goodie/Roman.pm b/lib/DDG/Goodie/Roman.pm index befc04f0b..89eaf20b3 100755 --- a/lib/DDG/Goodie/Roman.pm +++ b/lib/DDG/Goodie/Roman.pm @@ -5,35 +5,82 @@ use strict; use DDG::Goodie; use Roman; +use List::Util qw/any/; use utf8; -triggers any => "roman", "arabic"; +triggers startend => "roman", "roman numeral", "roman numerals", "roman number", + "arabic", "arabic numeral", "arabic numerals", "arabic number"; zci is_cached => 1; zci answer_type => "roman_numeral_conversion"; -handle remainder => sub { - my $in = uc shift; - $in =~ s/(?:\s*|in|to|numerals?|number)//gi; +handle query => sub { + my ($query) = @_; - return unless $in; - - my $out; - if ($in =~ /^\d+$/) { - $out = uc(roman($in)); - } elsif ($in =~ /^[mdclxvi]+$/i) { - $in = uc($in); - $out = arabic($in); + # By default, we convert from roman to arabic. + my $input = 'roman'; + my $input_value = ''; + my $output = 'arabic'; + my $output_value = ''; + + # These two lists are used to load the converter without any answer. + my @roman_to_arabic = ( + qr/^roman$/i, + qr/^convert\s+(?:into|to)\s+arabic\s*(numerals?)?$/i + ); + my @arabic_to_roman = ( + qr/^arabic$/i, + qr/^convert\s+(?:into|to)\s+roman\s*(numerals?)?$/i + ); + + # These two lists are used to load the converter with an answer. + my @roman_number_to_arabic = ( + qr/^convert\s+(\D+)\s+(?:into|in|to)\s*arabic\s*(numerals?)?/i, + qr/^roman\s+(?:numerals?)?\s*(\D+)$/i, + qr/^arabic\s+(?:numerals?)?\s*(\D+)$/i, + qr/^(\D+)\s+(?:into|in|to)?\s+arabic\s*(numerals?)?/i + ); + my @arabic_number_to_roman = ( + qr/^convert\s+(\d+)\s+(?:into|in|to)\s*roman\s*(numerals?)?/i, + qr/^roman\s+(?:numerals?)?\s*(\d+)$/i, + qr/^arabic\s+(?:numerals?)?\s*(\d+)$/i, + qr/^(\d+)\s+(?:into|in|to)?\s+roman\s*(numerals?)?/i + ); + + if (any { $query =~ $_ } @roman_to_arabic) { + # Default settings, nothing to do. + } elsif (any { $query =~ $_ } @arabic_to_roman) { + $input = 'arabic'; + $output = 'roman'; + } elsif (any { ($input_value) = $query =~ $_ } @roman_number_to_arabic) { + if (isroman $input_value) { + $input_value = uc $input_value; + $output_value = arabic $input_value; + } else { + $input_value = ''; + } + } elsif (any { ($input_value) = $query =~ $_ } @arabic_number_to_roman) { + $input = 'arabic'; + $output = 'roman'; + $input_value = $input_value; + $output_value = Roman $input_value; + } else { + # In this case, we do not trigger the ia. + return undef; } - return unless $out; - return $out . ' (roman numeral conversion)', structured_answer => { + return 'roman numeral converter', structured_answer => { data => { - title => $out, - subtitle => "Roman numeral conversion: $in" + input => $input, + input_value => $input_value, + output => $output, + output_value => $output_value }, templates => { - group => 'text' + group => 'text', + options => { + subtitle_content => 'DDH.roman.roman' + } } }; }; diff --git a/share/goodie/roman/roman.css b/share/goodie/roman/roman.css new file mode 100644 index 000000000..e0a8f82c1 --- /dev/null +++ b/share/goodie/roman/roman.css @@ -0,0 +1,29 @@ +.converter { + font-size: 1.5em; + font-weight: bold; +} + +.converter__input, .converter__output { + float: left; + width: 30%; +} + +.converter__input__label, .converter__output__label { + display: block; + text-align: center; +} + +.converter__input__field, .converter__output__field { + display: block; + width: 100%; + height: 1.5em; + font-size: 1.5em; + text-align: center; +} + +.converter__equal { + float: left; + width: 10%; + margin-top: 2em; + text-align: center; +} diff --git a/share/goodie/roman/roman.handlebars b/share/goodie/roman/roman.handlebars new file mode 100644 index 000000000..c36750e2d --- /dev/null +++ b/share/goodie/roman/roman.handlebars @@ -0,0 +1,11 @@ +
+
+ + +
+
=
+
+ + +
+
diff --git a/share/goodie/roman/roman.js b/share/goodie/roman/roman.js new file mode 100644 index 000000000..4d3f5ca23 --- /dev/null +++ b/share/goodie/roman/roman.js @@ -0,0 +1,218 @@ +DDH.roman = DDH.roman || {}; + +(function(DDH) { + "use strict"; + + /* Reference: https://en.wikipedia.org/wiki/Roman_numerals + * Inputs can be between 1 and 3999. + * We rely on the substractive notation. + */ + + var romanTable = { + 'I': 1, + 'V': 5, + 'X': 10, + 'L': 50, + 'C': 100, + 'D': 500, + 'M': 1000 + }; + + /* The arabicTable is used to convert an arabic digit into roman characters. + * The field 'before' contains the substractive notation. Hence, we need + * specific cases when converting to roman notation for the digits 4 and 9. + */ + + var arabicTable = { + 1: {digit: 'I', before: 'IV'}, + 5: {digit: 'V'}, + 10: {digit: 'X', before: 'XL'}, + 50: {digit: 'L'}, + 100: {digit: 'C', before: 'CD'}, + 500: {digit: 'D'}, + 1000: {digit: 'M'} + }; + + /* validationTable and conversionTable are used to avoid nested conditionals + * in the conversion process. + */ + + var validationTable = { + 'roman': isRoman, + 'arabic': isArabic + }; + + var conversionTable = { + 'roman': { 'arabic': romanToArabic }, + 'arabic': { 'roman': arabicToRoman } + }; + + /* Roman regular expression + * + * It is composed of 4 blocks: + * - M{0,3} -> from M to MMM + * - (?:D?C{0,3}|C[DM]) -> from C to CM + * - (?:L?X{0,3}|X[LC]) -> from X to XC + * - (?:V?I{0,3}|I[VX]) -> from I to IX + */ + + function isRoman(input) { + var r = /^M{0,3}(?:D?C{0,3}|C[DM])(?:L?X{0,3}|X[LC])(?:V?I{0,3}|I[VX])$/i; + return input != '' && + input.match(r); + } + + /* romanToArabic expects a non-null string representing a roman number. + * + * The algorithm relies on the order of the characters. If c is the + * current character, then there are two cases: Either, c is smaller + * than the previous character we add the value of the roman character. + * Or, c is greater than the previous character. In this case, we are + * in a substractive notation context. So we substract the previously + * added value and we also substract the value of the previous value to + * the value of the current character (hence the -2 * ...). + * The first previous value is initialized to the greatest possible value, + * 1000, to ensure the additive notation context at the beginning. + */ + + function romanToArabic(roman) { + var romanDigits = roman.toUpperCase().split(''); + var previousArabicValue = 1000; + + return romanDigits.reduce(function(acc, romanDigit) { + var arabicValue = romanTable[romanDigit]; + var nextAcc = acc; + + if (arabicValue <= previousArabicValue) { + nextAcc += arabicValue; + } else { + nextAcc += -2 * previousArabicValue + arabicValue; + } + previousArabicValue = arabicValue; + + return nextAcc; + }, 0); + } + + function isArabic(input) { + return input != '0' && + input.match(/\d+/) && + parseInt(input) <= 3999; + } + + /* arabicToRoman expects a non-null string composed of digits. + * + * Because of the restriction on roman numbers, we limit arabic numbers to + * 3999. + * + * Arabic numbers use base 10 and can be expressed by the following + * expression n = a x 10^3 + b x 10^2 + c x 10 + d where a, b, c, d + * are digits between 0 and 9. Hence, we need to consider specific + * cases to convert to roman numbers. + * The process goes from left to right. + * If the value of the current digit is 4 or 9, we need to switch to + * the substractive notation. + * If the value of the current digit is 5, we need to use the right + * component in the roman notation (V, L, D). + * The remaining cases are straightforward. + */ + + function arabicToRoman(arabic) { + var arabicValue = parseInt(arabic); + var previousComponent; + var romanDigits = ''; + + [1000, 100, 10, 1].forEach(function (component) { + var leftDigit = Math.floor(arabicValue / component); + + if (leftDigit >= 1 && leftDigit <= 3) { + var romanDigit = arabicTable[component].digit.repeat(leftDigit); + romanDigits += romanDigit; + } else if (leftDigit == 4) { + var romanDigit = arabicTable[component].before; + romanDigits += romanDigit; + } else if (leftDigit == 5) { + var romanDigit = arabicTable[component * 5].digit; + romanDigits += romanDigit; + } else if (leftDigit >= 6 && leftDigit <= 8) { + var romanDigit = arabicTable[component * 5].digit; + var end = arabicTable[component].digit.repeat(leftDigit - 5); + romanDigits += romanDigit + end; + } else if (leftDigit == 9) { + var romanDigit = arabicTable[previousComponent].digit; + romanDigits += arabicTable[component].digit + romanDigit; + } else { + /* if 0 then there is nothing to do. */ + } + + previousComponent = component; + arabicValue -= component * leftDigit; + }); + + return romanDigits; + } + + function upperCaseFirstLetter (string) { + var first = string.charAt(0); + return first.toUpperCase() + string.slice(1); + } + + function buildConverter(input, output) { + var $root = DDH.getDOM('roman'); + + return { + inputLabel: upperCaseFirstLetter(input), + isInputValid: validationTable[input], + input: $root.find('.converter__input__field'), + outputLabel: upperCaseFirstLetter(output), + isOutputValid: validationTable[output], + output: $root.find('.converter__output__field'), + inputToOutput: conversionTable[input][output], + outputToInput: conversionTable[output][input] + }; + } + + DDH.roman.build = function(ops) { + var input = ops.data.input; + var output = ops.data.output; + var inputValue = ops.data.input_value; + var outputValue = ops.data.output_value; + + return { + onShow: function() { + var $converter = buildConverter(input, output); + + $('.converter__input__label').html($converter.inputLabel); + $('.converter__output__label').html($converter.outputLabel); + $converter.input.val(inputValue); + $converter.output.val(outputValue); + + $converter.input.keyup(function (e) { + var input = $converter.input.val(); + if (input == '') { + $converter.output.val(''); + } else if (! $converter.isInputValid(input)) { + $converter.output.val(''); + } else { + var output = $converter.inputToOutput(input); + $converter.output.val(output); + } + }); + + $converter.output.keyup(function (e) { + var output = $converter.output.val(); + + if (output == '') { + $converter.input.val(''); + } else if (! $converter.isOutputValid(output)) { + $converter.input.val(''); + } else { + var input = $converter.outputToInput(output); + $converter.input.val(input); + } + }); + } + }; + }; + +})(DDH); \ No newline at end of file diff --git a/t/Roman.t b/t/Roman.t index 77928740c..9954564f8 100755 --- a/t/Roman.t +++ b/t/Roman.t @@ -10,32 +10,46 @@ zci answer_type => 'roman_numeral_conversion'; zci is_cached => 1; sub build_test { - my ($text, $input, $answer) = @_; - return test_zci($text, structured_answer => { + my ($input, $input_value, $output, $output_value) = @_; + return test_zci('roman numeral converter', structured_answer => { data => { - title => $answer, - subtitle => "Roman numeral conversion: $input" + input => $input, + input_value => $input_value, + output => $output, + output_value => $output_value }, templates => { - group => 'text' + group => 'text', + options => { + subtitle_content => 'DDH.roman.roman' + } } }); } ddg_goodie_test( [qw( DDG::Goodie::Roman )], - 'roman 155' => build_test('CLV (roman numeral conversion)', '155', 'CLV'), - "roman xii" => build_test("12 (roman numeral conversion)", 'XII', '12'), - "roman mmcml" => build_test("2950 (roman numeral conversion)", 'MMCML', '2950'), - "roman 2344" => build_test("MMCCCXLIV (roman numeral conversion)", '2344', 'MMCCCXLIV'), - "arabic cccxlvi" => build_test("346 (roman numeral conversion)", 'CCCXLVI', '346'), - 'roman numeral MCCCXXXVII' => build_test('1337 (roman numeral conversion)', 'MCCCXXXVII', '1337'), - 'roman 1337' => build_test('MCCCXXXVII (roman numeral conversion)', '1337', 'MCCCXXXVII'), - 'roman IV' => build_test('4 (roman numeral conversion)', 'IV', '4'), - '10 in roman numeral' => build_test('X (roman numeral conversion)', '10', 'X'), - '11 in roman numerals' => build_test('XI (roman numeral conversion)', '11', 'XI'), - 'xiii to arabic' => build_test('13 (roman numeral conversion)', 'XIII', '13'), - '20 to roman numerals' => build_test('XX (roman numeral conversion)', '20', 'XX'), + 'roman 15' => build_test('arabic', '15', 'roman', 'XV'), + "roman xii" => build_test('roman', 'XII', 'arabic', '12'), + "roman mmcml" => build_test('roman', 'MMCML', 'arabic', '2950'), + "roman 2344" => build_test('arabic', '2344', 'roman', 'MMCCCXLIV'), + "arabic cccxlvi" => build_test('roman', 'CCCXLVI', 'arabic', '346'), + 'roman MCCCXXXVII' => build_test('roman', 'MCCCXXXVII', 'arabic','1337'), + 'roman numeral 10' => build_test('arabic', '10', 'roman', 'X'), + 'roman numeral XVI' => build_test('roman', 'XVI', 'arabic', '16'), + 'roman 1337' => build_test('arabic', '1337', 'roman','MCCCXXXVII'), + 'roman IV' => build_test('roman', 'IV', 'arabic', '4'), + '1492 in roman numerals' => build_test('arabic', '1492', 'roman', 'MCDXCII'), + '10 into roman' => build_test('arabic', '10', 'roman', 'X'), + '3999 in roman numeral' => build_test('arabic', '3999', 'roman', 'MMMCMXCIX'), + 'xiii to arabic' => build_test('roman', 'XIII', 'arabic', '13'), + 'CXIII to arabic' => build_test('roman', 'CXIII', 'arabic', '113'), + 'convert XXX into arabic' => build_test('roman', 'XXX', 'arabic', '30'), + 'convert MMCC to arabic' => build_test('roman', 'MMCC', 'arabic', '2200'), + 'convert 15 to roman' => build_test('arabic', '15', 'roman', 'XV'), + 'convert 30 into roman' => build_test('arabic', '30', 'roman', 'XXX'), + 'convert to arabic' => build_test('roman', '', 'arabic', ''), + 'foo to arabic numerals' => build_test('roman', '', 'arabic', '') ); done_testing;