Skip to the code
cf.

Connect four

Another game for the tea shop's window table. Build ConnectFour, the default export, with no props: two players, Red and Yellow, take turns dropping discs into a board 7 columns wide and 6 rows tall. A disc falls to the lowest empty spot in its column. Red goes first. The first player with four discs in a line, across, up and down, or diagonally either way, wins.

Above the board, put 7 buttons named Drop in column 1 to Drop in column 7 (with aria-label). A full column's button is disabled. Once someone has won, or the board is full, every drop button is disabled.

Draw the board as an element with role="grid" holding 6 role="row" elements, top row first, each with 7 role="gridcell" elements. Give every cell an aria-label that says where it is and what's in it: Row 1, column 1: empty, Row 6, column 4: Red, Row 5, column 4: Yellow. Row 1 is the top and row 6 is the bottom.

A <p role="status"> shows Red's turn or Yellow's turn during the game, then Red wins! or Yellow wins!, or Draw when the board is full and nobody has four in a line. A last disc that fills the board and completes a line is a win, not a draw.

A New game button empties the board and gives Red the first turn.

<p role="status">Yellow's turn</p>
<button aria-label="Drop in column 1">↓</button>
...
<div role="grid" aria-label="Board">
  <div role="row">
    <div role="gridcell" aria-label="Row 1, column 1: empty"></div>
    ...
  </div>
  ...
  <div role="row">
    ...
    <div role="gridcell" aria-label="Row 6, column 4: Red"></div>
    ...
  </div>
</div>
<button>New game</button>

What the tests check

  1. starts with an empty board and Red to play
    render(<App />);
    expect(screen.getAllByRole("row").length).toBe(6);
    const cells = screen.getAllByRole("gridcell");
    expect(cells.length).toBe(42);
    for (const cell of cells) expect(cell.getAttribute("aria-label")).toMatch(/: empty$/);
    expect(within(screen.getAllByRole("row")[0]).getAllByRole("gridcell")[0]).toHaveAttribute("aria-label", "Row 1, column 1: empty");
    for (let column = 1; column <= 7; column++) {
      expect(screen.getByRole("button", { name: "Drop in column " + column })).toBeEnabled();
    }
    expect(screen.getByRole("status")).toHaveTextContent("Red's turn");
    
  2. drops discs to the lowest empty spot, taking turns
    const drop = async (...columns) => {
      for (const column of columns) await user.click(screen.getByRole("button", { name: "Drop in column " + column }));
    };
    render(<App />);
    await drop(4);
    expect(screen.getByRole("gridcell", { name: "Row 6, column 4: Red" })).toBeInTheDocument();
    expect(screen.getByRole("status")).toHaveTextContent("Yellow's turn");
    await drop(4);
    expect(screen.getByRole("gridcell", { name: "Row 5, column 4: Yellow" })).toBeInTheDocument();
    expect(screen.getByRole("status")).toHaveTextContent("Red's turn");
    await drop(1);
    expect(screen.getByRole("gridcell", { name: "Row 6, column 1: Red" })).toBeInTheDocument();
    expect(screen.getByRole("gridcell", { name: "Row 4, column 4: empty" })).toBeInTheDocument();
    
  3. disables a full column
    const drop = async (...columns) => {
      for (const column of columns) await user.click(screen.getByRole("button", { name: "Drop in column " + column }));
    };
    render(<App />);
    await drop(3, 3, 3, 3, 3);
    expect(screen.getByRole("button", { name: "Drop in column 3" })).toBeEnabled();
    await drop(3);
    expect(screen.getByRole("gridcell", { name: "Row 1, column 3: Yellow" })).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Drop in column 3" })).toBeDisabled();
    expect(screen.getByRole("button", { name: "Drop in column 2" })).toBeEnabled();
    expect(screen.getByRole("status")).toHaveTextContent("Red's turn");
    
  4. wins with four across
    const drop = async (...columns) => {
      for (const column of columns) await user.click(screen.getByRole("button", { name: "Drop in column " + column }));
    };
    render(<App />);
    await drop(1, 1, 2, 2, 3, 3);
    expect(screen.getByRole("status")).toHaveTextContent("Red's turn");
    await drop(4);
    expect(screen.getByRole("status")).toHaveTextContent("Red wins!");
    
  5. wins with four up and down
    const drop = async (...columns) => {
      for (const column of columns) await user.click(screen.getByRole("button", { name: "Drop in column " + column }));
    };
    render(<App />);
    await drop(1, 2, 1, 2, 1, 2, 3);
    expect(screen.getByRole("status")).toHaveTextContent("Yellow's turn");
    await drop(2);
    expect(screen.getByRole("status")).toHaveTextContent("Yellow wins!");
    
  6. wins with four on a rising diagonal
    const drop = async (...columns) => {
      for (const column of columns) await user.click(screen.getByRole("button", { name: "Drop in column " + column }));
    };
    render(<App />);
    // Red ends with discs at the bottom of column 1, then one row higher in each column to the right.
    await drop(1, 2, 2, 3, 3, 4, 3, 4, 4, 6);
    expect(screen.getByRole("status")).toHaveTextContent("Red's turn");
    await drop(4);
    expect(screen.getByRole("gridcell", { name: "Row 3, column 4: Red" })).toBeInTheDocument();
    expect(screen.getByRole("status")).toHaveTextContent("Red wins!");
    
  7. wins with four on a falling diagonal
    const drop = async (...columns) => {
      for (const column of columns) await user.click(screen.getByRole("button", { name: "Drop in column " + column }));
    };
    render(<App />);
    // The mirror image: Red ends at the bottom of column 7, then one row higher in each column to the left.
    await drop(7, 6, 6, 5, 5, 4, 5, 4, 4, 2);
    expect(screen.getByRole("status")).toHaveTextContent("Red's turn");
    await drop(4);
    expect(screen.getByRole("status")).toHaveTextContent("Red wins!");
    
  8. no more discs once someone has won
    const drop = async (...columns) => {
      for (const column of columns) await user.click(screen.getByRole("button", { name: "Drop in column " + column }));
    };
    render(<App />);
    await drop(1, 1, 2, 2, 3, 3, 4);
    for (let column = 1; column <= 7; column++) {
      expect(screen.getByRole("button", { name: "Drop in column " + column })).toBeDisabled();
    }
    await drop(5);
    expect(screen.getByRole("gridcell", { name: "Row 6, column 5: empty" })).toBeInTheDocument();
    expect(screen.getByRole("status")).toHaveTextContent("Red wins!");
    
  9. calls a full board a draw only when nobody has four in a line
    const drop = async (...columns) => {
      for (const column of columns) await user.click(screen.getByRole("button", { name: "Drop in column " + column }));
    };
    const first = render(<App />);
    // Yellow's last disc, the 42nd, completes a line: a win, not a draw.
    await drop(2, 4, 3, 4, 1, 3, 6, 4, 5, 7, 3, 6, 3, 6, 7, 2, 5, 2, 7, 2, 4, 7, 7, 3, 1, 7, 6, 5, 6, 6, 2, 5, 4, 1, 2, 3, 4, 5, 1, 1, 1);
    expect(screen.getByRole("status")).toHaveTextContent("Yellow's turn");
    await drop(5);
    expect(screen.getByRole("status")).toHaveTextContent("Yellow wins!");
    first.unmount();
    render(<App />);
    await drop(3, 2, 1, 4, 3, 1, 1, 4, 5, 5, 4, 5, 2, 6, 2, 5, 4, 3, 6, 1, 6, 7, 7, 2, 7, 7, 4, 7, 3, 2, 6, 2, 1, 6, 6, 7, 5, 3, 3, 5, 1);
    expect(screen.getByRole("status")).toHaveTextContent("Yellow's turn");
    await drop(4);
    expect(screen.getByRole("status")).toHaveTextContent("Draw");
    expect(screen.getByRole("status")).not.toHaveTextContent("wins");
    for (let column = 1; column <= 7; column++) {
      expect(screen.getByRole("button", { name: "Drop in column " + column })).toBeDisabled();
    }
    
  10. New game empties the board and gives Red the first turn
    const drop = async (...columns) => {
      for (const column of columns) await user.click(screen.getByRole("button", { name: "Drop in column " + column }));
    };
    render(<App />);
    await drop(1, 1, 2, 2, 3, 3, 4);
    await user.click(screen.getByRole("button", { name: "New game" }));
    for (const cell of screen.getAllByRole("gridcell")) expect(cell.getAttribute("aria-label")).toMatch(/: empty$/);
    expect(screen.getByRole("status")).toHaveTextContent("Red's turn");
    expect(screen.getByRole("button", { name: "Drop in column 4" })).toBeEnabled();
    await drop(4);
    expect(screen.getByRole("gridcell", { name: "Row 6, column 4: Red" })).toBeInTheDocument();
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).