Chinese Zodiac calculation in Perl and Elm
This week the Perl Weekly Challenge task one was about calculating the Chinese Zodiac element and animal. I've solved it in Perl and Elm and share them here. Both I did in a TDD style; the Elm one benefits from my having solved in Perl first I think.
Perl
The Perl version I ended up with this test:
use Test2::V0 -target => 'Zodiac';
is $CLASS->sign_from_year(1938), 'Earth Tiger', '1938 -> Earth Tiger';
is $CLASS->sign_from_year(1967), 'Fire Goat', '1967 -> Fire Goat';
is $CLASS->sign_from_year(1973), 'Water Ox', '1973 -> Water Ox';
is $CLASS->sign_from_year(2003), 'Water Goat', '2003 -> Water Goat';
is $CLASS->sign_from_year(2017), 'Fire Rooster', '2017 -> Fire Rooster';
done_testing;
The accompanying lib file looks like this:
package Zodiac;
use Moo;
sub _build_zodiac_table {
my @elements = (
qw/
Wood
Fire
Earth
Metal
Water
/
);
my @animals = (
qw/
Rat
Ox
Tiger
Rabbit
Dragon
Snake
Horse
Goat
Monkey
Rooster
Dog
Pig
/
);
my $elem_key = 0;
my %table;
for ( my $i = 1; $i <= 61; $i = $i + 2 ) {
$table{$i} = $elements[$elem_key];
$table{ $i + 1 } = $elements[$elem_key];
$elem_key++;
$elem_key = 0 if $elem_key > 4;
}
my $animal_key = 0;
for ( my $i = 1; $i <= 61; $i++ ) {
$table{$i} .= ' ' . $animals[$animal_key];
$animal_key++;
$animal_key = 0 if $animal_key > 11;
}
return \%table;
}
sub sign_from_year {
my ( $self, $year ) = @_;
my $table = _build_zodiac_table();
my $step1 = $year - 3;
my $step2 = int $step1 / 60;
my $step3 = $step1 - ( 60 * $step2 );
return $table->{$step3};
}
1;
I find this a bit clunky, the code that generates the lookup table is a bit mystical and has some magic numbers. As you'll see in the Elm version, actually just having the lookup table as a simple array might be better for readability.
The sign_from_year is pretty simple I think, it "should" reference the formula from the wiki page really. I've left it pretty much as the formula describes... which I think in this case makes sense for a future developer looking at the code. It's not concise, but reflects the "business logic" and the language matches that of the language of the "domain". The wiki page author I hope would be able to see the steps they described in that function. So if I misunderstood the steps, hopefully they would see it without being a Perl developer.
Elm
The equivalent Elm test code looks like this:
module Zodiac_test exposing (..)
import Array
import Browser exposing (element)
import Expect exposing (Expectation)
import Fuzz exposing (Fuzzer, int, list, string)
import Html exposing (footer)
import Test exposing (..)
import Zodiac exposing (..)
sign_from_year_test =
describe "Zodiac sign_from_year"
[ test "1967 -> Fire Goat" <|
\_ ->
let
sign =
Zodiac.sign_from_year 1967
in
Expect.equal sign "Fire Goat"
, test "2017 -> Fire Rooster" <|
\_ ->
let
sign =
Zodiac.sign_from_year 2017
in
Expect.equal sign "Fire Rooster"
, test "1938 -> Earth Tiger" <|
\_ ->
let
sign =
Zodiac.sign_from_year 1938
in
Expect.equal sign "Earth Tiger"
, test "1973 -> Water Ox" <|
\_ ->
let
sign =
Zodiac.sign_from_year 1973
in
Expect.equal sign "Water Ox"
]
table_index_test =
describe "Zodiac table_index"
[ test "1967 -> 44" <|
\_ ->
let
index =
Zodiac.table_index 1967
in
Expect.equal index 44
, test "2017 -> 34" <|
\_ ->
let
index =
Zodiac.table_index 2017
in
Expect.equal index 34
, test "1973" <|
\_ ->
let
index =
Zodiac.table_index 1973
in
Expect.equal index 50
]
table_test =
describe "Zodiac table"
[ test "60 rows" <|
\_ ->
let
ztable =
Zodiac.table
in
Expect.equal (Array.length ztable) 60
, test "row 44 -> Fire Goat" <|
\_ ->
let
ztable =
Zodiac.table
element =
case Array.get 44 ztable of
Just foo ->
foo
Nothing ->
"Error!"
in
Expect.equal element "Fire Goat"
, test "row 34 -> Fire Rooster" <|
\_ ->
let
ztable =
Zodiac.table
element =
case Array.get 34 ztable of
Just foo ->
foo
Nothing ->
"Error!"
in
Expect.equal element "Fire Rooster"
]
And the logic looks like this:
module Zodiac exposing (..)
import Array
table_index : Int -> Int
table_index year =
let
step1 =
year - 3
step2 =
step1 // 60
step3 =
step1 - (60 * step2)
in
step3
sign_from_year : Int -> String
sign_from_year year =
let
zodiac_table =
table
index =
table_index year
in
case Array.get index zodiac_table of
Just sign ->
sign
Nothing ->
"ERROR: year->" ++ String.fromInt year ++ " index->" ++ String.fromInt index
table =
let
zodiac =
[ "" -- Left blank intentionally
, "Wood Rat"
, "Wood Ox"
, "Fire Tiger"
, "Fire Rabbit"
, "Earth Dragon"
, "Earth Snake"
, "Metal Horse"
, "Metal Goat"
, "Water Monkey"
, "Water Rooster"
, "Wood Dog"
, "Wood Pig"
, "Fire Rat"
, "Fire Ox"
, "Earth Tiger"
, "Earth Rabbit"
, "Metal Dragon"
, "Metal Snake"
, "Water Horse"
, "Water Goat"
, "Wood Monkey"
, "Wood Rooster"
, "Fire Dog"
, "Fire Pig"
, "Earth Rat"
, "Earth Ox"
, "Metal Tiger"
, "Metal Rabbit"
, "Water Dragon"
, "Water Snake"
, "Wood Horse"
, "Wood Goat"
, "Fire Monkey"
, "Fire Rooster"
, "Earth Dog"
, "Earth Pig"
, "Metal Rat"
, "Metal Ox"
, "Water Tiger"
, "Water Rabbit"
, "Wood Dragon"
, "Wood Snake"
, "Fire Horse"
, "Fire Goat"
, "Earth Monkey"
, "Earth Rooster"
, "Metal Dog"
, "Metal Pig"
, "Water Rat"
, "Water Ox"
, "Wood Tiger"
, "Wood Rabbit"
, "Fire Dragon"
, "Fire Snake"
, "Earth Horse"
, "Earth Goat"
, "Metal Monkey"
, "Metal Rooster"
, "Water Dog"
]
in
Array.fromList zodiac
You can see that I benefited from having written the Perl solution already, it is a good lesson to any of us. The second implementation of your solution is often better than your first as you learnt a few things doing the first one. Too often, once we do the first implementation we never do a second. Be that in another language like this; or a refactor in the same language.
I use a simple list, rather than the complicated looping I did in Perl. Which for me is more elegant, a new developer coming to my code I think will see the zodiac signs and understand it a lot more easily than the calculated version.
The other thing I do better in the Elm version is break the code into three components, and test them accordingly. With the Perl version I basically just did a "service" test or "integration" test. Though they are all "unit" tests.
The tests in Elm verbose. That may be the way I wrote them, it might be the library. In either case I don't mind. I like the test format, it's descriptive and the verbosity is balanced against specific and readable tests. I am not a fan of super complicated tests when I can't easily see the input, the command call and the expected value. I like that this test tells me what I am testing.
The table_test
may seem a bit excessive when you compare it to the code it is testing. It is, the reason being that I had a different approach when I started (calculating the table) so I had tests to confirm I had the right number of elements. Trying to test and code it proved more difficult than it felt worth doing. So I simplified the code. I did not remove the tests as they kept passing when I changed approach.
Comparing the two implementations, I prefer the Elm one this time (unlike when I did this previously). I think the reason is that this time I started in a TDD style with the Elm, which meant that I broke it into easily testable units. Which helped with the design of the code.
I wish I had recorded my coding up the Elm implementation as the end result does not show the way the TDD approach and an exquisite compiler helped me to quickly take small steps towards the solution. Next time I shall try and be good and do that.
Thanks once again to Mohammad for running the challenge. Please chat with me about this via the @perlkiwi twitter handle.