DrillLab

Redux Toolkit 版 TodoTodo with Redux Toolkit

React困难 · Hard约 40 分钟~40 min浏览器里能跑Runs in the browser
§01

题面The problem

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

四个空。第 3 个空是这道题的加分点, 第 4 个空写错会让 reducer 不纯。

Four blanks. The 3rd one is the bonus point of this question; get the 4th one wrong and the reducer is no longer pure.

验收标准Acceptance criteria
  • 用 createSlice 写出 added / toggled / removed / clearedDone / filterChangedUse createSlice to write added, toggled, removed, clearedDone and filterChanged
  • id 在 prepare 里生成,不在 reducer 里 —— reducer 必须纯,同一个 action 跑两次结果要一样The id is generated in prepare, not in the reducer. A reducer has to be pure: running the same action twice must give the same result
  • Immer 给的是草稿代理,但传进来的 state 对象本身不能被改动(测试用冻结的 state 验)Immer hands you a draft proxy, but the state object passed in must not be changed. The test proves it with a frozen state
  • 派生数据用 selector:selectVisible / selectRemaining / selectFilterDerived data goes through selectors: selectVisible, selectRemaining and selectFilter
  • 筛选不能改底层数据 —— 切回 all 时全部条目都还在Filtering must not change the underlying data. Switching back to all brings every item back
  • reducer 要能脱离 React 单独测(所以这道题的测试里一行 render 都没有)The reducer must be testable on its own, without React. That is why the tests for this problem never call render

预计 40 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 40 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.

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

展开讲解Walkthrough

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

展开《缺口三 · 同一个 Todo 换成 Redux Toolkit》(3 段 · 约 24 分钟)Expand “缺口三 · 同一个 Todo 换成 Redux Toolkit” (3 sections · ~24 min)

完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 缺口三 · 同一个 Todo 换成 Redux Toolkit

§01

createSlice:一次生成 reducer 和 actionscreateSlice: one call gives you the reducer and the actions

手写 Redux 的那套样板已经过时了。The hand-written Redux boilerplate is out of date.

老写法要写三份: action types 常量、 action creators、 一个大 switch 的 reducer。createSlice把三份合成一份—— 你只写 reducers 对象, 它自动生成对应的 action creator 和 type (type 是 "todos/added"这样的 切片名/reducer 名)。

Immer 那一点必须说清state.items.push(...)看起来违反了「state 只读」, 其实你拿到的是一个草稿代理(draft proxy)—— 你的修改被记录下来, Immer 最终产出一个新对象, 原 state 一个字节没动。测试里我用expect(next).not.toBe(empty)和「原 state 长度仍是 0」两条断言证明了这一点。

但有个边界要记住: 这个「能 mutate」的特权只在 createSlice /createReducer 里成立。 在组件里、在 selector 里、 在自己写的普通函数里,该守的不可变规则一条都不能少

prepare 是这道题的加分点。生成 id 要用nanoid(), 而 reducer 必须是纯函数 —— 不能出现随机数和时间(否则同样的 state + action 会得到不同结果,时间旅行就废了)。prepare让你在创建 action 的时候生成 id,reducer 只负责把它放进去。

The old way needed three pieces: action type constants, action creators, and a reducer built around one big switch. createSlice folds the three into one — you only write the reducers object and it generates the matching action creator and type (the type is "todos/added", that is, sliceName/reducerName).

The Immer part has to be said clearly: state.items.push(...) looks like it breaks “state is read-only”, but what you hold is a draft proxy — your edits get recorded and Immer produces a new object at the end, leaving the original state untouched byte for byte. The test proves it with two assertions: expect(next).not.toBe(empty) and “the original state still has length 0”.

But there is a boundary to remember: this mutate-anyway privilege only holds inside createSlice / createReducer. In components, in selectors, in your own plain functions, every immutability rule still applies.

prepare is the bonus point of this question. Generating the id needs nanoid(), and a reducer has to be pure — no random numbers, no clock (otherwise the same state + action gives different results and time travel is dead). prepare lets you generate the id while the action is being created, so the reducer only puts it in place.

TypeScriptsrc/store/todosSlice.ts(实测 8/8 通过)src/store/todosSlice.ts (8/8 in a real run)已跑通Verified
1import { createSlice, nanoid } from "@reduxjs/toolkit";
2import type { PayloadAction } from "@reduxjs/toolkit";
3
4export type Filter = "all" | "active" | "done";
5export interface Todo { id: string; text: string; done: boolean }
6export interface TodosState { items: Todo[]; filter: Filter }
7
8const initialState: TodosState = { items: [], filter: "all" };
9
10const todosSlice = createSlice({
11 name: "todos",
12 initialState,
13 reducers: {
14 // 看着像在改 state,其实 RTK 内置的 Immer 给的是草稿代理,
15 // 最终产出的是新对象 —— 所以并不违反「state 只读」这条原则。
16 added: {
17 // prepare 让 action creator 只收 text,id 在这里生成 —— 这样
18 // reducer 里就不会出现 nanoid(),reducer 保持纯函数。
19 reducer(state, action: PayloadAction<Todo>) {
20 state.items.push(action.payload);
21 },
22 prepare(text: string) {
23 return { payload: { id: nanoid(), text: text.trim(), done: false } };
24 },
25 },
26 toggled(state, action: PayloadAction<string>) {
27 const t = state.items.find((x) => x.id === action.payload);
28 if (t) t.done = !t.done;
29 },
30 removed(state, action: PayloadAction<string>) {
31 state.items = state.items.filter((x) => x.id !== action.payload);
32 },
33 clearedDone(state) {
34 state.items = state.items.filter((x) => !x.done);
35 },
36 filterChanged(state, action: PayloadAction<Filter>) {
37 state.filter = action.payload;
38 },
39 },
40});
41
42export const { added, toggled, removed, clearedDone, filterChanged } = todosSlice.actions;
43export default todosSlice.reducer;
44
45/* ---------- selectors:组件只订阅它真正要用的那部分 ---------- */
46
47export const selectFilter = (s: { todos: TodosState }) => s.todos.filter;
48export const selectRemaining = (s: { todos: TodosState }) =>
49 s.todos.items.filter((t) => !t.done).length;
50
51export const selectVisible = (s: { todos: TodosState }) => {
52 const { items, filter } = s.todos;
53 if (filter === "all") return items;
54 return items.filter((t) => (filter === "done" ? t.done : !t.done));
55};
1import { createSlice, nanoid } from "@reduxjs/toolkit";
2import type { PayloadAction } from "@reduxjs/toolkit";
3
4export type Filter = "all" | "active" | "done";
5export interface Todo { id: string; text: string; done: boolean }
6export interface TodosState { items: Todo[]; filter: Filter }
7
8const initialState: TodosState = { items: [], filter: "all" };
9
10const todosSlice = createSlice({
11 name: "todos",
12 initialState,
13 reducers: {
14 // It looks like state is being changed, but the Immer built into RTK hands you
15 // a draft proxy and produces a new object — so "state is read-only" still holds.
16 added: {
17 // prepare lets the action creator take only text and build the id here — that way
18 // nanoid() never appears in the reducer, and the reducer stays a pure function.
19 reducer(state, action: PayloadAction<Todo>) {
20 state.items.push(action.payload);
21 },
22 prepare(text: string) {
23 return { payload: { id: nanoid(), text: text.trim(), done: false } };
24 },
25 },
26 toggled(state, action: PayloadAction<string>) {
27 const t = state.items.find((x) => x.id === action.payload);
28 if (t) t.done = !t.done;
29 },
30 removed(state, action: PayloadAction<string>) {
31 state.items = state.items.filter((x) => x.id !== action.payload);
32 },
33 clearedDone(state) {
34 state.items = state.items.filter((x) => !x.done);
35 },
36 filterChanged(state, action: PayloadAction<Filter>) {
37 state.filter = action.payload;
38 },
39 },
40});
41
42export const { added, toggled, removed, clearedDone, filterChanged } = todosSlice.actions;
43export default todosSlice.reducer;
44
45/* ---------- selectors: a component subscribes only to the part it really uses ---------- */
46
47export const selectFilter = (s: { todos: TodosState }) => s.todos.filter;
48export const selectRemaining = (s: { todos: TodosState }) =>
49 s.todos.items.filter((t) => !t.done).length;
50
51export const selectVisible = (s: { todos: TodosState }) => {
52 const { items, filter } = s.todos;
53 if (filter === "all") return items;
54 return items.filter((t) => (filter === "done" ? t.done : !t.done));
55};
TypeScriptsrc/store/index.ts已跑通Verified
1import { configureStore } from "@reduxjs/toolkit";
2import todos from "./todosSlice";
3
4// configureStore 默认就装好了 thunk 和 DevTools,不用自己 applyMiddleware
5export const makeStore = () => configureStore({ reducer: { todos } });
6
7export type AppStore = ReturnType<typeof makeStore>;
8export type RootState = ReturnType<AppStore["getState"]>;
9export type AppDispatch = AppStore["dispatch"];
1import { configureStore } from "@reduxjs/toolkit";
2import todos from "./todosSlice";
3
4// configureStore already sets up thunk and DevTools; no applyMiddleware needed
5export const makeStore = () => configureStore({ reducer: { todos } });
6
7export type AppStore = ReturnType<typeof makeStore>;
8export type RootState = ReturnType<AppStore["getState"]>;
9export type AppDispatch = AppStore["dispatch"];
configureStore 默认就装好了 thunk 和 DevTools —— 不用再手写 applyMiddleware(thunk) 和那段 window.__REDUX_DEVTOOLS_EXTENSION__ 判断。configureStore already sets up thunk and DevTools, so you no longer write applyMiddleware(thunk) by hand, nor that window.__REDUX_DEVTOOLS_EXTENSION__ check.
§02

selector:这才是 Redux 比 Context 强的地方Selectors: this is where Redux beats Context

三个 useSelector 各自订阅一小块。Three useSelector calls, each subscribing to one small part.

组件里写了三个 useSelector: 可见列表、剩余条数、当前筛选。每一个只在自己那部分变化时触发重渲染。

对比 Context(变式五那道题): context value 一变,所有 useTheme()的组件全部重渲染, 哪怕它只用了 theme而变的是 toggleTheme这就是八股 #349 里说的「Context 缺精细订阅」, 在这里能具体看到。

一个必须知道的坑: selector 不要返回新对象。useSelector默认用 === 比较结果, 返回 { a, b }这样的新对象每次都不相等, 于是每次 store 有任何变化都重渲染 —— 等于优化白做。
解法:拆成多个useSelector(本实现的做法,最简单), 或者用 createSelector做记忆化,或者传shallowEqual 当第二个参数。

注意 selectVisible是从 items 派生的, 筛选不改底层数据—— 和变式一里「写操作必须作用于完整数据」 是同一条规矩。测试里专门有一条验证「筛完底层仍是两条」。

The component has three useSelector calls: the visible list, the remaining count, the current filter. Each one re-renders only when its own slice changes.

Compare with Context (the variant 5 question): the moment the context value changes, every useTheme() component re-renders, even one that only uses theme while what changed was toggleTheme. That is the “Context has no fine-grained subscription” point from interview #349, in concrete form.

One trap you have to know: a selector must not return a new object. useSelector compares its result with === by default, and a fresh object like { a, b } is never equal, so any change anywhere in the store re-renders — the optimization cancels itself out.
Fixes: split it into several useSelector calls (what this implementation does, and the simplest), memoize with createSelector, or pass shallowEqual as the second argument.

Note that selectVisible is derived from items, and filtering does not touch the underlying data — the same rule as “writes have to act on the complete data” in variant 1. One test is there to verify that after filtering the underlying data still has two entries.

TSXsrc/components/TodoApp/index.tsx(实测通过)src/components/TodoApp/index.tsx (passes in a real run)已跑通Verified
1import React, { useState } from "react";
2import { useDispatch, useSelector } from "react-redux";
3import {
4 added,
5 clearedDone,
6 filterChanged,
7 removed,
8 selectFilter,
9 selectRemaining,
10 selectVisible,
11 toggled,
12 type Filter,
13} from "../../store/todosSlice";
14
15const TodoApp: React.FC = () => {
16 const dispatch = useDispatch();
17 // 三个 selector 各自订阅一小块:只有这一块变了才重渲染,
18 // 这正是 Context 做不到的(context 一变所有消费者都重渲染)
19 const visible = useSelector(selectVisible);
20 const remaining = useSelector(selectRemaining);
21 const filter = useSelector(selectFilter);
22
23 const [text, setText] = useState("");
24 const invalid = text.trim() === "";
25
26 return (
27 <div data-testid="todo-app">
28 <form
29 onSubmit={(e) => {
30 e.preventDefault();
31 if (invalid) return;
32 dispatch(added(text)); // prepare 里生成 id
33 setText("");
34 }}
35 >
36 <input value={text} onChange={(e) => setText(e.target.value)} data-testid="input" />
37 <button type="submit" disabled={invalid} data-testid="submit">Add</button>
38 </form>
39
40 <span data-testid="remaining">{remaining} left</span>
41 <button onClick={() => dispatch(clearedDone())} data-testid="clear-done">Clear done</button>
42
43 {(["all", "active", "done"] as Filter[]).map((f) => (
44 <button
45 key={f}
46 onClick={() => dispatch(filterChanged(f))}
47 aria-pressed={filter === f}
48 data-testid={`filter-${f}`}
49 >
50 {f}
51 </button>
52 ))}
53
54 <ul data-testid="list">
55 {visible.map((t) => (
56 <li key={t.id} data-done={t.done}>
57 <input
58 type="checkbox"
59 checked={t.done}
60 onChange={() => dispatch(toggled(t.id))}
61 aria-label={`toggle ${t.text}`}
62 />
63 <span>{t.text}</span>
64 <button onClick={() => dispatch(removed(t.id))} aria-label={`delete ${t.text}`}>
65 Delete
66 </button>
67 </li>
68 ))}
69 </ul>
70 </div>
71 );
72};
73
74export default TodoApp;
1import React, { useState } from "react";
2import { useDispatch, useSelector } from "react-redux";
3import {
4 added,
5 clearedDone,
6 filterChanged,
7 removed,
8 selectFilter,
9 selectRemaining,
10 selectVisible,
11 toggled,
12 type Filter,
13} from "../../store/todosSlice";
14
15const TodoApp: React.FC = () => {
16 const dispatch = useDispatch();
17 // Three selectors, each subscribing to one small part: a re-render happens only when
18 // that part changes. Context cannot do this (one context change re-renders every consumer)
19 const visible = useSelector(selectVisible);
20 const remaining = useSelector(selectRemaining);
21 const filter = useSelector(selectFilter);
22
23 const [text, setText] = useState("");
24 const invalid = text.trim() === "";
25
26 return (
27 <div data-testid="todo-app">
28 <form
29 onSubmit={(e) => {
30 e.preventDefault();
31 if (invalid) return;
32 dispatch(added(text)); // the id is built in prepare
33 setText("");
34 }}
35 >
36 <input value={text} onChange={(e) => setText(e.target.value)} data-testid="input" />
37 <button type="submit" disabled={invalid} data-testid="submit">Add</button>
38 </form>
39
40 <span data-testid="remaining">{remaining} left</span>
41 <button onClick={() => dispatch(clearedDone())} data-testid="clear-done">Clear done</button>
42
43 {(["all", "active", "done"] as Filter[]).map((f) => (
44 <button
45 key={f}
46 onClick={() => dispatch(filterChanged(f))}
47 aria-pressed={filter === f}
48 data-testid={`filter-${f}`}
49 >
50 {f}
51 </button>
52 ))}
53
54 <ul data-testid="list">
55 {visible.map((t) => (
56 <li key={t.id} data-done={t.done}>
57 <input
58 type="checkbox"
59 checked={t.done}
60 onChange={() => dispatch(toggled(t.id))}
61 aria-label={`toggle ${t.text}`}
62 />
63 <span>{t.text}</span>
64 <button onClick={() => dispatch(removed(t.id))} aria-label={`delete ${t.text}`}>
65 Delete
66 </button>
67 </li>
68 ))}
69 </ul>
70 </div>
71 );
72};
73
74export default TodoApp;
TSXuseSelector 最常见的性能坑The most common performance trap with useSelector示意Illustrative
1// ✗ selector 返回新对象:每次 store 变都重渲染
2const { visible, remaining } = useSelector((s) => ({
3 visible: selectVisible(s),
4 remaining: selectRemaining(s),
5}));
6
7// ✓ 拆开,各自比较各自的值
8const visible = useSelector(selectVisible);
9const remaining = useSelector(selectRemaining);
1// ✗ the selector returns a new object: every store change re-renders
2const { visible, remaining } = useSelector((s) => ({
3 visible: selectVisible(s),
4 remaining: selectRemaining(s),
5}));
6
7// ✓ split them, so each one is compared against its own value
8const visible = useSelector(selectVisible);
9const remaining = useSelector(selectRemaining);
§03

和变式一(useState 版)对比:换来了什么,代价是什么Compared with variant one, the useState version: what you gain and what it costs

变式一(useState)本题(Redux Toolkit)
状态放哪组件内全局 store
谁能改这个组件任何组件 dispatch 就行
逻辑能不能脱离 React 测不能(要 render)—— reducer 是纯函数
调试console.logDevTools 能看每个 action 和 state diff
代码量一个文件slice + store + 组件,三个文件
依赖两个包

结论怎么说(面试里问「该不该上 Redux」时用):这道题的规模用 Redux 是过度设计—— 一个组件自己的列表,useState 就够。Redux 的价值要在 「同一份状态被很多不相关的组件读写」 或者「需要按 action 追溯 bug」 的时候才兑现。
能主动说出「这题我会用 useState, 但如果需求是 XX 我会上 Redux」, 比闷头写完 Redux 更能显示判断力。

顺带看得出的一个真实收益: 因为 reducer 是纯函数,八条测试里有五条完全不需要渲染任何组件—— 直接 reducer(state, action) 断言。 测试跑得更快、失败信息更准。

Variant 1 (useState)This one (Redux Toolkit)
Where the state livesinside the componenta global store
Who can change itthis componentany component that dispatches
Can the logic be tested without Reactno (you have to render)yes — the reducer is a pure function
Debuggingconsole.logDevTools shows every action and the state diff
Amount of codeone fileslice + store + component, three files
Dependenciesnonetwo packages

How to phrase the conclusion (for when they ask “should we bring in Redux?”): at this size Redux is over-engineering — one component’s own list is fine with useState. Redux pays off when the same state is read and written by many unrelated components, or when you need to trace a bug action by action.
Saying “I would use useState here, but if the requirement were XX I would reach for Redux” shows more judgment than silently writing the whole Redux setup.

One real benefit that falls out of this: because the reducer is a pure function, five of the eight tests render no component at all — they assert on reducer(state, action) directly. The tests run faster and the failure messages are more precise.

TSXsrc/Rtk.test.tsx(DrillLab 自出,本机跑过 8/8)src/Rtk.test.tsx (written by DrillLab, 8/8 locally)已跑通Verified
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { Provider } from "react-redux";
4import { expect, test } from "vitest";
5import TodoApp from "./components/TodoApp";
6import { makeStore } from "./store";
7import reducer, {
8 added,
9 clearedDone,
10 filterChanged,
11 removed,
12 selectVisible,
13 toggled,
14 type TodosState,
15} from "./store/todosSlice";
16
17const empty: TodosState = { items: [], filter: "all" };
18
19/* ---------- reducer 是纯函数,可以脱离 React 单测 ---------- */
20
21test("[371] added 追加一条,且不改原 state(Immer 产出的是新对象)", () => {
22 const next = reducer(empty, added("买牛奶"));
23
24 expect(next.items).toHaveLength(1);
25 expect(next.items[0].text).toBe("买牛奶");
26 expect(next.items[0].done).toBe(false);
27 expect(empty.items).toHaveLength(0); // 原 state 没动
28 expect(next).not.toBe(empty); // 是新对象
29});
30
31test("[371] added 会 trim,且 id 在 prepare 里生成(reducer 保持纯)", () => {
32 const next = reducer(empty, added(" 写文档 "));
33 expect(next.items[0].text).toBe("写文档");
34 expect(next.items[0].id).toBeTruthy();
35
36 // 同一段文本两次 dispatch,id 不同 —— 说明 id 不是 reducer 算的
37 const a = added("x");
38 const b = added("x");
39 expect(a.payload.id).not.toBe(b.payload.id);
40});
41
42test("[371] toggled 只翻转命中的那条", () => {
43 let s = reducer(empty, added("A"));
44 s = reducer(s, added("B"));
45 const idA = s.items[0].id;
46
47 const next = reducer(s, toggled(idA));
48 expect(next.items[0].done).toBe(true);
49 expect(next.items[1].done).toBe(false);
50});
51
52test("[371] removed 按 id 删;clearedDone 只删已完成", () => {
53 let s = reducer(empty, added("A"));
54 s = reducer(s, added("B"));
55 const [a, b] = s.items;
56
57 expect(reducer(s, removed(a.id)).items.map((t) => t.text)).toEqual(["B"]);
58
59 s = reducer(s, toggled(b.id));
60 expect(reducer(s, clearedDone()).items.map((t) => t.text)).toEqual(["A"]);
61});
62
63test("[371] selectVisible 按 filter 派生,不改底层数据", () => {
64 let s = reducer(empty, added("A"));
65 s = reducer(s, added("B"));
66 s = reducer(s, toggled(s.items[0].id));
67
68 const withFilter = (f: Parameters<typeof filterChanged>[0]) => ({
69 todos: reducer(s, filterChanged(f)),
70 });
71
72 expect(selectVisible({ todos: s }).map((t) => t.text)).toEqual(["A", "B"]);
73 expect(selectVisible(withFilter("done")).map((t) => t.text)).toEqual(["A"]);
74 expect(selectVisible(withFilter("active")).map((t) => t.text)).toEqual(["B"]);
75
76 // 筛选只影响「看到什么」,底层 items 仍是两条
77 expect(s.items).toHaveLength(2);
78});
79
80/* ---------- 组件 + Provider 的集成测试 ---------- */
81
82const renderApp = () =>
83 render(
84 <Provider store={makeStore()}>
85 <TodoApp />
86 </Provider>,
87 );
88
89test("[371] 通过 UI 新增,列表和计数都跟着变", async () => {
90 renderApp();
91 expect(screen.getByTestId("submit")).toBeDisabled();
92
93 await userEvent.type(screen.getByTestId("input"), "买牛奶");
94 await userEvent.click(screen.getByTestId("submit"));
95
96 expect(screen.getByTestId("list")).toHaveTextContent("买牛奶");
97 expect(screen.getByTestId("remaining")).toHaveTextContent("1 left");
98 expect(screen.getByTestId("input")).toHaveValue("");
99});
100
101test("[371] 勾选一条,remaining 减一", async () => {
102 renderApp();
103 await userEvent.type(screen.getByTestId("input"), "A");
104 await userEvent.click(screen.getByTestId("submit"));
105 await userEvent.type(screen.getByTestId("input"), "B");
106 await userEvent.click(screen.getByTestId("submit"));
107
108 expect(screen.getByTestId("remaining")).toHaveTextContent("2 left");
109 await userEvent.click(screen.getByLabelText("toggle A"));
110 expect(screen.getByTestId("remaining")).toHaveTextContent("1 left");
111});
112
113test("[371] 筛选不会丢数据,切回 all 两条都在", async () => {
114 renderApp();
115 for (const t of ["A", "B"]) {
116 await userEvent.type(screen.getByTestId("input"), t);
117 await userEvent.click(screen.getByTestId("submit"));
118 }
119 await userEvent.click(screen.getByLabelText("toggle A"));
120
121 await userEvent.click(screen.getByTestId("filter-done"));
122 expect(screen.getByTestId("list")).toHaveTextContent("A");
123 expect(screen.getByTestId("list")).not.toHaveTextContent("B");
124
125 await userEvent.click(screen.getByTestId("filter-all"));
126 expect(screen.getByTestId("list")).toHaveTextContent("A");
127 expect(screen.getByTestId("list")).toHaveTextContent("B");
128});
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { Provider } from "react-redux";
4import { expect, test } from "vitest";
5import TodoApp from "./components/TodoApp";
6import { makeStore } from "./store";
7import reducer, {
8 added,
9 clearedDone,
10 filterChanged,
11 removed,
12 selectVisible,
13 toggled,
14 type TodosState,
15} from "./store/todosSlice";
16
17const empty: TodosState = { items: [], filter: "all" };
18
19/* ---------- a reducer is a pure function, so it can be unit-tested without React ---------- */
20
21test("[371] added appends one item and does not change the old state (Immer produces a new object)", () => {
22 const next = reducer(empty, added("买牛奶"));
23
24 expect(next.items).toHaveLength(1);
25 expect(next.items[0].text).toBe("买牛奶");
26 expect(next.items[0].done).toBe(false);
27 expect(empty.items).toHaveLength(0); // the old state was not touched
28 expect(next).not.toBe(empty); // it is a new object
29});
30
31test("[371] added trims, and the id is built in prepare (the reducer stays pure)", () => {
32 const next = reducer(empty, added(" 写文档 "));
33 expect(next.items[0].text).toBe("写文档");
34 expect(next.items[0].id).toBeTruthy();
35
36 // dispatch the same text twice and the ids differ — proof the reducer does not compute the id
37 const a = added("x");
38 const b = added("x");
39 expect(a.payload.id).not.toBe(b.payload.id);
40});
41
42test("[371] toggled flips only the item it matched", () => {
43 let s = reducer(empty, added("A"));
44 s = reducer(s, added("B"));
45 const idA = s.items[0].id;
46
47 const next = reducer(s, toggled(idA));
48 expect(next.items[0].done).toBe(true);
49 expect(next.items[1].done).toBe(false);
50});
51
52test("[371] removed deletes by id; clearedDone deletes only the finished ones", () => {
53 let s = reducer(empty, added("A"));
54 s = reducer(s, added("B"));
55 const [a, b] = s.items;
56
57 expect(reducer(s, removed(a.id)).items.map((t) => t.text)).toEqual(["B"]);
58
59 s = reducer(s, toggled(b.id));
60 expect(reducer(s, clearedDone()).items.map((t) => t.text)).toEqual(["A"]);
61});
62
63test("[371] selectVisible derives from filter and does not change the underlying data", () => {
64 let s = reducer(empty, added("A"));
65 s = reducer(s, added("B"));
66 s = reducer(s, toggled(s.items[0].id));
67
68 const withFilter = (f: Parameters<typeof filterChanged>[0]) => ({
69 todos: reducer(s, filterChanged(f)),
70 });
71
72 expect(selectVisible({ todos: s }).map((t) => t.text)).toEqual(["A", "B"]);
73 expect(selectVisible(withFilter("done")).map((t) => t.text)).toEqual(["A"]);
74 expect(selectVisible(withFilter("active")).map((t) => t.text)).toEqual(["B"]);
75
76 // filtering only affects what you see; the underlying items are still two
77 expect(s.items).toHaveLength(2);
78});
79
80/* ---------- integration test: the component plus the Provider ---------- */
81
82const renderApp = () =>
83 render(
84 <Provider store={makeStore()}>
85 <TodoApp />
86 </Provider>,
87 );
88
89test("[371] adding through the UI updates both the list and the count", async () => {
90 renderApp();
91 expect(screen.getByTestId("submit")).toBeDisabled();
92
93 await userEvent.type(screen.getByTestId("input"), "买牛奶");
94 await userEvent.click(screen.getByTestId("submit"));
95
96 expect(screen.getByTestId("list")).toHaveTextContent("买牛奶");
97 expect(screen.getByTestId("remaining")).toHaveTextContent("1 left");
98 expect(screen.getByTestId("input")).toHaveValue("");
99});
100
101test("[371] ticking one item lowers remaining by one", async () => {
102 renderApp();
103 await userEvent.type(screen.getByTestId("input"), "A");
104 await userEvent.click(screen.getByTestId("submit"));
105 await userEvent.type(screen.getByTestId("input"), "B");
106 await userEvent.click(screen.getByTestId("submit"));
107
108 expect(screen.getByTestId("remaining")).toHaveTextContent("2 left");
109 await userEvent.click(screen.getByLabelText("toggle A"));
110 expect(screen.getByTestId("remaining")).toHaveTextContent("1 left");
111});
112
113test("[371] filtering loses no data; switch back to all and both items are there", async () => {
114 renderApp();
115 for (const t of ["A", "B"]) {
116 await userEvent.type(screen.getByTestId("input"), t);
117 await userEvent.click(screen.getByTestId("submit"));
118 }
119 await userEvent.click(screen.getByLabelText("toggle A"));
120
121 await userEvent.click(screen.getByTestId("filter-done"));
122 expect(screen.getByTestId("list")).toHaveTextContent("A");
123 expect(screen.getByTestId("list")).not.toHaveTextContent("B");
124
125 await userEvent.click(screen.getByTestId("filter-all"));
126 expect(screen.getByTestId("list")).toHaveTextContent("A");
127 expect(screen.getByTestId("list")).toHaveTextContent("B");
128});
Terminal验证命令The command used to check it已跑通Verified
1# 这一道要单独装依赖(本站另外几道复用 react-notes-app 的 node_modules)
2$ npm i @reduxjs/toolkit react-redux
3$ npx vitest run
4
5 Test Files 1 passed (1)
6 Tests 8 passed (8)
1# This problem installs its own dependencies (the other problems here reuse the node_modules of react-notes-app)
2$ npm i @reduxjs/toolkit react-redux
3$ npx vitest run
4
5 Test Files 1 passed (1)
6 Tests 8 passed (8)
§04

参考答案Reference solution

这道题没有配套的分级提示 —— 卡住了先看上面的讲解那一节。This problem has no graded hints. If you are stuck, read the walkthrough above first.

这份答案在本机真跑过测试。但先确认你自己动手写过一遍 —— 读懂答案和写出答案是两种能力,考场上考的是后一种。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.