[TDD][Codewars] Buying A Car

Codewars 上隨機挑到的一個題目:Buying A Car

 

題目描述:

A man has a rather old car being worth $2000. He saw a secondhand car being worth $8000. He wants to keep his old car until he can buy the secondhand one.

He thinks he can save $1000 each month but the prices of his old car and of the new one decrease of 1.5 percent per month. Furthermore the percent of loss increases by a fixed 0.5percent at the end of every two months.

Example of percents lost per month:

If, for example, at the end of first month the percent of loss is 1, end of second month percent of loss is 1.5, end of third month still 1.5, end of 4th month 2 and so on ...

Can you help him? Our man finds it difficult to make all these calculations.

How many months will it take him to save up enough money to buy the car he wants, and how much money will he have left over?

Parameters and return of function:

parameter (positive int, guaranteed) startPriceOld (Old car price)
parameter (positive int, guaranteed) startPriceNew (New car price)
parameter (positive int, guaranteed) savingperMonth 
parameter (positive float or int, guaranteed) percentLossByMonth

nbMonths(2000, 8000, 1000, 1.5) should return [6, 766] or (6, 766)

where 6 is the number of months at the end of which he can buy the new car and 766 is the nearest integer to '766.158...' .

Note: Selling, buying and saving are normally done at end of month. Calculations are processed at the end of each considered month but if, by chance from the start, the value of the old car is bigger than the value of the new one or equal there is no saving to be made, no need to wait so he can at the beginning of the month buy the new car:

nbMonths(12000, 8000, 1000, 1.5) should return [0, 4000]
nbMonths(8000, 8000, 1000, 1.5) should return [0, 0]

We don't take care of a deposit of savings in a bank.


這一題最難的在於題目講得不清不楚,而不是實現的演算法。
題目說明: 舊車換新車,當每個月存多少錢之後,應該在幾個月後可以買車,還剩下多少錢。

不論舊車或新車都有兩種折舊的方式:

  1. 落地折舊價比例:題目中參數的 percentLossByMonth
  2. 每兩個月的折舊比例:0.5 percent

所以四個參數,分別為:

  1. startPriceOld:舊車目前的價格
  2. startPriceNew:新車目前的價格
  3. savingperMonth:每個月要存的錢
  4. percentLossByMonth:落地折舊價比例

回傳 int[] 兩個結果:

  1. 幾個月之後可以買
  2. 買完之後還剩下多少錢

Step 1, 新增測試案例,nbMonths_old_2000_new_8000_perMonthSave_1000_percentLoss_1point5

測試代碼:

    [Test]
    public void nbMonths_old_12000_new_8000_perMonthSave_1000_percentLoss_1point5()
    {
        int[] r = new int[] { 0, 4000 };
        Assert.AreEqual(r, BuyCar.nbMonths(12000, 8000, 1000, 1.5f));
    }

生產代碼:

    internal class BuyCar
    {
        public static int[] nbMonths(int startPriceOld, int startPriceNew, int savingperMonth, float percentLossByMonth)
        {
            throw new NotImplementedException();
        }
    }
說明:當舊車的價錢為 12000 塊,比新車的價錢 8000 塊還多出 4000 塊。那期望的結果就是 0 個月就可以換新車,而且現金還剩 4000 元。

Step 2, hard-code 判斷式以通過測試案例

生產代碼:當舊車價錢比新車高時,回傳 {0, 舊車-新車差值} 以通過測試案例。

    internal class BuyCar
    {
        public static int[] nbMonths(int startPriceOld, int startPriceNew, int savingperMonth, float percentLossByMonth)
        {
            var month = 0;
            var leftOverMoney = 0;
            if (startPriceOld >= startPriceNew)
            {
                leftOverMoney = startPriceOld - startPriceNew;
            }

            return new int[] {month, leftOverMoney};
        }
    }

Step 3, 新增測試案例,nbMonths_old_7000_new_8000_perMonthSave_1000_percentLoss_1point5

測試代碼:新車多舊車 1000 元,一個月存 1000 元,會被落地折舊價所影響。

生產代碼:多出一條 condition 分支,使用 do while 迴圈判斷,每個月舊車與新車都要折舊遞減。(因為剛好一個月就結束,所以落地折舊價寫在迴圈裡面沒有問題)

這一步程式碼,邁得有點大。但加入的商業邏輯其實不算多,勉強算違背了 uncle Bob 的 The Three Laws of TDD 跟 baby step 的精神。

Step 4, 新增舊車換新車剛好結清的測試案例,nbMonths_old_7000_new_8000_perMonthSave_985_percentLoss_1point5

測試代碼:

生產代碼:修正 while 迴圈條件的 bug。

重構:移除不必要的判斷式: month % 2 != 0

重構:合併折舊算法,提煉出新舊差值的部分,以差值算折舊即可。

Step 5, 補上每兩個月折舊 0.5% 的測試案例,nbMonths_old_2000_new_8000_perMonthSave_1000_percentLoss_1point5

測試代碼:折舊率每個月遞減的情況為 {0.985, 0.98, 0.98, 0.975, 0.975, 0.97}第一個月吃落地價折舊比例,第二個月開始,每兩個月遞減 0.5%

    [Test]
    public void nbMonths_old_2000_new_8000_perMonthSave_1000_percentLoss_1point5()
    {
        int[] r = new int[] { 6, 766 };
        //{ 0.985 , 0.98 , 0.98 , 0.975 , 0.975 , 0.97 }
        //new one after 6 month: 6978.4558389
        //old one after 6 month: 1744.613959725

        Assert.AreEqual(r, BuyCar.nbMonths(2000, 8000, 1000, 1.5f));
    }

生產代碼調整:將落地價先抽到 do while 迴圈以外,因為只需計算一次。透過 month % 2 判斷這次的月份是否需折舊遞減 0.5%。

調整生產代碼:commit 前不小心刪掉了 month++ 的代碼,所以要 fix bug。

重構:

  1. 將每兩個月折舊遞減的 0.5% 抽到 const:LossRatioBiMonth
  2. 去除一開始因應第一個測試案例的判斷式: if (startPriceOld >= startPriceNew) ,因為商業邏輯以被後面的 do while 迴圈涵蓋。
  3. 將 do while 迴圈改成 while 迴圈,在這例子上更好懂一些。

最終生產代碼版本:

    internal class BuyCar
    {
        private const double LossRatioBiMonth = 0.005d;

        public static int[] nbMonths(int startPriceOld, int startPriceNew, int savingperMonth, float percentLossByMonth)
        {
            double oldOneValue = startPriceOld;
            double newOneValue = startPriceNew;
            var month = 0;
            var savingAmount = 0;

            double ratio = 1 - (double)((decimal)percentLossByMonth / 100);
            while ((oldOneValue + savingAmount) < newOneValue)
            {
                month++;
                savingAmount += savingperMonth;

                ratio -= month % 2 == 0 ? LossRatioBiMonth : 0;

                oldOneValue *= ratio;
                newOneValue *= ratio;
            }

            var leftOverMoney = oldOneValue + savingAmount - newOneValue;

            return new int[] { month, (int)Math.Round(leftOverMoney, 0) };
        }
    }

通過 Codewars 上所有測試案例

結論

這個 Codewars 的 kata 其實是在頗久之前練習的,因為被題目搞得有點毛躁,亂了節奏,所以中間有些步子跨了大步一點。感想就像「讓子彈飛」電影的這一幕:

沒有 PO 可以詢問需求,需求又不清不楚的時候,測試案例真的很難生。

GitHub commit history: Buying A Car

對敏捷開發有興趣的朋友,可以參考我的粉絲專頁:91敏捷開發之路

若需要聯絡我,可以透過粉絲專頁私訊或是側欄的關於我。