Skip to content

Commit c47bdc7

Browse files
committed
fix(pomodoro): 修复目标跳过退化为取消致空目标番茄无法启动
跳过与取消是两种意图:跳过表示不设目标但开始计时,取消表示关闭弹窗不开始。原实现两者共用同一关闭回调,导致空目标番茄钟无法启动。现拆分为取消、跳过、开始三个按钮,并补充多路径回归测试。
1 parent 2485f8c commit c47bdc7

3 files changed

Lines changed: 184 additions & 7 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* @ai-context: GoalInput 回归测试——"跳过"与空输入点"开始"都必须启动计时
3+
* (提交空目标),只有真正取消(Modal 关闭按钮)才不启动。
4+
* @ai-context: Regression test — "skip" and empty-input "start" must both
5+
* launch the timer with an empty goal; only an explicit close cancels.
6+
*/
7+
import { describe, it, expect, vi, beforeEach } from 'vitest';
8+
import { render, screen, fireEvent } from '@testing-library/react';
9+
import GoalInput from './GoalInput';
10+
11+
// GoalMemory 读取 Dexie,测试中隔离掉真实数据库
12+
vi.mock('@/lib/storage', () => ({
13+
db: {
14+
pomodoroGoals: {
15+
orderBy: () => ({
16+
reverse: () => ({
17+
limit: () => ({ toArray: () => Promise.resolve([]) }),
18+
}),
19+
}),
20+
},
21+
},
22+
}));
23+
24+
// Modal 播放开关音效,测试中无需真实音频
25+
vi.mock('@/lib/audio/SoundPlayer', () => ({
26+
soundPlayer: { play: vi.fn() },
27+
}));
28+
29+
describe('GoalInput - 空目标番茄钟', () => {
30+
const onSubmit = vi.fn();
31+
const onClose = vi.fn();
32+
33+
function renderGoalInput() {
34+
return render(
35+
<GoalInput
36+
open
37+
onClose={onClose}
38+
onSubmit={onSubmit}
39+
rememberGoal={false}
40+
onRememberChange={vi.fn()}
41+
/>,
42+
);
43+
}
44+
45+
beforeEach(() => {
46+
onSubmit.mockClear();
47+
onClose.mockClear();
48+
});
49+
50+
it('点击"跳过"应提交空目标以启动计时,而非仅关闭弹窗', () => {
51+
renderGoalInput();
52+
53+
fireEvent.click(screen.getByRole('button', { name: '跳过' }));
54+
55+
// 核心断言:跳过 = 无目标但开始计时
56+
expect(onSubmit).toHaveBeenCalledWith('');
57+
});
58+
59+
it('输入为空时点击"开始"也应启动计时', () => {
60+
renderGoalInput();
61+
62+
fireEvent.click(screen.getByRole('button', { name: '开始' }));
63+
64+
expect(onSubmit).toHaveBeenCalledWith('');
65+
});
66+
67+
it('输入为空时按 Enter 也应启动计时', () => {
68+
renderGoalInput();
69+
70+
fireEvent.keyDown(screen.getByPlaceholderText('这个番茄要做什么?'), { key: 'Enter' });
71+
72+
expect(onSubmit).toHaveBeenCalledWith('');
73+
});
74+
75+
it('有目标时点击"开始"提交去空格后的目标', () => {
76+
renderGoalInput();
77+
78+
fireEvent.change(screen.getByPlaceholderText('这个番茄要做什么?'), {
79+
target: { value: ' 背单词 ' },
80+
});
81+
fireEvent.click(screen.getByRole('button', { name: '开始' }));
82+
83+
expect(onSubmit).toHaveBeenCalledWith('背单词');
84+
});
85+
86+
it('点击"取消"不提交、不启动计时', () => {
87+
renderGoalInput();
88+
89+
fireEvent.click(screen.getByRole('button', { name: '取消' }));
90+
91+
expect(onSubmit).not.toHaveBeenCalled();
92+
expect(onClose).toHaveBeenCalled();
93+
});
94+
95+
it('点击关闭按钮也是取消:不提交、不启动计时', () => {
96+
renderGoalInput();
97+
98+
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
99+
100+
expect(onSubmit).not.toHaveBeenCalled();
101+
expect(onClose).toHaveBeenCalled();
102+
});
103+
});

‎client/src/features/pomodoro/components/GoalInput.tsx‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
/**
22
* @ai-context: 通用组件:GoalInput。
3+
* @ai-context: “跳过”与“取消”是两种意图:跳过 = 不设目标但开始计时
4+
* (onSubmit('')),取消 = 关闭弹窗不开始(onClose,仅 Modal 关闭按钮)。
5+
* 两者共用同一回调会使空目标番茄钟无法启动(内测反馈 bug)。
6+
* @ai-context: "Skip" and "cancel" are distinct intents: skip starts the timer
7+
* with an empty goal; cancel closes the dialog without starting.
38
*/
49
import { useState, useRef, useEffect } from 'react';
510
import { Modal } from '@/components/ui/Modal';
@@ -8,7 +13,9 @@ import GoalMemory from './GoalMemory';
813

914
interface GoalInputProps {
1015
open: boolean;
16+
/** 取消:关闭弹窗且不开始计时 */
1117
onClose: () => void;
18+
/** 提交并开始计时;空字符串表示本次番茄不设目标 */
1219
onSubmit: (goal: string) => void;
1320
rememberGoal: boolean;
1421
onRememberChange: (v: boolean) => void;
@@ -32,14 +39,9 @@ export default function GoalInput({
3239
}
3340
}, [open]);
3441

42+
/** 提交目标并开始;输入为空时等同于“跳过”(无目标但仍然开始计时) */
3543
const handleSubmit = () => {
36-
const trimmed = text.trim();
37-
if (trimmed) {
38-
onSubmit(trimmed);
39-
} else {
40-
// 空内容时等同于跳过
41-
onClose();
42-
}
44+
onSubmit(text.trim());
4345
};
4446

4547
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -63,7 +65,12 @@ export default function GoalInput({
6365
size="sm"
6466
footer={
6567
<>
68+
{/* 取消:关闭弹窗,不开始番茄 */}
6669
<Button variant="ghost" size="md" onClick={onClose}>
70+
取消
71+
</Button>
72+
{/* 跳过:不设目标,但仍然开始计时(用 secondary 与“取消”在视觉上拉开差异) */}
73+
<Button variant="secondary" size="md" onClick={() => onSubmit('')}>
6774
跳过
6875
</Button>
6976
<Button variant="primary" size="md" onClick={handleSubmit}>
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# 知识卡片 · 踩坑记录
2+
3+
## 基本信息
4+
5+
| 字段 | 内容 |
6+
|------|------|
7+
| 标题 | 番茄钟"跳过"退化为"取消":一个 onClose 回调承载两种意图,空目标番茄无法启动 |
8+
| 日期 | 2026-07-31 |
9+
| 类型 | 踩坑记录 |
10+
| 标签 | #React #番茄钟 #弹窗交互 #回调语义 #意图区分 |
11+
12+
---
13+
14+
## 症状
15+
16+
内测反馈:**番茄钟的目标设置中,"跳过"按钮类似于取消,无法开始计时,即无法实现空目标的番茄钟。**
17+
18+
实测三条路径全部失效(均只关弹窗、不启动计时):
19+
1. 点"跳过"
20+
2. 输入为空时点"开始"
21+
3. 输入为空时按 Enter
22+
23+
## 环境
24+
25+
| 项目 | 版本/信息 |
26+
|------|----------|
27+
| 组件 | `GoalInput`(Radix Dialog 封装的 `Modal` + 双按钮 footer) |
28+
| 相关文件 | `client/src/features/pomodoro/components/GoalInput.tsx`、`client/src/features/pomodoro/pages/PomodoroPage.tsx` |
29+
30+
## 排查过程(按 debug-sop)
31+
32+
1. **分类**:逻辑错误(必现),无异常无日志
33+
2. **隔离**:计时启动只发生在 `PomodoroPage.handleGoalSubmit` 内的 `start()`,而该函数只挂在 `onSubmit`;`onClose` 被绑定为纯 `setGoalModalOpen(false)`
34+
3. **列出 GoalInput 内所有"离开弹窗"的出口**,逐一核对调用的是 `onSubmit` 还是 `onClose`:
35+
- "跳过"按钮 → `onClose`(❌ 不启动)
36+
- 空输入点"开始"/按 Enter → `handleSubmit` else 分支 → `onClose`,且注释写着"空内容时等同于跳过"(❌ 不启动)
37+
- "开始"且有输入 → `onSubmit`(✅ 启动)
38+
4. **确认 store 层无阻碍**:`currentGoal: string | null`、`recordSession` 用 `currentGoal ?? undefined`、目标文字渲染有 `currentGoal &&` 判空——空目标在数据层本就受支持,问题纯在 UI 意图传递
39+
40+
## 根因
41+
42+
**一个回调承载了两种语义相反的意图**:`onClose` 同时被用于表达"取消(不开始)"与"跳过目标(仍要开始)"。父组件只能把 `onClose` 实现为"关闭弹窗"这一交集语义,于是"跳过"这条用户意图中的"开始计时"部分被静默丢弃。
43+
44+
注释"空内容时等同于跳过"暴露了误解:作者认为"跳过"本身就是不开始——而用户预期的"跳过"是**跳过目标设置**,不是跳过这次番茄。
45+
46+
## 修复方案
47+
48+
1. `GoalInput`:明确区分两种意图,并让两种意图各自拥有可见入口
49+
- footer 改为三按钮,视觉层级递增:**取消**(ghost → `onClose`)/ **跳过**(secondary → `onSubmit('')`)/ **开始**(primary → `handleSubmit`)
50+
- “跳过”用 secondary 而非 ghost,与“取消”在视觉上拉开差异,防止用户再次误认为同类动作
51+
- `handleSubmit` 简化为 `onSubmit(text.trim())`,空输入自然走“跳过”语义(点“开始”/按 Enter 一致)
52+
- `onClose` 只代表取消(“取消”按钮 + Modal 右上角关闭按钮)
53+
- props 注释标注两个回调的语义边界
54+
2. `PomodoroPage.handleGoalSubmit`:`setCurrentGoal(goal || null)`(空串转 null,避免 `recordSession` 记下空字符串目标);`rememberGoal && goal` 才写目标记忆库,防止存入空记录
55+
3. 回归测试 `GoalInput.test.tsx` 覆盖 6 条路径:跳过 / 空输入点开始 / 空输入按 Enter / 有目标提交 trim / “取消”不启动 / 关闭按钮不启动
56+
57+
## 教训
58+
59+
- **弹窗回调命名即契约**:`onClose` / `onCancel` / `onSkip` / `onSubmit` 表达不同意图,出现"XX 等同于 YY"的注释时,往往说明两种意图被强行合并——这是 bug 高发信号
60+
- **可选输入的"跳过"必须是提交路径,而非关闭路径**:跳过的是「填写这一步」,不是「整个流程」
61+
- **区分两种意图后,两者都需要可见入口且视觉上可辨**:仅把“取消”留给右上角 X 会让用户找不到退出;同为 ghost 的两个按钮会被误认为同类动作,需用 variant 层级(ghost/secondary/primary)表达“退出/次要前进/主要前进”
62+
- 排查"按钮点了没反应"类问题的高效方法:**枚举组件内所有退出出口,逐个核对其调用的回调**,而不是从状态流反向猜
63+
- 数据层已支持可空字段(`string | null`)不代表 UI 打通了空值路径,两层需分别验证
64+
65+
## 相关提交
66+
67+
- fix(pomodoro): 跳过目标设置应启动空目标番茄钟(待提交)

0 commit comments

Comments
 (0)