2026秋软件工程结对作业(第二次之程序实现)
2026秋软件工程第二次结对作业(程序实现):校园失物招领 Web 应用
| 这个作业属于哪个课程 | H202601软件工程与软件工程实践 |
|---|---|
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次之程序实现) |
| 这个作业的目标 | 结对实现"校园失物招领"的核心模块,完成发布、浏览、搜索、详情与状态更新的完整流程 |
| 学号 | 102401511 / 102401512 |
| GitHub 仓库 | https://github.com/ren3717/102401511-102401512 |
结对成员:何锦宏(102401511)、任奥辉(102401512)
- 我的博客:本页
- 队友何锦宏的博客:【替换为队友博客链接】
- 项目仓库:https://github.com/ren3717/102401511-102401512
一、具体分工
| 成员 | 学号 | 负责内容 |
|---|---|---|
| 何锦宏 | 102401511 | 需求梳理与功能划分、数据层设计(store.js 的字段与校验规则)、搜索与筛选功能实现、博客撰写与文档整理 |
| 任奥辉 | 102401512 | 页面结构与样式实现(6 个页面 + style.css)、发布页与图片压缩上传、详情页与状态更新流程、单元测试用例编写与执行 |
两人共同完成的部分:接口约定(数据层对外提供哪些方法、返回什么格式)、联调走查、单元测试用例的评审。
协作方式:由任奥辉在 GitHub 上创建仓库 102401511-102401512,何锦宏 fork 该仓库后提交
Pull Request 参与开发。开工前先把数据层的接口约定好,之后分头开发,每完成一个功能就提交一次代码,
避免最后一次性合并产生大量冲突。
二、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 60 | 75 |
| Estimate | 估计这个任务需要多少时间 | 60 | 75 |
| Development | 开发 | 900 | 1150 |
| Analysis | 需求分析(包括学习新技术) | 90 | 120 |
| Design Spec | 生成设计文档 | 60 | 80 |
| Design Review | 设计复审 | 40 | 35 |
| Coding Standard | 代码规范(为目前的开发制定合适的规范) | 30 | 25 |
| Design | 具体设计 | 90 | 110 |
| Coding | 具体编码 | 420 | 520 |
| Code Review | 代码复审 | 60 | 90 |
| Test | 测试(自我测试,修改代码,提交修改) | 110 | 170 |
| Reporting | 报告 | 240 | 285 |
| Test Report | 测试报告 | 60 | 70 |
| Size Measurement | 计算工作量 | 30 | 25 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 150 | 190 |
| 合计 | 1200 | 1510 |
耗时分析(哪里估错了、为什么):
- 测试环节超时最多(预估 110 分钟,实际 170 分钟)。原以为写完用例跑通就行,
实际花在"设计用例"上的时间远超预期——尤其是要覆盖边界值和异常输入,
光是想清楚"物品名称长度"这一个字段该测哪几个值就讨论了很久。
不过这部分多花的时间是值得的,它真的找出了两个 bug(详见第六节)。 - 具体编码超时 100 分钟。主要卡在两个地方:一是图片压缩上传(要用 canvas 处理、
还要考虑存储容量),二是同一毫秒内发布多条信息导致排序不稳定这个隐蔽问题。 - 需求分析超时 30 分钟。第一次作业的原型只考虑了"顺利路径",
这次要把"发布信息不完整""搜索无结果""信息已被标记为已结束"这些分支也梳理出来,
讨论的时间比预想的多。 - 唯一低于预估的是"Coding Standard"和"Size Measurement",
因为我们在开工前就约定好了提交规范(Conventional Commits)和代码风格,没有额外花时间。
三、解题思路描述与设计实现说明
3.1 技术选型:为什么选择 Web 而不是 App
作业允许在 Web 和 App 中二选一,我们选择 Web(PC 端网页),理由有三点:
- 验收成本最低。作业要求"下载所有文件后用谷歌浏览器运行 html 文件就能展现预期结果"。
纯静态网页不需要安装 Android SDK、不需要连模拟器,助教下载解压后双击即可,
不会因为环境问题导致演示失败。 - 与第一次作业的原型一致。原型设计的就是小程序式的移动端界面,
Web 可以复用同一套交互逻辑和配色,不需要重新设计。 - 便于分工。HTML/CSS/JS 的文件边界清晰,两人在同一个仓库里各自改动不同文件,
冲突少。
最终技术栈:纯 HTML + CSS + 原生 JavaScript,零第三方框架、零构建工具。
数据保存在浏览器的 localStorage 中,因此不需要服务器和数据库。
有人可能会问:为什么不用 Vue 或 React?我们的判断是,本项目的页面数量和状态复杂度都很低,
引入框架反而增加了"助教必须先 npm install 才能运行"的门槛,与作业的验收方式冲突。
我们宁可把精力放在把核心流程做扎实上。
关于"没有账号系统"的设计说明
作业明确不要求实名认证与后台管理,所以本项目没有登录注册功能。这带来一个必须回答的设计问题:
「我的发布」里的"我"到底是谁?
我们的做法是用 isMine 字段区分:在本机发布的信息标记为 isMine: true,
在"我的发布"页面里可以管理;演示数据标记为 isMine: false,模拟"其他同学发布的信息"。
因为所有数据本来就只存在于本机浏览器中,所以"本机发布的就是我发布的"这个判断是成立的。
数据的边界也因此很明确:
| 场景 | 结果 |
|---|---|
| 刷新页面、关掉浏览器再打开 | 数据还在(localStorage 记住了) |
| 换一台电脑,或换一个浏览器打开 | 看到初始的 10 条演示数据,看不到我在本机发布的内容 |
| 助教下载后第一次打开 | 自动载入演示数据,无需注册登录即可完整体验全部流程 |
这个方案在真实产品里显然不够用(真正的校园失物招领平台需要服务器来共享数据),
但在本次作业的范围内它是合适的:既满足了"发布者才能修改自己信息状态"的权限要求,
又让测试人员零成本上手。
3.2 整体架构:把逻辑从界面里剥出来
这是本次设计中最关键的一个决定。我们没有把所有代码写在一个文件里,而是分成了三层:

| 层 | 文件 | 职责 | 是否依赖页面 DOM |
|---|---|---|---|
| 页面层 | *.html |
静态结构 | 是 |
| 页面逻辑层 | page-*.js |
收集输入、调用数据层、渲染界面 | 是 |
| 核心逻辑层 | store.js、utils.js |
校验、增删改查、搜索、格式化 | 否 |
| 存储 | localStorage |
数据持久化 | 否 |
为什么这样分层? 因为作业要求做单元测试。如果所有逻辑都写在页面事件里,
测试就必须启动浏览器、模拟点击、读取 DOM 才能验证,成本和稳定性都很差。
把"校验规则""搜索匹配""状态更新"这些逻辑放到不依赖 DOM 的 store.js 中之后,
单元测试就可以直接调用函数、断言返回值,测试代码只有几行:
it('标记后,搜索"进行中"就查不到这条了(其他用户看到的状态同步变化)', function () {
var store = Store.create(createFakeStorage());
var item = store.add(validInput()).item;
expect(store.query({ status: 'open' })).toHaveLength(1);
store.updateStatus(item.id, Store.STATUS.CLOSED);
expect(store.query({ status: 'open' })).toHaveLength(0); // 状态同步了
expect(store.query({ status: 'closed' })).toHaveLength(1);
});
3.3 核心流程与异常分支
老师在第一次作业的反馈中提到:"不能只按一条顺利成功的路线设计产品"。
因此这次我们把每个流程的失败分支也画进了流程图,并且每一条都有对应的界面反馈。

流程一(发布信息):选择类型 → 填写信息 → 校验 → 通过则写入并跳转成功页。
校验不通过时,错误信息逐项显示在对应字段下方、输入框标红,并自动滚动到第一个出错的字段。
流程二(浏览/搜索):进入首页 → 浏览或输入关键词 → 筛选 → 有结果显示列表(关键词高亮),
无结果显示空状态提示与"重置筛选"按钮。
流程三(联系与更新状态):查看详情 → 判断"是不是我发布的"(isMine 字段)——
不是我的话显示"一键复制联系方式";是我发布的话显示"标记为已找到 / 已归还"。
状态更新后,首页、搜索页、详情页读的是同一份数据,状态自动保持一致。
3.4 关键代码片段
片段一:状态更新的完整闭环
这是作业要求的核心功能,也是我们在第一次作业中被指出"流程没交代清楚"的地方——
谁来改?从哪个入口改?改完其他人在哪看到结果?
/**
* 更新状态:发布者把"进行中"改成"已找到 / 已归还"。
* @param {String} id 信息 id
* @param {String} status STATUS.CLOSED 或 STATUS.OPEN(允许改回)
*/
function updateStatus(id, status) {
if (status !== STATUS.OPEN && status !== STATUS.CLOSED) {
return { ok: false, error: 'BAD_STATUS', message: '状态值不合法' };
}
var items = list();
var found = null;
for (var i = 0; i < items.length; i++) {
if (items[i].id === id) { found = items[i]; break; }
}
if (!found) return { ok: false, error: 'NOT_FOUND', message: '信息不存在或已被删除' };
found.status = status;
found.updatedAt = Date.now();
found.closedAt = status === STATUS.CLOSED ? Date.now() : null;
var w = persist(items);
if (!w.ok) return w;
return { ok: true, item: found };
}
解释:所有对外方法都返回统一格式 {ok:true, ...} 或 {ok:false, error, message},
调用方只需要判断 ok,不用写 try/catch。这样"id 不存在""状态值非法""存储写满"
三种失败情况都有明确的返回值,页面可以直接拿 message 显示提示。
closedAt 记录了结束时间,界面上就能显示"结束于 3 小时前"。
片段二:XSS 防护(用户输入不能直接塞进页面)
/**
* HTML 转义。作用:防止用户输入的 <script> 等被当成标签执行(XSS)。
* 所有"用户填写的内容"渲染进页面前都必须先过这个函数。
*/
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
解释:物品名称、描述、联系方式都是用户自由输入的,如果直接拼进 innerHTML,
有人在物品名称里写 <img src=x onerror=alert(1)> 就能执行脚本。
所有渲染路径都先调用 escapeHtml,连"关键词高亮"也做了处理——先转义再高亮,
所以在搜索框里输入 <b> 也只会被当成普通文字显示(这一点有专门的测试用例覆盖)。
片段三:图片压缩上传
/**
* 图片压缩:把用户选的图片用 canvas 缩小到最长边 maxSize,
* 再以 JPEG 格式导出 dataURL。这样一张几 MB 的照片会变成几十 KB,
* 才能存进容量有限的 localStorage。
*/
function compressImage(file, maxSize, quality) {
var limit = maxSize || 900;
var q = quality || 0.72;
return new Promise(function (resolve, reject) {
// ... 省略文件读取部分 ...
img.onload = function () {
var scale = Math.min(1, limit / Math.max(img.width, img.height));
var canvas = document.createElement('canvas');
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/jpeg', q)); // 压缩后通常只有几十 KB
};
});
}
解释:localStorage 一般只有 5MB 左右,一张手机拍的照片就可能好几 MB,
直接存进去会立刻写满。所以先在浏览器端用 canvas 缩小到最长边 900px、JPEG 质量 0.72
再保存。即使压缩后仍超过 800KB 的图片也会被拒绝并提示用户,而不是让整个存储失败。
四、附加特点设计与展示
4.1 设计思路与意义
我们在"方便用户"这个方向上做了几个小设计,都是围绕一个判断:
失物招领这个场景里,用户最需要的是"快"和"不白跑一趟"。
| 附加特点 | 对应的用户痛点 | 意义 |
|---|---|---|
| 多维筛选(分类 + 地点 + 状态组合) | 群聊里信息一多就翻不到 | 把"翻消息"变成"点两下",原型阶段的筛选只做了类型,这次把分类和地点也补上了 |
| 一键复制联系方式 | 手机号要长按选中、容易漏字符 | 一次点击完成,复制成功有明确反馈 |
| 搜索历史 | 前几天搜过的关键词记不住 | 保存在本机,可一键清空,兼顾方便与隐私 |
| 关键词高亮 | 一屏十几条信息,看不出哪条命中 | 命中的文字用黄色底纹标出,扫一眼就能定位 |
| "已结束"提示条 | 不知道这条信息还有没有人管,白打一次电话 | 明确告诉用户"已被发布者标记为已归还,不用再联系",这是减少无效联系的关键 |
| 图片压缩上传 | 照片太大传不上去 | 浏览器端自动压缩,用户在无感知的情况下完成上传 |
| 空状态与错误提示 | 搜不到时不知道是没搜到还是程序坏了 | 每种情况都有明确文案和下一步操作按钮 |
其中我们最看重的是"已结束"提示条。第一次作业的反馈里提到"联系之后如何结束这条信息没有交代清楚",
所以这次我们不只是把状态改掉,还让其他用户在详情页看到一段明确的说明:
ℹ️ 这条信息已被发布者标记为「已归还」,其他同学在首页、搜索页看到的状态同样是「已归还」,请不要再重复联系,避免打扰。
4.2 实现思路与代码
以"多维筛选"为例,实现上没有为每种组合写分支,而是把所有条件做成一个可选参数对象,
层层过滤。任何条件为空就跳过,这样"全部""只看寻物""只找图书馆的卡证"都是同一个函数:
/**
* 查询列表。所有条件都可选,组合起来就是"筛选"。
* @param {Object} opt {type, category, place, status, keyword, mine, sort}
*/
function query(opt) {
var o = opt || {};
var items = list();
if (o.type) items = items.filter(function (it) { return it.type === o.type; });
if (o.category) items = items.filter(function (it) { return it.category === o.category; });
if (o.place) items = items.filter(function (it) { return it.place === o.place; });
if (o.status) items = items.filter(function (it) { return it.status === o.status; });
if (o.mine === true) items = items.filter(function (it) { return it.isMine === true; });
if (o.keyword) items = searchIn(items, o.keyword);
var sort = o.sort || 'new';
items.sort(function (a, b) {
if (sort === 'old') return a.createdAt - b.createdAt;
if (sort === 'time') return (b.time || 0) - (a.time || 0);
return b.createdAt - a.createdAt;
});
return items;
}
"一键复制联系方式"则要考虑浏览器兼容——新版浏览器用 Clipboard API,
但在某些环境下会失败,所以准备了回退方案:
/** 复制文本到剪贴板:优先用 Clipboard API,失败时回退到 execCommand */
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text)
.then(function () { return true; })
.catch(function () { return fallbackCopy(text); }); // 失败时回退
}
return Promise.resolve(fallbackCopy(text));
}
4.3 成果展示
| 首页(多维筛选 + 数据概览) | 搜索页(关键词高亮 + 搜索历史) |
|---|---|
![]() |
![]() |
| 发布页(类型切换 + 表单校验) | 我的发布(状态管理) |
|---|---|
![]() |
![]() |
| 详情页(进行中:可复制联系方式) | 详情页(已结束:状态提示条) |
|---|---|
![]() |
![]() |
五、目录说明与使用说明
5.1 目录结构
102401511-102401512/
├── index.html 首页:浏览信息、分类筛选、数据概览
├── search.html 搜索页:关键词搜索、组合筛选、搜索历史
├── publish.html 发布页:表单填写、字段校验、图片上传
├── success.html 发布成功页
├── detail.html 详情页:物品完整信息、联系方式、状态更新入口
├── mine.html 我的发布:管理自己发布的信息、标记状态、删除
│
├── css/
│ └── style.css 全部样式(变量 → 布局 → 组件 → 页面 → 响应式)
│
├── js/
│ ├── utils.js 【核心】工具函数:时间格式化、HTML 转义、关键词高亮、
│ │ 联系方式校验、URL 参数解析、图片压缩
│ ├── store.js 【核心】数据层:增删改查、字段校验、搜索匹配、状态更新
│ ├── seed.js 演示数据(10 条,首次打开时载入)
│ ├── ui.js 公共界面组件:导航栏、页脚、卡片渲染、提示、确认弹窗
│ ├── page-index.js 首页逻辑
│ ├── page-search.js 搜索页逻辑
│ ├── page-publish.js 发布页逻辑
│ ├── page-detail.js 详情页逻辑
│ └── page-mine.js 我的发布页逻辑
│
├── tests/ 单元测试(不属于运行时依赖,可单独删除)
│ ├── test.html 测试运行页面(双击即可运行)
│ ├── mini-test.js 自研的极简测试框架
│ ├── utils.test.js 工具函数测试用例
│ └── store.test.js 数据层测试用例
│
├── images/ 站点图标
└── README.md 目录说明与使用说明(与本节内容一致)
组织思路:按"职责"而不是按"文件类型"划分。js/ 目录下,
utils.js 和 store.js 是不依赖页面的核心逻辑,page-*.js 是页面专属逻辑,
ui.js 是跨页面复用的界面组件。这样看到一个文件名就能判断它属于哪一层,
测试时也只需要加载前两个文件。
5.2 测试人员如何运行
不需要安装任何环境、不需要联网、不需要启动服务器。
- 从 GitHub 下载本项目全部文件(保持目录结构不变)
- 用谷歌浏览器(Chrome)打开
index.html
首次打开会看到 10 条演示数据。建议按下面的顺序体验完整流程:
| 步骤 | 操作 | 验证的功能 |
|---|---|---|
| 1 | 首页切换「全部 / 寻物 / 招领」,使用分类、地点、状态筛选 | 浏览与筛选 |
| 2 | 顶部搜索框输入「校园卡」回车 | 关键词搜索 |
| 3 | 点击任意卡片「查看详情」 | 查看详情 |
| 4 | 详情页点「📋 复制联系方式」 | 联系发布者 |
| 5 | 右上角「+ 发布信息」,填写后提交 | 发布信息 → 发布成功 |
| 6 | 进入「我的发布」,点「✓ 标记为已找到 / 已归还」 | 更新状态 |
| 7 | 回到首页,确认状态已同步 | 状态同步 |
运行单元测试:用 Chrome 打开 tests/test.html,页面会自动运行并显示结果(正常应全绿)。
补充说明:如果想重新演示,可以在「我的发布」页面点击「恢复演示数据」,
数据会回到初始状态。所有数据都保存在本机浏览器中,清空浏览器数据即恢复出厂状态。
六、单元测试
6.1 测试工具的选择与学习过程
我们选用的工具是自己写的一个极简测试框架 mini-test.js(约 120 行)。
这个决定是在踩坑之后做出的。一开始我们打算用 Jest,但很快发现两个问题:
- Jest 需要 Node.js 和 npm,项目就变成"助教必须先装环境才能跑测试",
与作业"下载后双击就能运行"的验收方式冲突; - 我们真正的测试目标是两个不依赖 DOM 的模块,逻辑很简单,
引入一个几百 MB 的测试框架性价比很低。
于是我们退一步想:单元测试框架到底为我们做什么? 读了几篇讲单元测试的文章后,
我们把它拆成四件事——组织用例(describe / it)、断言(expect(...).toBe(...))、
捕获异常(让一个用例失败不影响其他用例)、汇总报告(通过几个、失败几个)。
想清楚之后,自己实现一遍反而比学 Jest 的配置更透彻。
给同样想入门的同学:写一个能用的测试框架其实只需要四步。
第一步:用数组收集用例。 describe 建一个分组,it 往当前分组里塞一个用例。
var suites = []; // 收集到的测试分组
var current = null; // 当前正在收集的分组
function describe(name, fn) {
current = { name: name, cases: [] };
suites.push(current);
fn();
current = null;
}
function it(name, fn) {
current.cases.push({ name: name, fn: fn });
}
第二步:实现断言。 核心就是"不符合预期就抛异常",异常里带上实际值和期望值,
失败时才能看出问题出在哪。
function expect(actual) {
return {
toBe: function (expected) {
if (actual !== expected) fail('值不相等', actual, expected);
},
toHaveLength: function (n) {
if (!actual || actual.length !== n) fail('长度不符合预期', actual && actual.length, n);
},
toBeFalsy: function () {
if (actual) fail('期望为假值', actual);
}
// ... 其他断言
};
}
function fail(message, actual, expected) {
var err = new Error(message + '(实际得到 ' + stringify(actual) + ',期望 ' + stringify(expected) + ')');
err.isAssertion = true; // 标记为"断言失败"而非"代码异常"
throw err;
}
第三步:执行用例并分类统计。 用 try/catch 把每个用例包起来,
这样一个用例失败不会影响后面的用例继续执行,我们还能区分"断言失败"
和"代码本身报错"两种失败。
function run() {
var results = { total: 0, passed: 0, failed: 0, error: 0, suites: [] };
suites.forEach(function (suite) {
var sr = { name: suite.name, cases: [] };
suite.cases.forEach(function (c) {
var r = { name: c.name, ok: true, message: '' };
try {
c.fn();
} catch (e) {
r.ok = false;
r.message = e.message;
r.assertion = !!e.isAssertion; // 断言失败还是运行异常
}
results.total++;
if (r.ok) results.passed++;
else if (r.assertion) results.failed++;
else results.error++;
sr.cases.push(r);
});
results.suites.push(sr);
});
return results;
}
第四步:把结果渲染成报告。 用红色标出失败用例并把错误信息显示出来。
我们的报告页面是用浏览器直接打开的,所以助教点开就能看到 117 个绿色的对勾。
就这样,四步下来一个能用的测试框架就有了。写法上我们故意和 Jest 保持一致
(describe / it / expect 的用法完全一样),这样以后要用 Jest 时几乎不用改测试代码。
6.2 测试代码展示
我们的测试分成两个文件,分别对应两个核心模块:
utils.test.js —— 测试工具函数,覆盖 genId、pad2、formatTime、timeAgo、
escapeHtml、truncate、normalize、highlight、isValidContact、isValidTitle、
parseQuery、buildQuery、parseDateTimeLocal、debounce 共 14 个函数。
store.test.js —— 测试数据层,覆盖 validate(校验)、add、getById、query(筛选)、
search(搜索)、updateStatus(状态更新)、remove、stats、replaceAll 等接口。
举几个有代表性的用例:
/* 1. 边界值分析:物品名称规定 2~30 字,就测 1 字、2 字、30 字、31 字 */
it('非法:物品名称只有 1 个字(下边界)', function () {
var r = Store.validate(validInput({ title: '伞' }));
expect(r.errors.title).toContain('2~30');
});
/* 2. 时间边界:刚好等于当前时间应当允许 */
it('边界:时间刚好是当前时间(允许)', function () {
var r = Store.validate(validInput({ time: Date.now() }));
expect(r.ok).toBe(true);
});
/* 3. 安全:往搜索框里输入 HTML 标签,应当被当成普通文字 */
it('安全:原文中的 HTML 被转义后再高亮', function () {
var out = U.highlight('<script>x</script>', 'x');
expect(out.indexOf('<script>')).toBe(-1);
expect(out).toContain('<mark class="hl">x</mark>');
});
/* 4. 状态转换:进行中 → 已结束 → 回到进行中,closedAt 要清空 */
it('状态可以从已结束改回进行中(closedAt 被清空)', function () {
var store = Store.create(createFakeStorage());
var item = store.add(validInput()).item;
store.updateStatus(item.id, Store.STATUS.CLOSED);
var r = store.updateStatus(item.id, Store.STATUS.OPEN);
expect(r.item.status).toBe('open');
expect(r.item.closedAt).toBeNull();
});
/* 5. 容错:本地数据损坏时不能崩溃 */
it('存储里是损坏的 JSON 时返回空列表,不会让页面崩溃', function () {
var s = createFakeStorage();
s._write(Store.KEY, '{这不是合法的 JSON');
var store = Store.create(s);
expect(store.list()).toHaveLength(0);
});
6.3 测试数据是怎么构造的
这部分是我们花时间最多、也最有收获的地方。总体的思路是:先分类,再取边界,最后想极端情况。
(1)等价类划分 —— 先想"输入能分成几类"。
以校验函数为例,一个字段的输入只有三种命运:合法、格式错误、缺失。
每一类取一个有代表性的值就够了,不用把 100 个合法名称都测一遍。
(2)边界值分析 —— 每条规则的临界值都要测。
校验规则写的是"2~30 字",那 1、2、30、31 这四个值就必须各测一次。
我们规定描述上限 200 字,就测 200 字(应通过)和 201 字(应失败)。
时间字段则是"不能晚于当前时间",那就取 Date.now()(刚好现在,允许)
和 Date.now() + 24小时(未来,拒绝)。
(3)特殊值 —— 专门测"不正常的输入"。
null、undefined、空字符串、只有空格的字符串、超长文本、含 <script> 的字符串、
损坏的 JSON、存储写满……这些都是我们主动列出来逐个测的。
(4)测试替身(Test Double)—— 让测试之间互不干扰。
数据层要用 localStorage,但测试不能操作真实的浏览器存储,否则用例之间会互相污染
(上一条用例存的数据会影响下一条),而且会弄脏用户自己填的数据。所以我们写了一个假的存储对象:
/** 内存版存储:模拟 localStorage 的接口 */
function createFakeStorage() {
var data = {};
return {
getItem: function (k) {
return Object.prototype.hasOwnProperty.call(data, k) ? data[k] : null;
},
setItem: function (k, v) { data[k] = String(v); },
_write: function (k, v) { data[k] = v; } // 测试用:直接塞入损坏数据
};
}
Store.create(storage) 接受任何实现了 getItem / setItem 的对象,
所以测试时传入假存储即可。这个设计一开始就是为了测试而做的
(生产环境传 window.localStorage,测试环境传假对象)。
(5)如果测试人员要"刁难"我们,我们准备了这些。
作业提到要考虑"将来测试人员的刁难"。我们设想的刁难大概有这几类,都提前准备了对策:
| 可能的刁难 | 我们的处理 | 对应的测试用例 |
|---|---|---|
输入 <script>alert(1)</script> 当物品名 |
全链路转义,高亮函数也先转义再处理 | escapeHtml / highlight 的多个用例 |
| 提交一个 5000 字的描述 | 输入框设 maxlength,数据层再校验一次 |
描述超长用例 |
| 联系方式填中文或表情 | 正则限制字符范围并给出明确提示 | isValidContact 的多个用例 |
| 把时间填成明天 | 明确拒绝,提示"时间不能晚于当前时间" | 时间边界用例 |
| 手改浏览器存储里的数据、把 JSON 改坏 | list() 用 try/catch 包住解析,坏数据返回空数组 |
损坏 JSON 用例 |
| 用无痕模式(禁用本地存储)打开 | 退化为内存模式,功能仍可用,只是刷新后丢失 | 不传 storage 的用例 |
| 连续标记同一条信息的完成/取消 | 状态可双向切换,closedAt 正确清空 |
状态转换用例 |
| 同一毫秒内连续发两条 | 时间戳强制递增,排序结果稳定 | 排序用例(这个真的发现了 bug) |
6.4 测试发现的真实缺陷
单元测试确实找出了两个我们自己没想到的 bug,这也是这次做测试最大的收获。
缺陷一:同一毫秒内生成 1000 个 ID 会重复。
genId() 原本是"时间戳 + 4 位随机数"。我们写了一个用例连续生成 1000 个 ID 检查唯一性,
结果发现平均每 1000 个里会有 1 个重复。原因是同一毫秒内时间戳相同,
4 位随机数在 36 进制下只有 168 万种组合,1000 次抽样撞车的概率并不低(生日悖论)。
修复方式是加一个自增序号,并把随机部分加长到 6 位:
var idSeq = 0;
function genId(prefix) {
idSeq = (idSeq + 1) % 46656; // 保证同毫秒内不重复
var t = Date.now().toString(36);
var s = idSeq.toString(36);
var r = Math.random().toString(36).slice(2, 8); // 6 位随机,跨会话不重复
return (prefix || 'lf') + '_' + t + s + '_' + r;
}
缺陷二:同一毫秒内发布的多条信息,排序结果不稳定。
排序用例断言"最早发布的那条排第一",结果随机失败。排查后发现:
测试里连续调用 add() 三次,三条的 createdAt 是同一个毫秒值,
排序时键相等,JavaScript 的 sort 在键相等时保持原有顺序,
于是 sort:'old' 和 sort:'new' 得到了一样的结果。
修复方式是在新增时保证时间戳严格递增:
// 同一毫秒内连续发布多条时,Date.now() 会得到相同的值,
// 导致"按发布时间排序"的结果不确定。这里手动保证时间戳严格递增。
var now = Date.now();
var maxTs = 0;
for (var i = 0; i < items.length; i++) {
if (items[i].createdAt > maxTs) maxTs = items[i].createdAt;
}
if (now <= maxTs) now = maxTs + 1;
这两个 bug 都有一个共同点:人手动点界面几乎不可能复现
(谁也不会在一毫秒内点三次发布按钮),但它们确实是逻辑上的缺陷。
这让我们真正理解了"自动化测试"的价值——它把人工难以复现的情况变成了每次都能跑一遍的检查。
6.5 测试结果

117 / 117 个用例通过 (25 个测试分组,耗时约 4ms)
对测试完备性的自我评价:目前覆盖了全部核心逻辑(校验、增删改查、搜索、状态更新、容错),
但我们清楚地知道还有没覆盖到的部分:
- 页面交互层没有自动化测试。比如"点击发布按钮后是否正确跳转"这类行为,
我们目前靠人工走查(参照第五节的体验顺序逐条验证),没有做端到端测试。
如果要补,需要引入 Puppeteer 之类的工具,本次时间不够。 - 多图上传、图片压缩的实际效果依赖浏览器 API(Canvas),
在我们的测试框架里没法验证,只能人工测试。 - 并发场景没有测试。localStorage 本身没有并发问题,但如果将来换成后端接口,
两个用户同时标记同一条信息就需要重新考虑。
我们认为这三点是"已知的未知",写在这里是为了让测试人员知道边界在哪里,
也能让下一版的改进方向更清楚。
七、GitHub 代码签入记录

本项目共 11 次提交,遵循约定式提交规范(Conventional Commits),
按功能模块划分,每完成一个功能提交一次:
| 提交前缀 | 含义 | 次数 |
|---|---|---|
chore |
构建、工具、配置改动 | 1 |
feat |
新功能 | 6 |
style |
样式与界面调整 | 1 |
test |
测试相关 | 1 |
docs |
文档 | 1 |
八、遇到的困难及解决方法
困难一:透明点击热区在预览时不能响应
问题描述:做原型的时候,我们把墨刀里的透明矩形盖在卡片上当作点击热区,
但预览时点它没有任何反应,跳转不生效。
做过的尝试:
- 检查交互设置,确认"点击 → 跳转到页面"确实绑上了;
- 怀疑是图层顺序问题,把热区调到最上层——还是不行;
- 换成不透明的矩形测试,发现能正常跳转。这就定位到了问题:
透明度的设置方式不对,我们用的是一个"看起来透明"的方式,
而这个方式下组件实际不接收点击事件。
是否解决:解决了。改用"填充色透明度设为 0"的方式,而不是把整个组件的可见性关掉,
点击就能正常响应了。这也直接影响了这次程序实现——我们在 Web 版里做详情页跳转时,
特意避免了"透明区域"这种方案,直接用卡片本身绑定事件,从根上绕开了这个问题。
有何收获:界面元素"看起来在"和"能被操作"是两件不同的事。
这次经历让我们在做 Web 版时,更倾向于用语义正确的方式实现交互,
而不是靠视觉上的取巧。
困难二:图片上传后存储空间被撑爆
问题描述:发布页做完图片上传后,测试时选了一张某次活动的照片,
结果发布失败,控制台报 QuotaExceededError。而且失败之后,
之前的数据也变得不完整了。
做过的尝试:
- 先确认是不是文件太大——用手机拍的照片确实有 3~5MB;
- 想过限制上传张数,但一张照片就能超限,限制张数解决不了问题;
- 又想过干脆不做图片功能,但作业的原型里有照片,删掉功能是退步;
- 最后查到浏览器可以在上传前用 Canvas 压缩图片,就试了一下。
是否解决:解决了,而且做得比"能用"更完善了一些:
- 上传时用 canvas 把图片最长边压到 900px、JPEG 质量 0.72,
实测一张 3MB 的照片压完只有 60~80KB; - 压缩后仍超过 800KB 的图片直接拒绝并提示,不让它把存储写满;
- 写入捕获
QuotaExceededError,返回明确提示"浏览器存储空间已满,请删除部分带图片的信息后重试",
而不是默默失败; - 专门写了两个测试用例覆盖这些异常路径。
有何收获:第一,遇到问题先定位而不是先绕开——"到底哪一步撑爆了存储"这个问题问清楚了,
方案自然就出来了。第二,失败路径要当成功能来设计,捕获异常并给出人能看懂的提示,
和让功能正常工作同样重要。
困难三:两人并行开发时的接口约定
问题描述:确定分工后我们分头开发,任奥辉做页面、何锦宏做数据层。
结果第一次合并时代码跑不起来——页面里调用的是 store.getList(),
而数据层提供的方法叫 store.query(),参数和返回值也对不上。
做过的尝试:
- 一开始想"边写边改",谁发现问题谁改,但改了几次之后两边都乱了;
- 后来决定先停下来,把数据层的接口写在文档里:方法名、参数、返回值的格式都写清楚,
包括失败时返回什么。例如统一约定"所有方法返回{ok:true/false, ...}"; - 页面先按约定调用,数据层按约定实现,写完一个方法就提交一次。
是否解决:解决了。合并后的第一次联调就基本跑通了。
有何收获:接口先定、再并行开发,比"先写起来再说"效率高得多。
这次的经验也让我们理解了为什么要有"设计复审"这个环节——
它不是形式,而是避免返工的真正手段。
九、评价队友
何锦宏(102401511)
值得学习的地方:
- 需求梳理能力强。他在动手写代码前,先把第一次作业的原型逐条对着作业要求过了一遍,
明确指出"状态由谁改、在哪改、改完在哪看"这三问没有回答清楚,
还画了一张状态流转图。这个问题定义清楚了,我写代码的时候方向很明确。 - 对异常路径有敏感度。我写数据层时只想着"正常工作时要返回什么",
他提醒我"id 不存在怎么办、存储满了怎么办、数据被改坏了怎么办",
后来这些异常处理成了我们单元测试用例的主要来源。 - 文档意识好。他坚持把接口约定写成文档再开工,虽然当时觉得有点费时间,
但正是这一步让我们后来的合并很顺利。
需要改进的地方:
- 写代码时注释偏少,有些函数名字比较简略,我读的时候要花时间理解意图。
后期我们约定"复杂逻辑必须写清楚为什么这么写",情况有所改善。 - 有时会同时展开好几个功能,导致某几个提交里的改动混在一起,
不好回溯。后来我们约定"一次提交只做一件事"。
任奥辉(102401512)
这次主要负责页面与测试。做得比较好的地方是把页面结构写清楚、
按时完成了单元测试;需要改进的地方是前期对边界情况考虑不足,
有两个 bug 是靠测试才发现的,说明写代码时"想到正常情况就动手了",
以后应当在动手前多问自己几句"如果输入不合法会怎么样"。
十、结语
对比第一次作业,这次最大的不同是:原型里的每个方块,都要真的跑起来才算数。
第一次画原型时,我们默认用户会规规矩矩地填完整所有字段、搜索一定能搜到东西、
信息发出去就一定有人回应。真正开始写代码才发现,"不顺利"才是常态——
表单会有漏填,搜索会没结果,物品可能一直没人认领,照片可能太大传不上去。
把这些"不顺利"一个个处理掉,程序才算真的能用。
另一个体会来自单元测试。写之前我们觉得"测试不就是跑一遍确认能用吗",
真正动手才发现,测试的价值在于它逼你把每个函数的边界想清楚。
那两个被测试揪出来的 bug,就是我们"想当然"的代价。
以后再写程序,我们会把"这个函数什么情况下会出错"当成设计的一部分,
而不是等出了问题再回头补。






浙公网安备 33010602011771号