2026秋软件工程第四次个人作业(第二次结对作业)
2026秋软件工程第二次结对作业(程序实现):校园失物招领 Web 应用
| 这个作业属于哪个课程 | H202601软件工程与软件工程实践 |
|---|---|
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次之程序实现) |
| 这个作业的目标 | 结对实现“校园失物招领”的核心模块,完成发布、浏览、搜索、详情与状态更新的完整流程 |
| 学号 | 102401511 / 102401512 |
| GitHub 仓库 | 102401511-102401512 |
结对成员:何锦宏(102401511)、任奥辉(102401512)
- 我的博客:本页
- 队友任奥辉的博客:https://www.cnblogs.com/ren3717/p/23210323
- 项目仓库: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、JavaScript 的文件边界清晰,两人在同一个仓库中分别修改不同文件,产生冲突的可能性较低。
最终技术栈:纯 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));
};
});
}
解释:localStorage 一般只有 5 MB 左右的容量,而一张手机拍摄的照片就可能有几 MB,直接保存很容易占满存储空间。
因此,我们先在浏览器端使用 Canvas 将图片最长边压缩至 900 px,并以 0.72 的 JPEG 质量导出。即使压缩后,超过 800 KB 的图片仍会被拒绝,并向用户显示提示,避免整个存储过程失败。
四、附加特点设计与展示
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 下载本项目的全部文件,并保持原有目录结构不变。
- 使用谷歌浏览器打开
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 的模块,逻辑比较简单,引入体积较大的测试框架性价比不高。
于是,我们进一步思考:单元测试框架到底需要为我们完成什么?
阅读相关资料后,我们把它分解为四项工作:组织用例(describe / it)、进行断言(expect(...).toBe(...))、捕获异常(保证一个用例失败后,其他用例仍能继续执行)以及汇总报告(统计通过和失败的用例数量)。
理清这些需求后,自己实现一个简单框架,反而让我们更加深入地理解了单元测试的运行方式。
对于同样想入门的同学,编写一个基本可用的测试框架只需要四步。
第一步:使用数组收集测试用例。 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)等价类划分:先考虑“输入可以分成哪几类”。
以校验函数为例,一个字段的输入通常可以分为三类:合法、格式错误和缺失。每一类选择一个有代表性的值即可,不需要把大量含义相同的合法名称都测试一遍。
(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 位随机数”的方式生成 ID。我们编写了一个连续生成 1000 个 ID 并检查唯一性的测试用例,结果发现偶尔会出现重复。
原因是同一毫秒内生成的时间戳相同,4 位随机数在 36 进制下只有约 168 万种组合,大量抽样时仍然存在碰撞概率。
修复方式是增加一个自增序号,并将随机部分增加至 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);
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 个测试分组,耗时约 4 ms)
对测试完备性的自我评价:目前的测试已经覆盖全部核心逻辑,包括校验、增删改查、搜索、状态更新和异常处理,但仍有以下内容没有覆盖:
- 页面交互层没有实现自动化测试。 例如,“点击发布按钮后是否正确跳转”目前通过人工走查验证,没有进行端到端测试。如需补充,需要引入 Puppeteer 等工具,但本次作业时间有限。
- 图片上传与图片压缩的实际效果依赖浏览器 API(Canvas)。 当前测试框架无法直接验证,只能进行人工测试。
- 并发场景尚未测试。
localStorage本身不涉及服务器并发,但如果将来改用后端接口,两个用户同时修改同一条信息时,就需要重新考虑并发控制。
我们认为这三点属于“已知的未知”。将它们写出来,是为了让测试人员了解当前测试的边界,也为下一版本提供明确的改进方向。
七、GitHub 代码签入记录

本项目共包含 13 次提交,其中包括队友贡献的 1 次提交和 1 次 Pull Request 合并提交。
项目遵循约定式提交规范(Conventional Commits),按照功能模块划分提交,每完成一个功能至少提交一次:
| 提交前缀 | 含义 | 次数 |
|---|---|---|
chore |
构建、工具、配置改动 | 1 |
feat |
新功能 | 7 |
style |
样式与界面调整 | 1 |
test |
测试相关 | 1 |
docs |
文档 | 1 |
| — | 队友提交的 README 补充 + PR 合并提交 | 2 |
协作方式:任奥辉在仓库中完成主体开发后,我 Fork 该仓库,在自己的副本中补充 README 内容并提交,随后发起 Pull Request(#1),经审核后合并至主分支。这样,仓库中同时保留了两人的提交记录。
八、遇到的困难及解决方法
困难一:透明点击热区在预览时不能响应
问题描述:制作原型时,我们将墨刀中的透明矩形覆盖在卡片上作为点击热区,但预览时点击后没有任何反应,页面跳转无法生效。
做过的尝试:
- 检查交互设置,确认“点击 → 跳转到页面”已经绑定。
- 怀疑是图层顺序问题,将热区调整至最上层,但问题仍未解决。
- 将透明矩形改为不透明矩形进行测试,发现可以正常跳转。由此确定,问题出在透明度的设置方式上:我们使用的是一种“看起来透明”的方式,但在这种设置下,组件实际上无法接收点击事件。
是否解决:已解决。我们改用“将填充色透明度设置为 0”的方式,而不是关闭整个组件的可见性,点击即可正常响应。
这一问题也影响了后续 Web 版的实现。在详情页跳转中,我们避免使用透明点击区域,直接为卡片本身绑定事件,从根本上规避了类似问题。
有何收获:界面元素“看起来存在”和“能够被操作”是两件不同的事。这次经历让我们在实现 Web 交互时,更倾向于采用语义正确的方式,而不是仅依靠视觉效果实现功能。
困难二:图片上传后存储空间被占满
问题描述:发布页完成图片上传功能后,我们选择了一张活动照片进行测试,结果发布失败,控制台显示 QuotaExceededError,部分已有数据也出现了不完整的情况。
做过的尝试:
- 首先确认是否因为文件过大。手机拍摄的照片通常有 3~5 MB。
- 尝试限制上传数量,但一张照片就可能超过容量,因此限制数量无法解决根本问题。
- 考虑过取消图片功能,但第一次作业的原型已经包含照片,直接删除这一功能会导致实现与原型不一致。
- 最终了解到浏览器可以使用 Canvas 在上传前压缩图片,于是采用了这一方案。
是否解决:已解决,并补充了以下处理:
- 上传时使用 Canvas 将图片最长边压缩至 900 px,JPEG 质量设置为 0.72。实测一张 3 MB 的图片压缩后约为 60~80 KB。
- 压缩后仍超过 800 KB 的图片会被拒绝,并向用户显示提示,避免写满存储空间。
- 写入时捕获
QuotaExceededError,并提示“浏览器存储空间已满,请删除部分带图片的信息后重试”,而不是直接失败。 - 编写了两个单元测试用例,用于覆盖相关异常路径。
有何收获:第一,遇到问题后应该先定位原因,而不是立即绕开。明确“到底是哪一步占满了存储空间”后,解决方案也随之清晰。第二,失败路径也应作为功能的一部分进行设计。捕获异常并给出用户能够理解的提示,与保证正常流程可用同样重要。
困难三:两人并行开发时的接口约定
问题描述:确定分工后,我们分别开发页面和数据层。第一次合并时,程序无法正常运行:页面调用的是 store.getList(),而数据层提供的方法是 store.query(),方法名称、参数和返回值都不一致。
做过的尝试:
- 一开始采用“边写边改”的方式,谁发现问题就由谁修改。但经过几次修改后,两边的实现都变得混乱。
- 后来我们暂停编码,将数据层接口写入文档,明确方法名、参数、返回值格式以及失败时的返回内容。例如,统一约定所有方法返回
{ok: true/false, ...}。 - 页面按照约定调用接口,数据层按照约定实现接口,每完成一个方法就提交一次代码。
是否解决:已解决。合并后的第一次联调基本运行成功。
有何收获:先确定接口,再并行开发,比“先写起来再说”更加高效。这次经历也让我们理解了设置“设计复审”环节的原因:它不是形式上的要求,而是减少返工的有效手段。
九、评价队友
本次结对作业中,任奥辉主要负责页面实现、交互细节完善以及部分单元测试。他值得我学习的地方,首先是执行力强。在确定页面结构和数据接口后,他能够按照分工及时完成发布、详情和状态管理等页面,并主动检查页面在 Chrome 中的显示效果。
其次,他对界面细节比较耐心。对于表单提示、无搜索结果、状态标签等容易被忽略的内容,他会结合实际操作反复调整,使页面不仅“能够运行”,也更容易理解和使用。
在协作过程中,他也能根据测试结果及时修改问题。部分边界情况在开发初期考虑得不够完整,例如无效输入和异常数据的处理,但在单元测试发现问题后,他能够快速定位原因并完成修正。
需要改进的是,今后可以在编码前先列出正常路径和异常路径,再开始实现功能,从而减少后期返工;提交代码时也可以进一步细化 Commit 信息,让每次修改的目的更加清楚。
总体而言,他能够认真完成分工、及时沟通并配合修改,是一位可靠的结对伙伴。
十、结语
从原型设计到程序实现,我们对“完成一个产品”有了更加具体的认识。第一次结对作业主要关注需求和页面流程,而这次作业要求我们将原型中的功能真正落实到代码中。只有发布、搜索、查看详情、联系发布者和更新状态等环节能够连续运行,原型中的设计才真正具有使用价值。
开发过程并不总会按照预期进行。用户可能漏填必填项,搜索结果可能为空,也可能尝试访问不存在的信息。因此,我们在实现正常流程的同时,增加了表单校验、空状态提示和异常数据处理。对这些细节的完善,使网页不只是“可以打开”,而是能够针对不同操作给出清楚、合理的反馈。
单元测试也帮助我们发现了一些仅靠手动操作不容易察觉的问题。通过为核心函数设计正常值、边界值和异常值,我们对数据存储、关键词搜索和状态更新等逻辑进行了更加系统的检查。这让我们认识到,测试不是开发结束后的附加步骤,而应当从功能设计阶段就开始考虑。
在结对协作方面,明确分工、提前约定数据接口以及及时提交代码,减少了开发过程中的重复劳动和合并冲突。通过 GitHub 的 Fork、Commit 和 Pull Request,我们不仅记录了项目的开发过程,也学习了更加规范的协作方式。
本次作业仍有可以继续完善的地方,但我们已经完成了从需求分析、原型设计到编码实现、协作管理和程序测试的完整实践。相比最终呈现出的页面,这些在实现过程中发现问题、调整方案并验证结果的经历,对我们今后的软件开发更有价值。







浙公网安备 33010602011771号