2026秋软件工程结对作业(第二次之程序实现)

2026秋软件工程第二次结对作业(程序实现):校园失物招领 Web 应用

这个作业属于哪个课程 H202601软件工程与软件工程实践
这个作业要求在哪里 2026秋软件工程结对作业(第二次之程序实现)
这个作业的目标 结对实现"校园失物招领"的核心模块,完成发布、浏览、搜索、详情与状态更新的完整流程
学号 102401511 / 102401512
GitHub 仓库 https://github.com/ren3717/102401511-102401512

结对成员:何锦宏(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

耗时分析(哪里估错了、为什么):

  1. 测试环节超时最多(预估 110 分钟,实际 170 分钟)。原以为写完用例跑通就行,
    实际花在"设计用例"上的时间远超预期——尤其是要覆盖边界值和异常输入,
    光是想清楚"物品名称长度"这一个字段该测哪几个值就讨论了很久。
    不过这部分多花的时间是值得的,它真的找出了两个 bug(详见第六节)。
  2. 具体编码超时 100 分钟。主要卡在两个地方:一是图片压缩上传(要用 canvas 处理、
    还要考虑存储容量),二是同一毫秒内发布多条信息导致排序不稳定这个隐蔽问题。
  3. 需求分析超时 30 分钟。第一次作业的原型只考虑了"顺利路径",
    这次要把"发布信息不完整""搜索无结果""信息已被标记为已结束"这些分支也梳理出来,
    讨论的时间比预想的多。
  4. 唯一低于预估的是"Coding Standard"和"Size Measurement",
    因为我们在开工前就约定好了提交规范(Conventional Commits)和代码风格,没有额外花时间。

三、解题思路描述与设计实现说明

3.1 技术选型:为什么选择 Web 而不是 App

作业允许在 Web 和 App 中二选一,我们选择 Web(PC 端网页),理由有三点:

  1. 验收成本最低。作业要求"下载所有文件后用谷歌浏览器运行 html 文件就能展现预期结果"。
    纯静态网页不需要安装 Android SDK、不需要连模拟器,助教下载解压后双击即可,
    不会因为环境问题导致演示失败。
  2. 与第一次作业的原型一致。原型设计的就是小程序式的移动端界面,
    Web 可以复用同一套交互逻辑和配色,不需要重新设计。
  3. 便于分工。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 核心流程与异常分支

老师在第一次作业的反馈中提到:"不能只按一条顺利成功的路线设计产品"。
因此这次我们把每个流程的失败分支也画进了流程图,并且每一条都有对应的界面反馈。

image

流程一(发布信息):选择类型 → 填写信息 → 校验 → 通过则写入并跳转成功页。
校验不通过时,错误信息逐项显示在对应字段下方、输入框标红,并自动滚动到第一个出错的字段。

流程二(浏览/搜索):进入首页 → 浏览或输入关键词 → 筛选 → 有结果显示列表(关键词高亮),
无结果显示空状态提示与"重置筛选"按钮。

流程三(联系与更新状态):查看详情 → 判断"是不是我发布的"(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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

解释:物品名称、描述、联系方式都是用户自由输入的,如果直接拼进 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 成果展示

首页(多维筛选 + 数据概览) 搜索页(关键词高亮 + 搜索历史)
image image
发布页(类型切换 + 表单校验) 我的发布(状态管理)
image image
详情页(进行中:可复制联系方式) 详情页(已结束:状态提示条)
image image

五、目录说明与使用说明

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 测试人员如何运行

不需要安装任何环境、不需要联网、不需要启动服务器。

  1. 从 GitHub 下载本项目全部文件(保持目录结构不变)
  2. 用谷歌浏览器(Chrome)打开 index.html

首次打开会看到 10 条演示数据。建议按下面的顺序体验完整流程:

步骤 操作 验证的功能
1 首页切换「全部 / 寻物 / 招领」,使用分类、地点、状态筛选 浏览与筛选
2 顶部搜索框输入「校园卡」回车 关键词搜索
3 点击任意卡片「查看详情」 查看详情
4 详情页点「📋 复制联系方式」 联系发布者
5 右上角「+ 发布信息」,填写后提交 发布信息 → 发布成功
6 进入「我的发布」,点「✓ 标记为已找到 / 已归还」 更新状态
7 回到首页,确认状态已同步 状态同步

运行单元测试:用 Chrome 打开 tests/test.html,页面会自动运行并显示结果(正常应全绿)。

补充说明:如果想重新演示,可以在「我的发布」页面点击「恢复演示数据」,
数据会回到初始状态。所有数据都保存在本机浏览器中,清空浏览器数据即恢复出厂状态。


六、单元测试

6.1 测试工具的选择与学习过程

我们选用的工具是自己写的一个极简测试框架 mini-test.js(约 120 行)。

这个决定是在踩坑之后做出的。一开始我们打算用 Jest,但很快发现两个问题:

  1. Jest 需要 Node.js 和 npm,项目就变成"助教必须先装环境才能跑测试",
    与作业"下载后双击就能运行"的验收方式冲突;
  2. 我们真正的测试目标是两个不依赖 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 测试结果

image

117 / 117 个用例通过        (25 个测试分组,耗时约 4ms)

对测试完备性的自我评价:目前覆盖了全部核心逻辑(校验、增删改查、搜索、状态更新、容错),
但我们清楚地知道还有没覆盖到的部分:

  • 页面交互层没有自动化测试。比如"点击发布按钮后是否正确跳转"这类行为,
    我们目前靠人工走查(参照第五节的体验顺序逐条验证),没有做端到端测试。
    如果要补,需要引入 Puppeteer 之类的工具,本次时间不够。
  • 多图上传、图片压缩的实际效果依赖浏览器 API(Canvas),
    在我们的测试框架里没法验证,只能人工测试。
  • 并发场景没有测试。localStorage 本身没有并发问题,但如果将来换成后端接口,
    两个用户同时标记同一条信息就需要重新考虑。

我们认为这三点是"已知的未知",写在这里是为了让测试人员知道边界在哪里,
也能让下一版的改进方向更清楚。


七、GitHub 代码签入记录

image

本项目共 11 次提交,遵循约定式提交规范(Conventional Commits),
按功能模块划分,每完成一个功能提交一次:

提交前缀 含义 次数
chore 构建、工具、配置改动 1
feat 新功能 6
style 样式与界面调整 1
test 测试相关 1
docs 文档 1

八、遇到的困难及解决方法

困难一:透明点击热区在预览时不能响应

问题描述:做原型的时候,我们把墨刀里的透明矩形盖在卡片上当作点击热区,
但预览时点它没有任何反应,跳转不生效。

做过的尝试:

  1. 检查交互设置,确认"点击 → 跳转到页面"确实绑上了;
  2. 怀疑是图层顺序问题,把热区调到最上层——还是不行;
  3. 换成不透明的矩形测试,发现能正常跳转。这就定位到了问题:
    透明度的设置方式不对,我们用的是一个"看起来透明"的方式,
    而这个方式下组件实际不接收点击事件。

是否解决:解决了。改用"填充色透明度设为 0"的方式,而不是把整个组件的可见性关掉,
点击就能正常响应了。这也直接影响了这次程序实现——我们在 Web 版里做详情页跳转时,
特意避免了"透明区域"这种方案,直接用卡片本身绑定事件,从根上绕开了这个问题。

有何收获:界面元素"看起来在"和"能被操作"是两件不同的事。
这次经历让我们在做 Web 版时,更倾向于用语义正确的方式实现交互,
而不是靠视觉上的取巧。

困难二:图片上传后存储空间被撑爆

问题描述:发布页做完图片上传后,测试时选了一张某次活动的照片,
结果发布失败,控制台报 QuotaExceededError。而且失败之后,
之前的数据也变得不完整了。

做过的尝试:

  1. 先确认是不是文件太大——用手机拍的照片确实有 3~5MB;
  2. 想过限制上传张数,但一张照片就能超限,限制张数解决不了问题;
  3. 又想过干脆不做图片功能,但作业的原型里有照片,删掉功能是退步;
  4. 最后查到浏览器可以在上传前用 Canvas 压缩图片,就试了一下。

是否解决:解决了,而且做得比"能用"更完善了一些:

  • 上传时用 canvas 把图片最长边压到 900px、JPEG 质量 0.72,
    实测一张 3MB 的照片压完只有 60~80KB;
  • 压缩后仍超过 800KB 的图片直接拒绝并提示,不让它把存储写满;
  • 写入捕获 QuotaExceededError,返回明确提示"浏览器存储空间已满,请删除部分带图片的信息后重试",
    而不是默默失败;
  • 专门写了两个测试用例覆盖这些异常路径。

有何收获:第一,遇到问题先定位而不是先绕开——"到底哪一步撑爆了存储"这个问题问清楚了,
方案自然就出来了。第二,失败路径要当成功能来设计,捕获异常并给出人能看懂的提示,
和让功能正常工作同样重要。

困难三:两人并行开发时的接口约定

问题描述:确定分工后我们分头开发,任奥辉做页面、何锦宏做数据层。
结果第一次合并时代码跑不起来——页面里调用的是 store.getList(),
而数据层提供的方法叫 store.query(),参数和返回值也对不上。

做过的尝试:

  1. 一开始想"边写边改",谁发现问题谁改,但改了几次之后两边都乱了;
  2. 后来决定先停下来,把数据层的接口写在文档里:方法名、参数、返回值的格式都写清楚,
    包括失败时返回什么。例如统一约定"所有方法返回 {ok:true/false, ...}";
  3. 页面先按约定调用,数据层按约定实现,写完一个方法就提交一次。

是否解决:解决了。合并后的第一次联调就基本跑通了。

有何收获:接口先定、再并行开发,比"先写起来再说"效率高得多。
这次的经验也让我们理解了为什么要有"设计复审"这个环节——
它不是形式,而是避免返工的真正手段。


九、评价队友

何锦宏(102401511)

值得学习的地方:

  1. 需求梳理能力强。他在动手写代码前,先把第一次作业的原型逐条对着作业要求过了一遍,
    明确指出"状态由谁改、在哪改、改完在哪看"这三问没有回答清楚,
    还画了一张状态流转图。这个问题定义清楚了,我写代码的时候方向很明确。
  2. 对异常路径有敏感度。我写数据层时只想着"正常工作时要返回什么",
    他提醒我"id 不存在怎么办、存储满了怎么办、数据被改坏了怎么办",
    后来这些异常处理成了我们单元测试用例的主要来源。
  3. 文档意识好。他坚持把接口约定写成文档再开工,虽然当时觉得有点费时间,
    但正是这一步让我们后来的合并很顺利。

需要改进的地方:

  1. 写代码时注释偏少,有些函数名字比较简略,我读的时候要花时间理解意图。
    后期我们约定"复杂逻辑必须写清楚为什么这么写",情况有所改善。
  2. 有时会同时展开好几个功能,导致某几个提交里的改动混在一起,
    不好回溯。后来我们约定"一次提交只做一件事"。

任奥辉(102401512)

这次主要负责页面与测试。做得比较好的地方是把页面结构写清楚、
按时完成了单元测试;需要改进的地方是前期对边界情况考虑不足,
有两个 bug 是靠测试才发现的,说明写代码时"想到正常情况就动手了",
以后应当在动手前多问自己几句"如果输入不合法会怎么样"。


十、结语

对比第一次作业,这次最大的不同是:原型里的每个方块,都要真的跑起来才算数。

第一次画原型时,我们默认用户会规规矩矩地填完整所有字段、搜索一定能搜到东西、
信息发出去就一定有人回应。真正开始写代码才发现,"不顺利"才是常态——
表单会有漏填,搜索会没结果,物品可能一直没人认领,照片可能太大传不上去。
把这些"不顺利"一个个处理掉,程序才算真的能用。

另一个体会来自单元测试。写之前我们觉得"测试不就是跑一遍确认能用吗",
真正动手才发现,测试的价值在于它逼你把每个函数的边界想清楚。
那两个被测试揪出来的 bug,就是我们"想当然"的代价。
以后再写程序,我们会把"这个函数什么情况下会出错"当成设计的一部分,
而不是等出了问题再回头补。

posted @ 2026-10-06 20:38  ren3717  阅读(2)  评论(0)    收藏  举报