Skip to the code
cf.

Memory game

The tea shop wants a little game for its loyalty page: pairs of tea cards, face down, and you find the pairs. Build MemoryGame, the default export.

It takes cards, a list of names, and shuffle, a function that takes an array and returns it in a new order. Both are optional: by default cards is six teas of your choice and shuffle is a real random shuffle. When the game starts, call shuffle once with every name twice ([...cards, ...cards]) and lay the cards out in the order it returns. Don't shuffle again while the game goes on.

Each card is a button with the accessible name Card 1, Card 2, and so on, in layout order (use aria-label). A face-down card has no text. A face-up card shows its name as its text and is disabled.

Clicking a face-down card turns it face up. Turning up a second card is one move. If the two cards have the same name, they're a pair: they stay face up for the rest of the game. If not, they stay face up for 1 second (1000 ms) and then both turn face down again. While a mismatched pair is showing, every other card is disabled too.

Show the number of moves as Moves: 0. When every pair is found, a <p aria-live="polite"> says You won in 6 moves! (with the real count). Before that it's empty.

If the game leaves the page while a mismatched pair is showing, clear the timer.

<MemoryGame />                                            // six teas, shuffled
<MemoryGame cards={["Sencha", "Matcha", "Chai"]} shuffle={(deck) => deck} />
 
// renders, with Card 1 turned over:
<p>Moves: 0</p>
<button aria-label="Card 1" disabled>Sencha</button>
<button aria-label="Card 2"></button>
...
<p aria-live="polite"></p>

What the tests check

  1. deals every card twice, face down
    render(<App cards={["Sencha", "Matcha", "Chai"]} shuffle={(deck) => deck} />);
    for (let n = 1; n <= 6; n++) {
      const card = screen.getByRole("button", { name: "Card " + n });
      expect(card.textContent).toBe("");
      expect(card).toBeEnabled();
    }
    expect(screen.queryByRole("button", { name: "Card 7" })).not.toBeInTheDocument();
    expect(screen.getByText("Moves: 0")).toBeInTheDocument();
    
  2. lays the cards out in the order shuffle returns, and shuffles only once
    const card = (n) => screen.getByRole("button", { name: "Card " + n });
    const shuffle = spy((deck) => [...deck].reverse());
    render(<App cards={["Sencha", "Matcha", "Chai"]} shuffle={shuffle} />);
    expect(shuffle.callCount).toBeGreaterThan(0);
    expect(shuffle.calls[0].args[0]).toEqual(["Sencha", "Matcha", "Chai", "Sencha", "Matcha", "Chai"]);
    const calls = shuffle.callCount;
    await user.click(card(1));
    expect(card(1)).toHaveTextContent("Chai");
    await user.click(card(4));
    expect(card(4)).toHaveTextContent("Chai");
    expect(shuffle.callCount).toBe(calls);
    
  3. turning a card over shows its name and disables it
    const card = (n) => screen.getByRole("button", { name: "Card " + n });
    render(<App cards={["Sencha", "Matcha", "Chai"]} shuffle={(deck) => deck} />);
    await user.click(card(2));
    expect(card(2)).toHaveTextContent("Matcha");
    expect(card(2)).toBeDisabled();
    expect(card(1).textContent).toBe("");
    expect(screen.getByText("Moves: 0")).toBeInTheDocument();
    
  4. a matching pair stays face up
    const card = (n) => screen.getByRole("button", { name: "Card " + n });
    const clock = useFakeTimers();
    render(<App cards={["Sencha", "Matcha", "Chai"]} shuffle={(deck) => deck} />);
    await user.click(card(1));
    await user.click(card(4));
    expect(screen.getByText("Moves: 1")).toBeInTheDocument();
    await clock.advance(3000);
    expect(card(1)).toHaveTextContent("Sencha");
    expect(card(4)).toHaveTextContent("Sencha");
    expect(card(1)).toBeDisabled();
    expect(card(4)).toBeDisabled();
    expect(card(2)).toBeEnabled();
    
  5. a pair that doesn't match turns back over after 1 second
    const card = (n) => screen.getByRole("button", { name: "Card " + n });
    const clock = useFakeTimers();
    render(<App cards={["Sencha", "Matcha", "Chai"]} shuffle={(deck) => deck} />);
    await user.click(card(1));
    await user.click(card(2));
    expect(card(1)).toHaveTextContent("Sencha");
    expect(card(2)).toHaveTextContent("Matcha");
    expect(screen.getByText("Moves: 1")).toBeInTheDocument();
    await clock.advance(999);
    expect(card(2)).toHaveTextContent("Matcha");
    await clock.advance(1);
    expect(card(1).textContent).toBe("");
    expect(card(2).textContent).toBe("");
    expect(card(1)).toBeEnabled();
    await user.click(card(1));
    expect(card(1)).toHaveTextContent("Sencha");
    
  6. no other card can be turned while a mismatched pair is showing
    const card = (n) => screen.getByRole("button", { name: "Card " + n });
    const clock = useFakeTimers();
    render(<App cards={["Sencha", "Matcha", "Chai"]} shuffle={(deck) => deck} />);
    await user.click(card(1));
    await user.click(card(2));
    expect(card(3)).toBeDisabled();
    await user.click(card(3));
    expect(card(3).textContent).toBe("");
    expect(screen.getByText("Moves: 1")).toBeInTheDocument();
    await clock.advance(1000);
    expect(card(3)).toBeEnabled();
    
  7. clicking the same card twice doesn't make a pair
    const card = (n) => screen.getByRole("button", { name: "Card " + n });
    useFakeTimers();
    render(<App cards={["Sencha", "Matcha", "Chai"]} shuffle={(deck) => deck} />);
    await user.click(card(1));
    await user.click(card(1));
    expect(screen.getByText("Moves: 0")).toBeInTheDocument();
    await user.click(card(4));
    expect(screen.getByText("Moves: 1")).toBeInTheDocument();
    expect(card(4)).toHaveTextContent("Sencha");
    expect(card(4)).toBeDisabled();
    
  8. counts one move for every pair turned over
    const card = (n) => screen.getByRole("button", { name: "Card " + n });
    const clock = useFakeTimers();
    render(<App cards={["Sencha", "Matcha", "Chai"]} shuffle={(deck) => deck} />);
    await user.click(card(1));
    await user.click(card(2));
    await clock.advance(1000);
    await user.click(card(3));
    await user.click(card(6));
    await user.click(card(2));
    await user.click(card(4));
    await clock.advance(1000);
    expect(screen.getByText("Moves: 3")).toBeInTheDocument();
    
  9. says you won once every pair is found
    const card = (n) => screen.getByRole("button", { name: "Card " + n });
    const clock = useFakeTimers();
    render(<App cards={["Sencha", "Matcha", "Chai"]} shuffle={(deck) => deck} />);
    await user.click(card(1));
    await user.click(card(5));
    await clock.advance(1000);
    await user.click(card(1));
    await user.click(card(4));
    await user.click(card(2));
    await user.click(card(5));
    expect(screen.queryByText(/You won/)).not.toBeInTheDocument();
    await user.click(card(6));
    await user.click(card(3));
    expect(screen.getByText("You won in 4 moves!")).toBeInTheDocument();
    
  10. clears the flip-back timer when it leaves the page
    const card = (n) => screen.getByRole("button", { name: "Card " + n });
    const clock = useFakeTimers();
    const { unmount } = render(<App cards={["Sencha", "Matcha", "Chai"]} shuffle={(deck) => deck} />);
    await user.click(card(1));
    await user.click(card(2));
    expect(clock.pending()).toBeGreaterThan(0);
    unmount();
    expect(clock.pending()).toBe(0);
    

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).