2026秋软件工程结对作业(第二次)——校园失物招领「拾回」程序实现

2026秋软件工程结对作业(第二次)——校园失物招领「拾回」程序实现

项目 内容
这个作业属于哪个课程 H202601软件工程与软件工程实践
这个作业要求在哪里 2026秋软件工程结对作业(第二次)——程序实现
这个作业的目标 基于第一次结对作业的原型设计,完成其核心功能,使「发布信息—浏览或搜索—查看详情—联系发布者—更新状态」的基本流程清晰、可用
学号 姓名
102402113 吴佳仪
102402114 谢慧彬
102402115 张润

本作业博客:102402114谢慧彬(第二次结对作业)
结对同学的博客: 102402113吴佳仪(第二次结对作业) / 102402115张润(第二次结对作业)
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 → 渲染」。

整体分三层:

  1. 数据层 —— js/api.js 读写浏览器 localStorage(键 items)完成持久化,对外只暴露 list / get / publish / resolve 四个方法;没有后端、没有网络请求。首次打开(或本地数据损坏)时,把 js/seed.js 里的 10 条演示数据灌进本地。
  2. 业务逻辑层 —— js/core.js,全部是纯函数(无 DOM、无网络、无文件 IO):搜索、筛选、排序、HTML 转义、关键词高亮、发布校验、状态文案。
  3. 视图层 —— 5 个 HTML 页面 + css/style.css + js/ui.js(卡片渲染、Toast、一键复制、匿名 uid)。

为什么这样分? 因为「测试」这件事,必须在架构上先留好位置。如果业务规则散落在各个页面的 DOM 事件里,单元测试就只能靠模拟浏览器,成本和稳定性都会崩掉。把规则抽成纯函数之后,测试只需要 require 一个文件。

状态闭环怎么走? 这是本次的核心。发布者标记「已找到 / 已归还」后,条目 status 从 active 变成 resolved。首页取数时不带 status,数据层默认按 active 过滤,于是条目自然从信息流里消失(自动下架);而「我的发布」显式要 status=all,所以历史记录还在。没有删除任何数据,一条筛选规则同时满足了两个界面。

3.2 关键流程图

(1) 分层架构与数据流

12-分层架构与数据流

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

13-核心业务闭环

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;,关键词一旦命中实体中间的字符(比如搜 39),<mark> 就会被插到实体内部,把 &#39; 劈成 &#3<mark>9</mark>;。实体被劈开后不再是转义序列,浏览器会把它当普通文本显示 —— 用户看到的就是字面的 &#39;。


四、附加特点设计与展示

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 关键代码片段

  1. 写入端: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(...) —— 数据层内部怎么存、怎么取,调用方完全无感。

  1. 读取端: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。

运行步骤:

  1. 把本项目所有文件下载到本地(保持目录结构不变)。
  2. 用 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 实体 &#39;、正则元字符 .*+?^${}()|[]\、超长重复串 不注入、不报错、更不能被放行入库

将来测试人员可能怎么刁难我们?我们的预判和对策:

可能的刁难 我们的处理
输入空内容 / 只输空格 str() 统一 trim(),纯空格视为空,用例已覆盖
输入超长内容撑爆列表 LIMITS 长度上限,页面与数据层共用同一条规则
输入 <script> 之类的内容 所有渲染前过 escapeHtml,已用真实浏览器验证不产生可执行标签
搜索正则元字符(如 . *) 关键词先转义再构造正则,用例覆盖「用 . 搜 a.b」
搜 39 这种会命中 HTML 实体的词 highlight 改成先切分再逐段转义(见 8.2),有专门的回归用例
连点提交按钮 / 用回车键提交 独立的 submitting 闸门,实测连点 3 次只提交 1 次
浏览器隐私模式 / 写入失败 写不进时退回内存种子,页面照常可看,不白屏
重复标记同一条 按钮 disabled + 数据 status 双重判断
猜别人的 uid 改别人的条目 这是已知短板,无登录方案挡不住(详见第十节)

6.4 测试结果

61c1acb2-d9f6-4e55-a69c-5e5cd3ba5c1f

七、GitHub 的代码签入记录

仓库地址: https://github.com/Xasteroid-star/102402113-102402114-102402115

按功能拆分提交粒度,每做完一个可验收的功能就 commit 一次,message 采用 feat / fix / docs / test / chore 前缀标明改动类型。
99677cddb1528f3740f23c4c3837f393_720
一部分commit截图:
d07a13316af74dd608468be5788805ff


八、遇到的代码模块异常或结对困难及解决方法

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 实体劈成两半

问题描述。 首页有条数据把 ' 显示成了字面的 &#39;。顺着查下去,是 items.map(UI.itemCard) 把数组下标当成了高亮关键词传进去,下标 9 让 <mark> 插进了转义产物 &#39; 的中间。修掉调用方之后再往下想一层:highlight 自己呢?它的实现同样是「先整体转义、再在转义结果里 replace」——病根完全一样,而且它是被页面、数据层、测试三方共用的。

做过哪些尝试。 两个版本对跑:highlight("张三'的雨伞", "39"),修复前输出 张三&#<mark>39</mark>;的雨伞(实体被劈开),修复后输出 张三&#39;的雨伞。

是否解决。 解决了,改成「先按关键词切分原文、再逐段转义并包 <mark>」(代码见 3.3(2))。要点:捕获组让关键词落在 split 结果的奇数下标上,<mark> 只可能出现在段与段之间,结构上杜绝了劈开实体;每段仍逐段 escapeHtml,XSS 防护不变。

有何收获。 如果只修首页那一个调用点,这颗雷还埋在 core.js 里,会从搜索页、详情页反复冒出来。这个缺陷是靠写测试挖出来的 —— 要触发它,得刚好搜 39、amp 这种命中 HTML 实体的词,真人几乎不可能碰巧这么搜。

8.3 三次「假 bug」

自动化脚本报的错,第一嫌疑人是脚本自己。 我们在这上面栽了三次:断言里漏了一个斜杠('detail.html' 写成 '/detail.html' 才对)、点击坐标落在手机视口之外(补 scrollIntoView 才好)、断言等的是「网络请求结束」——可纯静态页面根本没有请求,等待条件从头就是错的(改成等某个 DOM 节点出现才对)。这三条当时如果不较真,很可能就被当成应用 bug 去「修」了 —— 那比不测试更糟:不但浪费一天,还会把本来正确的代码改坏。


九、评价你的队友

吴佳仪(102402113)——第三棒,闭环、测试、收尾

值得学习:

  • 没被表面现象带跑,追到真正坏的地方。
  • 修在根上:事件委托、改 highlight,不打补丁。
  • 用自动化证明做完:74 个单测 + CDP 取证。
  • 闭环意识强,测试、取证、交接串起来。

需要改进:

  • core.test.js 查询流程是复刻,应让 api.test.js 直接打真实数据层。
  • 只测了纯函数,DOM 逻辑没单测,不能自动回归。
  • 没覆盖“清空数据 → 个人主页重置”路径。
  • 没登录、不共享数据,匿名 uid 存 localStorage。
  • 多标签页并发写无保护,后写覆盖先写。

张润(102402115)—— 第二棒,做体验的人

值得学习的地方。

  • 交互细节超出作业要求。 搜索历史支持单条删除这个细节,任务书根本没要求,但确实让页面更好用;热门搜索、状态栏实时时间、收藏分享这些也都做出了质感。
  • 代码风格统一。 沿用了第一棒的注释口吻和 Core 复用习惯,第三棒接手时几乎没有阅读成本,说明交接沟通是到位的。
  • 按约定 push 并发了 PR,流程规范。

需要改进的地方。

  • 搜索页热门搜索无法点击,是这个环节引入的回归。 在给搜索历史加单条删除、重写那块渲染逻辑时,误删了热门搜索的监听器绑定。**

十、自己的反思与不足

做对的三件事:

  1. 架构选型有远见。 把 core.js 抽成被页面、数据层、测试三方共用的纯函数模块,这个决定直接让第三棒的单元测试变得可行,也让「页面上校验的规则」和「数据层里执行的规则」不可能不一致。这不是顺手能想到的,需要提前判断「什么会被复用」。
  2. 接口设计克制而干净。 数据层只暴露 list / get / publish / resolve 四个方法,其中「status 缺省为 active」这一个约定,同时实现了首页的自动下架和「我的发布」的历史回看 —— 没有多余的方法,也没有为「将来可能用到」而预先设计的抽象。
  3. 交接干净。 仓库建好、目录清晰、README 初稿到位。

需要改进的地方:

  • 没先看作业要求,第一版技术方向就偏了。 作业明确要求:PC 端 Web 网页统一用谷歌浏览器,其他同学下载所有文件后,用谷歌浏览器运行 HTML 文件就能展现预期结果。但一开始却做了偏后端的版本,server.js、require、数据层这些都不是“下载后直接打开 HTML”能跑的。
  • 发布页与我的发布交接时仍是半成品:表单校验规则虽已沉淀在 core.js,但页面还没接上,用户要提交之后才知道哪里错了。

如果重来一次,我们会改:

  1. 先写测试,再写实现。 highlight 那个实体劈开的缺陷,如果一开始就有针对性用例,不会拖到写测试阶段才发现。
  2. 接棒前先数清楚要改几个文件、几个页面,再报 PSP 预估。 这是这次 PSP 给出的最明确教训。
posted @ 2026-10-09 00:31  Xx__X  阅读(12)  评论(0)    收藏  举报