Roman numerals (improvements) (#4402)

* 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.
master
gargaml 2017-08-09 10:59:17 +02:00 committed by Harrison Chienjo
parent 27c5c308de
commit 026279bb6a
5 changed files with 353 additions and 34 deletions

View File

@ -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'
}
}
};
};

View File

@ -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;
}

View File

@ -0,0 +1,11 @@
<form class="converter">
<div class="converter__input">
<label class="converter__input__label">Roman</label>
<input type="text" class="converter__input__field" />
</div>
<div class="converter__equal">=</div>
<div class="converter__output">
<label class="converter__output__label">Arabic</label>
<input type="text" class="converter__output__field" />
</div>
</form>

218
share/goodie/roman/roman.js Normal file
View File

@ -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);

View File

@ -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;