软工第二次结对作业
2026秋软件工程结对作业(第二次)——校园失物招领「拾回」程序实现
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601软件工程与软件工程实践 |
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次)——程序实现 |
| 这个作业的目标 | 基于第一次结对作业的原型设计,完成其核心功能,使「发布信息—浏览或搜索—查看详情—联系发布者—更新状态」的基本流程清晰、可用 |
| 学号 | 姓名 |
| 102402113 | 吴佳仪 |
| 102402114 | 谢慧彬 |
| 102402115 | 张润 |
本作业博客:软工第二次结对作业
结对同学的博客:https://www.cnblogs.com/Xxjupiter/p/23230712 / https://www.cnblogs.com/ll102402113/p/23217051
GitHub 仓库:Xasteroid-star/102402113-102402114-102402115
一、具体分工
| 棒次 | 成员 | 负责内容 |
|---|---|---|
| 第一棒 基础框架 |
102402114 谢慧彬 | 建 GitHub 仓库、目录、README 初稿、公共 CSS、页面骨架、数据层 js/api.js、数据模型、localStorage 增删改查、种子数据 js/seed.js、基础空页面、首页、全部/寻物/招领筛选、排序、搜索、关键词高亮 |
| 第二棒 页面与优化 |
102402115 张润 | 页面与优化 、关键词高亮、搜索历史、热门搜索、无结果空状态、详情页、一键复制联系方式、分享、收藏;做完 push,发 PR、完善README 初稿 |
| 第三棒 闭环、测试、收尾 |
102402113 吴佳仪 | 闭环、测试、收尾;发布页、表单校验、发布成功、我的发布、已找到/已归还、自动下架、单元测试、边界处理、异常记录、完善 README、汇总博客、最终提交 |
技术选型与范围约束: 采用纯静态方案(原生 HTML / CSS / JS + 浏览器 localStorage 持久化),零第三方依赖、无后端、无数据库 —— 把文件拷到本地、用浏览器打开 index.html 即可运行。严格按第一次作业划定的范围,不做后台管理、实名认证、站内聊天、地图定位。
二、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时 | 实际耗时 |
|---|---|---|---|
| Planning | 计划 | 25 | 30 |
| Estimate | 估计这个任务需要多少时间 | 25 | 30 |
| Development | 开发 | 335 | 425 |
| Analysis | 需求分析(包括学习新技术) | 35 | 45 |
| Design Spec | 生成设计文档 | 25 | 30 |
| Design Review | 设计复审 | 15 | 15 |
| Coding Standard | 代码规范(为目前的开发制定合适的规范) | 15 | 15 |
| Design | 具体设计 | 45 | 55 |
| Coding | 具体编码 | 150 | 200 |
| Code Review | 代码复审 | 50 | 70 |
| Test | 测试(自我测试,修改代码,提交修改) | 80 | 110 |
| Reporting | 报告 | 110 | 140 |
| Test Report | 测试报告 | 30 | 40 |
| Size Measurement | 计算工作量 | 10 | 10 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 70 | 90 |
| 合计 | 550 | 710 |
三、解题思路描述与设计实现说明
3.1 代码实现思路
把业务规则全部下沉到一份被页面、数据层、测试三方共用的纯函数文件里,页面只负责「取数据 → 调 Core → 渲染」。
整体分三层:
- 数据层 ——
js/api.js读写浏览器localStorage(键items)完成持久化,对外只暴露list / get / publish / resolve四个方法;没有后端、没有网络请求。首次打开(或本地数据损坏)时,把js/seed.js里的 10 条演示数据灌进本地。 - 业务逻辑层 ——
js/core.js,全部是纯函数(无 DOM、无网络、无文件 IO):搜索、筛选、排序、HTML 转义、关键词高亮、发布校验、状态文案。 - 视图层 —— 5 个 HTML 页面 +
css/style.css+js/ui.js(卡片渲染、Toast、一键复制、匿名 uid)。
为什么这样分? 因为「测试」这件事,必须在架构上先留好位置。如果业务规则散落在各个页面的 DOM 事件里,单元测试就只能靠模拟浏览器,成本和稳定性都会崩掉。把规则抽成纯函数之后,测试只需要 require 一个文件。
状态闭环怎么走? 这是本次的核心。发布者标记「已找到 / 已归还」后,条目 status 从 active 变成 resolved。首页取数时不带 status,数据层默认按 active 过滤,于是条目自然从信息流里消失(自动下架);而「我的发布」显式要 status=all,所以历史记录还在。没有删除任何数据,一条筛选规则同时满足了两个界面。
3.2 关键流程图
(1) 分层架构与数据流

(2) 核心业务闭环:发布 → 浏览/搜索 → 详情 → 联系 → 更新状态 → 自动下架

3.3 重要的代码片段与解释
(1) core.js
(function (root) {
'use strict';
if (typeof module !== 'undefined' && module.exports) {
module.exports = Core;
} else {
root.Core = Core;
}
})(typeof window !== 'undefined' ? window : this);
这是整个项目可测试性的根基。它同时满足了两个原本冲突的需求:浏览器要用 window.Core,Node 要用 module.exports,靠一段 UMD 风格的判断同时提供。
(2) highlight() —— 先切分、再逐段转义
function highlight(text, kw) {
const raw = String(text == null ? '' : text);
const q = String(kw == null ? '' : kw).trim();
if (!q) return escapeHtml(raw);
const qEsc = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return raw.split(new RegExp('(' + qEsc + ')', 'gi')).map(function (part, i) {
return i % 2 ? '<mark>' + escapeHtml(part) + '</mark>' : escapeHtml(part);
}).join('');
}
这段代码修掉了一个缺陷:最初版本是「先把整段文本 escapeHtml 转义,再在转义结果里 replace 关键词」。问题在于 escapeHtml("'") 的结果是 ',关键词一旦命中实体中间的字符(比如搜 39),<mark> 就会被插到实体内部,把 ' 劈成 <mark>9</mark>;。实体被劈开后不再是转义序列,浏览器会把它当普通文本显示 —— 用户看到的就是字面的 '。
四、附加特点设计与展示
4.1 附加特点一览
| 特点 | 实现思路 | 成果截图 |
|---|---|---|
| 关键词高亮 | 命中片段用 <mark> 包裹并染成品牌绿;先切分再逐段转义,实体不会被劈开 |
第 2 张 |
| 无结果空状态 | 搜不到时不是白屏,而是给出「未找到相关信息 / 换个关键词试试,如「校园卡」「钥匙」」 | 第 3 张 |
| 一键复制联系方式 | 优先 navigator.clipboard.writeText,失败自动降级到 execCommand,再失败提示手动复制 |
第 4 张 |
| 收藏 | 详情页「☆」切换为「★」,条目 id 存 localStorage 的 favorites,「我的发布」统计收藏数 |
第 4 张 |
| 分享 | 能调用系统分享就调 navigator.share,不支持则复制当前页链接并 Toast 提示 |
第 4 张 |
| 搜索历史 + 热门搜索 | 历史存 localStorage(最多 10 条、支持单条删除);热门为固定榜单,前两名带「热」标 |
第5、6张 |
| 按类型 / 分类筛选与排序 | 首页「全部 / 寻物 / 招领」分段控件 + 最新发布排序,条件交给 Core.filterItems / Core.sortItems 处理 |
第 1 张 |
|
|
搜「校园卡」命中 2 条,标题中的「校园卡」被 <mark> 包成绿色。
|
|
热门搜索排行榜以及搜索历史记录
|
|
4.2 实现思路及设计创意之处
我们最得意的一个设计,就是把「已找到 / 已归还」做成了一条筛选规则,而不是直接删掉信息。
普通的做法是:东西找到了,就把这条信息删了,或者加个“已删除”的标记,然后每个页面都判断一遍“这条是不是删了”。我们没这么干,我们只做了一件事:把状态从“进行中”改成“已解决”,然后让首页默认只显示“进行中”的。
- 意义: 信息不用删。 发的人还能在“我的发布”里看到自己以前发过什么,但别人的首页已经看不到这条了。一个规则,同时满足了两个地方的需求,不用写两套逻辑,也不用多存一个字段。
- 附带好处: 以后想反悔也容易。 万一以后要加“撤销标记”,只要把状态改回“进行中”就行,不存在“删掉了怎么恢复”的问题。
- 对用户的意义: 对用户来说更清爽。 校园失物招领里,已经找到或归还的信息如果还挂在列表上,别人每看到一条就得点进去确认一下“这个是不是还在”,很浪费注意力。自动下架等于把这份注意力还给了那些真正还没解决的问题。
4.3 关键代码片段
- 写入端:
API.resolve(id)把该条status改成resolved并写回localStorage
// js/api.js —— 数据层:list / get / publish / resolve 全部读写 localStorage
list: function (params) {
params = params || {};
var result = Core.searchItems(readAll(), params.q);
result = Core.filterItems(result, {
type: params.type,
category: params.category,
status: params.status || 'active', // ← 缺省即 active:这就是「自动下架」
publisher: params.publisher
});
return Promise.resolve(Core.sortItems(result, params.sort || 'newest'));
},
resolve: function (id) {
var items = readAll();
var item = items.filter(function (it) { return it.id === id; })[0];
if (!item) return Promise.reject(fail('未找到该条信息', { status: 404 }));
item.status = 'resolved';
writeAll(items); // ← 写回 localStorage
return Promise.resolve(item);
}
这四个方法一律返回 Promise,页面只关心 .then(...) —— 数据层内部怎么存、怎么取,调用方完全无感。
- 读取端:
status的缺省值是active。首页和搜索页不传这个参数,于是已解决的条目天然不再出现;「我的发布」显式传status=all,于是历史全都在。
// my.html —— 读取端:显式要 all,历史记录才会回来
// 首页默认 status=active,已解决的自动下架,这里仍可见
API.list({ publisher: myUid, status: 'all' }).then(function (items) {
allItems = items;
renderStats();
renderList();
});
4.4 实现成果展示
自动下架前后对比
|
|
逐项校验提示以及发布成功:
|
|
我的发布:标记前后的状态变化(标签「进行中」→「已归还」,按钮 →「已处理」,统计「已解决」0 → 1):
|
|
五、目录说明和使用说明
5.1 目录是如何组织的
102402113-102402114-102402115/
├── README.md ← 目录说明 + 使用说明 + 单元测试 + 存储说明
├── package.json ← 零依赖;仅一条 test 脚本;engines: node >= 18
├── .gitignore ← 忽略 node_modules、package-lock.json、博客素材截图等
├── index.html 104 行 首页(信息流 + 全部/寻物/招领筛选 + 排序)
├── search.html 151 行 搜索页(搜索历史 + 热门搜索 + 高亮 + 空状态)
├── detail.html 130 行 详情页(联系 + 一键复制 + 收藏 + 分享)
├── publish.html 216 行 发布页(表单 + 逐项校验 + 防重复提交 + 成功弹层)
├── my.html 169 行 我的发布(统计 + 筛选 + 标记已找到/已归还)
├── css/
│ └── style.css 207 行 公共样式
├── js/
│ ├── core.js 131 行 纯函数(搜索/筛选/排序/校验/高亮/转义/状态文案)★ 三方共用
│ ├── seed.js 170 行 种子演示数据(10 条,首次打开灌入 localStorage)
│ ├── api.js 103 行 数据层(localStorage 读写:list / get / publish / resolve)
│ └── ui.js 127 行 公共 UI 工具(卡片渲染 / Toast / 一键复制 / 匿名 uid)
├── assets/img/ ← 图片素材(当前用 emoji 图标占位)
└── tests/
├── core.test.js 441 行 纯函数单元测试(node:test,51 个用例)
└── api.test.js 318 行 数据层单元测试(localStorage 桩,23 个用例)
组织思路: HTML 放在根目录 —— 页面少、跳转都是相对路径,最省心;css/ 与 js/ 各自成目录,其中 js/core.js 是被页面、数据层、单元测试三方共用的关键文件;tests/ 只放单元测试;screenshots/ 只放博客素材(已在 .gitignore 中忽略,避免把图片塞进版本库)。整个项目没有任何 node_modules,因为零依赖。
5.2 测试人员如何运行
环境要求:
- 不需要安装任何环境、不需要联网、不需要启动服务器。
- 浏览器用谷歌 Chrome。
运行步骤:
- 把本项目所有文件下载到本地(保持目录结构不变)。
- 用 Chrome 打开
index.html即可。
首次打开会自动载入 10 条演示数据,直接就能浏览、搜索、发布。
常见问题:
| 情况 | 处理 |
|---|---|
| 想重置数据 | F12 打开 Console,执行 localStorage.removeItem('items') 后刷新,会重新载入 10 条演示数据 |
| 想跑单元测试(可选,仅开发自测) | 需要 Node.js ≥ 18,执行 npm test |
5.3 数据层接口约定(js/api.js)
本版本没有后端、也没有 REST API,取而代之的是 js/api.js 暴露的四个方法。它们全部读写浏览器
localStorage(键 items),并一律返回 Promise:
| 方法 | 说明 |
|---|---|
API.list(params) |
列表。params 含 type(lost/found)、category、q(搜索)、sort(newest)、publisher(uid)、status(默认 active,即排除已解决 = 自动下架;传 all 返回全部) |
API.get(id) |
详情。找不到时返回「未找到该条信息」 |
API.publish(item) |
发布。复用 Core.validatePublish 校验,非法时带 errors,由页面逐项回填 |
API.resolve(id) |
更新状态 → status:'resolved'(标记已找到 / 已归还 → 自动下架) |
数据模型:
{
"id": "t...",
"type": "lost | found",
"title": "物品名称",
"category": "证件卡类 | 钥匙 | 雨伞 | 水杯 | 耳机 | 图书 | 其他",
"location": "地点",
"time": "2026-10-02 12:30",
"description": "描述",
"contact": "联系方式",
"image": "",
"status": "active | resolved",
"publisher": "发布者匿名 uid(无登录方案)",
"publisherName": "发布者昵称(展示用,选填)",
"dept": "发布者学院(展示用,选填)",
"createdAt": 1790899200000
}
「已找到」还是「已归还」由 type 推导:lost → 已找到,found → 已归还。
注意:
localStorage是每台浏览器各一份、互不相通的(纯前端方案,没有共享服务器)。所以演示时每台电脑看到的是自己浏览器里的数据;「我的发布」也只含本机发布的条目。
六、单元测试
6.1 选用的测试工具,以及一份简易教程
我们选用 Node.js 内置的 node:test + node:assert。 不装 Mocha、Jest、Chai —— 项目本身就是零第三方依赖的,为了测试再引入一整套框架(连同它几十上百个传递依赖)是自相矛盾的。Node 从 18 开始内置了 node:test,对本项目完全够用。
我们整理的简易教程:
第 1 步:先理解单元测试在做什么。 一句话:给函数喂一个已知的输入,断言它吐出已知的输出。
// 被测函数(js/core.js)
function resolvedLabel(type) {
return type === 'lost' ? '已找到' : '已归还';
}
// 测试(tests/core.test.js)
test('寻物(lost)→ 已找到', () => {
assert.strictEqual(Core.resolvedLabel('lost'), '已找到');
});
第 2 步:建测试文件。 文件名以 .test.js 结尾放在 tests/ 下,用 CommonJS 引入被测模块:
const { test, describe } = require('node:test');
const assert = require('node:assert');
const Core = require('../js/core.js');
describe('validatePublish 表单校验', () => {
test('缺少物品名称时给出提示', () => {
const r = Core.validatePublish({ type: 'lost', title: '', category: '钥匙',
location: '三教', time: '10-02', contact: 'wx_a' });
assert.strictEqual(r.ok, false);
assert.strictEqual(r.errors.title, '请填写物品名称');
});
});
注意: 网上教程多用 ESM 的
import { test } from 'node:test'。但本项目package.json没有"type": "module",core.js走的是module.exports分支,用 ESM 还得额外配置。所以我们用等价的 CJS 写法。
第 3 步:在 package.json 里配一条命令。
"scripts": { "test": "node --test \"tests/**/*.test.js\"" }
之后 npm test 即可。
第 4 步:先写测试,再写实现。
常用断言速查:
| 断言 | 用途 |
|---|---|
assert.strictEqual(a, b) |
全等,最常用 |
assert.deepStrictEqual(a, b) |
对象 / 数组逐层比较 |
assert.ok(x) |
真值判断 |
assert.doesNotThrow(fn) |
断言不会抛异常 |
6.2 部分单元测试代码,以及它们测试的函数
被测对象分两部分:js/core.js 的 7 个纯函数(外加一条组合管线,共 51 个用例),以及数据层 js/api.js(23 个用例)。
(1)validatePublish —— 发布校验,重点在边界值
describe('validatePublish 表单校验', () => {
test('全部填好 → ok=true 且没有错误', () => {
const v = Core.validatePublish(OK);
assert.strictEqual(v.ok, true);
assert.deepStrictEqual(v.errors, {});
});
test('缺少必填项 → 逐项返回错误,且 ok=false', () => {
const v = Core.validatePublish({});
assert.strictEqual(v.ok, false);
assert.deepStrictEqual(Object.keys(v.errors).sort(),
['category', 'contact', 'location', 'time', 'title', 'type']);
});
test('边界:超过长度上限被拦下,刚好等于上限通过', () => {
const L = Core.LIMITS;
assert.strictEqual(Core.validatePublish(Object.assign({}, OK,
{ title: '伞'.repeat(L.title) })).ok, true); // 刚好等于上限 → 通过
assert.strictEqual(Core.validatePublish(Object.assign({}, OK,
{ title: '伞'.repeat(L.title + 1) })).errors.title,
'物品名称不超过 ' + L.title + ' 个字'); // 越界一格 → 拦下
});
});
(2)filterItems —— 组合筛选,重点在「自动下架」的语义
describe('filterItems 筛选', () => {
test('status=active 排除已解决条目(自动下架)', () => {
const active = Core.filterItems(NORMAL, { status: 'active' });
assert.ok(active.every(i => i.status === 'active'));
assert.ok(!active.some(i => i.id === 't-done'));
});
test('status=all 保留已解决条目(我的发布要看历史)', () => {
assert.strictEqual(Core.filterItems(NORMAL, { status: 'all' }).length, 4);
});
test('多条件组合是交集', () => {
const r = Core.filterItems(NORMAL, { type: 'lost', category: '钥匙', status: 'active' });
assert.deepStrictEqual(r.map(i => i.id), ['t-key']);
assert.deepStrictEqual(Core.filterItems(NORMAL, { type: 'found', category: '钥匙' }), []);
});
});
完整覆盖情况:
| 被测函数 | 用例数 | 验证内容 |
|---|---|---|
searchItems |
10 | 命中标题 / 描述、大小写与中文、搜不到、空关键词与纯空格返回全部、不改动原数组 |
filterItems |
9 | 按 type / category / publisher 筛选;status=active 的自动下架语义;多条件取交集 |
sortItems |
4 | newest 倒序、createdAt 缺失或相同、不改动原数组 |
escapeHtml |
3 | 五个 HTML 特殊字符、XSS 载荷、null/undefined/数字不抛异常 |
highlight |
9 | 高亮包裹、不破坏 HTML 实体、正则元字符不误伤、XSS 不注入 |
validatePublish |
9 | 各必填项缺失报错、非法 type 报错、长度上限的边界值、超长载荷被拦 |
resolvedLabel |
2 | lost → 已找到、found → 已归还 |
| 列表默认管线 | 5 | 复刻 API.list 的「搜索 → 筛选 → 排序」全流程,含「已解决条目默认被排除」 |
小计(core.test.js) |
51 |
此外,tests/api.test.js 用一个内存 Map 造 localStorage 桩,直接对真实数据层 js/api.js 下断言(23 个用例):
| 被测方法 / 场景 | 用例数 | 验证内容 |
|---|---|---|
| 种子初始化 | 4 | 首次读取灌 10 条;坏 JSON 退回种子;本地已有数据不被种子覆盖 |
API.list |
7 | 默认 active 自动下架;status=all 看历史;type / category / publisher 组合筛选;排序;空库返回 [] |
API.get |
2 | 按 id 取到条目;不存在时带中文提示 |
API.publish |
5 | 校验不通过不入库;合法表单落库且字段正确;去首尾空格;描述选填;超长被拦 |
API.resolve |
3 | 状态改 resolved 并持久化;不存在时报错;重复标记幂等 |
| 闭环 | 2 | 发布 → 首页可见 → 标记 → 首页消失,且搜索也搜不到 |
| 合计 | 74 |
6.3 构造测试数据思路,以及将来测试人员的刁难
思路:按四类数据组织,每一类解决一个不同的问题。
| 类型 | 举例 | 要验证什么 |
|---|---|---|
| 正常 | 校园卡、宿舍钥匙、黑色雨伞这类典型条目 | 主流程能走通 |
| 边界 | 空数组、空 / 纯空格关键词、首尾空格、createdAt 相同或缺失、字段长度刚好等于上限与刚好越界一格 |
不越界、也不误杀 |
| 异常 | 缺字段、字段为 null / undefined、非法 type |
不抛异常,且错误信息明确 |
| 恶意 | XSS 载荷 <img src=x onerror=...>、HTML 实体 '、正则元字符 .*+?^${}()|[]\、超长重复串 |
不注入、不报错、更不能被放行入库 |
将来测试人员可能怎么刁难我们?我们的预判和对策:
| 可能的刁难 | 我们的处理 |
|---|---|
| 输入空内容 / 只输空格 | str() 统一 trim(),纯空格视为空,用例已覆盖 |
| 输入超长内容撑爆列表 | LIMITS 长度上限,页面与数据层共用同一条规则 |
输入 <script> 之类的内容 |
所有渲染前过 escapeHtml,已用真实浏览器验证不产生可执行标签 |
搜索正则元字符(如 . *) |
关键词先转义再构造正则,用例覆盖「用 . 搜 a.b」 |
搜 39 这种会命中 HTML 实体的词 |
highlight 改成先切分再逐段转义(见 8.2),有专门的回归用例 |
| 连点提交按钮 / 用回车键提交 | 独立的 submitting 闸门,实测连点 3 次只提交 1 次 |
| 浏览器隐私模式 / 写入失败 | 写不进时退回内存种子,页面照常可看,不白屏 |
| 重复标记同一条 | 按钮 disabled + 数据 status 双重判断 |
| 猜别人的 uid 改别人的条目 | 这是已知短板,无登录方案挡不住(详见第十节) |
6.4 测试结果
七、GitHub 的代码签入记录
仓库地址: https://github.com/Xasteroid-star/102402113-102402114-102402115
按功能拆分提交粒度,每做完一个可验收的功能就 commit 一次,message 采用 feat / fix / docs / test / chore 前缀标明改动类型。

一部分commit截图:

八、遇到的代码模块异常或结对困难及解决方法
8.1 搜索页「点击无反应」
做过哪些尝试。 没有照着描述直接改代码 —— 先用 CDP 脚本(Node ≥ 22 内置 WebSocket 直连 Chrome 调试端口,仍然是零依赖)在真实浏览器里逐个点。结果 11 张结果卡片在桌面 1200×900 与手机 390×844 两种视口下全部跳转正常,也就是「卡片点击无跳转」根本复现不出来。把页面上所有可点击元素逐个测过去,才发现真正坏的是热门搜索:点了完全没反应。再用 git log 顺这个文件的改动史往回翻,看到了现场 —— 第二棒在给「搜索历史」加单条删除、重写那块渲染逻辑时,把 hotList 那行监听器连同周围代码一起删掉了,而且没有补回来。
是否解决。 解决了,但不是把那行加回去,而是改成事件委托:
// 监听器只绑一次,绑在「不会被重建」的父容器上;
// renderDefault() 无论重绘多少次,监听器都还在。
hotList.addEventListener('click', function (e) {
var row = e.target.closest('.h');
if (row) doSearch(row.dataset.kw);
});
有何收获。报障描述 ≠ 根因,照着描述改,改一天也修不好; git log 是排查回归最有效。
8.2 Core.highlight 把 HTML 实体劈成两半
问题描述。 首页有条数据把 ' 显示成了字面的 '。顺着查下去,是 items.map(UI.itemCard) 把数组下标当成了高亮关键词传进去,下标 9 让 <mark> 插进了转义产物 ' 的中间。修掉调用方之后再往下想一层:highlight 自己呢?它的实现同样是「先整体转义、再在转义结果里 replace」——病根完全一样,而且它是被页面、数据层、测试三方共用的。
做过哪些尝试。 两个版本对跑:highlight("张三'的雨伞", "39"),修复前输出 张三&#<mark>39</mark>;的雨伞(实体被劈开),修复后输出 张三'的雨伞。
是否解决。 解决了,改成「先按关键词切分原文、再逐段转义并包 <mark>」(代码见 3.3(2))。要点:捕获组让关键词落在 split 结果的奇数下标上,<mark> 只可能出现在段与段之间,结构上杜绝了劈开实体;每段仍逐段 escapeHtml,XSS 防护不变。
有何收获。 如果只修首页那一个调用点,这颗雷还埋在 core.js 里,会从搜索页、详情页反复冒出来。这个缺陷是靠写测试挖出来的 —— 要触发它,得刚好搜 39、amp 这种命中 HTML 实体的词,真人几乎不可能碰巧这么搜。
8.3 三次「假 bug」
自动化脚本报的错,第一嫌疑人是脚本自己。 我们在这上面栽了三次:断言里漏了一个斜杠('detail.html' 写成 '/detail.html' 才对)、点击坐标落在手机视口之外(补 scrollIntoView 才好)、断言等的是「网络请求结束」——可纯静态页面根本没有请求,等待条件从头就是错的(改成等某个 DOM 节点出现才对)。这三条当时如果不较真,很可能就被当成应用 bug 去「修」了 —— 那比不测试更糟:不但浪费一天,还会把本来正确的代码改坏。
九、评价你的队友
谢慧彬(102402114)—— 第一棒,打地基的人
值得学习的地方。
- 架构选型有远见。 把
core.js抽成被页面、数据层、测试三方共用的纯函数模块,这个决定直接让第三棒的单元测试变得可行,也让「页面上校验的规则」和「数据层里执行的规则」不可能不一致。这不是顺手能想到的,需要提前判断「什么会被复用」。 - 接口设计克制而干净。 数据层只暴露
list / get / publish / resolve四个方法,其中「status缺省为active」这一个约定,同时实现了首页的自动下架和「我的发布」的历史回看 —— 没有多余的方法,也没有为「将来可能用到」而预先设计的抽象。 - 交接干净。 仓库建好、目录清晰、README 初稿到位。
需要改进的地方。
- 发布页与我的发布交接时仍是半成品:表单校验规则虽已沉淀在
core.js,但页面还没接上,用户要提交之后才知道哪里错了;收尾没跟上:后面增加纯静态,需要重新测试。
吴佳仪(102402113)—— 第三棒,闭环、测试、收尾
值得学习:
- 没被表面现象带跑,追到真正坏的地方。
- 修在根上:事件委托、改
highlight,不打补丁。 - 用自动化证明做完:74 个单测 + CDP 取证。
- 闭环意识强,测试、取证、交接串起来。
需要改进:
core.test.js查询流程是复刻,应让api.test.js直接打真实数据层。- 只测了纯函数,DOM 逻辑没单测,不能自动回归。
- 没覆盖“清空数据 → 个人主页重置”路径。
- 没登录、不共享数据,匿名 uid 存
localStorage。 - 多标签页并发写无保护,后写覆盖先写。
十、自己的反思与不足(B)
做对的两件事:
- 接住了体验优化这一棒,并主动补全了漏网之鱼。 除了搜索历史单条删除、状态栏实时时间,还顺手把
my.html、detail.html、publish.html三个漏改的状态栏补全了。 - 在踩坑中补齐了工程基本功。 从
node server.js没反应、到src refspec does not match any、再到nothing to commit,一步步把环境、分支、commit、PR 走通,真正理解了“本地改 → 提交 → 推送 → PR → 合并”这条链路。
还不够的地方:
- 优化时引入了一个回归。 重写历史记录渲染、加单条删除小叉号时,误删了热门搜索的事件绑定,是 C 同学收尾测试时发现的。这提醒我:重写渲染逻辑时,同一区域其他交互也必须一并回归。
- 对“直接打开 HTML 就能用”这条要求反应太晚。 一路都在
node server.js下测试,没人早一点对照作业要求做交付形态核对。如果我早问一句“助教不装 Node 能不能看”,矛盾会更早暴露。 - 测试只测“能跑”,没测“边界”。 删掉
data/items.json后页面空白,一度以为数据坏了,后来发现是代码只写回空数组、不重建种子数据。这让我意识到:测试还要主动制造“文件被删、数据为空”这类异常场景。
如果重来一次,我们会改:
- 先写测试,再写实现。
highlight那个实体劈开的缺陷,如果一开始就有针对性用例,不会拖到写测试阶段才发现。 - 接棒前先数清楚要改几个文件、几个页面,再报 PSP 预估。 这是这次 PSP 给出的最明确教训。

浙公网安备 33010602011771号