diff --git a/.gitignore b/.gitignore index 3a85790..1449474 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ # https://dart.dev/guides/libraries/private-files # Created by `dart pub` .dart_tool/ +tdd_*.md diff --git a/README.md b/README.md index e7c506b..d8ef3b1 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,179 @@ gildedRose.updateQuality(); // Updates all items per domain rules --- +### 4. String Calculator ✅ + +**Implementation:** [`lib/string_calculator.dart`](lib/string_calculator.dart) +**Tests:** [`test/string_calculator_test.dart`](test/string_calculator_test.dart) + +A Bug Hunt Kata demonstrating how to use TDD to discover and fix bugs in existing code. Each bug is exposed with a RED test, then fixed with GREEN implementation. + +#### Domain Context + +A simple calculator that sums numbers from a string input with various delimiter support: + +**Features:** + +1. **Empty String:** Returns 0 +2. **Single Number:** Returns that number (`"5"` → 5) +3. **Comma Delimiter:** Sums comma-separated numbers (`"1,2,3"` → 6) +4. **Custom Delimiters:** Supports format `"//[delimiter]\n[numbers]"` (`"//;\n1;2"` → 3) +5. **Ignore Large Numbers:** Numbers > 1000 are ignored (`"2,1001"` → 2) + +#### The Bug Hunt Approach + +**Different from Previous Katas:** This wasn't built test-first. Instead, we started with **buggy working code** and used tests to expose and fix bugs one by one. + +**Bug Hunt Process:** +1. **RED:** Write test exposing a specific bug +2. **GREEN:** Fix only that bug +3. **Commit:** Document the bug found and fixed +4. **Repeat:** Move to next bug + +#### Bugs Found & Fixed + +**Bug #1: Empty String Returns Wrong Value** +- **Bug:** Returned 1 instead of 0 +- **Test:** `expect(calculator.add(''), equals(0))` +- **Fix:** Changed return value from 1 to 0 +- **Commits:** RED → GREEN + +**Bug #2: Single Number Off-By-One** +- **Bug:** Added +1 to parsed number +- **Test:** `expect(calculator.add('5'), equals(5))` +- **Expected:** 5, **Actual:** 6 +- **Fix:** Removed `+ 1` from parsing +- **Commits:** RED → GREEN + +**Bug #3: Summation Loop Misses Last Element** +- **Bug:** Loop condition `i < length - 1` skipped last item +- **Test:** `expect(calculator.add('1,2'), equals(3))` +- **Expected:** 3, **Actual:** 1 +- **Fix:** Changed to `i < length` +- **Commits:** RED → GREEN + +**Bug #4: Custom Delimiter Not Extracted** +- **Bug:** Delimiter extraction line was commented out +- **Test:** `expect(calculator.add('//;\n1;2'), equals(3))` +- **Error:** FormatException trying to parse '1;2' +- **Fix:** Uncommented `delimiter = parts[0].substring(2)` +- **Commits:** RED → GREEN + +**Bug #5: Missing Feature - Ignore Numbers > 1000** +- **Bug:** All numbers included in sum +- **Test:** `expect(calculator.add('2,1001'), equals(2))` +- **Expected:** 2, **Actual:** 1003 +- **Fix:** Added `.where((n) => n <= 1000)` filter +- **Commits:** RED → GREEN + +#### Usage + +```dart +import 'package:tdd_katas/string_calculator.dart'; + +final calculator = StringCalculator(); + +calculator.add(''); // Returns: 0 +calculator.add('5'); // Returns: 5 +calculator.add('1,2,3'); // Returns: 6 +calculator.add('//;\n1;2'); // Returns: 3 +calculator.add('2,1001'); // Returns: 2 (1001 ignored) +``` + +#### The "Aha!" Moments + +1. **Tests as Bug Detectors:** Each test acted like a spotlight, illuminating exactly ONE bug at a time. No guessing—the test tells you what's broken. + +2. **RED-GREEN Still Works:** Even when fixing bugs (not adding features), the RED-GREEN rhythm provides safety. You're never fixing blind. + +3. **Regression Prevention:** After fixing each bug, ALL previous tests stay green. This proves you didn't break something while fixing something else. + +4. **Incremental Debugging:** Fixing one bug at a time with commits creates a clear audit trail. You can see exactly what each bug was and how it was fixed. + +5. **Real-World Skill:** This mirrors production work—most code you touch is existing code with bugs, not greenfield TDD. + +--- + +### 5. Mars Rover ✅ + +**Implementation:** [`lib/mars_rover.dart`](lib/mars_rover.dart) +**Tests:** [`test/mars_rover_test.dart`](test/mars_rover_test.dart) + +A Command Pattern kata simulating a robotic rover navigating a plateau on Mars. Demonstrates clean separation of concerns, value objects, and command-based control. + +#### Domain Context + +A rover explores a rectangular plateau with coordinate-based navigation: + +**Core Concepts:** +- **Position:** (x, y) coordinates on the plateau grid +- **Direction:** Cardinal directions (N, E, S, W) +- **Plateau:** Grid with defined boundaries that wrap around (toroidal topology) + +**Commands:** +- `L` - Turn left 90 degrees (changes direction, not position) +- `R` - Turn right 90 degrees (changes direction, not position) +- `M` - Move forward one grid point in current direction + +**Example Navigation:** +``` +Starting: (0,0) facing North +Commands: "MMRMMLM" +- MM: Move to (0,2) facing North +- R: Turn to face East (still at 0,2) +- MM: Move to (2,2) facing East +- L: Turn to face North (still at 2,2) +- M: Move to (2,3) facing North +Result: (2,3) facing North +``` + +#### Key Design Decisions + +**Direction Enum:** Encapsulates rotation logic using modular arithmetic. Each direction knows how to turn left/right, eliminating conditional branching. + +**Value Objects:** +- `Position` is immutable—movement returns new position instances +- `Plateau` encapsulates boundary wrapping logic +- Prevents invalid states at the type level + +**Command Pattern (Implicit):** The `execute()` method delegates to command handlers (`turnLeft()`, `turnRight()`, `moveForward()`). Each command is isolated and testable. + +**Wrapping Logic:** Plateau boundaries wrap around (toroidal topology). Moving past edge (e.g., x=5→6 on 5x5 grid) wraps to opposite side (x=0). + +#### Usage + +```dart +import 'package:tdd_katas/mars_rover.dart'; + +// Create rover at position (1,2) facing North on 5x5 plateau +final rover = Rover( + x: 1, + y: 2, + direction: 'N', + plateauWidth: 5, + plateauHeight: 5, +); + +rover.execute('LMLMLMLMM'); + +print('Position: (${rover.x}, ${rover.y})'); // Position: (1, 3) +print('Direction: ${rover.direction}'); // Direction: N +``` + +#### The "Aha!" Moments + +1. **Enums as Behavior Carriers:** Direction enum doesn't just store values—it encapsulates rotation logic. Turning left/right becomes `direction.turnLeft()`, eliminating lookup tables. + +2. **Value Objects Prevent Bugs:** Immutable `Position` means movement can't corrupt state. New position calculated, validated, then assigned. Boundary wrapping isolated in `Plateau`. + +3. **Switch Expressions Shine:** Modern Dart's `switch` expression makes direction-based movement elegant and exhaustive. Compiler enforces handling all directions. + +4. **Modular Arithmetic for Rotation:** `(index + 1) % 4` handles right rotation elegantly. No if-statements, no edge cases—math models the domain perfectly. + +5. **Refactoring Without Fear:** Two refactoring commits drastically improved code structure. Tests stayed green throughout, proving behavior preservation. + +--- + ## Running Tests ```bash @@ -229,6 +402,8 @@ dart test dart test test/roman_numerals_test.dart dart test test/bowling_game_test.dart dart test test/gilded_rose_test.dart +dart test test/string_calculator_test.dart +dart test test/mars_rover_test.dart # Run with coverage dart test --coverage @@ -536,22 +711,23 @@ Decision: Skip refactor commit—code is already excellent. ## Comparing the Katas -### All Three Katas at a Glance +### All Five Katas at a Glance -| Aspect | Roman Numerals | Bowling Game | Gilded Rose | -|--------|----------------|--------------|-------------| -| **Complexity** | Beginner | Intermediate | Advanced | -| **Approach** | Greenfield TDD | Greenfield TDD | Legacy refactoring | -| **State** | Stateless | Stateful | Stateful | -| **Algorithm** | Table-driven lookup | Frame iteration | Strategy pattern | -| **Key Challenge** | Pattern recognition | State & bonuses | Refactoring safely | -| **Design Pattern** | Value Object | Implicit strategy | Explicit strategy | -| **Lines of Code** | ~45 production | ~30 production | ~120 production | -| **Test Count** | ~15 tests | ~10 tests | ~17 tests | -| **Aha! Moment** | Table = data structure | Simple → complex works | Refactoring = safety | +| Aspect | Roman Numerals | Bowling Game | Gilded Rose | String Calculator | Mars Rover | +|--------|----------------|--------------|-------------|-------------------|------------| +| **Complexity** | Beginner | Intermediate | Advanced | Beginner | Intermediate | +| **Approach** | Greenfield TDD | Greenfield TDD | Legacy refactoring | Bug hunting | Greenfield TDD | +| **State** | Stateless | Stateful | Stateful | Stateless | Stateful | +| **Algorithm** | Table-driven | Frame iteration | Strategy pattern | String parsing | Command pattern | +| **Key Challenge** | Pattern recognition | State & bonuses | Refactoring safely | Finding bugs | Navigation & wrapping | +| **Design Pattern** | Value Object | Implicit strategy | Explicit strategy | Filters & pipes | Command + Value Objects | +| **Lines of Code** | ~45 production | ~30 production | ~120 production | ~25 production | ~95 production | +| **Test Count** | ~15 tests | ~10 tests | ~17 tests | 6 tests | 23 tests | +| **Aha! Moment** | Table = data | Simple → complex | Refactor = safe | Tests find bugs | Enums carry behavior | ### What Each Kata Teaches +**Roman Numerals:** **Roman Numerals:** - Converting domain rules into data structures - Value Objects for enforcing constraints @@ -561,22 +737,37 @@ Decision: Skip refactor commit—code is already excellent. - State management without over-engineering - Look-ahead logic in sequential data - How correct abstractions scale beyond test cases + **Gilded Rose:** - Safely refactoring legacy code with characterization tests - Strategy pattern for eliminating conditional complexity - Open-Closed Principle for extensibility - Working effectively with code you didn't write +**String Calculator:** +- Using tests to expose bugs in existing code +- Bug hunting with RED-GREEN discipline +- Incremental debugging with clear commits +- Regression prevention through test accumulation + +**Mars Rover:** +- Command pattern for behavior delegation +- Value Objects for domain modeling (Position, Plateau, Direction) +- Enums as behavior carriers, not just constants +- Coordinate systems and wrapping logic +- Progressive refactoring with confidence + ### Progressive Learning Path 1. **Roman Numerals first:** Learn TDD fundamentals without state complexity 2. **Bowling Game second:** Apply TDD to stateful problems -3. **Gilded Rose third:** Master refactoring legacy code with tests as safety net -4. **Next kata:** Choose based on what you want to practice: - - **String Calculator:** Parsing, validation, error handling - - **Mars Rover:** Command pattern, multiple behaviors - - **Prime Factors:** Mathematical decomposition, algorithmic thinkingsts - - **Mars Rover:** Command pattern, multiple behaviors +3. **Mars Rover third:** Master Command pattern and value objects +4. **Gilded Rose fourth:** Refactor legacy code with tests as safety net +5. **String Calculator fifth:** Practice bug hunting and fixing with TDD +6. **Next kata:** Choose based on what you want to practice: + - **Prime Factors:** Mathematical decomposition, algorithmic thinking + - **Tennis Scoring:** State machines, domain language + - **FizzBuzz:** Classic conditional logic exercise --- diff --git a/bin/tdd_katas.dart b/bin/tdd_katas.dart index 987a845..24f0e29 100644 --- a/bin/tdd_katas.dart +++ b/bin/tdd_katas.dart @@ -1,5 +1,3 @@ -import 'package:tdd_katas/roman_numerals.dart' as roman_numerals; - void main(List arguments) { - print('Hello world: ${roman_numerals.integerToRoman(1)}'); + print('TDD Katas exercises, please read the README.md file.'); } diff --git a/lib/gilded_rose.dart b/lib/gilded_rose.dart index 01c7722..6a813d9 100644 --- a/lib/gilded_rose.dart +++ b/lib/gilded_rose.dart @@ -14,7 +14,6 @@ class Item { /// Quality bounds - domain constraints const int _minQuality = 0; const int _maxQuality = 50; -const int _legendaryQuality = 80; // unused, just for documentation /// Helper methods for quality management void _degradeQuality(Item item, int amount) { diff --git a/lib/mars_rover.dart b/lib/mars_rover.dart new file mode 100644 index 0000000..221e450 --- /dev/null +++ b/lib/mars_rover.dart @@ -0,0 +1,106 @@ +enum Direction { + north('N'), + east('E'), + south('S'), + west('W'); + + final String code; + const Direction(this.code); + + static Direction fromCode(String code) { + return Direction.values.firstWhere((d) => d.code == code); + } + + Direction turnLeft() { + return Direction.values[(index + 3) % 4]; + } + + Direction turnRight() { + return Direction.values[(index + 1) % 4]; + } +} + +class Position { + final int x; + final int y; + + Position(this.x, this.y); + + Position moveNorth() => Position(x, y + 1); + Position moveEast() => Position(x + 1, y); + Position moveSouth() => Position(x, y - 1); + Position moveWest() => Position(x - 1, y); +} + +class Plateau { + final int width; + final int height; + + Plateau(this.width, this.height); + + Position wrap(Position position) { + final wrappedX = _wrapCoordinate(position.x, width); + final wrappedY = _wrapCoordinate(position.y, height); + return Position(wrappedX, wrappedY); + } + + int _wrapCoordinate(int value, int max) { + final wrapped = value % (max + 1); + return wrapped < 0 ? wrapped + max + 1 : wrapped; + } +} + +class Rover { + Position _position; + Direction _direction; + final Plateau plateau; + + Rover({ + required int x, + required int y, + required String direction, + int plateauWidth = 100, + int plateauHeight = 100, + }) : _position = Position(x, y), + _direction = Direction.fromCode(direction), + plateau = Plateau(plateauWidth, plateauHeight); + + int get x => _position.x; + int get y => _position.y; + String get direction => _direction.code; + + void turnLeft() { + _direction = _direction.turnLeft(); + } + + void turnRight() { + _direction = _direction.turnRight(); + } + + void moveForward() { + final newPosition = switch (_direction) { + Direction.north => _position.moveNorth(), + Direction.east => _position.moveEast(), + Direction.south => _position.moveSouth(), + Direction.west => _position.moveWest(), + }; + _position = plateau.wrap(newPosition); + } + + void execute(String commands) { + for (var i = 0; i < commands.length; i++) { + final command = commands[i]; + switch (command) { + case 'L': + turnLeft(); + break; + case 'R': + turnRight(); + break; + case 'M': + moveForward(); + break; + } + } + } +} diff --git a/lib/string_calculator.dart b/lib/string_calculator.dart new file mode 100644 index 0000000..6e5ec31 --- /dev/null +++ b/lib/string_calculator.dart @@ -0,0 +1,55 @@ +/// String Calculator - Buggy Implementation +/// This code has intentional bugs for the Bug Hunt Kata exercise +/// +/// Requirements: +/// 1. Empty string returns 0 +/// 2. Single number returns that number +/// 3. Two numbers comma-delimited returns sum +/// 4. Handle newlines as delimiters +/// 5. Support custom delimiters: "//[delimiter]\n[numbers]" +library; + +class StringCalculator { + int add(String numbers) { + // Bug 1: Empty string handling + if (numbers.isEmpty) { + return 0; // Fixed: Return 0 for empty string + } + + // Bug 2: Single number parsing + if (!numbers.contains(',') && + !numbers.contains('\n') && + !numbers.startsWith('//')) { + return int.parse(numbers); // Fixed: Removed off-by-one error + } + + String delimiter = ','; + String numbersToProcess = numbers; + + // Custom delimiter support + if (numbers.startsWith('//')) { + // Bug 4: Custom delimiter not actually used + final parts = numbers.split('\n'); + delimiter = parts[0].substring(2); // Fixed: Extract custom delimiter + numbersToProcess = parts.skip(1).join('\n'); + } + + // Bug 3 & 4: Delimiter handling issues + final numList = numbersToProcess + .replaceAll('\n', delimiter) + .split(delimiter) + .where((s) => s.isNotEmpty) + .map((s) => int.parse(s)) + .where((n) => n <= 1000) // Filter out numbers > 1000 + .toList(); + + // Bug 3: Off-by-one in summation + int sum = 0; + for (int i = 0; i < numList.length; i++) { + // Fixed: Include last element + sum += numList[i]; + } + + return sum; + } +} diff --git a/test/mars_rover_test.dart b/test/mars_rover_test.dart new file mode 100644 index 0000000..53e4540 --- /dev/null +++ b/test/mars_rover_test.dart @@ -0,0 +1,194 @@ +import 'package:tdd_katas/mars_rover.dart'; +import 'package:test/test.dart'; + +void main() { + group('Mars Rover:', () { + test('rover reports initial position and direction', () { + final rover = Rover(x: 0, y: 0, direction: 'N'); + + expect(rover.x, equals(0)); + expect(rover.y, equals(0)); + expect(rover.direction, equals('N')); + }); + + group('Turning Left:', () { + test('from North faces West', () { + final rover = Rover(x: 0, y: 0, direction: 'N'); + rover.turnLeft(); + expect(rover.direction, equals('W')); + }); + + test('from West faces South', () { + final rover = Rover(x: 0, y: 0, direction: 'W'); + rover.turnLeft(); + expect(rover.direction, equals('S')); + }); + + test('from South faces East', () { + final rover = Rover(x: 0, y: 0, direction: 'S'); + rover.turnLeft(); + expect(rover.direction, equals('E')); + }); + + test('from East faces North', () { + final rover = Rover(x: 0, y: 0, direction: 'E'); + rover.turnLeft(); + expect(rover.direction, equals('N')); + }); + }); + + group('Turning Right:', () { + test('from North faces East', () { + final rover = Rover(x: 0, y: 0, direction: 'N'); + rover.turnRight(); + expect(rover.direction, equals('E')); + }); + + test('from East faces South', () { + final rover = Rover(x: 0, y: 0, direction: 'E'); + rover.turnRight(); + expect(rover.direction, equals('S')); + }); + + test('from South faces West', () { + final rover = Rover(x: 0, y: 0, direction: 'S'); + rover.turnRight(); + expect(rover.direction, equals('W')); + }); + + test('from West faces North', () { + final rover = Rover(x: 0, y: 0, direction: 'W'); + rover.turnRight(); + expect(rover.direction, equals('N')); + }); + }); + + group('Moving Forward:', () { + test('facing North increases Y', () { + final rover = Rover(x: 0, y: 0, direction: 'N'); + rover.moveForward(); + expect(rover.x, equals(0)); + expect(rover.y, equals(1)); + }); + + test('facing East increases X', () { + final rover = Rover(x: 0, y: 0, direction: 'E'); + rover.moveForward(); + expect(rover.x, equals(1)); + expect(rover.y, equals(0)); + }); + + test('facing South decreases Y', () { + final rover = Rover(x: 0, y: 5, direction: 'S'); + rover.moveForward(); + expect(rover.x, equals(0)); + expect(rover.y, equals(4)); + }); + + test('facing West decreases X', () { + final rover = Rover(x: 5, y: 0, direction: 'W'); + rover.moveForward(); + expect(rover.x, equals(4)); + expect(rover.y, equals(0)); + }); + }); + + group('Executing Command Sequences:', () { + test('single command L', () { + final rover = Rover(x: 0, y: 0, direction: 'N'); + rover.execute('L'); + expect(rover.direction, equals('W')); + }); + + test('single command R', () { + final rover = Rover(x: 0, y: 0, direction: 'N'); + rover.execute('R'); + expect(rover.direction, equals('E')); + }); + + test('single command M', () { + final rover = Rover(x: 0, y: 0, direction: 'N'); + rover.execute('M'); + expect(rover.y, equals(1)); + }); + + test('complex sequence MMRMMLM', () { + final rover = Rover(x: 0, y: 0, direction: 'N'); + rover.execute('MMRMMLM'); + expect(rover.x, equals(2)); + expect(rover.y, equals(3)); + expect(rover.direction, equals('N')); + }); + + test('example from Mars Rover kata', () { + final rover = Rover(x: 1, y: 2, direction: 'N'); + rover.execute('LMLMLMLMM'); + expect(rover.x, equals(1)); + expect(rover.y, equals(3)); + expect(rover.direction, equals('N')); + }); + }); + + group('Plateau Boundaries:', () { + test('wraps around when moving North past boundary', () { + final rover = Rover( + x: 0, + y: 5, + direction: 'N', + plateauWidth: 5, + plateauHeight: 5, + ); + rover.moveForward(); + expect(rover.y, equals(0)); + }); + + test('wraps around when moving East past boundary', () { + final rover = Rover( + x: 5, + y: 0, + direction: 'E', + plateauWidth: 5, + plateauHeight: 5, + ); + rover.moveForward(); + expect(rover.x, equals(0)); + }); + + test('wraps around when moving South past boundary', () { + final rover = Rover( + x: 0, + y: 0, + direction: 'S', + plateauWidth: 5, + plateauHeight: 5, + ); + rover.moveForward(); + expect(rover.y, equals(5)); + }); + + test('wraps around when moving West past boundary', () { + final rover = Rover( + x: 0, + y: 0, + direction: 'W', + plateauWidth: 5, + plateauHeight: 5, + ); + rover.moveForward(); + expect(rover.x, equals(5)); + }); + + test('example with wrapping', () { + final rover = Rover( + x: 5, + y: 5, + direction: 'N', + plateauWidth: 5, + plateauHeight: 5, + ); + rover.execute('MMM'); + expect(rover.y, equals(2)); + }); + }); + }); +} diff --git a/test/string_calculator_test.dart b/test/string_calculator_test.dart new file mode 100644 index 0000000..1d4ee44 --- /dev/null +++ b/test/string_calculator_test.dart @@ -0,0 +1,41 @@ +import 'package:tdd_katas/string_calculator.dart'; +import 'package:test/test.dart'; + +void main() { + group('String Calculator - Bug Hunt', () { + late StringCalculator calculator; + + setUp(() { + calculator = StringCalculator(); + }); + + test('empty string returns 0', () { + expect(calculator.add(''), equals(0)); + }); + + test('single number returns that number', () { + expect(calculator.add('5'), equals(5)); + expect(calculator.add('42'), equals(42)); + }); + + test('two comma-delimited numbers return sum', () { + expect(calculator.add('1,2'), equals(3)); + expect(calculator.add('10,20'), equals(30)); + }); + + test('multiple comma-delimited numbers return sum', () { + expect(calculator.add('1,2,3'), equals(6)); + expect(calculator.add('5,10,15,20'), equals(50)); + }); + + test('custom delimiter works correctly', () { + expect(calculator.add('//;\n1;2'), equals(3)); + expect(calculator.add('//|\n10|20|30'), equals(60)); + }); + + test('numbers greater than 1000 are ignored', () { + expect(calculator.add('2,1001'), equals(2)); + expect(calculator.add('1000,1001,2'), equals(1002)); + }); + }); +}