Skip to the code
cf.

Progress bars with a queue

The tea shop's admin page uploads product photos, and the server takes at most three at a time. Build App, a simulation of that: an Add button, and a list of progress bars that each fill from 0 to 100 over 2 seconds.

Each click on Add adds a bar at the end: an element with role="progressbar", aria-label Bar 1, Bar 2, and so on, aria-valuemin="0", aria-valuemax="100", and aria-valuenow with its value, starting at 0. Draw it however you like (a div whose width is the value as a percentage works).

Every 100 ms, each filling bar goes up by 5, so a bar is full (100) 2 seconds after it starts, and then stays at 100. At most three bars fill at once: the first three bars that aren't full yet, in the order they were added. The others wait at 0, and the earliest waiting bar starts on the first 100 ms step after a filling one becomes full. A bar added while fewer than three are filling starts on the next step.

Run no timer while there's nothing to fill (no bars, or every bar full), and leave none running when the component is removed. It must fill at the same pace in Strict Mode, which runs state updater functions twice in development.

// Add clicked 4 times, 1 second later:
<div role="progressbar" aria-label="Bar 1" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50"></div>
<div role="progressbar" aria-label="Bar 2" ... aria-valuenow="50"></div>
<div role="progressbar" aria-label="Bar 3" ... aria-valuenow="50"></div>
<div role="progressbar" aria-label="Bar 4" ... aria-valuenow="0"></div>

What the tests check

  1. starts with an Add button, no bars, and no timer
    const values = () => screen.queryAllByRole("progressbar").map((bar) => Number(bar.getAttribute("aria-valuenow")));
    const add = (times = 1) => Array.from({ length: times }).reduce((done) => done.then(() => user.click(screen.getByRole("button", { name: "Add" }))), Promise.resolve());
    const clock = useFakeTimers();
    render(<App />);
    expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
    expect(values()).toEqual([]);
    expect(clock.pending()).toBe(0);
    
  2. Add adds empty bars named Bar 1, Bar 2, and so on
    const values = () => screen.queryAllByRole("progressbar").map((bar) => Number(bar.getAttribute("aria-valuenow")));
    const add = (times = 1) => Array.from({ length: times }).reduce((done) => done.then(() => user.click(screen.getByRole("button", { name: "Add" }))), Promise.resolve());
    useFakeTimers();
    render(<App />);
    await add(2);
    const bar = screen.getByRole("progressbar", { name: "Bar 1" });
    expect(bar).toHaveAttribute("aria-valuenow", "0");
    expect(bar).toHaveAttribute("aria-valuemin", "0");
    expect(bar).toHaveAttribute("aria-valuemax", "100");
    expect(screen.getByRole("progressbar", { name: "Bar 2" })).toBeInTheDocument();
    
  3. a bar goes up by 5 every 100 ms and is full after 2 seconds
    const values = () => screen.queryAllByRole("progressbar").map((bar) => Number(bar.getAttribute("aria-valuenow")));
    const add = (times = 1) => Array.from({ length: times }).reduce((done) => done.then(() => user.click(screen.getByRole("button", { name: "Add" }))), Promise.resolve());
    const clock = useFakeTimers();
    render(<App />);
    await add();
    await clock.advance(100);
    expect(values()).toEqual([5]);
    await clock.advance(900);
    expect(values()).toEqual([50]);
    await clock.advance(1000);
    expect(values()).toEqual([100]);
    await clock.advance(1000);
    expect(values()).toEqual([100]);
    
  4. fills at most three bars at once
    const values = () => screen.queryAllByRole("progressbar").map((bar) => Number(bar.getAttribute("aria-valuenow")));
    const add = (times = 1) => Array.from({ length: times }).reduce((done) => done.then(() => user.click(screen.getByRole("button", { name: "Add" }))), Promise.resolve());
    const clock = useFakeTimers();
    render(<App />);
    await add(5);
    await clock.advance(1000);
    expect(values()).toEqual([50, 50, 50, 0, 0]);
    
  5. a waiting bar starts as soon as a filling one is full
    const values = () => screen.queryAllByRole("progressbar").map((bar) => Number(bar.getAttribute("aria-valuenow")));
    const add = (times = 1) => Array.from({ length: times }).reduce((done) => done.then(() => user.click(screen.getByRole("button", { name: "Add" }))), Promise.resolve());
    const clock = useFakeTimers();
    render(<App />);
    await add(4);
    await clock.advance(2000);
    expect(values()).toEqual([100, 100, 100, 0]);
    await clock.advance(100);
    expect(values()).toEqual([100, 100, 100, 5]);
    await clock.advance(1900);
    expect(values()).toEqual([100, 100, 100, 100]);
    
  6. a bar added while there's room starts at once
    const values = () => screen.queryAllByRole("progressbar").map((bar) => Number(bar.getAttribute("aria-valuenow")));
    const add = (times = 1) => Array.from({ length: times }).reduce((done) => done.then(() => user.click(screen.getByRole("button", { name: "Add" }))), Promise.resolve());
    const clock = useFakeTimers();
    render(<App />);
    await add();
    await clock.advance(1000);
    await add();
    await clock.advance(1000);
    expect(values()).toEqual([100, 50]);
    
  7. the bars fill in the order they were added
    const values = () => screen.queryAllByRole("progressbar").map((bar) => Number(bar.getAttribute("aria-valuenow")));
    const add = (times = 1) => Array.from({ length: times }).reduce((done) => done.then(() => user.click(screen.getByRole("button", { name: "Add" }))), Promise.resolve());
    const clock = useFakeTimers();
    render(<App />);
    await add(3);
    await clock.advance(1000);
    await add(2);
    await clock.advance(1000);
    expect(values()).toEqual([100, 100, 100, 0, 0]);
    await clock.advance(500);
    expect(values()).toEqual([100, 100, 100, 25, 25]);
    
  8. stops its timer when every bar is full, and starts again for a new one
    const values = () => screen.queryAllByRole("progressbar").map((bar) => Number(bar.getAttribute("aria-valuenow")));
    const add = (times = 1) => Array.from({ length: times }).reduce((done) => done.then(() => user.click(screen.getByRole("button", { name: "Add" }))), Promise.resolve());
    const clock = useFakeTimers();
    render(<App />);
    await add(2);
    await clock.advance(2000);
    expect(clock.pending()).toBe(0);
    await add();
    await clock.advance(1000);
    expect(values()).toEqual([100, 100, 50]);
    
  9. fills at the same pace in Strict Mode
    const values = () => screen.queryAllByRole("progressbar").map((bar) => Number(bar.getAttribute("aria-valuenow")));
    const add = (times = 1) => Array.from({ length: times }).reduce((done) => done.then(() => user.click(screen.getByRole("button", { name: "Add" }))), Promise.resolve());
    const clock = useFakeTimers();
    render(
      <React.StrictMode>
        <App />
      </React.StrictMode>,
    );
    await add(4);
    await clock.advance(1000);
    expect(values()).toEqual([50, 50, 50, 0]);
    
  10. clears its timer when removed
    const values = () => screen.queryAllByRole("progressbar").map((bar) => Number(bar.getAttribute("aria-valuenow")));
    const add = (times = 1) => Array.from({ length: times }).reduce((done) => done.then(() => user.click(screen.getByRole("button", { name: "Add" }))), Promise.resolve());
    const clock = useFakeTimers();
    const { unmount } = render(<App />);
    await add(4);
    await clock.advance(500);
    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).