# Problem Description
This is a shortest path finding problem in a directed graph where:
- We need to find the shortest path between two given nodes in the graph
- The path must follow the direction of the arrows
- The input nodes are represented as single letters
- We need to return the complete path as a list of nodes
- If no path exists between the start and end nodes, return None

# Visual Facts
- The graph contains 5 nodes labeled A, B, C, D, and E
- There are 7 directed edges in total:
  * C → A
  * C → D
  * D → A
  * A → B
  * B → D
  * B → E
- All nodes are represented as circles with single letter labels
- The edges are represented as purple arrows

# Visual Patterns
- Distance between nodes should be measured by the number of edges traversed, not any physical distance in the visualization


# Solution
def solution(start: str, end: str) -> list[str]:
    """
    Find the shortest path from one point to another

    Parameters:
        start (str): The starting node of the path.
        end (str): The ending node of the path.

    Returns:
        Optional[List[str]]: A list of nodes representing the path from start to end, if such a path exists. Returns None if no path exists.

    Example:
        path = solution('C', 'B') # Output might be ['C', 'A', 'B']
    """
    graph = {
        'A': ['B'],
        'B': ['D', 'E'],
        'C': ['A', 'D'],
        'D': ['A'],
        'E': []  # No outgoing edges from E
    }

    if start not in graph or end not in graph:
        return None

    queue = [[start]]  # Initialize the queue with the starting node in a path list
    visited = set()  # To keep track of visited nodes

    while queue:
        path = queue.pop(0)  # Get the first path from the queue (FIFO)
        node = path[-1]  # Get the last node in the path
        if node in visited:
            continue
        visited.add(node)
        # Check if we've reached the end node
        if node == end:
            return path
        # Explore adjacent nodes
        for neighbor in graph[node]:
            new_path = path + [neighbor]  # Create a new path including the neighbor
            queue.append(new_path)
    # If the loop ends, no path was found
    return None


# Test Script
def test():
    test_cases = [
        ('A', 'B', ['A', 'B']),
        ('C', 'E', ['C', 'A', 'B', 'E']),
        ('A', 'E', ['A', 'B', 'E']),
        ('B', 'D', ['B', 'D']),
        ('C', 'B', ['C', 'A', 'B']),
        ('C', 'D', ['C', 'D']),
        ('B', 'E', ['B', 'E'])
    ]

    for start, end, expected_output in test_cases:
        try:
            output = solution(start, end)
        except Exception as e:
            error_message = (
                f"An exception occurred while running the test case:\n"
                f"`start`: {start}\n"
                f"`end`: {end}\n\n"
                f"Exception: '{str(e)}'"
            )
            assert False, error_message

        if output != expected_output:
            error_message = (
                f"The following Test Case failed:\n"
                f"`start`: {start}\n"
                f"`end`: {end}\n\n"
                f"Actual Output: {output}\n"
                f"Expected Output: {expected_output}"
            )
            assert False, error_message

    none_cases = [
        ['E', 'C'],
        ['E', 'B']
    ]
    
    for start, end in none_cases:
        try:
            output = solution(start, end)
        except Exception as e:
            error_message = (
                f"An exception occurred while running the test case:\n"
                f"`start`: {start}\n"
                f"`end`: {end}\n\n"
                f"Exception: '{str(e)}'"
            )
            assert False, error_message

        if output is not None:
            error_message = (
                f"The following Test Case failed:\n"
                f"`start`: {start}\n"
                f"`end`: {end}\n\n"
                f"Actual Output: {output}\n"
                f"Expected Output: None"
            )
            assert False, error_message

test()