第四次作业
校园失物招领 —— 结对编程作业(第四次)
〇、结对信息
- 结对同学博客链接:本作业独立完成,无结对同学
- 本作业博客链接:https://www.cnblogs.com/chirp77/p/23271832
- GitHub 项目地址:https://github.com/chirp77/102401510
一、具体分工
本作业由 102401510 林富强 独立完成,全部模块(页面骨架、core.js 逻辑层、app.js 界面层、样式、演示数据、单元测试、README、博客)均为本人编写。所有提交均发生在同一仓库,无需 fork / Pull Request 协作。
二、PSP 表格
预估耗时在动手写代码之前记录,实际耗时在完成后如实回填。
差异最大的两项(具体设计、具体编码)在"九、过程总结"中分析原因。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | ||
| Estimate | 估计这个任务需要多少时间 | 150 | 130 |
| Development | 开发 | ||
| Analysis | 需求分析(包括学习新技术) | 120 | 90 |
| Design Spec | 生成设计文档 | 90 | 60 |
| Design Review | 设计复审 | 30 | 45 |
| Coding Standard | 代码规范(为目前的开发制定合适的规范) | 30 | 20 |
| Design | 具体设计 | 120 | 180 |
| Coding | 具体编码 | 480 | 620 |
| Code Review | 代码复审 | 60 | 90 |
| Test | 测试(自我测试,修改代码,提交修改) | 120 | 160 |
| Reporting | 报告 | ||
| Test Report | 测试报告 | 60 | 55 |
| Size Measurement | 计算工作量 | 30 | 20 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 60 | 50 |
| 合计 | 1350 | 1520 |
三、解题思路描述与设计实现说明
3.1 代码实现思路(文字描述)
一句话:把业务规则与界面彻底分开,让"最难出错的部分"可以被测试。
- 三层结构:
core.js(纯逻辑层,不依赖 DOM,浏览器 / Node 双环境加载)→app.js(界面层:路由、渲染、事件)→index.html(骨架)。业务规则只存在于 core.js,界面层不写业务判断,因此单元测试可以直接覆盖全部核心逻辑。 - 路由:采用 hash 路由(
#/list、#/item/:id、#/publish、#/mine),双击 HTML 即可用,无需服务器。 - 不可变更新:状态流转 / 增删改一律返回新数组,原数组不被原地修改,配合"重新渲染"模式,界面状态永远和数据一致,不会出现"改了数据页面没跟上"。
- 数据管线:搜索(多关键词 AND + 高亮)→ 筛选(类型 / 分类 / 区域 / 状态)→ 排序(未归还优先 + 时间序),三层串联为一条
query()管线。 - 持久化:localStorage 存 JSON;不可用时自动降级内存存储,保证任何环境下页面可用。
- 安全:所有用户输入拼接进 HTML 前必须经过
escapeHtml转义,防止发布内容注入脚本。
3.2 关键流程图 / 数据流图
① 系统数据流图
② 核心业务流程(发布 → 浏览/搜索 → 详情 → 联系 → 更新状态)
③ 状态流转图
3.3 重要代码片段与解释
片段 1:搜索 → 筛选 → 排序的查询管线(core.js)
/** 列表页的完整管线:先搜、再筛、最后排序 */
function query(items, options) {
var o = options || {};
var result = searchItems(items, o.keyword); // 多关键词 AND 匹配
result = filterItems(result, { // 类型/分类/区域/状态
type: o.type, category: o.category,
area: o.area, status: o.status
});
return sortItems(result, o.sort); // 未归还优先 + 时间序
}
解释:三个环节各是一个纯函数、各自可单测,query 只负责把它们串起来。任何一环出问题,单元测试能直接定位到函数,而不是从整页渲染里猜。
片段 2:状态流转的不可变更新(core.js)
function changeStatus(items, id, status, now) {
if (status !== STATUS.OPEN && status !== STATUS.DONE) return null;
// 找到目标下标;不存在返回 null
var next = list.slice(); // 复制数组
next[idx] = Object.assign({}, list[idx],
{ status: status, updatedAt: d.toISOString() });
return next; // 只返回新数组,不改原数据
}
解释:"已归还"是本次作业最容易出流程 bug 的地方(谁来改、从哪改、别人在哪看到结果)。我们约定:只有发布者能改(界面上只有"我的信息"才出现改状态按钮),改动走不可变更新 + 全量重渲染,列表卡片、详情、侧栏计数、找回率一次性同步。
片段 3:XSS 防护(core.js)
function escapeHtml(value) {
return str(value).replace(/[&<>"']/g, function (c) {
return { '&': '&', '<': '<', '>': '>',
'"': '"', "'": ''' }[c];
});
}
解释:发布的内容是任意同学的输入,标题里写 <img onerror=...> 就能在别人页面上执行脚本。因此所有用户输入在拼接进 innerHTML 之前都必须过这一道。高亮函数也是先转义再匹配,保证转义实体不会被关键词切开。
片段 4:localStorage 失效兜底(core.js)
function pickStorage(preferred) {
var candidates = [];
if (preferred) candidates.push(preferred);
try { if (typeof localStorage !== 'undefined') candidates.push(localStorage); }
catch (e) { /* 忽略 */ }
candidates.push(memoryStorage()); // 内存兜底
for (var i = 0; i < candidates.length; i++) {
try {
var probe = '__lf_probe__';
candidates[i].setItem(probe, '1');
candidates[i].removeItem(probe); // 真实写入探测
return candidates[i];
} catch (e) { /* 换下一个 */ }
}
return memoryStorage();
}
解释:Chrome 在 file:// 下、隐私模式下 localStorage 可能不可用或抛异常。用一次真实写入探测,失败就自动降级,保证助教在任何环境双击 HTML 都不会白屏。
四、附加特点设计与展示
特点 1:四维筛选 + 实时计数 + 一键重置
- 意义:信息多了之后"浏览"就退化成"翻页",按类型 / 分类 / 区域 / 状态筛选,让"我想找图书馆的耳机"变成三次点击。
- 实现思路:侧栏每个筛选项后显示当前数量(
countBy统计),选中即成为筛选条件,顶部出现可单独移除的"筛选胶囊"。 - 成果展示:【待截图:首页选中"区域:图书馆"后的界面,注意计数与胶囊】
特点 2:一键复制联系方式 + 类型自动识别
- 意义:联系发布者是流程的终点,手抄手机号 / 微信号最容易出错。识别出联系方式类型并一键复制,直接减少"加错人"。
- 实现思路:
classifyContact按 手机号 → QQ → 微信 → 邮箱 的顺序正则识别(顺序本身有测试锁住);复制走navigator.clipboard,file://下不可用时自动退回execCommand兜底。 - 成果展示:【待截图:详情页"联系 TA"弹层 + 复制成功 toast】
特点 3:照片上传自动压缩(最长边 720px)
- 意义:手机照片 3~5MB,两三张就会撑爆 localStorage 的 5MB 配额导致整站存不进去。压缩后单张约几十 KB。
- 实现思路:
FileReader→Image→ Canvas 等比缩放 →toDataURL('image/jpeg', 0.72),压缩发生在存入之前。 - 成果展示:【待截图:发布页已上传 2 张照片的预览】
特点 4:「我的发布」看板:找回率统计 + 清空
- 意义:发布者需要一个"我发出的信息现在什么状态"的入口;找回率让"状态维护"这件事有了正反馈。
- 实现思路:
stats函数统计 我发布的 / 寻找中 / 已归还 / 找回率(四舍五入百分比)。 - 成果展示:【待截图:"我的发布"页统计条】
(另有:搜索关键词高亮、空结果引导、安全提示、多关键词 AND 搜索等,一并算作人性化设计,见功能清单。)
五、目录说明和使用说明
(详见 GitHub 仓库 README,与博客同步摘录)
├── index.html 首页入口(唯一需要打开的页面)
├── css/style.css 全部样式
├── js/core.js 纯逻辑层(校验/检索/状态流转/持久化),可被单测直接引入
├── js/seed.js 首次打开自动载入的演示数据
├── js/app.js 界面交互层(路由/渲染/事件/表单/图片压缩)
└── test/
├── index.html 单元测试报告页(Chrome 直接打开即跑)
├── core.test.js 91 个测试用例
└── mini-test.js 零依赖测试框架(API 仿 Jest)
测试人员如何运行:
- 主程序:Chrome 双击
index.html(无任何环境要求,首次打开自动载入演示数据); - 单元测试:Chrome 打开
test/index.html,自动运行 91 个用例并显示报告; - 数据说明:数据存在浏览器 localStorage(键
lost-found/items/v1),F12 Console 执行localStorage.removeItem('lost-found/items/v1')后刷新可恢复演示数据。
六、单元测试
6.1 测试工具与简易教程
选用工具:自研零依赖框架 mini-test.js(约 200 行),API 仿 Jest。
为什么不用 Jest / Mocha:本作业硬性要求"下载全部文件后用 Chrome 直接打开 html 就出预期结果"。引入 npm 工具链意味着助教必须先装 Node、跑 install,反而抬高验收门槛。自研框架让测试页双击即跑;且 API 与 Jest 对齐,日后迁移只需换运行器,用例一行不改。
简易教程(3 分钟上手):
// 1. 打开 test/index.html 自动运行;要新增用例,编辑 test/core.test.js
describe('被测函数:谁', function () {
it('什么输入,期望什么结果', function () {
expect(C.searchItems(sampleItems(), '校园卡')).toHaveLength(1);
expect(C.classifyContact('13800138000')).toBe('手机号');
expect(C.validateItem({}).valid).toBe(false);
});
});
// 2. 保存后刷新 test/index.html 即重新运行
// 3. 支持的断言:toBe / toEqual / toBeTruthy / toBeFalsy / toBeNull /
// toContain / toHaveLength / toBeGreaterThan / toThrow / toBeTypeOf,均可 .not 取反
6.2 部分测试代码及说明
测试的函数:validateItem(表单校验,白盒覆盖每个字段的每个分支)
describe('validateItem:发布表单校验', function () {
it('物品名称超长时报错(边界值:刚好 24 字通过,25 字失败)', function () {
var exactly24 = new Array(C.MAX_LEN.title + 1).join('伞');
expect(exactly24).toHaveLength(24);
expect(C.validateItem(validForm({ title: exactly24 })).valid).toBe(true);
var over25 = exactly24 + '伞';
expect(C.validateItem(validForm({ title: over25 })).valid).toBe(false);
});
it('分类传了不存在的 key 也应报错(防止前端被篡改)', function () {
expect(C.validateItem(validForm({ category: 'not-a-real-cat' })).errors.category)
.toBe('请选择物品分类');
});
it('多个字段同时出错时,应一次性全部收集', function () {
var r = C.validateItem({ type: 'lost', title: '', category: '', area: '', place: '', contact: '' });
expect(Object.keys(r.errors).length).toBeGreaterThan(4);
});
});
测试的函数:searchItems(防住真实踩过的坑)
it('按地点命中:搜「图书馆」应找到地点在图书馆的那条', function () {
// 这条专门防一个真实踩过的坑:只匹配标题时搜地点搜不到
expect(ids(C.searchItems(sampleItems(), '图书馆'))).toEqual(['A2']);
});
测试的函数:changeStatus(状态流转,不可变更新的核心)
it('寻找中 → 已归还,并刷新更新时间', function () {
var next = C.changeStatus(sampleItems(), 'A1', C.STATUS.DONE, new Date('2026-10-11T12:00:00.000Z'));
expect(C.findById(next, 'A1').status).toBe(C.STATUS.DONE);
expect(C.findById(next, 'A1').updatedAt).toBe('2026-10-11T12:00:00.000Z');
expect(C.findById(next, 'A1').createdAt).toBe('2026-10-10T10:00:00.000Z');
});
6.3 测试数据构造思路
- 设计方法:以白盒为主——先读 core.js 每个函数的每个分支(空值 / 边界值 / 非法值 / 正常值),保证每个分支至少一个用例;再结合等价类划分(如联系方式按"手机号类 / QQ 类 / 微信类 / 邮箱类 / 非法类"各取代表值)和边界值分析(标题 24/25 字、地点 30/31 字)。
- 固定样本数据:
sampleItems()造 3 条属性互异的记录,保证每条用例的期望值可以手算(如排序['A2','A1','A3']),而不是"测了但说不出对不对"。 - 防测试人员刁难:专门构造了一批"不按套路出牌"的输入——
null/undefined/ 空数组 / 全空白字符串(所有函数不得抛异常);- 篡改分类 key、传非法状态值(前端可被 F12 改,逻辑层必须拒绝);
- 注入
<img onerror>、<script>(转义后不得成为标签); - 模拟 localStorage 配额写满 / 每次调用都抛异常(必须自动降级不白屏);
- 损坏的 JSON、非数组 JSON(load 返回 null 而非崩溃)。
- 自评:91 个用例覆盖全部 12 个套件、全部导出函数,配合运行一次仅需数毫秒,可随时重跑,能满足本项目的测试要求;未覆盖 DOM 交互层(app.js),该层以人工走查补充(见下节异常记录)。
七、GitHub 签入记录
功能划分与提交对应(每个功能做完、可运行后至少一次 commit):
| commit | 内容 |
|---|---|
feat: 页面骨架与整体布局 |
顶栏 / 侧栏 / 内容区 / 弹层结构 |
feat: 核心逻辑层 core.js |
校验 / 检索 / 筛选 / 排序 / 状态流转 / 持久化 |
feat: 界面交互层 app.js |
路由 / 渲染 / 事件 / 表单 / 图片压缩 |
feat: 演示数据 seed.js |
首次打开载入演示数据 |
test: 零依赖测试框架与 91 个单元测试 |
mini-test.js + core.test.js + 报告页 |
docs: README 目录说明与使用说明 |
README.md |
八、遇到的代码模块异常或结对困难及解决方法
| # | 问题描述 | 做过哪些尝试 | 是否解决 | 有何收获 |
|---|---|---|---|---|
| 1 | 测试报告页在浏览器打开白屏 | 逐行排查发现 test/index.html 里引用了 test/mini-test.js——页面本身就在 test/ 目录下,相对路径应为 mini-test.js,写错成了 test/test/... |
✅ 已解决 | 相对路径的基准是当前页面所在目录而非项目根目录;跨目录引用要站在被打开文件的视角检查 |
| 2 | 测试用例用了 toBeGreaterThan,框架未实现该断言,1 例失败 |
在 mini-test.js 中补充该 matcher(与 Jest 语义一致),并更新框架文档注释 | ✅ 已解决 | 断言库要先"列清单"再写用例;框架与用例同步演进 |
| 3 | 用户输入直接拼 innerHTML,存在 XSS 风险 | 统一加 escapeHtml 转义层,高亮函数改为先转义再匹配;写专门测试锁住 |
✅ 已解决 | 前端"纯本地"不代表无安全边界,输入都是不可信的 |
| 4 | Chrome file:// / 隐私模式下 localStorage 不可用或抛异常 |
用一次真实写入探测,失败自动降级内存存储 | ✅ 已解决 | 验收环境不可控,容错要兜到最坏情况 |
| 5 | 手机照片太大,2~3 张就写爆 localStorage 5MB 配额 | Canvas 等比压缩到 720px、JPEG 0.72 再存 dataURL | ✅ 已解决 | 存储配额是"隐藏需求",上传功能必须测真实照片 |
| 6 | 发布页切换"寻物/招领"时,已填内容被重渲染清空 | 切换前先快照表单值,重建后回填 | ✅ 已解决 | 重渲染与用户输入是敌人,凡是会重建 DOM 的交互都要考虑输入保留 |
| 7 | 卡片内"改状态"按钮点击时误触发了卡片的"打开详情" | 事件委托里用 closest('[data-act]') 从内到外找最近的动作元素,并 preventDefault |
✅ 已解决 | 委托模式下"按钮嵌在可点击卡片里"必须显式处理冒泡 |
九、过程总结(PSP 分析)
- 具体设计超时 50%(120→180 分钟):低估了状态流转"谁来改、从哪改、别人在哪看到"这一连串问题的设计成本,画了两版状态流转图才定稿;
- 编码超时 29%(480→620 分钟):图片压缩、存储降级、XSS 防护等"隐藏需求"不在最初的功能列表里,是编码中发现的返工点;
- 测试超时 33%(120→160 分钟):为"测试人员会怎么刁难"补了一批异常输入用例(空值、篡改 key、损坏 JSON、配额写满),比预想耗时;
- 改进计划:下次把"环境容错"和"安全"在需求分析阶段就列为显式任务;测试用例与编码同步写而非写完补。

浙公网安备 33010602011771号