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
parent
27c5c308de
commit
026279bb6a
|
@ -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'
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -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);
|
48
t/Roman.t
48
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;
|
||||
|
|
Loading…
Reference in New Issue