กลับมาพบกันอีกครั้งกับ TDD Kata ครั้งนี้เราจะมาลองใช้ TDD เพื่อแก้โจทย์ Advent of Code 2025 Day 7: Laboratories ด้วยภาษา Rust กัน

โจทย์จำลองสถานการณ์ที่เราอยู่ในห้องทดลองเกี่ยวกับการเทเลพอร์ต และเจอข้อผิดพลาด 0H-N0 ที่เกี่ยวข้องกับ tachyon manifold เราจึงต้องวิเคราะห์แผนผังของ manifold เพื่อหาว่าเกิดการแยกของลำแสง (beam split) ขึ้นกี่ครั้ง

สารบัญ

TL;DR

GitHub


กติกา

แผนผังของ tachyon manifold เป็นตารางกริดที่มีสัญลักษณ์ดังนี้:

  • S - จุดเริ่มต้นของลำแสง (tachyon beam) ลำแสงจะเคลื่อนที่ลงด้านล่างเสมอ
  • . - ช่องว่าง ลำแสงสามารถผ่านได้
  • ^ - ตัวแยกลำแสง (splitter) เมื่อลำแสงชนจะหยุดและแยกออกเป็นสองลำแสงใหม่ โดยลำแสงใหม่จะเคลื่อนที่ต่อจากด้านซ้ายและด้านขวาของ splitter

ตัวอย่างแผนผัง:

.......S.......
...............
.......^.......
...............
......^.^......
...............
.....^.^.^.....
...............
....^.^...^....
...............
...^.^...^.^...
...............
..^...^.....^..
...............
.^.^.^.^.^...^.
...............

เมื่อลำแสงเคลื่อนที่ลงมาเรื่อย ๆ จะเกิดการแยกตัวออกไปเรื่อย ๆ จนกระทั่งลำแสงทั้งหมดออกจากแผนผังหรือเจอ splitter:

.......S.......
.......|.......
......|^|......
......|.|......
.....|^|^|.....
.....|.|.|.....
....|^|^|^|....
....|.|.|.|....
...|^|^|||^|...
...|.|.|||.|...
..|^|^|||^|^|..
..|.|.|||.|.|..
.|^|||^||.||^|.
.|.|||.||.||.|.
|^|^|^|^|^|||^|
|.|.|.|.|.|||.|

ในตัวอย่างนี้เกิดการแยกลำแสงทั้งหมด 21 ครั้ง


เตรียมพร้อม

โจทย์นี้เราจะใช้โปรเจกต์ Rust ที่มีอยู่แล้วใน advent-of-code/2025 โดยเพิ่ม binary ใหม่สำหรับ day 7

เริ่มต้นด้วยการเพิ่ม [[bin]] ใน Cargo.toml:

[[bin]]
name = "day7"
path = "src/bin/day7/main.rs"

จากนั้นสร้างโครงสร้างไฟล์:

mkdir -p advent-of-code/2025/src/bin/day7
touch advent-of-code/2025/src/bin/day7/main.rs

ในโปรเจกต์นี้มี library utilities ไว้ให้แล้ว เช่น advent_of_code_2025::read_lines สำหรับอ่านไฟล์อินพุต

ก่อนอื่นมาเขียนโครงหลักของโปรแกรมที่ใช้อ่านอินพุตจากไฟล์ตามสไตล์ AOC:

// src/bin/day7/main.rs

use advent_of_code_2025::read_lines;
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    let input = read_lines(&args[1]).unwrap();
    let grid = parse_grid(&input);

    println!("First part answer: {}", cal_first_part_answer(&grid));
    println!("Second part answer: {}", cal_second_part_answer(&grid));
}

fn parse_grid(input: &[String]) -> Vec<Vec<u8>> {
    input.iter().map(|line| line.bytes().collect()).collect()
}

fn cal_first_part_answer(grid: &[Vec<u8>]) -> usize {
    0
}

fn cal_second_part_answer(grid: &[Vec<u8>]) -> usize {
    0
}

เท่านี้เราก็มีโครงโปรแกรมที่พร้อมทำงานกับไฟล์อินพุตจริงแล้ว ต่อไปเราจะใช้ TDD เพื่อพัฒนา parse_grid, find_start, cal_first_part_answer และ cal_second_part_answer กัน


Step 1: Parse the Grid

สิ่งแรกที่ต้องทำคือการแปลงอินพุตสตริงให้เป็นกริด (2D vector) ที่เราสามารถทำงานด้วยได้

เริ่มต้นด้วยการเขียนเทสก่อน:

// src/bin/day7/main.rs

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_grid() {
        let input = vec![
            ".......S.......".to_string(),
            "...............".to_string(),
            ".......^.......".to_string(),
            "...............".to_string(),
            "......^.^......".to_string(),
        ];
        let grid = parse_grid(&input);
        assert_eq!(grid.len(), 5);
        assert_eq!(grid[0].len(), 15);
        assert_eq!(grid[0][7], b'S');
        assert_eq!(grid[2][7], b'^');
        assert_eq!(grid[4][6], b'^');
        assert_eq!(grid[4][8], b'^');
    }
}

รันเทส:

cd advent-of-code/2025 && cargo test --bin day7
running 1 test
test tests::test_parse_grid ... ok

เทสผ่าน เพราะเราได้เขียน parse_grid ไว้ในโครงหลักแล้ว


Step 2: Find Starting Position

ลำแสงเริ่มต้นที่ตำแหน่ง S เราต้องหาว่ามันอยู่ตรงไหนในกริด

เขียนเทส:

#[cfg(test)]
mod tests {
    use super::*;

    // ...

    #[test]
    fn test_find_start() {
        let input = vec![
            ".......S.......".to_string(),
            "...............".to_string(),
            ".......^.......".to_string(),
        ];
        let grid = parse_grid(&input);
        let (row, col) = find_start(&grid).unwrap();
        assert_eq!((row, col), (0, 7));
    }

    #[test]
    fn test_find_start_no_s() {
        let input = vec![
            "...............".to_string(),
            "...............".to_string(),
        ];
        let grid = parse_grid(&input);
        assert!(find_start(&grid).is_none());
    }
}

รันเทสแล้วพัง เพราะยังไม่มี find_start อยู่เลย จากนั้นเขียนฟังก์ชันนี้:

fn find_start(grid: &[Vec<u8>]) -> Option<(usize, usize)> {
    for (row, line) in grid.iter().enumerate() {
        for (col, &ch) in line.iter().enumerate() {
            if ch == b'S' {
                return Some((row, col));
            }
        }
    }
    None
}

รันเทส:

running 3 tests
test tests::test_find_start ... ok
test tests::test_find_start_no_s ... ok
test tests::test_parse_grid ... ok

ได้ (0, 7) ตามที่คาดหวัง และเคสที่ไม่มี S ก็ return None ตามที่ตั้งใจไว้


Step 3: Count Splits - No Splitters

กรณีที่ง่ายที่สุดคือไม่มี splitter เลย ลำแสงจะเคลื่อนที่ลงมาตรง ๆ จนออกจากกริด โดยไม่เกิดการแยกเลย

เขียนเทส:

#[cfg(test)]
mod tests {
    use super::*;

    // ...

    #[test]
    fn test_no_splitters() {
        let input = vec![
            "S..".to_string(),
            "...".to_string(),
            "...".to_string(),
        ];
        let grid = parse_grid(&input);
        assert_eq!(cal_first_part_answer(&grid), 0);
    }

    #[test]
    fn test_no_splitters_single_column() {
        let input = vec![
            "S".to_string(),
            ".".to_string(),
            ".".to_string(),
            ".".to_string(),
        ];
        let grid = parse_grid(&input);
        assert_eq!(cal_first_part_answer(&grid), 0);
    }
}

รันเทสแล้วผ่าน เพราะฟังก์ชัน cal_first_part_answer ที่ return 0 อยู่แล้วตรงกับเคสที่ไม่มี splitter


Step 4: Count Splits - One Splitter

ถ้ามี splitter ตัวเดียว ลำแสงจะแยกครั้งเดียว

เขียนเทส:

#[cfg(test)]
mod tests {
    use super::*;

    // ...

    #[test]
    fn test_one_splitter() {
        let input = vec![
            "S".to_string(),
            "^".to_string(),
            ".".to_string(),
        ];
        let grid = parse_grid(&input);
        assert_eq!(cal_first_part_answer(&grid), 1);
    }
}

รันเทส:

running 6 tests
test tests::test_find_start ... ok
test tests::test_find_start_no_s ... ok
test tests::test_parse_grid ... ok
test tests::test_no_splitters ... ok
test tests::test_no_splitters_single_column ... ok
test tests::test_one_splitter ... FAILED

failures:

---- tests::test_one_splitter stdout ----

thread 'tests::test_one_splitter' (192728) panicked at src/bin/day7/main.rs:95:9:
assertion `left == right` failed
  left: 0
 right: 1

พังตามคาด เพราะ cal_first_part_answer ยัง return 0 อยู่เลย

จากนั้นเขียน cal_first_part_answer จริง โดยแทนที่ฟังก์ชันเดิมที่ return 0:

fn cal_first_part_answer(grid: &[Vec<u8>]) -> usize {
    let start = find_start(grid).expect("grid must have a start position");
    let mut split_count = 0;
    let mut beams = vec![start];
    let mut visited_splitters = vec![vec![false; grid[0].len()]; grid.len()];

    while let Some((row, col)) = beams.pop() {
        let mut r = row + 1;

        while r < grid.len() {
            match grid[r][col] {
                b'^' => {
                    if !visited_splitters[r][col] {
                        visited_splitters[r][col] = true;
                        split_count += 1;
                        if col > 0 {
                            beams.push((r, col - 1));
                        }
                        if col + 1 < grid[0].len() {
                            beams.push((r, col + 1));
                        }
                    }
                    break;
                }
                _ => {
                    r += 1;
                }
            }
        }
    }

    split_count
}

รันเทส:

running 6 tests
test tests::test_find_start ... ok
test tests::test_one_splitter ... ok
test tests::test_no_splitters ... ok
test tests::test_no_splitters_single_column ... ok
test tests::test_find_start_no_s ... ok
test tests::test_parse_grid ... ok

ผ่าน เคสที่มี splitter ตัวเดียวผ่านแล้ว มาเพิ่มเคสอีกที่ splitter ไม่ได้อยู่ตรงแนวกับ S:

#[cfg(test)]
mod tests {
    use super::*;

    // ...

    #[test]
    fn test_one_splitter_offset() {
        let input = vec![
            "S..".to_string(),
            "...".to_string(),
            "..^".to_string(),
        ];
        let grid = parse_grid(&input);
        assert_eq!(cal_first_part_answer(&grid), 0);
    }
}

รันเทส:

running 7 tests
test tests::test_one_splitter_offset ... ok
test tests::test_find_start_no_s ... ok
test tests::test_find_start ... ok
test tests::test_no_splitters ... ok
test tests::test_no_splitters_single_column ... ok
test tests::test_one_splitter ... ok
test tests::test_parse_grid ... ok

เทส เพราะ ^ อยู่ที่ตำแหน่ง (2,2) แต่ลำแสงเคลื่อนที่ลงมาตรง ๆ จาก S ที่ (0,0) ในแนวคอลัมน์ 0 จึงไม่เจอ splitter นี้


Step 5: Count Splits - Multiple Splitters in a Line

เมื่อ splitter หลายตัวอยู่ในแนวเดียวกัน ลำแสงจะแยกหลายครั้ง

เขียนเทส:

#[cfg(test)]
mod tests {
    use super::*;

    // ...

    #[test]
    fn test_two_splitters_in_line() {
        let input = vec![
            "S".to_string(),
            "^".to_string(),
            "^".to_string(),
            ".".to_string(),
        ];
        let grid = parse_grid(&input);
        assert_eq!(cal_first_part_answer(&grid), 1);
    }

    #[test]
    fn test_three_splitters_in_line() {
        let input = vec![
            "S".to_string(),
            "^".to_string(),
            "^".to_string(),
            "^".to_string(),
            ".".to_string(),
        ];
        let grid = parse_grid(&input);
        assert_eq!(cal_first_part_answer(&grid), 1);
    }
}

รันเทส:

running 9 tests
test tests::test_find_start ... ok
test tests::test_find_start_no_s ... ok
test tests::test_no_splitters ... ok
test tests::test_no_splitters_single_column ... ok
test tests::test_one_splitter ... ok
test tests::test_one_splitter_offset ... ok
test tests::test_parse_grid ... ok
test tests::test_three_splitters_in_line ... ok
test tests::test_two_splitters_in_line ... ok

ทั้ง 9 test ผ่าน แต่ละเคสได้แค่ 1 split เพราะหลังจาก splitter ตัวแรกที่ (1,0) ลำแสงใหม่ที่แยกไปทางซ้ายและขวาจะออกนอกกริด (เนื่องจากกริดกว้างแค่ 1 คอลัมน์) ทำให้ splitter ตัวถัดไปในแนวเดียวกันไม่ถูกเจอ


Step 6: Count Splits - Diamond Pattern

ลองเคสที่ splitter วางตัวเป็นรูปเพชร ซึ่งจะทำให้เกิดการแยกที่ซับซ้อนขึ้น

#[cfg(test)]
mod tests {
    use super::*;

    // ...

    #[test]
    fn test_diamond_pattern() {
        let input = vec![
            "..S..".to_string(),
            ".....".to_string(),
            "..^..".to_string(),
            ".....".to_string(),
            ".^.^.".to_string(),
        ];
        let grid = parse_grid(&input);
        assert_eq!(cal_first_part_answer(&grid), 3);
    }
}

รันเทส:

running 10 tests
test tests::test_diamond_pattern ... ok
test tests::test_find_start ... ok
test tests::test_find_start_no_s ... ok
test tests::test_no_splitters ... ok
test tests::test_no_splitters_single_column ... ok
test tests::test_one_splitter_offset ... ok
test tests::test_one_splitter ... ok
test tests::test_three_splitters_in_line ... ok
test tests::test_parse_grid ... ok
test tests::test_two_splitters_in_line ... ok

ผ่าน 10 test ได้ 3 ครั้งตามที่เราตั้งใจ


Step 7: The Full Example

ถึงเวลาทดสอบกับตัวอย่างเต็มที่ให้ไว้ในโจทย์ ซึ่งควรจะได้ผลลัพธ์เป็น 21

#[cfg(test)]
mod tests {
    use super::*;

    // ...

    #[test]
    fn test_first_part() {
        let input = vec![
            ".......S.......".to_string(),
            "...............".to_string(),
            ".......^.......".to_string(),
            "...............".to_string(),
            "......^.^......".to_string(),
            "...............".to_string(),
            ".....^.^.^.....".to_string(),
            "...............".to_string(),
            "....^.^...^....".to_string(),
            "...............".to_string(),
            "...^.^...^.^...".to_string(),
            "...............".to_string(),
            "..^...^.....^..".to_string(),
            "...............".to_string(),
            ".^.^.^.^.^...^.".to_string(),
            "...............".to_string(),
        ];
        let grid = parse_grid(&input);
        assert_eq!(cal_first_part_answer(&grid), 21);
    }
}

รันเทส:

running 11 tests
test tests::test_diamond_pattern ... ok
test tests::test_find_start ... ok
test tests::test_find_start_no_s ... ok
test tests::test_first_part ... ok
test tests::test_no_splitters ... ok
test tests::test_no_splitters_single_column ... ok
test tests::test_one_splitter ... ok
test tests::test_one_splitter_offset ... ok
test tests::test_parse_grid ... ok
test tests::test_three_splitters_in_line ... ok
test tests::test_two_splitters_in_line ... ok

ผ่าน ได้ 21 ตามโจทย์


Part 2: Quantum Tachyon Manifold

ปรากฏว่า manifold ที่แท้จริงนั้นเป็นแบบ quantum ซึ่งมีคุณสมบัติพิเศษคือเมื่ออนุภาคชน splitter อนุภาคจะแยกไปทั้งทางซ้ายและทางขวาพร้อมกัน โดยเวลาจะแตกออกเป็นสองเส้นทาง (timelines) ต่างกัน

หน้าที่ของเราคือหาจำนวน timelines ทั้งหมดที่เกิดขึ้นหลังจากอนุภาคหนึ่งตัวเดินทางผ่าน manifold ครบทุกเส้นทางที่เป็นไปได้

ก่อนอื่น implement stub สำหรับ cal_second_part_answer:

fn cal_second_part_answer(grid: &[Vec<u8>]) -> usize {
    0
}

จากนั้นเริ่มเขียนเทสทีละขั้น


Step 8: Count Timelines - No Splitters

กรณีที่ง่ายที่สุดคือไม่มี splitter เลย ลำแสงจะเคลื่อนที่ลงมาตรง ๆ จนออกจากกริด โดยมีแค่ 1 timeline

#[cfg(test)]
mod tests {
    use super::*;

    // ...

    #[test]
    fn test_second_part_no_splitters() {
        let input = vec![
            "S..".to_string(),
            "...".to_string(),
            "...".to_string(),
        ];
        let grid = parse_grid(&input);
        assert_eq!(cal_second_part_answer(&grid), 1);
    }
}

รันเทสแล้วพัง! เพราะฟังก์ชันยัง return 0:

running 12 tests
test tests::test_second_part_no_splitters ... FAILED
...

จากนั้นแก้ไข cal_second_part_answer ให้ return 1 สำหรับกรณีที่ไม่มี splitter แต่ยังไม่ต้อง implement จริง:

fn cal_second_part_answer(grid: &[Vec<u8>]) -> usize {
    1
}

รันเทสอีกครั้ง:

running 12 tests
test tests::test_second_part_no_splitters ... ok
...

ผ่านเคสพื้นฐานได้แล้ว ต่อไปลองเคสที่มี splitter


Step 9: Count Timelines - One Splitter

ถ้ามี splitter ตัวเดียว จะเกิด 2 timelines (แยกซ้าย-ขวา)

#[cfg(test)]
mod tests {
    use super::*;

    // ...

    #[test]
    fn test_second_part_one_splitter() {
        let input = vec![
            "S".to_string(),
            "^".to_string(),
            ".".to_string(),
        ];
        let grid = parse_grid(&input);
        assert_eq!(cal_second_part_answer(&grid), 2);
    }
}

รันเทส:

running 13 tests
test tests::test_second_part_one_splitter ... FAILED
...

พังเพราะตอนนี้ยัง return 1 อยู่ มา implement cal_second_part_answer จริง โดยใช้ recursion พร้อม memoization:

fn cal_second_part_answer(grid: &[Vec<u8>]) -> usize {
    let start = find_start(grid).expect("grid must have a start position");
    let mut memo = vec![vec![None; grid[0].len()]; grid.len()];
    count_timelines(grid, start.0 + 1, start.1, &mut memo)
}

fn count_timelines(
    grid: &[Vec<u8>],
    r: usize,
    c: usize,
    memo: &mut Vec<Vec<Option<usize>>>,
) -> usize {
    if r >= grid.len() {
        return 1;
    }
    if let Some(val) = memo[r][c] {
        return val;
    }
    let result = match grid[r][c] {
        b'^' => {
            let left = if c > 0 {
                count_timelines(grid, r, c - 1, memo)
            } else {
                1
            };
            let right = if c + 1 < grid[0].len() {
                count_timelines(grid, r, c + 1, memo)
            } else {
                1
            };
            left + right
        }
        _ => count_timelines(grid, r + 1, c, memo),
    };
    memo[r][c] = Some(result);
    result
}

รันเทส:

running 13 tests
test tests::test_second_part_one_splitter ... ok
test tests::test_second_part_no_splitters ... ok
...

ได้ 2 timelines ตามที่คาดหวัง มาลองเคสที่ซับซ้อนขึ้นอีกหน่อย


Step 10: Count Timelines - Multiple Splitters

เพิ่มเทสที่มี splitter หลายตัวในแนวเดียวกัน ควรจะได้ 2 timelines เช่นกันเพราะลำแสงที่แยกไปซ้าย-ขวาจะออกนอกกริด

#[cfg(test)]
mod tests {
    use super::*;

    // ...

    #[test]
    fn test_second_part_two_splitters() {
        let input = vec![
            "S".to_string(),
            "^".to_string(),
            "^".to_string(),
            ".".to_string(),
        ];
        let grid = parse_grid(&input);
        assert_eq!(cal_second_part_answer(&grid), 2);
    }
}

รันเทส:

running 14 tests
test tests::test_second_part_no_splitters ... ok
test tests::test_second_part_one_splitter ... ok
test tests::test_second_part_two_splitters ... ok
...

ผ่านทั้ง 3 เคส ลองเคสรูปเพชรกัน


Step 11: Count Timelines - Diamond Pattern

เอาเคสรูปเพชรมาลองกับ Part 2 ดูบ้าง คราวนี้ควรจะได้ 4 timelines เพราะแต่ละสาขาแยกเป็น timeline ของตัวเอง

#[cfg(test)]
mod tests {
    use super::*;

    // ...

    #[test]
    fn test_second_part_diamond_pattern() {
        let input = vec![
            "..S..".to_string(),
            ".....".to_string(),
            "..^..".to_string(),
            ".....".to_string(),
            ".^.^.".to_string(),
        ];
        let grid = parse_grid(&input);
        assert_eq!(cal_second_part_answer(&grid), 4);
    }
}

รันเทส:

running 15 tests
test tests::test_second_part_diamond_pattern ... ok
test tests::test_second_part_no_splitters ... ok
test tests::test_second_part_one_splitter ... ok
test tests::test_second_part_two_splitters ... ok
...

ผ่าน ได้ 4 timelines ตามที่คิดไว้


Step 12: Count Timelines - Full Example

ถึงเวลาทดสอบกับตัวอย่างเต็ม ซึ่งควรจะได้ 40 timelines

#[cfg(test)]
mod tests {
    use super::*;

    // ...

    #[test]
    fn test_second_part() {
        let input = vec![
            ".......S.......".to_string(),
            "...............".to_string(),
            ".......^.......".to_string(),
            "...............".to_string(),
            "......^.^......".to_string(),
            "...............".to_string(),
            ".....^.^.^.....".to_string(),
            "...............".to_string(),
            "....^.^...^....".to_string(),
            "...............".to_string(),
            "...^.^...^.^...".to_string(),
            "...............".to_string(),
            "..^...^.....^..".to_string(),
            "...............".to_string(),
            ".^.^.^.^.^...^.".to_string(),
            "...............".to_string(),
        ];
        let grid = parse_grid(&input);
        assert_eq!(cal_second_part_answer(&grid), 40);
    }
}

รันเทส:

running 16 tests
test tests::test_second_part ... ok
test tests::test_second_part_diamond_pattern ... ok
test tests::test_second_part_no_splitters ... ok
test tests::test_second_part_one_splitter ... ok
test tests::test_second_part_two_splitters ... ok
...

ผ่านทั้ง 16 test แล้ว

รันกับอินพุตจริง

เมื่อเทสผ่านทั้งหมดแล้ว เราสามารถรันโปรแกรมกับไฟล์อินพุตจริงได้เลย:

cd advent-of-code/2025 && cargo run --bin day7 -- src/bin/day7/input.txt

โปรแกรมจะอ่านแผนผังจากไฟล์ คำนวณจำนวนการแยกลำแสงและจำนวน timelines และพิมพ์ผลลัพธ์ออกมา