DrillLab

Todo List

React简单Easy约 25 分钟~25 min浏览器里能跑Runs in the browser
§01

题面The problem

先把要求读完,再动手。Read every requirement before you start.

实现一个完整的 Todo List:新增、勾选、删除、按 all / active / done 筛选、全选 / 全不选、清除已完成。可见列表、剩余条数和「是否全部完成」都是派生数据,不许再开 state;筛选只改显示,不改底层数据。Build a complete Todo List: add, check off, delete, filter by all / active / done, check or uncheck everything, and clear the completed ones. The visible list, the remaining count and whether everything is done are all derived values, so add no state for them; filtering changes only what is shown, never the underlying data.
验收标准Acceptance criteria
  • 输入框为空或只有空格时,Add 按钮 disabledThe Add button is disabled while the input is empty or holds only spaces
  • 提交后清空输入框,新条目追加到末尾Submitting clears the input and appends the new item to the end of the list
  • 勾选切换单条的 done —— map + 对象展开,不改原对象The checkbox toggles done on one item: use map plus object spread, and never change the original object
  • Delete 用 filter 删掉单条,不许 spliceDelete removes a single item with filter, never with splice
  • visible / remaining / allDone 三个都是派生数据,不许再开 statevisible, remaining and allDone are all derived values. Do not add state for them
  • 筛选 all / active / done 只影响显示,切回 all 时所有条目都还在The all / active / done filter only changes what is shown. Switching back to all brings every item back
  • Check all / Uncheck all 按「当前是否已全部完成」整体反转Check all / Uncheck all flips every item at once, based on whether they are all done right now
  • Clear completed 只删已完成的Clear completed removes only the items that are already done

预计 25 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 25 minutes. Overrunning on the first pass is normal; the second pass should fit.

§02

工作区Workspace

工作区是一个真的浏览器沙箱:左边写代码,右边实时预览,下面一个「跑测试」按钮。测试和本机那套是同一批断言,转写成了浏览器里能跑的写法。The workspace is a real in-browser sandbox: edit on the left, live preview on the right, one Run button below. The assertions are the same ones that pass on a real machine, rewritten for the browser runner.

需要联网。Requires an internet connection. 打包器和 npm 依赖都在 CodeSandbox 的远程服务上(评估过程见 docs/sandpack-evaluation.md),断网这块就起不来 —— 那就照下面的命令在本机跑。The bundler and the npm packages come from CodeSandbox's remote service, so this panel needs network access.

3 个起始文件 · 目标 8 passed3 starter files · target 8 passed
自己写出来、测试全绿之后再打勾。看懂答案不算。Tick this only after you wrote it yourself and the tests went green.
§03

展开讲解Walkthrough

下面是《变式一 · Todo List》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “Variation 1 · Todo List” — the same content as in the course, not a rewritten summary. Expand it when you stall.

展开《变式一 · Todo List》(6 段 · 约 14 分钟)Expand “Variation 1 · Todo List” (6 sections · ~14 min)

完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 变式一 · Todo ListVariation 1 · Todo List。.

§01

数据形状:只比 Note 多一个布尔字段The shape of the data: one boolean field more than Note

先看类型,其余都是从它推出来的。Start with the type. Everything else follows from it.

和 Note 对比一下:Todo 多了一个done: boolean,于是多出「翻转」这个操作;Filter 是个字面量联合,用来存筛选条件。

注意 Filter 不属于任何一条 todo —— 它是「界面当前怎么看这份数据」,所以它是独立的一个 state,而不是 todo 上的字段。这个区分在很多人那里是模糊的。

Put it next to Note: Todo adds exactly one field, done: boolean, and that one field buys you a new operation — toggling. Filter is a literal union that holds the current filter.

Notice that Filter belongs to no single todo — it is “how the UI is looking at this data right now”, so it is its own piece of state, not a field on a todo. Plenty of people are fuzzy about that line.

TypeScriptsrc/types/Todo.ts已跑通Verified
1export type Filter = "all" | "active" | "done";
2
3export type Todo = {
4 id: number;
5 text: string;
6 done: boolean;
7};
§02

翻转一条:map + 对象展开Flipping one item: map plus object spread

这是三件套之外的第四个动作,但底层还是 map。This is a fourth action beyond the usual three, but underneath it is still map.

Q1 的「就地更新」是整条替换(note.id === x ? submittedNote : note)。这里只想改一个字段,所以用对象展开 + 覆盖:

{ ...t, done: !t.done } 造了一个新对象:旧字段全部照抄,只把 done 换成反过来的值。

为什么不能直接 t.done = !t.done?那是在改原对象。虽然数组是新的(map 返回新数组),但里面那个 todo 对象还是同一个引用 —— 如果哪天你给列表项加了 React.memo,它会认为「props 没变」而不重渲染,于是勾选框不动。不可变更新要一路到底,不能只做外层。

The “update in place” in Q1 replaced the whole item (note.id === x ? submittedNote : note). Here you only want to change one field, so you use object spread plus an override:

{ ...t, done: !t.done } builds a new object: copy every old field, then swap done for its opposite.

Why not just t.done = !t.done? Because that mutates the original object. The array is new (map returns a new array), but the todo object inside is still the same reference — the day you wrap list items in React.memo, it decides “props did not change”, skips the re-render, and the checkbox stops moving. Immutable updates have to go all the way down, not just the outer layer.

TSX两种 map 更新Two kinds of map update已跑通Verified
1// 翻转一条
2setTodos((prev) =>
3 prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
4);
5
6// 对比 Q1 的整条替换
7setNotes((prev) =>
8 prev.map((n) => (n.id === next.id ? next : n)),
9);
1// Toggle one item
2setTodos((prev) =>
3 prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
4);
5
6// Compare with the whole-item replace of Q1
7setNotes((prev) =>
8 prev.map((n) => (n.id === next.id ? next : n)),
9);
TSX看起来像不可变,其实不是It looks immutable, and it is not示意Illustrative
1// ✗ 数组是新的,但对象被就地改了
2setTodos((prev) =>
3 prev.map((t) => {
4 if (t.id === id) t.done = !t.done; // 改的是原对象
5 return t;
6 }),
7);
1// ✗ The array is new, but the object was changed in place
2setTodos((prev) =>
3 prev.map((t) => {
4 if (t.id === id) t.done = !t.done; // this changes the original object
5 return t;
6 }),
7);
§03

三个派生数据,一个 state 都不加Three derived values, and not one new state

这道题最容易过度设计的地方是「剩余几项」和「筛选后的列表」—— 很多人会为它们各开一个 useState,再用 useEffect 同步。全都不需要。

visible / remaining /allDone 三个值都能从 todos 和filter 当场算出来,每次渲染重算,永远不会不一致。

关键陷阱:所有写操作都要作用于 todos,不是 visible。筛选到「已完成」时点删除,如果你写setTodos(visible.filter(...)),那些被筛掉的未完成项会全部消失。这一条在模拟考 A 里也考过,是同一个陷阱。

The easiest place to over-engineer this question is “how many left” and “the filtered list” — a lot of people give each one its own useState and then sync them with a useEffect. None of that is needed.

visible / remaining / allDone can all be computed on the spot from todos and filter, recomputed on every render, so they can never fall out of sync.

The trap that matters: every write goes through todos, not visible. Filter down to “done” and hit delete, and if you wrote setTodos(visible.filter(...)), every unfinished item that got filtered out disappears. Mock exam A tests the same trap.

TSX派生数据Derived values已跑通Verified
1const visible =
2 filter === "all"
3 ? todos
4 : todos.filter((t) => (filter === "done" ? t.done : !t.done));
5const remaining = todos.filter((t) => !t.done).length;
6const allDone = todos.length > 0 && remaining === 0;
7
8// 写操作一律作用于 todos
9const remove = (id: number) => {
10 setTodos((prev) => prev.filter((t) => t.id !== id));
11};
1const visible =
2 filter === "all"
3 ? todos
4 : todos.filter((t) => (filter === "done" ? t.done : !t.done));
5const remaining = todos.filter((t) => !t.done).length;
6const allDone = todos.length > 0 && remaining === 0;
7
8// Every write acts on todos
9const remove = (id: number) => {
10 setTodos((prev) => prev.filter((t) => t.id !== id));
11};
allDone 里那个 todos.length > 0 不能省 —— 空列表时 remaining 也是 0,不判断的话按钮一上来就显示「Uncheck all」。The todos.length > 0 inside allDone is not optional: with an empty list remaining is also 0, and without that check the button says Uncheck all from the very first render.
§04

两个批量操作The two bulk actions

全选 / 取消全选的正确语义是「以当前是否已全部完成为准,整体反转」—— 而不是「每一条各自翻转」。后者在混合状态下会得到一半勾一半不勾,不符合用户预期。

清除已完成就是一次 filter,和删除单条是同一个动作,只是条件不同。

Check all / uncheck all means “flip everything to one target value, decided by whether they are all done right now” — not “flip each item on its own”. The second reading leaves a mixed list half checked and half unchecked, which is not what the user expects.

Clear completed is one filter call. Same action as deleting a single item, different condition.

TSX批量操作The bulk actions已跑通Verified
1const toggleAll = () => {
2 const next = !allDone; // 先定一个统一的目标值
3 setTodos((prev) => prev.map((t) => ({ ...t, done: next })));
4};
5
6const clearDone = () => {
7 setTodos((prev) => prev.filter((t) => !t.done));
8};
1const toggleAll = () => {
2 const next = !allDone; // decide one shared target value first
3 setTodos((prev) => prev.map((t) => ({ ...t, done: next })));
4};
5
6const clearDone = () => {
7 setTodos((prev) => prev.filter((t) => !t.done));
8};
TSX示意Illustrative
1// ✗ 每条各自翻转 —— 混合状态下变成「反选」,不是「全选」
2setTodos((prev) => prev.map((t) => ({ ...t, done: !t.done })));
1// ✗ Toggling each item on its own — with a mixed list this inverts the selection instead of selecting all
2setTodos((prev) => prev.map((t) => ({ ...t, done: !t.done })));
§05

完整答案The complete answer

7 个测试全过。All 7 tests pass.

注意 data-testid 用了模板字符串生成(`filter-${f}`)—— 三个筛选按钮共用一段渲染代码,这是列表渲染的常见写法。

Note that data-testid is generated with a template string (`filter-${f}`) — the three filter buttons share one piece of render code, which is the normal way to render a list.

TSXsrc/components/TodoList/index.tsx(实测 7/7 通过)src/components/TodoList/index.tsx (7 of 7 pass here)已跑通Verified
1import React, { useState } from "react";
2import type { Filter, Todo } from "../../types/Todo";
3
4const TodoList: React.FC = () => {
5 const [todos, setTodos] = useState<Todo[]>([]);
6 const [text, setText] = useState("");
7 const [filter, setFilter] = useState<Filter>("all");
8
9 // 三个派生数据:都能从 todos 算出来,都不该做成 state
10 const visible =
11 filter === "all"
12 ? todos
13 : todos.filter((t) => (filter === "done" ? t.done : !t.done));
14 const remaining = todos.filter((t) => !t.done).length;
15 const allDone = todos.length > 0 && remaining === 0;
16
17 const isInvalid = text.trim() === "";
18
19 const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
20 event.preventDefault();
21 if (isInvalid) return;
22 setTodos((prev) => [...prev, { id: Date.now(), text: text.trim(), done: false }]);
23 setText("");
24 };
25
26 // 就地翻转一条:map + 对象展开,不改原对象
27 const toggle = (id: number) => {
28 setTodos((prev) =>
29 prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
30 );
31 };
32
33 const remove = (id: number) => {
34 setTodos((prev) => prev.filter((t) => t.id !== id));
35 };
36
37 // 全选/全不选:以「当前是否已全部完成」为准整体反转
38 const toggleAll = () => {
39 const next = !allDone;
40 setTodos((prev) => prev.map((t) => ({ ...t, done: next })));
41 };
42
43 const clearDone = () => {
44 setTodos((prev) => prev.filter((t) => !t.done));
45 };
46
47 return (
48 <div data-testid="todo-app">
49 <form onSubmit={handleSubmit} data-testid="todo-form">
50 <input
51 type="text"
52 value={text}
53 onChange={(e) => setText(e.target.value)}
54 data-testid="todo-input"
55 />
56 <button type="submit" disabled={isInvalid} data-testid="todo-submit">
57 Add
58 </button>
59 </form>
60
61 <div>
62 <button onClick={toggleAll} data-testid="toggle-all">
63 {allDone ? "Uncheck all" : "Check all"}
64 </button>
65 <button onClick={clearDone} data-testid="clear-done">
66 Clear completed
67 </button>
68 <span data-testid="remaining">{remaining} left</span>
69 </div>
70
71 <div>
72 {(["all", "active", "done"] as Filter[]).map((f) => (
73 <button key={f} onClick={() => setFilter(f)} data-testid={`filter-${f}`}
74 aria-pressed={filter === f}>
75 {f}
76 </button>
77 ))}
78 </div>
79
80 <ul data-testid="todo-list">
81 {visible.map((todo) => (
82 <li key={todo.id} data-done={todo.done}>
83 <input
84 type="checkbox"
85 checked={todo.done}
86 onChange={() => toggle(todo.id)}
87 aria-label={`toggle ${todo.text}`}
88 />
89 <span>{todo.text}</span>
90 <button onClick={() => remove(todo.id)} aria-label={`delete ${todo.text}`}>
91 Delete
92 </button>
93 </li>
94 ))}
95 </ul>
96 </div>
97 );
98};
99
100export default TodoList;
1import React, { useState } from "react";
2import type { Filter, Todo } from "../../types/Todo";
3
4const TodoList: React.FC = () => {
5 const [todos, setTodos] = useState<Todo[]>([]);
6 const [text, setText] = useState("");
7 const [filter, setFilter] = useState<Filter>("all");
8
9 // Three derived values: each computed from todos, none of them a state
10 const visible =
11 filter === "all"
12 ? todos
13 : todos.filter((t) => (filter === "done" ? t.done : !t.done));
14 const remaining = todos.filter((t) => !t.done).length;
15 const allDone = todos.length > 0 && remaining === 0;
16
17 const isInvalid = text.trim() === "";
18
19 const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
20 event.preventDefault();
21 if (isInvalid) return;
22 setTodos((prev) => [...prev, { id: Date.now(), text: text.trim(), done: false }]);
23 setText("");
24 };
25
26 // Toggle one item in place: map plus an object spread, original untouched
27 const toggle = (id: number) => {
28 setTodos((prev) =>
29 prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
30 );
31 };
32
33 const remove = (id: number) => {
34 setTodos((prev) => prev.filter((t) => t.id !== id));
35 };
36
37 // Check all / uncheck all: one shared target value from whether all are done
38 const toggleAll = () => {
39 const next = !allDone;
40 setTodos((prev) => prev.map((t) => ({ ...t, done: next })));
41 };
42
43 const clearDone = () => {
44 setTodos((prev) => prev.filter((t) => !t.done));
45 };
46
47 return (
48 <div data-testid="todo-app">
49 <form onSubmit={handleSubmit} data-testid="todo-form">
50 <input
51 type="text"
52 value={text}
53 onChange={(e) => setText(e.target.value)}
54 data-testid="todo-input"
55 />
56 <button type="submit" disabled={isInvalid} data-testid="todo-submit">
57 Add
58 </button>
59 </form>
60
61 <div>
62 <button onClick={toggleAll} data-testid="toggle-all">
63 {allDone ? "Uncheck all" : "Check all"}
64 </button>
65 <button onClick={clearDone} data-testid="clear-done">
66 Clear completed
67 </button>
68 <span data-testid="remaining">{remaining} left</span>
69 </div>
70
71 <div>
72 {(["all", "active", "done"] as Filter[]).map((f) => (
73 <button key={f} onClick={() => setFilter(f)} data-testid={`filter-${f}`}
74 aria-pressed={filter === f}>
75 {f}
76 </button>
77 ))}
78 </div>
79
80 <ul data-testid="todo-list">
81 {visible.map((todo) => (
82 <li key={todo.id} data-done={todo.done}>
83 <input
84 type="checkbox"
85 checked={todo.done}
86 onChange={() => toggle(todo.id)}
87 aria-label={`toggle ${todo.text}`}
88 />
89 <span>{todo.text}</span>
90 <button onClick={() => remove(todo.id)} aria-label={`delete ${todo.text}`}>
91 Delete
92 </button>
93 </li>
94 ))}
95 </ul>
96 </div>
97 );
98};
99
100export default TodoList;
§06

怎么验证How to check it

这就是跑出 7/7 的那个测试文件,原样贴在这里。This is the test file that produced 7 of 7, pasted exactly as it is.

想真正练这道题,就在一个空的 Vite + React + TS 项目里(react-notes-app 的脚手架直接能用)新建src/types/Todo.ts 和src/components/TodoList/index.tsx,把下面这个测试文件放到 src/TodoList.test.tsx,然后自己把组件写出来。

注意测试是按 data-testid 和aria-label 找元素的 ——这些名字必须和测试对上,这也是真实 assessment 的规矩(Q1 的六个 testid 一个都不能改)。

第 5 条 filters without losing data是专门抓「筛选态下改坏了底层数据」的:筛完再切回all,两条都得还在。

To really practise this one, open an empty Vite + React + TS project (the react-notes-app scaffold works as is), create src/types/Todo.ts and src/components/TodoList/index.tsx, drop the test file below into src/TodoList.test.tsx, and write the component yourself.

The tests find elements by data-testid and aria-label — those names have to match the tests, which is the rule in the real assessment too (you cannot rename a single one of the six testids in Q1).

Test 5, filters without losing data, exists to catch “the filter broke the underlying data”: filter, switch back to all, and both items must still be there.

Terminal验证命令The command that verifies it已跑通Verified
1npx vitest run src/TodoList.test.tsx # 7 passed
TSXsrc/TodoList.test.tsx(DrillLab 自出,本机跑过)src/TodoList.test.tsx (written for DrillLab, run here)已跑通Verified
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import TodoList from "./components/TodoList";
4
5const add = async (text: string) => {
6 await userEvent.type(screen.getByTestId("todo-input"), text);
7 await userEvent.click(screen.getByTestId("todo-submit"));
8};
9
10test("adds a todo and clears the input", async () => {
11 render(<TodoList />);
12 await add("买牛奶");
13 expect(screen.getByTestId("todo-list")).toHaveTextContent("买牛奶");
14 expect(screen.getByTestId("todo-input")).toHaveValue("");
15});
16
17test("submit disabled when input is only whitespace", async () => {
18 render(<TodoList />);
19 expect(screen.getByTestId("todo-submit")).toBeDisabled();
20 await userEvent.type(screen.getByTestId("todo-input"), " ");
21 expect(screen.getByTestId("todo-submit")).toBeDisabled();
22});
23
24test("toggles one todo without touching the others", async () => {
25 render(<TodoList />);
26 await add("A");
27 await add("B");
28 await userEvent.click(screen.getByLabelText("toggle A"));
29
30 const items = screen.getByTestId("todo-list").querySelectorAll("li");
31 expect(items[0].getAttribute("data-done")).toBe("true");
32 expect(items[1].getAttribute("data-done")).toBe("false");
33});
34
35test("remaining count is derived, not stored", async () => {
36 render(<TodoList />);
37 await add("A");
38 await add("B");
39 expect(screen.getByTestId("remaining")).toHaveTextContent("2 left");
40 await userEvent.click(screen.getByLabelText("toggle A"));
41 expect(screen.getByTestId("remaining")).toHaveTextContent("1 left");
42});
43
44test("filters without losing data", async () => {
45 render(<TodoList />);
46 await add("A");
47 await add("B");
48 await userEvent.click(screen.getByLabelText("toggle A"));
49
50 await userEvent.click(screen.getByTestId("filter-active"));
51 expect(screen.getByTestId("todo-list")).not.toHaveTextContent("A");
52 expect(screen.getByTestId("todo-list")).toHaveTextContent("B");
53
54 await userEvent.click(screen.getByTestId("filter-done"));
55 expect(screen.getByTestId("todo-list")).toHaveTextContent("A");
56 expect(screen.getByTestId("todo-list")).not.toHaveTextContent("B");
57
58 // 切回 all,两条都还在 —— 筛选不能动底层数据
59 await userEvent.click(screen.getByTestId("filter-all"));
60 expect(screen.getByTestId("todo-list")).toHaveTextContent("A");
61 expect(screen.getByTestId("todo-list")).toHaveTextContent("B");
62});
63
64test("toggle all then uncheck all", async () => {
65 render(<TodoList />);
66 await add("A");
67 await add("B");
68 await userEvent.click(screen.getByTestId("toggle-all"));
69 expect(screen.getByTestId("remaining")).toHaveTextContent("0 left");
70 await userEvent.click(screen.getByTestId("toggle-all"));
71 expect(screen.getByTestId("remaining")).toHaveTextContent("2 left");
72});
73
74test("clear completed removes only done todos", async () => {
75 render(<TodoList />);
76 await add("A");
77 await add("B");
78 await userEvent.click(screen.getByLabelText("toggle A"));
79 await userEvent.click(screen.getByTestId("clear-done"));
80
81 expect(screen.getByTestId("todo-list")).not.toHaveTextContent("A");
82 expect(screen.getByTestId("todo-list")).toHaveTextContent("B");
83});
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import TodoList from "./components/TodoList";
4
5const add = async (text: string) => {
6 await userEvent.type(screen.getByTestId("todo-input"), text);
7 await userEvent.click(screen.getByTestId("todo-submit"));
8};
9
10test("adds a todo and clears the input", async () => {
11 render(<TodoList />);
12 await add("买牛奶");
13 expect(screen.getByTestId("todo-list")).toHaveTextContent("买牛奶");
14 expect(screen.getByTestId("todo-input")).toHaveValue("");
15});
16
17test("submit disabled when input is only whitespace", async () => {
18 render(<TodoList />);
19 expect(screen.getByTestId("todo-submit")).toBeDisabled();
20 await userEvent.type(screen.getByTestId("todo-input"), " ");
21 expect(screen.getByTestId("todo-submit")).toBeDisabled();
22});
23
24test("toggles one todo without touching the others", async () => {
25 render(<TodoList />);
26 await add("A");
27 await add("B");
28 await userEvent.click(screen.getByLabelText("toggle A"));
29
30 const items = screen.getByTestId("todo-list").querySelectorAll("li");
31 expect(items[0].getAttribute("data-done")).toBe("true");
32 expect(items[1].getAttribute("data-done")).toBe("false");
33});
34
35test("remaining count is derived, not stored", async () => {
36 render(<TodoList />);
37 await add("A");
38 await add("B");
39 expect(screen.getByTestId("remaining")).toHaveTextContent("2 left");
40 await userEvent.click(screen.getByLabelText("toggle A"));
41 expect(screen.getByTestId("remaining")).toHaveTextContent("1 left");
42});
43
44test("filters without losing data", async () => {
45 render(<TodoList />);
46 await add("A");
47 await add("B");
48 await userEvent.click(screen.getByLabelText("toggle A"));
49
50 await userEvent.click(screen.getByTestId("filter-active"));
51 expect(screen.getByTestId("todo-list")).not.toHaveTextContent("A");
52 expect(screen.getByTestId("todo-list")).toHaveTextContent("B");
53
54 await userEvent.click(screen.getByTestId("filter-done"));
55 expect(screen.getByTestId("todo-list")).toHaveTextContent("A");
56 expect(screen.getByTestId("todo-list")).not.toHaveTextContent("B");
57
58 // Back to all and both are still there: filtering must not touch the data
59 await userEvent.click(screen.getByTestId("filter-all"));
60 expect(screen.getByTestId("todo-list")).toHaveTextContent("A");
61 expect(screen.getByTestId("todo-list")).toHaveTextContent("B");
62});
63
64test("toggle all then uncheck all", async () => {
65 render(<TodoList />);
66 await add("A");
67 await add("B");
68 await userEvent.click(screen.getByTestId("toggle-all"));
69 expect(screen.getByTestId("remaining")).toHaveTextContent("0 left");
70 await userEvent.click(screen.getByTestId("toggle-all"));
71 expect(screen.getByTestId("remaining")).toHaveTextContent("2 left");
72});
73
74test("clear completed removes only done todos", async () => {
75 render(<TodoList />);
76 await add("A");
77 await add("B");
78 await userEvent.click(screen.getByLabelText("toggle A"));
79 await userEvent.click(screen.getByTestId("clear-done"));
80
81 expect(screen.getByTestId("todo-list")).not.toHaveTextContent("A");
82 expect(screen.getByTestId("todo-list")).toHaveTextContent("B");
83});
§04

参考答案Reference solution

提示是一级一级放的。四级看完还写不出来,再开答案门。The hints come one level at a time. If all four leave you stuck, open the answer.

提示Hints共 4 级,已看 0 级4 levels, 0 opened
先自己想两分钟。想不出来再点右上角 —— 提示是一级一级放的,不会一次给完。Think for two minutes first. Then use the button above — hints come one level at a time, never all at once.

这份答案在本机真跑过测试。但先确认你自己动手写过一遍 —— 读懂答案和写出答案是两种能力,考场上考的是后一种。This answer really was run here and its tests passed. But write it yourself first — reading an answer and producing one are two different skills, and the exam tests the second.