Chinese Zodiac calculation in Perl and Elm

Tags:

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.