结对编程作业(第二次)
2026秋软件工程 · 结对编程作业(第二次)——校园失物招领
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 202601软件工程 |
| 这个作业要求在哪里 | 2026秋软件工程第二次结对作业之程序实现 |
| 这个作业的目标 | 基于第一次结对作业的原型设计,完成其核心功能,使“发布信息—浏览或搜索—查看详情—联系发布者—更新状态”的基本流程清晰、可用 |
| 学号 | 102401134 |
| 结对同学博客链接 | 102401137 |
| GitHub仓库 | 102401134-102401137 |
一、具体分工
| 成员 | GitHub | 负责内容 | 对应提交 |
|---|---|---|---|
| 102401134 王清源 | 2-qing |
需求梳理、核心逻辑模块 core.js、存储层 store.js、共享 UI 组件与样式表、首页、README |
前 6 条(10-01 ~ 10-02) |
| 102401137 佘博海 | ace7-Max |
发布页、详情页、「我的发布」页、单元测试、PSP 与测试文档 | 后 6 条(10-03 ~ 10-04) |
协作方式:王清源创建仓库并推送骨架(初始化 → core → store → ui → 首页 → README),
佘博海 fork 后在自己的分支上完成后半部分(发布页 → 详情页 → 我的发布 → 单元测试 → 文档 → 依赖锁定),
通过 Pull Request #1 合并回主仓库。两人共同参与需求分析与原型对齐。
二、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 40 | 55 |
| Estimate | 估计这个任务需要多少时间 | 40 | 55 |
| Development | 开发 | 600 | 720 |
| Analysis | 需求分析(包括学习新技术) | 60 | 70 |
| Design Spec | 生成设计文档 | 40 | 45 |
| Design Review | 设计复审 | 30 | 25 |
| Coding Standard | 代码规范(为目前的开发制定合适的规范) | 20 | 20 |
| Design | 具体设计 | 60 | 80 |
| Coding | 具体编码 | 300 | 360 |
| Code Review | 代码复审 | 40 | 50 |
| Test | 测试(自我测试,修改代码,提交修改) | 50 | 70 |
| Reporting | 报告 | 140 | 165 |
| Test Report | 测试报告 | 40 | 45 |
| Size Measurement | 计算工作量 | 20 | 20 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 80 | 100 |
| 合计 | Total | 780 | 940 |
偏差说明:
| 阶段 | 预估 | 实际 | 偏差原因 |
|---|---|---|---|
| 具体设计 | 60 | 80 | 中途推翻了「业务逻辑写在页面里」的初版方案,改为抽离纯函数模块,多花了时间 |
| 具体编码 | 300 | 360 | 单元测试暴露的边界问题(如手机号 trim 后再校验)需要回头改代码 |
| 测试 | 50 | 70 | 为了让测试既能跑命令行、又能在浏览器里可视化,额外配置了 Mocha 双环境 |
| 事后总结 | 80 | 100 | 撰写 README 与测试说明比预想中耗时 |
整体实际耗时约为预估的 1.2 倍,主要超支在设计返工与测试环境搭建上。
三、解题思路描述与设计实现说明
3.1 代码实现思路(文字描述)
本次作业基于第一次结对作业的原型设计(墨刀原型)实现,核心是让
「发布信息 — 浏览或搜索 — 查看详情 — 联系发布者 — 更新状态」 这条主流程跑通且好用。
我们采用 「分层 + 纯函数核心」 的思路:
① 核心逻辑与界面彻底分离。
把校验、搜索、筛选、排序、状态更新、统计这些业务规则全部写进 js/core.js。
这个文件里只有纯函数——同样的输入必然得到同样的输出,不读写 DOM、不碰 localStorage。
好处是:它既能在网页里用,也能被 Node.js 直接 require 做单元测试。
② 存储层封装。
js/store.js 把 localStorage 的读写包起来,页面不直接操作存储。
它还负责首次打开时写入示例数据,以及用本机记录识别「哪些是我发布的」。
③ UI 层复用。
js/ui.js 封装了信息卡片渲染、Toast 提示、内联 SVG 图标库、一键复制、导航高亮等通用能力,
四个页面共享同一套组件,保证视觉一致。
④ 页面层只做三件事。
每个页面一个脚本,职责就是「取数据 → 调 core → 渲染」,不承载任何业务规则。
数据模型(一条信息记录):
Item {
id, // 唯一标识
type, // 'lost' 寻物启事 | 'found' 失物招领
name, category, location, date, // 物品名称 / 类别 / 地点 / 日期
description, // 详细描述
contactName, contactType, contact, // 称呼 / 联系方式类型 / 联系方式
images: [], // 图片(dataURL,最多 3 张)
status, // 'active' 进行中 | 'resolved' 已解决
createdAt, updatedAt // 时间戳
}
分层架构:

3.2 关键实现的流程图与数据流图
① 核心业务流程图(用户视角)
从用户角度看,整个系统就是一个五步闭环。第 5 步「更新状态」尤其重要——它让信息从
「进行中」变为「已找到 / 已归还」,后来的人就不会再重复联系,这正是我们想解决的
「重复询问、无效联系」痛点。

② 数据流图(代码视角)

写入链路:发布页表单 → core.validateItem 校验 → core.createItem 构造记录 → store.add 落库 → localStorage。校验不通过就中止,把错误按字段回填到表单并高亮。
读取链路:localStorage → store.query → core.queryItems(筛选 → 搜索 → 排序) → ui.renderCards → 页面卡片列表。详情页的「标记已找到 / 已归还」走
core.updateStatus 返回新数组,再由 store.setStatus 落库。
③ 关键函数调用关系
index.html
└─ app.js ──┬─→ LFStore.initSeed() 首次写入示例数据
├─→ LFStore.query({keyword, filter, order})
│ └─→ LFCore.queryItems()
│ ├─→ filterItems() 多条件筛选
│ ├─→ searchItems() 关键词搜索
│ └─→ sortItems() 时间排序
└─→ LFUI.renderCards() 渲染卡片
publish.html
└─ publish.js ─┬─→ LFCore.validateItem() 表单校验
├─→ LFCore.createItem() 构造记录
└─→ LFStore.add() 入库
detail.html
└─ detail.js ─┬─→ LFStore.getById() 读取单条
├─→ LFUI.copyText() 复制联系方式
└─→ LFStore.setStatus() 更新状态
my-posts.html
└─ my-posts.js ─→ LFStore.getMine() 取出本机发布过的 id
3.3 重要代码片段及解释
① 组合查询 queryItems —— 把「筛选 → 搜索 → 排序」串成一条流水线
/** 组合:搜索 + 筛选 + 排序,页面直接调用它即可 */
function queryItems(items, options) {
var opt = options || {};
var result = filterItems(items, opt.filter); // 先按 类型/类别/状态 过滤
result = searchItems(result, opt.keyword); // 再按关键词模糊匹配
return sortItems(result, opt.order); // 最后按时间排序
}
为什么这样写:最初筛选写在页面里、搜索写在 core 里,两边各写一遍,
结果出现「先搜后筛」和「先筛后搜」结果不一致的问题。把三个动作收拢到 queryItems
之后,顺序被固定下来,页面只需传一次条件,也不可能再写错。
三个子函数都是「输入数组 → 输出新数组」,不修改传入的原数组,避免了副作用。
② 表单校验 validateItem —— 返回「字段 → 错误信息」的映射
function validateItem(data) {
var d = data || {};
var errors = {};
var name = trim(d.name);
if (!name) {
errors.name = '请填写物品名称';
} else if (name.length < LIMITS.name.min) { // 下界
errors.name = '物品名称至少 ' + LIMITS.name.min + ' 个字';
} else if (name.length > LIMITS.name.max) { // 上界
errors.name = '物品名称不超过 ' + LIMITS.name.max + ' 个字';
}
var contactType = trim(d.contactType); // 联系方式类型:wechat / qq / phone
var contact = trim(d.contact);
if (!contact) {
errors.contact = '请填写联系方式';
} else if (contact.length < LIMITS.contact.min) {
errors.contact = '联系方式至少 ' + LIMITS.contact.min + ' 个字符';
} else if (contact.length > LIMITS.contact.max) {
errors.contact = '联系方式不超过 ' + LIMITS.contact.max + ' 个字符';
} else if (contactType === 'phone' && !/^1[3-9]\d{9}$/.test(contact)) {
errors.contact = '手机号格式不正确(应为 11 位大陆手机号)';
} else if (contactType === 'qq' && !/^\d{5,12}$/.test(contact)) {
errors.contact = 'QQ 号格式不正确(应为 5~12 位数字)';
}
// ... 其余字段同理
return errors; // 空对象 = 校验通过
}
设计要点:
- 返回错误映射而不是布尔值。如果只返回
true/false,页面就不知道该在哪个输入框下面
显示什么提示;返回{字段名: 错误信息},页面可以直接errors[name]取用,
实现「逐字段高亮 + 提示」。 - 所有输入先
trim。用户在手机号前后多打了空格是很常见的,先trim再校验可以避免误报。
这一点专门写了用例 8 来验证。 - 校验顺序由宽到严:先查空 → 再查长度 → 最后查格式,这样提示信息最贴合用户当前的错误。
③ 不可变更新 updateStatus —— 不修改原数组
function updateStatus(items, id, status) {
if (status !== 'active' && status !== 'resolved') return items; // 非法状态直接忽略
var now = Date.now();
return (Array.isArray(items) ? items : []).map(function (it) {
if (it.id === id) {
var copy = Object.assign({}, it); // 复制一份再改,不动原对象
copy.status = status;
copy.updatedAt = now;
return copy;
}
return it;
});
}
设计要点:用 map 生成新数组、用 Object.assign 复制对象,而不是直接
items[i].status = ...。这样调用方的数据不会被「偷偷改掉」,调试时容易定位问题,
也方便测试时断言「原数组保持不变」(用例 21)。
四、附加特点设计与展示
4.1 创意独到之处及意义
① 关键词高亮显示
搜索后,命中的文字在卡片里以黄色高亮标出。信息量大时,用户扫一眼就知道自己搜的词
匹配在哪里,不用逐条读完整段描述。意义:把「搜索结果」从"一堆卡片"变成"一眼定位"。
② 多维筛选 + 排序
支持按「类型(寻物/招领)× 物品类别 × 状态」三个维度组合筛选,并可按发布时间正序/倒序。
意义:失物招领的信息会随时间快速堆积,只靠搜索不够——比如「我想看图书馆附近所有
还没找到的电子产品」,靠组合筛选两步就能得到。
③ 一键复制联系方式
详情页把微信 / QQ / 手机号一键复制到剪贴板。意义:手机号、微信号手抄容易出错,
复制能显著降低「联系错人」的概率,也让联系动作从三步变成一步。
④ 「我的发布」免登录管理
无需注册登录,用本机记录区分「我发布过的信息」,可直接标记状态、删除、导出。
意义:校园失物招领是典型的轻量场景,为了改个状态而要求注册账号,会直接劝退用户。
⑤ 发布表单即时校验
输入框失焦即校验,手机号、QQ 号按格式自动检查,错误字段变红并给出具体原因。
意义:避免「填完一长串表单、点发布、才发现手机号少了一位」的挫败感。
⑥ 图片上传与预览
最多上传 3 张照片,本地预览,详情页可切换查看(图片以 dataURL 存在本机)。
⑦ 数据导出
「我的发布」页可把自己发布的数据导出为 JSON 文件备份。
⑧ XSS 防护
所有用户输入在渲染前统一转义,防止有人在描述里写 <script> 注入脚本。
4.2 实现思路
| 特点 | 实现方式 |
|---|---|
| 关键词高亮 | core.highlight():先 escapeHtml 转义,再对关键词做正则替换包 <mark>。顺序很关键——先高亮后转义会把 <mark> 本身转义掉,先转义后高亮才安全 |
| 多维筛选 | core.filterItems() 接收 {type, category, status},三个条件依次短路判断,任一不匹配即返回 false |
| 一键复制 | 优先用 navigator.clipboard.writeText()(现代浏览器),失败时降级到 document.execCommand('copy') 兼容旧浏览器 |
| 免登录管理 | 发布时把记录 id 写进 localStorage 的 mine 列表;「我的发布」页据此过滤 |
| 即时校验 | 输入框绑定 blur 事件,调用同一个 core.validateItem,只取当前字段的错误信息回填 |
| 导出数据 | 把记录转成 JSON 字符串,用 Blob + URL.createObjectURL 生成下载链接 |
4.3 重要代码片段及解释
① 关键词高亮 highlight
function highlight(text, keyword) {
var raw = text === undefined || text === null ? '' : String(text);
var escaped = escapeHtml(raw); // 第一步:先转义,防 XSS
var kw = trim(keyword);
if (!kw) return escaped;
// 第二步:把关键词里的正则元字符转义,避免用户输入 "." "*" 等破坏正则
var safeKw = escapeHtml(kw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return escaped.replace(new RegExp(safeKw, 'gi'), function (m) {
return '<mark>' + m + '</mark>'; // 第三步:包上 <mark> 标签
});
}
这段有两个容易踩的坑,我们都处理了:
- 转义与高亮的顺序。必须先转义再高亮。反过来做的话,
<mark>标签本身会被转义成
<mark>,高亮就失效了。 - 正则元字符。用户如果搜
C++或a.b,直接塞进new RegExp()会语法错误或匹配到
意料之外的内容。所以先用.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')把元字符转义掉。
用例 30 专门用<img src=x onerror=alert(1)>这类恶意串做了验证。
② 一键复制 copyText(含降级)
function copyText(text) {
var t = Core.trim(text);
if (!t) return Promise.resolve(false);
// 现代浏览器:Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(t).then(function () { return true; })
.catch(function () { return fallbackCopy(t); }); // 失败则降级
}
return Promise.resolve(fallbackCopy(t)); // 老浏览器直接走降级
}
function fallbackCopy(text) {
try {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0'; // 视觉上不可见,但不影响选中
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand('copy');
document.body.removeChild(ta); // 用完立刻移除,不污染 DOM
return ok;
} catch (e) {
return false;
}
}
为什么要降级:navigator.clipboard 只在 HTTPS 或 localhost 下可用。本作业要求
「双击 HTML 文件就能运行」,也就是 file:// 协议,此时 Clipboard API 可能不可用,
必须有降级方案,否则助教测试时会发现「复制按钮点了没反应」。
4.4 实现成果展示
首页:搜索框、实时统计、类型切换、类别/状态筛选、排序、信息卡片列表。

发布页:寻物/招领类型切换、表单即时校验、图片上传预览、字数统计。

详情页:完整信息、状态徽章、一键复制联系方式、标记已找到/已归还。

我的发布:本机发布记录、状态筛选、快速改状态、导出数据。

五、目录说明和使用说明
5.1 目录是如何组织的
102401134-102401137/
├── index.html # 首页:搜索、统计、筛选、信息卡片列表
├── publish.html # 发布页:发布寻物 / 招领信息
├── detail.html # 详情页:查看完整信息、复制联系方式、更新状态
├── my-posts.html # 我的发布页:管理自己发布的信息
│
├── css/
│ └── style.css # 全站样式(变量、布局、组件、响应式,单一文件便于维护)
│
├── js/
│ ├── core.js # ★ 核心业务逻辑(纯函数,无 DOM 依赖,可被单元测试直接调用)
│ ├── store.js # 数据存储层:封装 localStorage 的读写
│ ├── ui.js # 共享 UI 组件:导航、卡片渲染、Toast、图标、复制等
│ ├── app.js # 首页逻辑
│ ├── publish.js # 发布页逻辑(表单校验、图片预览、提交)
│ ├── detail.js # 详情页逻辑
│ └── my-posts.js # 我的发布页逻辑
│
├── images/ # 图片素材(图标为内联 SVG,此目录留作扩展)
│
├── tests/ # 单元测试(Mocha + Chai)
│ ├── core.test.js # 31 个测试用例,覆盖核心逻辑的各类边界
│ ├── test.html # 浏览器版测试运行器(双击即可查看可视化结果)
│ └── lib/ # mocha.js / mocha.css / chai.js 浏览器构建(本地,离线可用)
│
├── docs/
│ ├── PSP.md # PSP 表格(预估 / 实际耗时)
│ └── 单元测试说明.md # 测试工具选择、学习过程、用例设计与数据构造思路
│
├── package.json # 单元测试依赖与脚本(npm test)
├── package-lock.json # 锁定依赖版本,保证他人 npm install 得到完全相同的版本
├── .gitignore # 忽略 node_modules 等
└── README.md # 目录说明与使用说明
组织思路:按「职责」而不是「文件类型」划分层次。HTML 放根目录便于双击打开;
脚本集中在 js/ 并按分层命名(core / store / ui / 页面脚本),一眼能看出依赖方向;
测试与文档各自独立成目录,不干扰运行时代码。
5.2 测试人员如何运行
运行网页(3 步)
- 下载 / 克隆本项目全部文件到本地任意目录;
- 用 Google Chrome 打开项目根目录下的
index.html; - 首次打开会自动写入 4 条示例数据,方便你立即体验搜索与筛选。
这些数据保存在你本机浏览器的 localStorage 中,不会影响其他同学。
如果浏览器对本地文件限制较严,也可以在项目根目录执行
python -m http.server 8000,再访问http://localhost:8000。
体验核心流程
| 步骤 | 操作 |
|---|---|
| 发布 | 点右上角「发布信息」→ 选择「我丢了东西 / 我捡到东西」→ 填写表单 → 发布 |
| 浏览 / 搜索 | 回首页输入关键词(如「一卡通」「图书馆」),或用类别、状态、排序筛选 |
| 查看详情 | 点击任意卡片进入详情页 |
| 联系发布者 | 点「复制」把联系方式复制到剪贴板 |
| 更新状态 | 在「我的发布」或详情页,标记为「已找到 / 已归还」 |
运行单元测试(两种方式)
- 方式 A(推荐,可视化):用 Chrome 打开
tests/test.html,页面直接显示 31 个用例的运行结果。 - 方式 B(命令行):
npm install # 首次运行需安装 mocha / chai(联网)
npm test # 运行全部单元测试
预期输出:31 passing。
tests/lib/已内置 mocha 与 chai 的浏览器构建,因此方式 A 无需联网。
六、单元测试
6.1 选用的测试工具、学习过程与简易教程
选用 Mocha + Chai,原因:
- Mocha 是使用最广的 JavaScript 测试框架,语法直观(
describe/it),
并且同时支持浏览器和 Node.js 两种运行环境——正好满足我们「既能在网页里点开看结果、
又能在命令行npm test」的需求。 - Chai 提供
assert断言风格,配合 Mocha 使用自然,能清晰表达
「相等 / 包含 / 属性存在 / 属性不存在」等断言。 - 二者都是纯 JavaScript 实现,零编译、零构建,学习成本低,适合课程项目。
我们的学习过程
- 先读官方文档:Mocha 官网的 Getting Started 与 Chai 的
assertAPI 文档; - 看教程打基础:参考廖雪峰的 JavaScript 教程与《测试框架 Mocha 实例教程》,
理解describe(一组测试)与it(单个用例)的组织方式; - 动手写第一个用例:先给最简单的
resolvedText()写断言,跑通后再逐步扩展到复杂逻辑; - 理解断言风格:从
assert.equal开始,逐步使用assert.deepEqual(比较对象)、
assert.property/assert.notProperty(判断字段是否报错)、assert.include(包含子串); - 理解白盒测试:结合代码分支(每个
if的两条路径)设计用例,做到「每条分支至少被执行一次」。
一份简易教程
# 1. 安装依赖
npm install mocha chai --save-dev
# 2. 写一个测试文件 hello.test.js
# const assert = require('chai').assert;
# describe('加法', () => {
# it('1 + 1 应等于 2', () => assert.equal(1 + 1, 2));
# });
# 3. 运行
npx mocha hello.test.js
若想在浏览器里看结果:新建一个 HTML,依次引入 mocha.js、chai.js,
调用 mocha.setup('bdd'),再引入被测脚本与测试脚本,最后调用 mocha.run()。
6.2 部分单元测试代码与测试的函数
测试文件 tests/core.test.js,共分 8 组、31 个用例:
| 分组 | 被测函数 | 用例数 | 关注点 |
|---|---|---|---|
| validateItem 表单校验 | validateItem / isValid |
8 | 必填项、长度边界、类别白名单、手机号 / QQ 号格式、trim |
| createItem 构造记录 | createItem |
3 | 补全字段、图片数量上限、id 唯一性 |
| searchItems 搜索 | searchItems |
5 | 空关键词、名称 / 地点 / 描述命中、大小写、无结果、首尾空格 |
| 筛选与排序 | filterItems / sortItems / queryItems |
4 | 单条件、多条件组合、时间正 / 倒序 |
| 状态更新与删除 | updateStatus / removeItem |
3 | 状态更新、非法状态忽略、不可变性 |
| 统计 | getStats |
2 | 各维度计数、空列表 |
| 文案与工具 | resolvedText / publisherRole / timeAgo / escapeHtml / highlight |
5 | 文案映射、相对时间、XSS 转义、高亮 |
| 示例数据 | seedItems |
1 | 结构完整性 |
部分测试代码:
/* ============ 测试数据工厂 ============ */
function makeValid(overrides) {
var base = {
type: 'lost', name: '黑色雨伞', category: '生活用品',
location: '图书馆一楼大厅', date: '2026-10-01',
description: '伞面带小碎花,伞柄有划痕',
contactName: '王同学', contactType: 'wechat', contact: 'wang_2026'
};
return Object.assign({}, base, overrides || {}); // 只覆盖要测的那个字段
}
/* ============ 用例 3:长度边界值 ============ */
it('用例3:物品名称长度边界(1 字过短、31 字过长、2 字与 30 字合法)', function () {
assert.property(Core.validateItem(makeValid({ name: '伞' })), 'name'); // 下界-1
assert.property(Core.validateItem(makeValid({ name: 'a'.repeat(31) })), 'name'); // 上界+1
assert.notProperty(Core.validateItem(makeValid({ name: '雨伞' })), 'name'); // 下界
assert.notProperty(Core.validateItem(makeValid({ name: 'a'.repeat(30) })), 'name'); // 上界
});
/* ============ 用例 20:组合查询 ============ */
it('用例20:queryItems 组合搜索 + 筛选 + 排序', function () {
var r = Core.queryItems(items, {
keyword: '电子',
filter: { status: 'active' },
order: 'newest'
});
assert.lengthOf(r, 1);
assert.equal(r[0].id, '1');
});
/* ============ 用例 21:不可变性 ============ */
it('用例21:更新指定 id 的状态为 resolved(不修改原数组)', function () {
var r = Core.updateStatus(items, '1', 'resolved');
assert.equal(r[0].status, 'resolved');
assert.equal(r[1].status, 'active');
assert.equal(items[0].status, 'active'); // 原数组保持不可变
});
/* ============ 用例 30:安全 ============ */
it('用例30:highlight 高亮关键词并保持 HTML 转义', function () {
var out = Core.highlight('校园一卡通丢失', '一卡通');
assert.include(out, '<mark>一卡通</mark>');
var evil = Core.highlight('<img src=x onerror=alert(1)>', 'img');
assert.notInclude(evil, '<img src=x'); // 恶意串必须被转义
});
运行结果:31 passing
6.3 构造测试数据的思路
① 用工厂函数 + Object.assign 覆盖单字段。
makeValid() 生成一份「合法表单数据」,makeItem() 生成一条标准记录,
再用 Object.assign 覆盖单个字段。这样每个用例只关心自己要测的那一个变量,
既避免数据冗余,又提高可读性——用例 3 一眼就能看出是在测「物品名称的长度」。
② 时间相关函数传入固定基准。
timeAgo、sortItems 这些依赖时间的函数,测试时传入固定基准值
(如 now = 1000 * 60 * 60 * 24 * 10),保证结果可复现,不受运行时刻影响。
③ 边界值优先。
长度、数量这类有上下界的字段,一律取「下界、下界-1、上界、上界+1」四个点。
用例 3(名称 2/30 合法,1/31 非法)和用例 7(描述允许为空、301 字超限被拒)都是这个思路。
④ 等价类划分。
手机号、QQ 号按「合法格式 / 非法格式 / 位数不足」划分等价类,每类取样。
用例 5 测了 13800138000(合法)、12345(位数不足)、23800138000(首位非法)三种。
⑤ 如何考虑将来测试人员的「刁难」
| 刁难方式 | 我们的应对用例 |
|---|---|
| 传空对象 / 缺字段 | 用例 2 直接传 {},验证逐项报错而不是抛异常崩溃 |
| 传非法枚举值 | 用例 22 给 updateStatus 传 'unknown-status',验证被安全忽略并返回原列表 |
| 大小写不一致 | 用例 15 用 'airpods' 去匹配 'AirPods Pro' |
| 输入带首尾空格 | 用例 8 用 ' 13800138000 ' 验证 trim 后能正确判断 |
| 恶意脚本注入 | 用例 29、30 用 <script>、<img onerror> 验证输出被转义 |
| 偷偷改数据 | 用例 21、23 断言「原数组不被修改」,防止调用方数据被污染 |
⑥ 测试评估
31 个用例覆盖了核心模块的所有分支(校验的每个字段、搜索的命中与未命中、
筛选的单 / 多条件、排序的正 / 倒序、状态更新的合法 / 非法),并对边界值、异常输入、
安全性做了针对性设计。可以认为:只要这些用例全部通过,核心业务逻辑在各种情况下
都能被正确处理。后续若新增功能(如按地点筛选、多标签),只需在对应分组补充用例即可。
七、GitHub 代码签入记录
仓库地址:https://github.com/2-qing/102401134-102401137
提交历史(共 14 条功能提交 + 1 条 PR 合并提交):
| # | Commit | 作者 | 时间 | 说明 |
|---|---|---|---|---|
| 1 | bbbb3b6 |
2-qing |
10-01 21:38 | chore: 初始化校园失物招领项目与依赖配置 |
| 2 | 8486388 |
2-qing |
10-01 23:47 | feat(core): 实现核心业务逻辑模块(校验/搜索/筛选/排序/状态更新/统计) |
| 3 | 56462df |
2-qing |
10-02 10:26 | feat(store): 实现 localStorage 存储层与「我的发布」识别 |
| 4 | 48a8340 |
2-qing |
10-02 15:43 | feat(ui): 实现共享 UI 组件、图标库与全站样式表 |
| 5 | 5f879d9 |
2-qing |
10-02 20:58 | feat(home): 实现首页(关键词搜索、数据统计、多维筛选与排序) |
| 6 | e9e11f0 |
2-qing |
10-02 22:31 | docs: 添加 README(目录说明与使用说明) |
| 7 | 705cb35 |
ace7-Max |
10-03 14:52 | feat(publish): 实现发布页(寻物/招领切换、表单即时校验、图片上传) |
| 8 | ba198a8 |
ace7-Max |
10-03 19:24 | feat(detail): 实现详情页(一键复制联系方式、状态更新、删除) |
| 9 | 6fc64f5 |
ace7-Max |
10-03 22:37 | feat(myposts): 实现「我的发布」页(状态管理、筛选、数据导出) |
| 10 | 34e6b11 |
ace7-Max |
10-04 10:18 | test: 添加 Mocha+Chai 单元测试(31 个用例)与浏览器测试运行器 |
| 11 | 89bb3bf |
ace7-Max |
10-04 13:46 | docs: 添加 PSP 表格、单元测试说明与博客素材 |
| 12 | 0e0a25d |
ace7-Max |
10-04 14:29 | chore: 锁定单元测试依赖版本(package-lock.json) |
| — | ed678fc |
2-qing |
10-06 14:00 | Merge pull request #1 from ace7-Max/feat/pages |
| 13 | b87c0eb |
2-qing |
10-06 14:03 | docs: 同步分工说明与仓库地址 |
| 14 | 31ee428 |
2-qing |
10-06 14:20 | docs: 移除博客草稿(博客素材仅本地保留,不随仓库发布) |
前 6 条由王清源完成(10-01 ~ 10-02),后 6 条由佘博海在 feat/pages 分支完成(10-03 ~ 10-04),
合并后王清源又补了 2 条文档修正提交。
签入规范:采用约定式提交(Conventional Commits),
feat(模块) 表示新功能、fix 表示修复、docs 表示文档、test 表示测试、chore 表示杂项。
每完成一个可运行的功能就提交一次,保证每次提交都是可运行的状态。
Pull Request 记录:佘博海 fork 后在 feat/pages 分支完成 6 次提交,
通过 PR #1 发起合并请求,王清源审查后合并到 master。

八、遇到的代码模块异常或结对困难及解决方法
困难一:搜索与筛选组合时结果不一致
问题描述
最初我们把「按类型/类别/状态筛选」写在页面脚本里,「关键词搜索」写在 core.js 里。
两边各自实现一遍,结果出现「先搜索再筛选」和「先筛选再搜索」得到不同结果的情况——
原因是页面里的筛选漏了「已解决」状态的边界处理。
做过哪些尝试
- 尝试在页面里手动组合两个结果 → 重复代码变多,改一处忘一处,问题依旧;
- 尝试把筛选也搬进 core,但保持两个独立函数由页面自己调顺序 → 顺序仍可能写错;
- 最终在 core 里提供统一的
queryItems(items, {filter, keyword, order}),
把「筛选 → 搜索 → 排序」的顺序固定下来,页面只负责传条件。
是否解决
已解决。用例 20 专门验证「搜索 + 筛选 + 排序」三条件同时生效的结果。
有何收获
体会到「业务规则必须收敛到一处」的价值。只要规则分散在两个地方,就一定会有不一致的那天。
把逻辑集中到纯函数后,不仅 bug 少了,测试也变得容易——只要测 queryItems 一个函数,
就覆盖了三种组合方式。
困难二:store.js 无法在 Node 里 require
问题描述
写单元测试时发现,core.js 能被 Node 正常 require,但 store.js 不行——
它的 IIFE 把模块挂到了 root.LFStore,而在 Node 的 CommonJS 模块作用域里
this 指向的是 module.exports,不是全局对象,导致 require 回来是个空对象。
做过哪些尝试
- 尝试在测试里手动
eval文件内容 → 太 hack,不可取; - 尝试改用全局变量 → 在 Node 里没有
window,同样不行; - 最终把
store.js改成标准的 UMD 写法:
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(require('./core.js')); // Node 环境
} else {
root.LFStore = factory(root.LFCore); // 浏览器环境
}
})(typeof self !== 'undefined' ? self : this, function (Core) {
// ... 模块实现
return { /* 导出的方法 */ };
});
同时把 localStorage 的访问改成通过 ls() 函数间接获取,这样测试时可以注入一个
模拟的 localStorage。
是否解决
已解决。改造后 store.js 在浏览器里行为完全不变(文件树哈希一致),
在 Node 里也能正常 require,我们还用它跑通了「发布 → 搜索 → 状态更新 → 删除」的
端到端验证脚本。
有何收获
第一次真正理解 UMD 存在的意义——它不是为了「显得高级」,
而是因为同一个模块确实要同时服务于浏览器和 Node 两种运行环境。
困难三:一键复制在 file:// 下失效
问题描述
作业要求「双击 HTML 文件就能运行」,也就是 file:// 协议。
而现代浏览器的 navigator.clipboard API 只在 HTTPS 或 localhost 下可用,
在 file:// 下会静默失败,表现为「点了复制按钮没反应」。
做过哪些尝试
- 一开始只用了
navigator.clipboard→ 本地打开时失效; - 加上
document.execCommand('copy')降级 → 仍然失败,因为没有选中任何内容; - 最终用「动态创建隐藏 textarea →
select()→execCommand('copy')→ 移除」的方案。
是否解决
已解决,并把这段逻辑封装成 LFUI.copyText(),全站统一使用。
有何收获
认识到「能跑」和「在目标环境下能跑」是两件事。作业明确规定了运行方式,
就必须针对那个方式验证,而不能只在开发服务器上测通了就认为没问题。
结对过程中的体会
- 接口先行:我们在动手前先约定好了
core.js要导出哪些函数、参数长什么样、
返回什么。这样佘博海写页面时不需要等王清源把 core 写完,可以并行推进。 - 分层带来了真正的并行:因为业务规则都在 core 里,页面层只要照着接口调用即可,
两人几乎没有互相阻塞。 - PR 是很好的 review 机会:合并前一起过了一遍 6 条提交,发现了一处文案不一致,
在合并前就改掉了。
九、评价你的队友
对佘博海(102401137)的评价
值得学习的地方
- 单元测试做得很扎实。31 个用例里,边界值和「刁难型」用例(空值、非法枚举、
大小写、恶意脚本注入、不可变性)基本都是他补的,很多是我没想到的角度。 - 能够及时从错误思路中抽离出来,灵活转换,不死磕。
需要改进的地方
- 前期对 MVP 边界把握不够,一度想把「按地点筛选」也塞进本期,后来一起讨论后砍掉了。
建议以后先明确本期范围再动手。 - commit 粒度偶尔偏大,有一次把两个小改动合在一个提交里了,建议坚持「一个功能一次提交」。
自我反思
- 核心逻辑抽离成纯函数这个决策是对的,但一开始没有把接口约定写下来,
导致沟通成本偏高。 - 下次应该先写一份简单的接口文档再开工。

浙公网安备 33010602011771号