[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.5 percent 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`
1. 每兩個月的折舊比例:**0.5 percent**

所以四個參數,分別為:

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

回傳 `int[]` 的兩個結果:

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

---
##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 元,會被落地折舊價所影響。

![一個月後可買新車的測試用例](http://upload-images.jianshu.io/upload_images/4291938-99414db7e600c294.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

![生產代碼迭代差異](http://upload-images.jianshu.io/upload_images/4291938-18d8d5d5e1a12270.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

##Step 4, 新增舊車換新車剛好結清的測試用例,nbMonths_old_7000_new_8000_perMonthSave_985_percentLoss_1point5
測試代碼:

![一個月後剛好結清的測試用例](http://upload-images.jianshu.io/upload_images/4291938-d345a2366ce82068.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

![修正 while condition bug](http://upload-images.jianshu.io/upload_images/4291938-53477d441c34f269.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

![重構:移除不必要的判斷式](http://upload-images.jianshu.io/upload_images/4291938-02e5efd208c1ae73.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

![重構:合併重複的折舊算法](http://upload-images.jianshu.io/upload_images/4291938-82eb07f78eebc532.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

##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%。

![加入每兩個月折舊一次的生產代碼](http://upload-images.jianshu.io/upload_images/4291938-d308af8ef7189d2d.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

![fix bug](http://upload-images.jianshu.io/upload_images/4291938-38cf656949633035.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

重構:

1. 將每兩個月折舊遞減的 0.5% 抽到 const:`LossRatioBiMonth`
1. 去除一開始因應第一個測試案例的判斷式: `if (startPriceOld >= startPriceNew) `,因為商業邏輯以被後面的 do while 迴圈涵蓋。
1. 將 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 上所有測試案例

![pass all test cases from Codewars](http://upload-images.jianshu.io/upload_images/4291938-468d7efbee955ba2.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

![沒有 baby step 的副作用](http://upload-images.jianshu.io/upload_images/4291938-cc94d245d67cddd4.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

> GitHub commit history: [Buying A Car](https://github.com/hatelove/BuyingACar/commits/master)
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容