软件工程第二次结对作业

Posted on 2026-10-10 00:42  52hzwhale  阅读(9)  评论(0)    收藏  举报

软件工程课程第二次结对作业:实现校园失物招领小程序

一、作业信息

项目 内容
这个作业属于哪个课程 软件工程
这个作业要求在哪里 作业要求
这个作业的目标 把第一次结对作业的原型实现成可运行的代码,并完成单元测试
姓名 张陈雨菲 刘俊延
学号 102401208 102401133
代码仓库 https://github.com/52HzWhale111/102401208-102401133

二、分工

第一次作业我们按「设计」分工(队友出布局与流程的想法,我落 UI),这次按用户流程分工,两个人各拥有一条能独立演示、独立测试的完整链路:

负责的链路 具体产出
张陈雨菲(102401208) 发布链路 首页、发布页、发布成功页、编辑页;js/core.js、js/store.js、js/seed.js、js/layout.js、js/card.js、js/form.js、shared/app.css;core.test.js / edit.test.js / square.test.js
刘俊延(102401133) 浏览链路 搜索页、广场页、详情页、我的页;侧滑菜单与空状态;browse.test.js

首页由两人共同讨论后实现。切分方式不是按页面个数平均分,而是先由队友把数据层和接口契约冻结下来(数据字段、状态取值、LF.search 的 query 约定、页面之间的参数怎么传),我拿着这份契约并行开发浏览链路。所以四个 PR 基本都是「我提分支 → 队友 review 后合并」,中途没有出现过两边对同一个字段各叫各的名字、合并时大面积返工的情况。

三、PSP 表格

psp

合计 1320 分钟(22 小时)预估 / 1620 分钟(27 小时)实际,超出的 5 小时集中在四块:分析、设计规范、设计各超 20 分钟,编码超 160 分钟,测试超 40 分钟,写 README 与博客超 40 分钟。

其中最值得记一笔的是「设计规范」和「设计」这块——接口契约的细节比想象中多。最初以为「定好字段名」就是定契约,实际写下来才发现要定的还包括:时间为空的条目怎么排序、?kind=lost 和 ?kind=found 之外传了别的值怎么退、状态筛选传 all 时算不算「不过滤」。这些边界每一条都要在契约里给个说法,否则两个人在各自的页面里会各自想一个。而编码超的 160 分钟(快 3 小时)也大多来自这里:契约里没写死的边界,两条链路各自撞上一次、再回头对齐,时间就是这么花掉的。

四、解题思路与设计实现

4.1 整体思路

第一次作业的原型是 16 个画板(10 个主页面 + 3 个状态页 + 3 个弹层)。这次要把它落成能跑的代码,主流程是:

发布信息 → 浏览 / 搜索 → 查看详情 → 联系发布者 → 更新状态(已找到 / 已归还)

流程图

页面流程图:从首页出发的五条去路(发布、搜索、广场、我的、返回),以及「详情页 → 联系方式 → 更新状态」这一小段闭环。

原型里为了讲清设计思路而画的东西(状态页、弹层、空状态),这次要求全部真的能点——点「+」要弹出发布选择、搜索不到东西要出现空状态、发布完要能在首页看到它。

4.2 分成七层的结构

js/core.js      纯逻辑层(校验、状态流转、搜索、排序、时间格式化、ID 生成)
js/store.js     数据层(localStorage 读写,不可用时静默降级为内存)
js/seed.js      种子数据(9 条示例信息)
js/layout.js    页面外壳(顶栏 / 底部 TabBar / 发布选择弹层 / 轻提示 / 一键复制)
js/card.js      信息卡片(首页、搜索、广场、我的四处共用同一套卡片)
js/form.js      发布 / 编辑表单(两页共用同一份标记与校验)
*.html          9 个页面,只负责挂载和取参数

分层的判据是「这一层能不能脱离浏览器单独测」。core.js 里全是纯函数(进什么、出什么,不碰 DOM 也不碰 localStorage),所以它能在 Node 里直接跑测试;layout.js、card.js 只管渲染,不存数据;store.js 是唯一碰 localStorage 的地方。这样做的实际收益在写测试那一节会看到——页面上跑的和我测的是同一份 core.js。

4.3 关键设计一:状态从七八种叫法收敛成两个值

第一次作业的需求文档和设计稿里,状态先后出现过 寻找中 / 待认领 / 找到 / 已找回 / 寻找失主中 / 焦急寻找中 / 已完成 七八种叫法。如果代码里照搬,状态流转就会变成七八个值之间的网状判断。

所以代码里只存 state: 'open' | 'done' 两个值,页面上显示什么文案由 LF.statusText() 派生:

open done
寻物 lost 焦急寻找中(橙) 已找到
招领 found 寻找失主中(绿) 已归还

好处是状态流转只有一条规则可判断(open → done,且只有发布者本人能操作,即 LF.canTransit),不必为每种叫法各写一个分支;文案以后想改,也只改派生函数一处,不动数据。把「显示成什么」和「实际是什么」分开,是这次收获最大的一条经验。

4.4 关键设计二:同一份代码既跑页面又跑测试

js/core.js 用 UMD 包裹:

(function (root, factory) {
  if (typeof module === 'object' && module.exports) module.exports = factory();  // Node / Mocha
  else root.LF = factory();                                                      // 浏览器
})(typeof self !== 'undefined' ? self : this, function () { /* ... */ return LF; });

这样 npm test 测的就是页面上正在跑的那一份代码,不存在「测试全绿、但页面走的是另一套逻辑」这种自己骗自己的情况。

之所以不用更现代的 ES module(import / export),是因为作业要求双击 html 就能运行:file:// 协议下 Chrome 会按 CORS 规则直接拦掉 ES module 的加载,必须起本地服务器才行。项目里所有 <script> 都是传统的 <script src>,这也是选 UMD 而不是 ESM 的原因——约束(双击即用)倒推出了技术选型,而不是先选一个时髦方案再回头发现跑不起来。

4.5 关键设计三:发布与编辑共用一张表单

编辑功能是作业没要求的,但「发布」和「编辑」在界面上一模一样(同样的字段、同样的校验、同样的分段控件),如果各写一份,改一处校验规则就得改两个地方。

所以把整张表单抽成了 js/form.js,两个页面只传一个 mode:

LF.form.mount(root, { mode: 'create' });   // publish.html
LF.form.mount(root, { mode: 'edit', item: it });  // edit.html

mode 决定的差别总共只有三处:分段控件的字(「发布寻物」/「寻物」)、按钮的字(「确认发布」/「保存修改」)、以及编辑页不显示「恢复草稿」(编辑时表单里已经有真实数据了,再提供草稿恢复没有意义)。

保存时也不能整条覆盖。LF.applyEdit 只按草稿改写用户填的那几个字段,保留 state(状态)、author(发布者)、createdAt(发布时间)、images(图片)——否则一条「已找到」的信息被人编辑一下描述,状态就会被悄悄退回「寻找中」。这类「保存后把别的字段顺手抹掉」的问题,是编辑功能里最容易埋的坑。

4.6 为什么所有页面都平铺在根目录

数据里存的图片路径形如 assets/items/item-01.svg。如果把页面放进子目录(如 detail/index.html),从该页解析这个相对路径会变成 detail/assets/... 而 404。全部平铺在根目录,可以让每条相对路径从任何页面都解析正确。这是一个用目录结构换掉一层路径处理代码的取舍。

五、附加特点

5.1 功能演示

下面这些动图是照着 6.1 的体验路径录的,顺序就是一条完整的用户旅程:

首页-寻物

首页点「我要寻物」,先看到大家捡到的东西,逛到底没找到才出现发布入口。

首页-招领

同理,捡到东西的人点「我要招领」,先确认失主是不是已经在找了。

搜索-有结果

输入关键词 + 类别 + 类型三个条件叠加筛选,上方显示命中的条数。

搜索-无结果

搜不到时的空状态,文案把用户接回主流程:「换个关键词试试,或者发布一条信息」。

广场页筛选功能

右上角下拉:最新 / 最早发布在前,以及近 7 天 / 30 天 / 半年 / 一年四档时间范围。

我的-筛选

「我的」页顶部三个统计格可以点,点了就是按已找到 / 寻找中筛选自己的发布记录。

更改状态

发布者本人可以把信息标记为已找到 / 已归还,减少别人重复联系。

复制联系方式

详情页的手机 / 微信 / QQ 都带复制按钮,不用长按选中。

5.2 要求之外多做的部分

作业只要求「发布 / 浏览 / 详情 / 联系 / 状态」五件事,以下是我们在要求之外多做、并且做进了测试的部分:

  1. 零依赖纯静态:不装 Node、不起服务器、不构建,双击 index.html 就能用。数据存 localStorage,刷新不丢;隐私模式下 localStorage 不可用时自动降级为内存存储,功能照常可用。
  2. 100 个单元测试(作业要求 ≥10 例)。四个测试文件按链路分工,见第七节。
  3. 把页面结构也当契约来测。除了测逻辑,我们还写了「结构自检」:断言页面里确实存在某个元素、某个 aria-* 属性、菜单项的数量和角色。这样「改样式时手滑删掉一个按钮」这种纯界面回归也能被 npm test 抓住,而不是靠人肉点一遍。
  4. 广场页的排序与时间范围筛选(作业未要求)。右上角一个下拉:最新 / 最早发布在前;近 7 天 / 近 30 天 / 近半年 / 近一年。
  5. 编辑已发布的信息(作业未要求):在「我的」页点记录右边的「⋯ → 编辑」进入,改完保存回「我的」并提示「修改已保存」。
  6. 一键复制联系方式:详情页的手机 / 微信 / QQ 都带复制按钮,免去长按选中的麻烦。
  7. 无障碍属性:下拉菜单带 aria-expanded / aria-haspopup / aria-controls,菜单项用 role="menuitemradio" + aria-checked 表达「六个选项里选一个」的语义,而不是拿六个普通按钮凑数。
  8. 空状态、加载中、加载失败三个状态页:原型里画了就真的做了,空状态文案是「换个关键词试试,或者发布一条信息」,把用户接回主流程。

六、目录与使用说明

6.1 运行(不需要安装任何东西)

下载仓库全部文件后,用谷歌浏览器打开根目录下的 index.html 即可,无需 Node.js、无需本地服务器、无需构建。

双击 index.html   ←  或者:右键 → 打开方式 → Google Chrome

建议的体验路径:首页「最新发布」→ 点底部中间的「+」→ 选「发布寻物」→ 填表 → 确认发布 → 回首页看它排在最顶部 → 刷新页面数据还在 → 进「我的」点整行打开详情 → 点「⋯ → 编辑」改一下 → 进「广场」点右上角「最新 ▾」切成「最早发布在前」,再选「近 7 天」看条数变化。

6.2 界面截图

01-首页

首页:顶部是搜索栏和「我要寻物 / 我要招领」两个入口,下面是「最新发布」信息流,底部是固定的 TabBar。

02-广场-排序与时间筛选

广场页:右上角「最新 ▾」就是排序与时间范围的下拉(第二次作业新加的功能),左侧「全部信息 · 共 9 条」是计数栏。

6.3 跑单元测试(可选,这一步需要 Node.js v18+)

npm install    # 只装一个 mocha,首次需要联网
npm test       # 预期输出:100 passing

6.4 目录结构

102401208-102401133/
├─ index.html              首页 —— 入口文件,助教打开的第一个文件
├─ publish.html            发布页 —— 发布寻物 / 发布招领(读 ?kind= 区分)
├─ publish-done.html       发布成功页 —— 回显刚发布的信息(读 ?id=)
├─ edit.html               编辑页 —— 修改本人发布过的信息(读 ?id=)
├─ search.html             搜索页 —— 关键词 + 类别 + 类型组合筛选
├─ square.html             广场页 —— 浏览全部信息(读 ?kind=)
├─ detail.html             详情页 —— 完整字段 + 发布者联系方式(读 ?id=)
├─ mine.html               我的页 —— 发布统计 + 标记已找到 / 已归还
├─ shared/app.css          全站样式:设计令牌 + 页面外壳 + 卡片 / 表单 / 按钮
├─ js/
│   ├─ core.js             ★ 纯逻辑层(UMD):校验、状态、搜索、时间格式化、ID
│   ├─ store.js            数据层:localStorage 读写(不可用时降级为内存)
│   ├─ seed.js             种子数据:9 条示例信息
│   ├─ layout.js           页面外壳:顶栏 / 底部 TabBar / 发布选择弹层 / 轻提示
│   ├─ card.js             信息卡片渲染:首页、搜索、广场、我的四处共用
│   └─ form.js             发布 / 编辑表单:两页共用,只有 mode 不同
├─ assets/
│   ├─ avatar.png          品牌头像
│   └─ items/item-01..09.svg   按物品类别生成的矢量占位图(不依赖外部素材)
├─ test/                   4 个测试文件,共 100 个用例
├─ package.json            npm test 配置(唯一依赖:mocha)
└─ README.md               详细说明(与本节内容一致,并多一节「需求实现对照」)

七、单元测试

npm test 一次跑完 test/ 下四个文件、共 100 个用例(作业要求 ≥10 例)。分工与覆盖范围:

文件 用例数 覆盖什么
core.test.js 34 表单校验(validateDraft)、补全成完整信息(normalizeDraft)、状态文案与配色、状态流转(canTransit / markDone)、搜索筛选(search)、escapeHtml、时间格式化、ID 生成、store 增删改查
edit.test.js 29 谁可以编辑(canEdit)、按草稿改写(applyEdit)、store.update、时间回填(toLocalInput),以及编辑页与「我的」页的结构自检
square.test.js 20 时间范围(withinRange)、排序与筛选叠加、广场页下拉的结构自检、种子数据的时间跨度
browse.test.js 17 页面外壳、[hidden] 兜底、搜索页空状态、详情页、我的页、侧滑菜单、空状态插画

举两个有代表性的测试:

一是防 XSS 的转义。 物品名称、描述都是用户自由输入,如果直接拼进 innerHTML,一条名字叫 <img onerror=...> 的信息就能打穿页面。所有输出路径都过 LF.escapeHtml,测试里专门用脚本串去戳它。

二是把「不能回归的 UI 细节」钉住。 走查时发现两个肉眼容易漏、改一次就可能复发的问题:完成状态的标签右边漏出一个下拉箭头、编辑页的返回键被两个字挤成两行。修完之后我们都补了测试(用例 ㉘㉙),断言的正是「这个标签不该有箭头」「返回键里不该有第二个字」。理由是:这类问题的正确形态只有一行 CSS 的差别,靠眼睛走查一定会复发,只有写成断言才能防住。

写测试还有个额外收获:因为 core.js 是 UMD 的,测试直接 require 了页面在用的那份代码,所以「测试通过」这句话是可以信的。

03-单元测试-100-passing

npm test 的输出(截图是最后 30 行,可以看到用例编号和 100 passing)。

八、签入记录

两人共用一个仓库,都用自己的 GitHub 账号提交,通过 Pull Request 合并(共 4 个 PR)。下面是提交记录:

签入记录

(本次作业的提交记录也可以在仓库的 Commits 页面查看:https://github.com/52HzWhale111/102401208-102401133/commits/main )

九、异常与解决

按「现象 → 原因 → 怎么解决」记五个耗时比较久的:

1. 编辑页的时间一保存就被抹掉。
现象:编辑一条已有信息,只要不动时间直接保存,时间就变空了。原因:种子数据里的 happenedAt 是 UTC 的 ISO 字符串,而 <input type="datetime-local"> 只认本地时间格式,两者差 8 小时且格式不同,直接塞进输入框会失败。解决:加了 LF.toLocalInput() 做时区与格式转换,回填和读取都走它,并补了测试。

2. 时间范围筛选会把下拉菜单一起弄没。
现象:选「近 7 天」筛出 0 条时,整条计数栏消失,而排序下拉就在计数栏里,于是用户没法把筛选改回来,进了死胡同。原因:原来写的是 elBar.hidden = !hasHit,没有区分「没有结果」和「工具条本身没有意义」。解决:只要用户设了筛选条件,计数栏就必须留着(哪怕显示 0 条),并补了用例 ⑯ 钉住。

3. 四个时间档位筛出来的条数一模一样。
现象:「近 7 天 / 30 天 / 半年 / 一年」点下去条数一样,看不出筛选到底生效没有。原因:9 条种子数据的时间全是「距今几小时」,都落在最近档里。解决:把其中几条刻意拉开到 13 个月 / 8 个月 / 2 个月 / 20 天前,四档依次筛出 5 / 6 / 7 / 8 条,只有「全部时间」才凑齐 9 条。这也说明测试要盯着数据本身——补了眼界测试(⑲⑳),断言四档条数递增,还断言这几条的文字描述里不能出现「昨天」这种和实际时间对不上的词。

4. 完成状态的标签右边多出一个下拉箭头。
现象:只有「已找到」这个静态标签右边漏出一个 ▾。原因:那个 ▾ 是用 background-image 画出来的,完成态只把内边距收窄了、没有把 background-image 清掉,箭头就留在了本来不该有下拉的标签上。解决:完成态显式 background-image: none。这是典型的「样式是叠加的,改一个属性不等于清掉另一个」。

5. GitHub 直连间歇性失败。
现象:git push 报 RPC failed; curl 55 Send failure、port 443 Could not connect。起初容易以为是自己操作错了,反复重来会浪费不少时间。原因:网络到 GitHub 的链路不稳定。解决:确认本地提交不会丢(这是最要紧的一点,先别慌),再按「重试 → 换网络 → 配代理 → 调 http.version 和 postBuffer」的顺序处理。经验是:网络问题要先把「已经做完的事」和「还没同步的事」分清楚,再去折腾网络。

十、评价队友

这次我负责浏览链路,队友负责发布链路和整个数据层,配合下来最大的感受是——跟她结对,我大部分时间是在一个已经搭好的地基上干活。开工第一天她就先把 core.js 写出来、把接口契约冻结了:字段叫什么、状态有哪几个取值、页面之间怎么传参、LF.search 的 query 长什么样,然后我才开始做搜索页和广场页,两条链路基本是并行的,最后四个 PR 合下来一次「两边字段对不上」的返工都没有。她抽出来的公共模块我也一直在用:卡片是 LF.card 一处渲染、首页搜索广场我的四个页面共用,页面外壳(顶栏、TabBar、发布弹层)是 LF.layout,我只要填自己的内容就行,省掉大量重复的活儿。第二是她做的事比分工表上写的多:编辑功能和广场页的排序 / 时间范围筛选都不是作业要求,是她自己加的,加完还都补了测试;单元测试她一个人写了 83 个用例,我做浏览链路时遇到拿不准的行为,经常是翻她的测试用例当文档看。还有一点印象很深的是她修 bug 的习惯——走查时发现的两个纯样式问题(「已找到」标签上多出来的下拉箭头、编辑页返回键被挤成两行),她修完都专门补了用例把正确形态钉住,理由是「这种差一行 CSS 的问题不写断言一定会复发」,这个思路我后来照搬到了自己的结构自检里。要说可以更好的地方:契约给的是主路,一些边界情况(参数认不出来怎么退、筛不到东西时界面该是什么样)是我做着做着才撞上的,得回头问她一句,如果一开始就把这些边界也写进约定,中间能少几轮往返;另外她的文档和注释写得比我细,但偶尔有没跟上代码的地方,比如 README 里浏览链路的用例数写的是 15、后来其实到 17 了——不过那几条用例是我加的,我也有责任。

十一、个人总结

这次我负责浏览链路的四个页面(搜索、广场、详情、我的)以及侧滑菜单和空状态。最大的收获是学会了在别人的契约上写代码:字段名、状态取值、参数怎么传都不是我能自己定的,我必须在约定好的范围里做。一开始不太习惯,觉得约束太多,后来发现正是因为这些约定,我才能不等接口写完就先按约定把页面搭出来(用一份假数据联调),最后一次合上没有出过字段对不上的问题。另外,我这次开始试着把界面的样子写成断言,那 17 条结构自检是我写的:断言页面里有几个 tab、菜单项是不是 menuitemradio、有没有 aria-checked。之前我以为测试就是测函数,做完这次才明白,界面上那些「改一次就可能退回去」的细节,写成断言比每次人肉点一遍可靠得多。也有一处做得不够好:引导浏览页的第一版我只搭了骨架,搜索框和类别筛选是后来补的——当时想着「先把页面立起来」,可实际上这两个才是这个页面的主要功能,顺序应该反过来。

博客园  ©  2004-2026
浙公网安备 33010602011771号 浙ICP备2021040463号-3