DrillLab

Todo List

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

题面The problem

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

已有 todosfilter 两个 state。 写出可见列表和「清除已完成」,注意筛选态下的写操作该作用于谁。

You already have the two states todos and filter. Write the visible list and the clear-completed action, and think about which list a write should act on while a filter is on.

验收标准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 “变式一 · Todo List” — the same content as in the course, not a rewritten summary. Expand it when you stall.

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

完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 变式一 · 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 三个值都能从 todosfilter 当场算出来,每次渲染重算,永远不会不一致。

关键陷阱:所有写操作都要作用于 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.tssrc/components/TodoList/index.tsx, 把下面这个测试文件放到 src/TodoList.test.tsx, 然后自己把组件写出来。

注意测试是按 data-testidaria-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-labelthose 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验证命令已跑通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.