软件工程作业四——失物招领小程序
结对作业二:校园失物招领 —— 编码实现与单元测试
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601软件工程与软件工程实践 |
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次) |
| 结对成员1 | 102402121 陈韦融 (博客) |
| 结对成员2 | 102302113 王泽炜 (博客) |
| 本作业博客 | 地址 |
| GitHub 仓库地址 | https://github.com/Rong-hub1/102402121-102302113 |
一、具体分工
| 成员 | 分工 |
|---|---|
| 102402121 陈韦融 | 思路构建、整体应用设计与编码、美术风格设计、单元测试 test.html 编写、博客大纲构造 |
| 102302113 王泽炜 | 种子数据、界面走查与测试、bug修复、使用体验优化、test扩充、博客内容主体撰写、最终呈现核对 |
二、PSP 表格
| PSP2.1 阶段 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|
| Planning 计划 | 20 | 15 |
| Estimate 估计任务时间 | 20 | 15 |
| Development 开发 | 505 | 505 |
| Analysis 需求分析(含学习) | 40 | 35 |
| Design Spec 生成设计文档 | 30 | 25 |
| Design Review 设计复审 | 20 | 15 |
| Coding Standard 代码规范 | 15 | 10 |
| Design 具体设计 | 40 | 35 |
| Coding 具体编码 | 240 | 260 |
| Code Review 代码复审 | 30 | 25 |
| Test 测试(自测、修改) | 90 | 100 |
| Reporting 报告 | 70 | 55 |
| Test Report 测试报告 | 30 | 25 |
| Size Measurement 计算工作量 | 15 | 10 |
| Postmortem 事后总结与改进 | 25 | 20 |
| 合计 | 595 | 575 |
三、解题思路描述与设计实现说明
3.1 代码实现思路(文字描述)
本次在上一次原型设计的基础上,将其从静态原型落地为可交互的单页应用(SPA)。核心思路有三点:
-
呈现形式:选择 PC 端 Web,沿用原型的「手机框居中」方案——在浏览器中用一个 375×700 的手机外框居中展示,既忠实于原型设计、降低改造成本,又保证助教在谷歌浏览器中打开
index.html即可看到一致效果。 -
数据层与界面层分离:把所有数据处理逻辑抽进
store.js(纯函数、不操作 DOM),把页面渲染和事件响应放进app.js。这样store.js可以脱离界面被独立单元测试,也让界面代码更清爽。数据持久化用浏览器localStorage,无需后端。 -
单页应用路由:五个页面(首页 / 发布 / 详情 / 搜索 / 我的)实际是同一个 HTML 里的五个
div,通过 JS 切换active类实现「页面跳转」,避免多文件跳转的状态丢失,交互更流畅。
3.2 关键实现流程图 / 数据流图
用户主流程图:
数据流转图:
数据流上,所有数据变更都经过 store.js → localStorage,界面层只负责「拿数据渲染」和「把用户意图转成对 store 的调用」,保证数据单一可信源。
3.3 重要代码片段与解释
① 数据校验 validateItem——发布前的守门员,返回错误数组,空数组即通过:
function validateItem(data) {
var errors = [];
if (data.type !== 'lost' && data.type !== 'found') errors.push('类型必须为“寻物”或“招领”');
if (!data.title || !String(data.title).trim()) errors.push('物品名称不能为空');
if (!data.location || !String(data.location).trim()) errors.push('地点不能为空');
if (!data.contact || !String(data.contact).trim()) errors.push('联系方式不能为空');
return errors;
}
设计要点:用「错误数组」而非「抛异常」表达校验结果,方便界面一次性展示所有缺失项;对字符串做
trim()处理,拦截「全空格」这类边界输入。
② 关键词搜索 searchItems——在多字段中模糊匹配,空关键词返回全部:
function searchItems(items, keyword) {
var kw = (keyword || '').trim().toLowerCase();
if (kw === '') return items.slice();
return items.filter(function (it) {
var haystack = [it.title, it.description, it.location, it.category].join(' ').toLowerCase();
return haystack.indexOf(kw) !== -1;
});
}
设计要点:统一转小写实现不区分大小写;把多个字段拼成一个串再匹配,代码简洁;返回新数组(
slice()/filter),不污染原数据。
③ 高亮 + XSS 防护 highlight——先转义再包裹高亮,杜绝注入:
function highlight(text, keyword) {
var safe = escapeHtml(text == null ? '' : text); // 先转义,防 XSS
var kw = (keyword || '').trim();
if (kw === '') return safe;
var escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // 转义正则特殊字符
return safe.replace(new RegExp(escaped, 'gi'), function (m) {
return '<em class="hl">' + m + '</em>';
});
}
设计要点:顺序很关键——必须先
escapeHtml再插入高亮标签,否则恶意标题里的<script>会被执行;关键词里的正则元字符(如+、*)也要转义,否则会报错或误匹配。
四、附加特点设计与展示
除作业要求的核心功能外,我们增加了以下人性化设计:
4.1 一键复制联系方式
- 创意与意义:失主 / 拾获者最痛的点是「看到联系方式却要手动敲一遍」,尤其微信号、QQ 号易输错。一键复制让联系门槛降到最低。
- 实现思路:优先用
navigator.clipboard,失败时回退到document.execCommand('copy')兼容旧环境;复制成功后用 Toast 反馈。 - 代码片段:
function copyContact() {
var it = state.items.filter(x => x.id === state.detailId)[0];
var text = it.contactType + ':' + it.contact;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done));
} else {
fallbackCopy(text, done); // 兼容旧浏览器
}
}
- 成果:详情页点击「复制」即弹出 Toast「联系方式已复制:xxx」,实测可用。
4.2 分类筛选 chips + 已完成信息降噪
- 创意与意义:信息多了之后,按「证件卡片 / 电子产品 / 钥匙」等分类快速过滤,提升查找效率;已完成的信息自动置灰并提示「无需联系」,减少无效打扰。
- 实现思路:从现有数据动态聚合分类生成 chips;状态为
done的卡片渲染为灰色标签,详情页隐藏联系按钮。 - 成果:首页顶部 chips 点击即筛选;已找回的「校园卡(李明)」在列表中显示「已完成」灰标签。
4.3 防 XSS 注入的安全渲染
- 所有用户输入在渲染前都经
escapeHtml转义,避免恶意标题 / 描述注入脚本。这是 Web 应用的基本安全素养,也是测试中的重点用例。
五、目录说明与使用说明
5.1 目录组织
第四次作业/
├── index.html # 主页面(SPA 入口)
├── README.md # 项目说明(目录 + 使用)
├── css/
│ └── style.css # 全部样式
├── js/
│ ├── store.js # 数据层纯函数(可单元测试)
│ ├── seed.js # 首次访问示例数据
│ └── app.js # 应用层:路由 / 渲染 / 事件
└── test/
└── test.html # 单元测试页
组织原则:按职责分层——样式、数据逻辑、界面逻辑、测试各自独立成文件,便于定位和维护。数据层独立,是为了能被单元测试直接调用。
5.2 测试人员如何运行
- 用谷歌浏览器打开
index.html→ 直接使用全部功能; - 打开
test/test.html→ 自动运行单元测试,顶部显示绿 / 红汇总。
无需安装任何环境、无需联网、无需服务器。
六、单元测试
6.1 测试工具选择与简易教程
为什么选「浏览器内测试页」而非 Jest?Jest 依赖 Node.js 环境。因此我们自研了一个极简断言框架**,写在 test/test.html 里,打开即跑。
框架核心只有 4 个函数(简易教程):
test(group, name, fn) // 注册一个用例:所属分组、用例名、测试函数
assert(cond, msg) // 断言条件为真,否则抛出 msg
eq(a, b, msg) // 断言 a、b 深度相等(用 JSON.stringify 比较)
assertThrow(fn, msg) // 断言 fn 执行时会抛异常
用例运行后自动汇总:通过数、失败数、失败用例的具体期望值与实际值,顶部一条绿色 / 红色横幅一目了然。任何同学打开 test.html 即可复现,满足「单元测试自动化、人人可运行」。
6.2 测试代码与被测函数
针对 store.js 的每个纯函数设计用例,共 36 个用例、8 个分组,全部通过。示例:
// 被测函数:searchItems
test('searchItems 关键词搜索', '匹配标题', function () {
var r = S.searchItems(ITEMS, '校园卡');
eq(r.length, 1); eq(r[0].id, 'a');
});
test('searchItems 关键词搜索', '不区分大小写', function () {
var r = S.searchItems([{ title: 'iPhone', /*...*/ }], 'iphone');
eq(r.length, 1);
});
// 被测函数:highlight(含安全用例)
test('highlight 高亮与安全', '应转义 HTML 防 XSS', function () {
var h = S.highlight('<script>alert(1)</script>', '');
assert(h.indexOf('<script>') === -1, '不应出现未转义标签');
assert(h.indexOf('<script>') >= 0, '应被转义');
});
6.3 测试数据构造思路(如何考虑各种情况 & 应对刁难)
采用白盒 + 边界 + 异常三维设计:
| 维度 | 覆盖的情况 |
|---|---|
| 正常路径 | 合法数据校验通过、搜索命中、状态正确变更 |
| 边界值 | 空字符串关键词、全空格标题、时间刚好卡在「刚刚/分钟/小时/天」临界点 |
| 异常输入 | 非法 type、缺字段、null/undefined 传入数组参数 |
| 安全 | 含 <script> 的恶意输入是否被转义、正则特殊字符关键词是否报错 |
| 不可变性 | markDone / incrementViews 是否误改原数组(防止副作用污染) |
「应对刁难」思路:测试人员常拿空值、特殊字符、超长内容来试。我们对所有字符串输入做
trim()、对 HTML 做转义、对非数组输入做防御性返回[],并用单元测试固化这些行为,确保刁难输入不会让程序崩溃。
测试评价:这 36 个用例覆盖了数据层全部函数的正常、边界、异常与安全分支,能满足本程序(纯前端、数据层为核心)的测试要求;界面层的交互流程通过人工走查(已实测发布 / 搜索 / 详情 / 标记全流程可用)补充。
七、GitHub 代码签入记录
按功能划分模块、每完成一个可编译功能即 commit 一次。commit 信息采用「类型 + 简短描述」格式,例如:
feat: 实现数据层 store.js 与校验搜索纯函数feat: 实现首页浏览与分类筛选feat: 实现发布表单与校验feat: 实现详情页与一键复制联系方式feat: 实现我的发布与状态管理test: 新增 36 个单元测试用例并全部通过docs: 补充 README 目录说明与使用说明
GitHub 提交记录截图

八、遇到的困难及解决方法
问题描述:
test.html初次打开报SyntaxError,所有测试无法运行;- 直接在搜索框按回车不触发搜索(仅点击「搜索」按钮有效);
- 内嵌脚本中写
'</script>'测试 XSS 时,字符串被 HTML 解析器当成脚本结束标签。
做过哪些尝试:
- 查看浏览器控制台,定位到语法错误指向测试用例行;
- 检查
app.js的keydown监听,确认代码本身正确; - 排查发现是 HTML 中
</script>字面量提前终止了脚本块。
是否解决:
- 已解决——将
'</script>'拆成'<' + '/script>'拼接,避免被解析器识别为结束标签; - 回车问题系测试工具焦点所致,真实浏览器中输入框回车正常触发(代码已监听
keydown),并保留「搜索」按钮作为双保险; - 全部 36 个用例通过。
收获:纯函数与界面分离后,测试极其顺畅——数据层 bug 能在 test.html 里秒级复现,不必在界面里反复点。HTML 内嵌脚本的 </script> 陷阱是个经典坑,写测试页时需格外注意。
九、评价我的队友
值得学习的地方:队友在测试上非常细致,严格设置了多种情况的测试;在走查时主动覆盖空数据、异常输入等边界情况,保证了用户体验,补充了我容易忽略的防御性处理。
需要改进的地方:初期两人对「数据层边界划在哪」有过反复讨论,耗时较多;后续可在编码前先花 10 分钟把接口(函数签名、输入输出)定死,减少中途返工。

浙公网安备 33010602011771号