软工第二次结队作业
结对作业2:校园失物招领小程序——核心功能实现
软件工程与实践 · 结对作业(2 人)· 2026-10-07 ~ 2026-10-09
| 这个作业属于哪个课程 | 2026-01软件工程与软件工程实践 |
| 这个作业的要求在哪里 | 结对作业二:核心功能实现 |
| 我的博客 | https://www.cnblogs.com/zzq1419 |
| 结对同学的博客 | https://www.cnblogs.com/zkk234 |
| 结对的 GitHub 仓库 | https://github.com/zzq1419/102402150-102402148 |
| 结对成员 | 102402150 周子钦、102402148 周凯 |
〇、和第一次作业的关系
第一次作业做完的是需求分析 + Figma 原型(7 个画框、18 条连线),这次把它落成能跑的程序:
7 个页面互通、数据能增删改、搜索与四重筛选真的能筛、发布带校验、状态能更新。
有几处是故意改掉的,免得看起来像漏做:
| 差异 | 原型 | 实现版 | 原因 |
|---|---|---|---|
| 手机状态栏 | 有 | 没有 | 原型是 App 截图,实现是网页 |
| 搜索筛选 | 3 个胶囊 | 4 个下拉框(多了"分类") | 多一维更好用,四项一行放不下 |
| 联系方式 | 直接显示 | 打码,点击复制才给完整 | 防爬虫批量抓号码 |
| 图片上传 | 占位方块 | 真能选图、本地压缩 | 原型只是示意 |
| 「管理账号」入口 | 有 | 去掉 | 原型里是没连页面的占位 |
一、具体分工
| 成员 | 负责内容 |
|---|---|
| 102402150 周子钦 | 整体架构与分层设计、utils.js 纯函数层、app.js 路由与事件、单元测试、README 与博客 |
| 102402148 周凯 | 主干流程与页面跳转关系核对、「我的发布」模块;复审后补齐三处"只做了一半"的功能(收藏入口 / 卡片缩略图 / 联系方式预填)并补了 10 条用例,经 Fork + Pull Request 合入主干 |
两个人不是各写一半再拼,而是先一起把数据结构和分层定死,再分头实现、互相复审。
他提的 PR 我逐行看过才合并,复审时还发现了一个问题(见困难 3)。
二、PSP 表格
| PSP2.1 | 阶段 | 预估 | 实际 |
|---|---|---|---|
| Planning | 计划 | 15 | 15 |
| · Estimate | 估计这个任务需要多少时间 | 15 | 15 |
| Development | 开发 | 335 | 445 |
| · Analysis | 需求分析(包括学习新技术、复习作业 1 原型) | 30 | 35 |
| · Design Spec | 生成设计文档 | 25 | 30 |
| · Design Review | 设计复审 | 15 | 20 |
| · Coding Standard | 代码规范(为目前的开发制定合适的规范) | 10 | 15 |
| · Design | 具体设计(分层、数据结构、路由) | 40 | 45 |
| · Coding | 具体编码 | 150 | 200 |
| · Code Review | 代码复审 | 25 | 40 |
| · Test | 测试(自我测试,修改代码,提交修改) | 40 | 60 |
| Reporting | 报告 | 40 | 40 |
| · Test Report | 测试报告 | 15 | 15 |
| · Size Measurement | 计算工作量 | 5 | 5 |
| · Postmortem | 事后总结,并提出过程改进计划 | 20 | 20 |
| 合计 | 390 | 500 |
差距最大的是"具体编码"(150 → 200):一是那几个 bug 都属于"代码看着对、跑起来不对",
光定位就花了近 40 分钟——预估只算了"写出来要多久",没算"确认它真的对"要多久;
二是复审时又挑出三处"功能只做了一半"的地方,补完还加了 10 条用例。
三、解题思路与设计实现说明
3.1 整体思路
两个问题决定了一切:数据存哪、代码怎么分。不允许后端,数据就只能存浏览器本地
(localStorage,刷新不丢)。更关键的是第二个:
把"不碰 DOM 的纯函数"单独抽成一层。
单元测试最怕"逻辑和界面缠在一起、不开浏览器就测不了"。把搜索、筛选、校验、格式化这些
输入决定输出的逻辑全放进 utils.js,它们就成了能在 Node 里直接跑的普通函数,
测试不用浏览器、不用模拟点击,跑一遍几十毫秒。
于是有了五层(图 1):数据层 data.js(只读常量)、纯函数层 utils.js(不碰 DOM 和存储)、
仓库层 store.js(增删改查 + 持久化)、视图层 ui.js(数据 → HTML 字符串)、
控制层 app.js(唯一碰 DOM 的地方,hash 路由 + 事件委托)。
为了"双击 index.html 就能跑",没用 ES Module、没用任何框架(file:// 下 import 会被 CORS 拦):
每个文件做一层环境判断,Node 里走 module.exports、浏览器里挂 window——
同一份 utils.js 既被网页加载,也能被 node tests/unit.test.js 直接 require。
3.2 关键实现的数据流图

用户点一下之后,app.js 解析 hash 决定显示哪页,从 store.js 取数据,交给 utils.js
筛选/校验/格式化,最后让 ui.js 拼成 HTML 写回 DOM。整条链路上 utils.js 和 store.js
都不碰 DOM——这就是它们能被单独测试的原因。
路由用 hash 路由(#/home、#/detail/LF1001):hash 变化不会请求服务器,
所以 file:// 双击打开也能跳转,刷新还能停在当前页。
真正踩坑的是返回。第一版所有返回按钮都写死 href="#/home",从详情页点返回一律弹回首页
——搜到一半点开一条,返回一下搜索结果全丢。改成站内返回栈才对(图 2):

3.3 关键代码片段
(1)返回栈的入栈规则
/* js/utils.js */
function pushRoute(stack, hash) {
var s = (stack || []).slice();
var h = String(hash == null ? '' : hash);
if (!h) return s;
var top = s[s.length - 1];
if (top === h) return s; // 1 同页重渲染 → 不动
if (s.length >= 2 && s[s.length - 2] === h) { // 2 浏览器后退 → 出栈
s.pop(); return s;
}
if (top && routePath(top) === routePath(h)) { // 3 同页参数变化 → 原地替换
s[s.length - 1] = h; return s;
}
var seen = s.indexOf(h);
if (seen > -1) return s.slice(0, seen + 1); // 4 回到访问过的页面 → 截断
s.push(h); // 5 新页面 → 入栈
return s;
}
为什么贴这段:它要是直接写在 app.js 里操作 history,就只能靠手点验证;抽成纯函数后,
"连点两次返回停在哪"变成可自动断言的用例。第 3 条规则还是测试时才发现要补的——
一开始只有"新页面入栈",于是在搜索页里改筛选也算新页面,筛三次要点三次返回才出得去。
(2)搜索高亮:先切分原文,再逐段转义
/* js/utils.js */
function highlight(text, q) {
var raw = String(text == null ? '' : text);
var kws = parseKeywords(q); // trim → 小写 → 按空白切 → 去重
if (!kws.length) return escapeHtml(raw);
var re = new RegExp(kws.map(escapeReg).join('|'), 'gi');
var out = '', last = 0, m;
while ((m = re.exec(raw)) !== null) {
/* 未命中部分原样转义;命中部分转义后再包 <mark> */
out += escapeHtml(raw.slice(last, m.index)) + '<mark>' + escapeHtml(m[0]) + '</mark>';
last = m.index + m[0].length;
}
return out + escapeHtml(raw.slice(last));
}
为什么不直接在转义后的字符串上替换:关键词含 &、< 时会对不上,而且替换出来的 <mark>
本身会被后续关键词命中、拼出嵌套标签。先在原文上切分、再逐段转义,两个问题就都没了。
3.4 数据结构
{
id: 'LF1013', // 取现有最大编号 +1,删掉中间项也不会重号
type: 'found', // found=失物招领 / lost=寻物启事
title: '测试钥匙串', cat: '钥匙',
area: '教学楼', // 由 place 自动推断,供"地点"筛选使用
place: '实验楼 5 楼机房', timeText: '2026-10-08 10:00',
contact: '13912345678', desc: '……',
images: [], status: 'open', owner: true, // 图(最多 3 张)/ 状态 / 是不是我发的
createdAt: 1759900000000
}
两个细节:area 由 place 自动推断(areaOfPlace('第二食堂二楼') 得出 '食堂'),
用户不用多填字段、地点筛选又能命中;示例数据的发布时间是"相对当前时间"生成的,
否则过几天打开,"24 小时内"会什么都筛不出来。
四、附加特点设计与展示
4.1 创意与意义
作业正文点名的两个方向(按类别/地点筛选、一键复制联系方式)都做了,另外加了六个:
- 联系方式默认打码,只显示
138****8000,点「一键复制」才给完整值——失物招领里是真实手机号,
直接摊开显示,一条爬虫就能抓走全校号码。 - 已归还 / 已找到的信息自动从首页隐藏——对应作业正文里"减少重复询问和无效联系"。
- 图片在浏览器本地压缩(canvas 等比缩到最长边 640px 存 dataURL),全程不联网、不上传服务器。
- 收藏了的东西找得回来。 星标能收藏,但收藏完没地方看就等于没做,所以在「我的发布」加了
「收藏 N」 分段——不受"我发布的"限制,别人发的那条也能在这儿找到。 - 列表卡片显示物品缩略图:有图片用第一张,没图片才退回分类图标。
(示例数据不带图片,所以双击打开看到的仍是图标,上传过图片的条目才显示缩略图。) - 发布页预填自己的联系方式,不用每次重打。
4.2 实现思路
| 特点 | 实现思路 |
|---|---|
| 联系方式打码 | 详情页判断 owner,不是自己发的走 maskContact();点复制时从 store 取完整值写剪贴板,页面上始终不出现完整号码 |
| 状态联动隐藏 | 首页查询固定带 status: 'open',标记完成后自然不满足条件;状态可逆 |
| 图片本地压缩 | FileReader → Image 取尺寸 → 按最长边 640px 缩放画到 canvas → toDataURL('image/jpeg', 0.7);带 8MB 上限与"不是图片"的兜底 |
| 一键复制 | 优先 navigator.clipboard,失败退回 execCommand('copy')(file:// 下前者可能不可用),都失败则提示手动长按 |
| 四重筛选 / 校验 | 筛选全走 filterItems() 取交集,状态存进 URL(刷新不丢、能分享);校验与界面分离,返回 {ok, errors},界面只负责标红并滚到第一条错误 |
| 收藏入口 | 收藏只是一张「编号 → 1」的表;列表取收藏编号与现有信息的交集,所以条目被删除后不会留下点不开的幽灵项,计数也不虚高 |
| 卡片缩略图 | 缩略图函数改成吃整个 item,有 images[0] 就渲染 <img>、否则回退图标;四个列表共用同一个函数,规则只有一处 |
| 联系方式预填 | 把 DATA.ME.contact 写进表单 value;并加测试兜底——预填值本身必须合法,否则预填反而会挡住发布 |
4.3 关键代码片段
(1)图片本地压缩("不联网也能用"的关键)
/* js/app.js */
function readImageFile(file, cb) {
if (!/^image\//.test(file.type)) { cb(null, '请选择图片文件'); return; }
if (file.size > 8 * 1024 * 1024) { cb(null, '图片太大了,请选 8MB 以内的'); return; }
var reader = new FileReader();
reader.onload = function () {
var img = new Image();
img.onload = function () {
var MAX = 640; // 最长边压到 640px
var k = Math.min(1, MAX / Math.max(img.width, img.height));
var cv = document.createElement('canvas');
cv.width = Math.round(img.width * k);
cv.height = Math.round(img.height * k);
cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
cb(cv.toDataURL('image/jpeg', 0.7)); // 压缩后才是 dataURL
};
img.src = reader.result;
};
reader.readAsDataURL(file);
}
(2)卡片缩略图:有图用图、无图回退图标
/* js/ui.js —— 参数兼容 thumb(item) 和 thumb('水杯') 两种写法 */
function thumb(it) {
var item = (typeof it === 'string') ? { cat: it } : (it || {});
var imgs = item.images || [];
if (imgs.length) {
return '<div class="thumb thumb-img"><img src="' + E(imgs[0]) + '"></div>';
}
var cat = item.cat; /* 没有图片才走原来那套分类图标 */
...
}
为什么值得贴:它把"发布时传的图"和"列表上能看见"接上了——之前这两件事是断开的
(图存进去了、详情页也显示,唯独列表不显示),属于功能做了一半。它同时修掉一个安全问题:
图片地址是用户可控的字符串,拼进 src 前必须走 E() 转义,否则
x" onerror="alert(1) 这种能撑出属性、执行脚本。
(3)收藏列表:用"交集"避免幽灵条目。 收藏表只存编号,列表是拿现有信息去过滤
(state.items.filter(it => favs[it.id]))。反过来直接遍历收藏表的话,条目被删掉之后编号还在表里,
页面就会出现点不开的卡片、计数也虚高。
4.4 实现成果展示
| 首页(分类筛选 + 悬浮发布按钮) | 搜索(关键词高亮 + 四重筛选) |
|---|---|
![]() |
![]() |
| 发布页 · 寻物(联系方式已预填) | 信息详情页(联系方式打码 + 一键复制) |
![]() |
![]() |
| 我的发布(分段含「收藏 2」;首条卡片用上了缩略图) | 收藏列表(自己收藏的和别人的混在一起排) |
![]() |
![]() |
底部导航中间凸出栏顶的"发布"按钮做成凸起,是为了让最常用的操作有更大的点击区域
(直径 44px,符合移动端最小舒适点击区),下半截仍嵌在导航栏里、不显得突兀。
五、目录说明与使用说明
5.1 目录组织
102402150-102402148/ ← 仓库根目录 = 项目根目录
├── index.html 入口页。只有骨架:外壳 + 挂载点 + 一条 noscript 提示
├── README.md 目录说明 + 使用说明
├── css/style.css 全部样式。顶部 :root 是设计变量(颜色/圆角/阴影/间距)
├── js/
│ ├── data.js 【数据层】分类、区域、时间片、图标、12 条示例信息
│ ├── utils.js 【纯函数层】搜索筛选、表单校验、时间格式化、返回栈
│ ├── store.js 【仓库层】增删改查 + localStorage 持久化
│ ├── ui.js 【视图层】把数据渲染成 HTML 字符串
│ └── app.js 【控制层】hash 路由 + 事件绑定
├── tests/
│ ├── unit.test.js 81 个单元测试用例(Node 和浏览器都能跑)
│ └── tests.html 浏览器版测试报告页,双击即可查看
├── screenshots/ README 的界面预览图(程序不依赖,删掉也能跑)
└── assets/ 预留的静态资源目录(图标都是内联 SVG,目前为空)
组织原则就一条:按"层"分,不按"页"分。 没有 home.js / search.js 这种按页面拆的文件——
7 个页面的渲染共用同一套组件函数,按页面拆会产生大量重复。依赖关系单向,
index.html 里的加载顺序就是依赖顺序:data.js → utils.js → store.js → ui.js → app.js。
5.2 测试人员如何运行
不需要安装任何东西,不需要 Node,不需要本地服务器。
- 打开仓库 https://github.com/zzq1419/102402150-102402148,点 Code → Download ZIP 解压(或
git clone); - 双击解压出来的
index.html,用 Google Chrome 打开(与作业要求一致)——地址栏会出现...#/home,页面直接可用。
想跑单元测试:node tests/unit.test.js(结尾打印 共 81 个用例:通过 81,失败 0),
或双击 tests/tests.html 看分组报告页。
双击后若是空白页:确认
css/、js/与index.html在同一层目录(别被解压工具打散)。
六、单元测试
6.1 用什么工具,以及一份简易教程
没用任何测试框架,自己写了个 20 行的迷你框架。 项目零依赖,为写测试去装 Jest 要引入
package.json 和 node_modules,与"双击就能跑"冲突;我们真正需要的只有三件事——
断言、收集结果、打印报告。
var results = [];
function it(name, fn) {
try { fn(); results.push({ name: name, ok: true }); }
catch (e) { results.push({ name: name, ok: false, msg: e.message }); }
}
function ok(cond, msg) { if (!cond) throw new Error(msg || '期望为真'); }
function eq(a, b, msg) { if (a !== b)
throw new Error((msg||'') + ':期望 ' + JSON.stringify(b) + ',实际 ' + JSON.stringify(a)); }
function deepEq(a, b, msg) { // 比数组/对象必须用它
if (JSON.stringify(a) !== JSON.stringify(b))
throw new Error((msg||'') + ':期望 ' + JSON.stringify(b) + ',实际 ' + JSON.stringify(a));
}
两个必须知道的坑: ① 比数组千万别用 eq——eq([1],[1]) 永远失败(!== 比的是引用),
我们第一版有 6 个用例就这么"红"了,报错还长得一模一样。② 涉及时间的函数要把"当前时间"
做成参数,测试时注入固定值,否则用例今天过、明天挂。
还有个判断"测试有没有生效"的办法:把已修好的 bug 故意改回去,看用例会不会变红。
6.2 部分测试代码与说明
it('[筛选] 按时间片筛选,边界按"距今多久"算', function () {
// 夹具五条数据的 createdAt 分别是 NOW-1h / -20h / -2天 / -6天 / -3h
eq(UTILS.filterItems(FIXTURE, { range: 'h24', now: NOW }).length, 3);
eq(UTILS.filterItems(FIXTURE, { range: 'd3', now: NOW }).length, 4);
eq(UTILS.filterItems(FIXTURE, { range: 'd7', now: NOW }).length, 5);
});
it('[缩略图] 有图片显示第一张图,没有图片才回退分类图标', function () {
var MARK = '<rect x="3" y="5.5"'; // 分类图标(内联 SVG)的开头几个字符
var withImg = UI.cardItem(mk({ images: ['data:image/jpeg;base64,AAAA'] }), '', false);
ok(withImg.indexOf('<img src="data:image/jpeg;base64,AAAA"') > -1, '有图片时应当渲染缩略图');
eq(withImg.indexOf(MARK), -1, '有图片时不该再画分类图标');
ok(UI.cardItem(mk({ images: [] }), '', false).indexOf(MARK) > -1, '无图时应当回退图标');
});
缩略图那条用例用的是白盒思路:不断言"看起来对不对",而是断言"产物里有没有某段 HTML"——
分类图标是一段内联 SVG,取它开头的 <rect x="3" y="5.5" 当指纹,就能判断"到底画了图标还是图片"。
这比截图比对稳,换个颜色也不会误报。
81 个用例的分组:
| 分组 | 数 | 覆盖什么 |
|---|---|---|
| 关键词解析 | 4 | 空白切分、大小写、去重、空输入容错、多词 AND |
| 筛选 filterItems | 13 | 六维筛选、条件交集、三种排序、纯函数不被污染 |
| 高亮与 HTML 安全 | 5 | 转义、高亮、XSS 不生效、正则特殊字符 |
| 发布表单校验 | 9 | 必填、长度上下界、分类白名单、联系方式三种格式 |
| 时间格式化 | 5 | 刚刚/分钟前/今天/昨天/跨年、补零、未来时间兜底 |
| 业务小工具 | 7 | 地点→区域推断、统计、状态翻转、编号自增不重号 |
| 视图渲染冒烟 | 17 | 9 个视图产物扫描 + 打码与按钮随身份切换 + 防注入 |
| 返回栈 pushRoute | 11 | 五条入栈规则 + 两条完整链路 |
| 收藏 / 缩略图 / 预填 | 10 | 收藏计数与列表、已完成条目仍列出、编号被删不出幽灵条目、有图显示图片 / 无图回退图标、四个列表共用同一套规则、图片地址引号被转义、两个发布页都预填且预填值合法 |
6.3 测试数据怎么构造,怎么防"刁难"
不用真实的 12 条数据,而是另造 5 条、每条负责一个测试维度:
| 夹具 | 类型 | 分类 | 区域 | 状态 | 距今 | 负责验证 |
|---|---|---|---|---|---|---|
| LF9001 | found | 证件卡类 | 教学楼 | open | 1 小时 | 关键词命中、最新排序 |
| LF9002 | found | 雨伞 | 图书馆 | open | 20 小时 | 分类/区域筛选、24 小时边界 |
| LF9003 | lost | 电子设备 | 食堂 | open | 2 天 | 类型筛选、三天边界 |
| LF9004 | lost | 水杯 | 运动场 | done | 6 天 | 状态筛选、已完成排最后、七天边界 |
| LF9005 | lost | 钥匙 | 宿舍楼 | open | 3 小时 | mineOnly |
真实数据每次改动都会让一堆用例跟着红;夹具是为测试而生的,改起来不会互相牵连。
边界要取"刚好在内"和"刚好在外":上面那条时间筛选用例让 24 小时 / 三天 / 一周恰好命中
3 / 4 / 5 条——只测"筛出来有东西"的话,阈值写成 < 还是 <= 根本测不出来。
把能想到的"刁难"提前堵上:
| 假想的刁难 | 应对 |
|---|---|
搜索框输入 <script>alert(1)</script> 或正则特殊字符 a+b |
断言标签被转义;escapeReg 转义后正常匹配 |
| 搜索框全空格 / 什么都不填 / 条件互相矛盾 | 返回空数组,不报错也不返回全部,界面走空状态 |
联系方式填 12 位、或 12800138000;名称只填 1 或 31 个字;连点两次提交 |
判非法 / 上下界都拦 / 每次生成新编号不重号 |
| 收藏过的那条后来被删了(或点了「恢复示例数据」) | 收藏栏取"编号交集",该条自动落空,不留点不开的卡片、计数也不虚高 |
够不够?说实话:能覆盖"业务逻辑正确",覆盖不了"体验正确"。
返回该退到哪一页是链路问题、文案有没有拼成 NaN 是产物问题、"功能是不是只做了一半"是完整性问题——
下一节的三个困难没有一个是最初那 70 条用例发现的,全是真机走查和看截图时才露出来,
之后才补测试把它们钉住。
七、GitHub 代码签入记录
共 17 次提交,按"一个功能一次提交"划分:前 12 次是主线开发(10-07 ~ 10-08),
第 13~15 次是周凯 Fork 后的补齐(10-09),第 16 次合并他的 PR,第 17 次合并后同步 README 配图。

| 提交 | 说明 |
|---|---|
chore: 添加 .gitignore… / chore: 初始化项目骨架… |
忽略临时文件;入口页 + 资源目录 |
feat: 定义设计变量与全局样式… / feat: 加入分类、区域、图标常量与示例数据 |
样式层与数据层 |
feat: 实现纯函数层… / feat: 实现数据仓库与 localStorage 持久化… |
纯函数层、仓库层 |
feat: 实现 7 个页面的视图渲染… / feat: 接入 hash 路由、事件委托… |
视图层、控制层 |
test: 加入 70 个单元测试用例… / test: 加入浏览器版测试报告页 |
单元测试与测试报告页 |
docs: 编写 README… / docs: README 增加「界面预览」… |
文档与仓库配图 |
feat: 补齐三处做了一半的功能… / test: 补 10 条用例… / docs: README 同步…(周凯) |
收藏 / 缩略图 / 预填;用例 70 → 81 |
Merge pull request #1 from zkk123-dd/… / docs: 重出界面预览截图…(我) |
复审后合并、同步配图 |
提交信息统一用 类型: 做了什么(chore / feat / test / docs),每个提交都能独立说明一件事。
协作按作业要求走:我建仓库 → 周凯 Fork 后在自己分支上提交 → 开 PR 合回主干(PR #1),
他那一支正好是"改功能 / 补测试 / 同步文档"三件事。
八、遇到的代码模块异常与结对困难
困难 1:发布成功页的文案变成了「NaN」
- 问题:成功页写的是"把状态改成「NaN」",本该是"已归还/已找到",而且控制台一个报错都没有。
- 尝试与解决:先查数据源(正常)→ 把文案单独
console.log(结果是好的,说明问题在
拼接过程)→ 拎出来看,行尾一个+、下一行行首又一个+,两个+撞成一元加号
('…「' + (+'已归还')),作用在字符串上就得到NaN。 - 收获:单测只覆盖
utils.js、测不到视图层,而视图层全是字符串拼接。于是补了
「视图渲染冒烟测试」(9 个视图全渲染,断言产物不含NaN/undefined/[object Object])
并把 bug 改回去验证用例真会变红。"不报错的 bug"比"报错的 bug"危险。
困难 2:左上角返回不管从哪进都弹回首页
- 问题:返回按钮都写成
href="#/home",从详情页点返回不管从哪进来一律回首页。 - 尝试与解决:想用
history.back(),但file://直接打开某页时站内没历史,一点就跳出站点;
改成自己维护站内返回栈。还补了一条漏掉的规则——第一版只有"新页面入栈",于是
在搜索页里改筛选也入栈,筛三次要点三次返回(端到端验证时点出来的)。 - 收获:规则抽成纯函数
pushRoute()后,"连点两次返回停在哪"变成可自动断言的用例。
判断一段逻辑该不该抽成纯函数,标准是"它能不能被断言"。
困难 3:三处"功能只做了一半"的地方
- 问题:走查时发现三处能点、不报错、但没做完的地方——① 详情页能收藏,但
全站找不到已收藏的列表;② 发布时能传图、详情页也显示,但列表卡片永远只画分类图标;
③DATA.ME.contact早就定义好了,发布表单却是空的。都"功能在",只是差最后一步。 - 尝试与解决:交给周凯在他的 fork 里补齐(第 13 个提交),我复审后合并。复审时又发现一个隐患:
收藏列表若直接遍历收藏表,条目被删除后会留下点不开的卡片、计数还虚高;
改成"拿现有信息去查收藏表"就自动落空,并补了用例钉住。 - 收获: “做完了"和"能用"是两件事。 一个功能要算完成,得把”** 入口 → 操作 → 结果**"三段走通:
能收藏只是中段,还得知道去哪看、看到什么。我们把这三处写成 10 条用例,
用测试来定义"什么叫做完"。
结对中的一次分歧:校验提示放哪
周凯主张点「发布」时统一弹提示、一次列全;我主张输入框下方即时提示。争到一半发现,两个人说的
其实是两个场景——前者解决"我填完了要提交",后者解决"我正在填、不确定对不对",最后两个都做了。
收获:分歧往往不是"对错"之争,而是两个人想的是不同场景。
九、评价我的队友(周子钦)
值得学习:
-
他判断一个功能算不算做好,用的是"用户走不走得通",不是"代码写没写"。 他这次列进需求的三处——收藏了没地方看、能传 3 张图但列表永远只显示分类图标、
DATA.ME.contact定义了却不预填——单拆开看,每一处都不算"缺":星标点得动、上传能用、那个常量也好端端放在数据层。缺的不是那几行,是写完的那半截用户接不上。 这种"看得见、不报错"的缝,正是我平时扫不出来的那一类。 -
改动边界划在前面,不靠事后解释。 他布置的时候就把范围钉死了:只动这三处,除此之外的任何改动一律先问他——连"顺手补个单测、同步一下 README"这种看起来理所当然的事,也照样先确认。我平时最容易犯的"顺手多改一点",在他这儿从一开始就不成立。 评审时不用去猜哪一行是捎带加的。
-
给需求像给验收单,不说"做好看点"。 他不会讲"首页卡片太单调,你优化一下",而是给一张"缺口 / 现象 / 可以怎么做"的表,现象细到"发布时能传 3 张图、详情页也会显示,但首页和搜索的卡片永远只显示分类图标"这一层。现象、原因、期望值一次给全,做事时基本不需要反问。
需要改进:
-
收尾的同步全靠人盯,容易慢一步。 功能改完,README 里的章节引用、
screenshots/下的截图不会自动跟着变。这次是他在 PR 描述里自己标出"两处章节引用偏了一位""04-我的发布.png还是三分段的旧版"——他心里是清楚的,但没有在改动当时一起收掉。 换一个不看 PR 描述的评审,这些就是"文档和代码对不上"。 -
验证这一步偏"交出去"。 改完之后他更习惯说"你跑一遍""打开浏览器看看",而不是自己先点一遍页面。功能到底能不能用,只有亲手走一遍才知道;这一步交给别人,等于把"确认能用"也一起交出去了。







浙公网安备 33010602011771号