iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Modern Web

React TDD 實戰:用 Vitest 打造可靠的前端應用系列 第 13

Day 13 - 擴展到百位數(11-100)

  • 分享至 

  • xImage
  •  

回顧昨天的成果

昨天我們成功建立了處理 1-10 的羅馬數字轉換器。今天我們要用 TDD 來擴展到 100!

本日學習地圖 🗺️

開始 → 測試 11-19 → 處理 20-39 → 挑戰 40 (XL)
  ↓
完成 ← 模式總結 ← 處理 90-100 ← 引入 50 (L)

發現問題:處理多個十位數

讓我們從 11 開始測試。建立 tests/day13/roman-numerals.test.ts

import { describe, it, expect } from 'vitest'
import { toRoman } from '../../src/roman/romanNumerals'

describe('Roman Numerals Extended', () => {
  it('converts 11 to XI', () => {
    expect(toRoman(11)).toBe('XI')
  })
})

執行測試...紅燈!現在的實作無法處理 11。我們需要用迴圈來重複處理十位數。

修正算法:重複處理邏輯

問題在於我們需要用 while 迴圈來重複處理相同的符號:

// 處理 10 - 改用 while 重複處理
while (num >= 10) {
  result += 'X'
  num -= 10
}

現在測試通過了!讓我們繼續測試更多數字。

挑戰數字 20:重複的 X

it('converts 20 to XX', () => {
  expect(toRoman(20)).toBe('XX')
})

it('converts 27 to XXVII', () => {
  expect(toRoman(27)).toBe('XXVII')
})

綠燈!因為我們用了 while 迴圈,20 會產生兩個 'X'。

挑戰數字 40:新的減法規則

羅馬數字中,40 不是 "XXXX",而是 "XL"(50-10)。這是另一個減法規則!

it('converts 40 to XL', () => {
  expect(toRoman(40)).toBe('XL')
})

紅燈!輸出是 "XXXX"。我們需要加入 40 的映射。

這裡我們發現了一個重要原則:減法組合(如 XL)必須放在對應的基本符號(如 X)之前,否則會被錯誤地處理成多個重複符號。

處理數字 50:L 符號

it('converts 50 to L', () => {
  expect(toRoman(50)).toBe('L')
})

it('converts 67 to LXVII', () => {
  expect(toRoman(67)).toBe('LXVII')
})

我們需要加入 50(L)的處理。這代表了羅馬數字中的五十位數基礎單位。

挑戰數字 90:XC 的減法規則

90 在羅馬數字中是 "XC"(100-10),不是 "LXXXX":

it('converts 90 to XC', () => {
  expect(toRoman(90)).toBe('XC')
})

it('converts 99 to XCIX', () => {
  expect(toRoman(99)).toBe('XCIX')
})

最終挑戰:數字 100(C)

it('converts 100 to C', () => {
  expect(toRoman(100)).toBe('C')
})

重構時間:清理演算法

我們發現了一個模式:羅馬數字轉換本質上是一個「查表」過程,我們總是選擇最大可能的符號:

export function toRoman(num: number): string {
  const values = [
    { value: 100, symbol: 'C' },
    { value: 90, symbol: 'XC' },
    { value: 50, symbol: 'L' },
    { value: 40, symbol: 'XL' },
    { value: 10, symbol: 'X' },
    { value: 9, symbol: 'IX' },
    { value: 5, symbol: 'V' },
    { value: 4, symbol: 'IV' },
    { value: 1, symbol: 'I' }
  ]
  
  let result = ''
  
  for (const { value, symbol } of values) {
    while (num >= value) {
      result += symbol
      num -= value
    }
  }
  
  return result
}

這個重構版本更簡潔、更清晰!

為什麼這個方法更好?

  1. 可讀性:用物件陣列清楚地表達所有的映射關係
  2. 可擴展性:新增新的數値-符號對只需要添加到陣列中
  3. 維護性:所有的映射關係都集中在一個地方
  4. 測試友好:更容易驗證和調試

全面測試驗證

讓我們加入完整的測試來驗證我們的實作:

describe('Roman Numerals Extended - Full Coverage', () => {
  const testCases = [
    { input: 1, expected: 'I' },
    { input: 4, expected: 'IV' },
    { input: 9, expected: 'IX' },
    { input: 10, expected: 'X' },
    { input: 11, expected: 'XI' },
    { input: 20, expected: 'XX' },
    { input: 40, expected: 'XL' },
    { input: 44, expected: 'XLIV' },
    { input: 50, expected: 'L' },
    { input: 90, expected: 'XC' },
    { input: 99, expected: 'XCIX' },
    { input: 100, expected: 'C' }
  ]
  
  testCases.forEach(({ input, expected }) => {
    it(`converts ${input} to ${expected}`, () => {
      expect(toRoman(input)).toBe(expected)
    })
  })
})

所有測試都通過!

今天的完整程式碼

完整實作 src/roman/romanNumerals.ts

export function toRoman(num: number): string {
  const values = [
    { value: 100, symbol: 'C' },
    { value: 90, symbol: 'XC' },
    { value: 50, symbol: 'L' },
    { value: 40, symbol: 'XL' },
    { value: 10, symbol: 'X' },
    { value: 9, symbol: 'IX' },
    { value: 5, symbol: 'V' },
    { value: 4, symbol: 'IV' },
    { value: 1, symbol: 'I' }
  ]
  
  let result = ''
  
  for (const { value, symbol } of values) {
    while (num >= value) {
      result += symbol
      num -= value
    }
  }
  
  return result
}

完整測試 tests/day13/roman-numerals.test.ts

import { describe, it, expect } from 'vitest'
import { toRoman } from '../../src/roman/romanNumerals'

describe('Roman Numerals Extended', () => {
  const testCases = [
    { input: 1, expected: 'I' },
    { input: 4, expected: 'IV' },
    { input: 9, expected: 'IX' },
    { input: 10, expected: 'X' },
    { input: 11, expected: 'XI' },
    { input: 20, expected: 'XX' },
    { input: 40, expected: 'XL' },
    { input: 44, expected: 'XLIV' },
    { input: 50, expected: 'L' },
    { input: 90, expected: 'XC' },
    { input: 99, expected: 'XCIX' },
    { input: 100, expected: 'C' }
  ]
  
  testCases.forEach(({ input, expected }) => {
    it(`converts ${input} to ${expected}`, () => {
      expect(toRoman(input)).toBe(expected)
    })
  })
})

發現的演算法模式

透過 TDD,我們發現了羅馬數字轉換的核心模式:

  1. 貪婪算法:總是選擇最大可能的符號,保證產生最短的羅馬數字表示
  2. 查表法:預定義所有可能的符號-數值對,讓代碼清晰可維護
  3. 減法優先:減法規則(IV, IX, XL, XC)必須在對應的加法之前
  4. 大到小順序:從最大的數值開始處理

驗證我們的實作

npm test tests/day13

所有測試都應該通過!我們現在有一個可以處理 1-100 所有數字的羅馬數字轉換器。

TDD 帶來的洞察

今天的 TDD 過程教會了我們:

  1. 問題驅動:每個新測試都揭露了演算法的不足
  2. 漸進改進:從簡單的 if-else 到優雅的查表法
  3. 重構價值:最終的查表法比原始實作清晰得多
  4. 模式識別:透過測試不同案例,發現了貪婪算法的模式

演算法複雜度分析

  • 時間複雜度:O(1) - 因為符號表是固定大小的
  • 空間複雜度:O(1) - 只使用固定的額外空間
  • 可維護性:高 - 代碼結構清晰,易於理解
  • 可擴展性:高 - 新增符號只需要修改查表

思考練習

在繼續之前,思考這些問題:

  • 如果要擴展到更大的數字,需要加入哪些符號?
  • 我們的查表法還需要哪些改進?
  • 這個演算法的擴展性如何?

提示:擴展到更大的數字

如果要處理更大的數字,我們需要:

  • D (500):五百的符號
  • CD (400):四百的減法組合
  • M (1000):一千的符號
  • CM (900):九百的減法組合

重點回顧

今天我們學到了:

  • 如何從 1-10 擴展到 1-100
  • 新的減法規則:XL(40)和 XC(90)
  • 新的基本符號:L(50)和 C(100)
  • 貪婪算法的應用
  • 查表法的優雅實作
  • 從複雜條件邏輯重構到簡潔迴圈

TDD 的威力

透過小步迭代,我們最終得到了優雅而高效的解決方案!每一個紅燈都指出了問題,每一個綠燈都確認了解決方案,而重構則讓我們的代碼越來越精練。


上一篇
Day 12 - 基本符號轉換(1-10) 🔢
下一篇
Day 14 - 完整範圍實作(1-3999) 🏛️
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言