第四次作业

校园失物招领 —— 结对编程作业(第四次)

〇、结对信息


一、具体分工

本作业由 102401510 林富强 独立完成,全部模块(页面骨架、core.js 逻辑层、app.js 界面层、样式、演示数据、单元测试、README、博客)均为本人编写。所有提交均发生在同一仓库,无需 fork / Pull Request 协作。


二、PSP 表格

预估耗时在动手写代码之前记录,实际耗时在完成后如实回填。
差异最大的两项(具体设计、具体编码)在"九、过程总结"中分析原因。

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划
Estimate 估计这个任务需要多少时间 150 130
Development 开发
Analysis 需求分析(包括学习新技术) 120 90
Design Spec 生成设计文档 90 60
Design Review 设计复审 30 45
Coding Standard 代码规范(为目前的开发制定合适的规范) 30 20
Design 具体设计 120 180
Coding 具体编码 480 620
Code Review 代码复审 60 90
Test 测试(自我测试,修改代码,提交修改) 120 160
Reporting 报告
Test Report 测试报告 60 55
Size Measurement 计算工作量 30 20
Postmortem & Process Improvement Plan 事后总结,并提出过程改进计划 60 50
合计 1350 1520

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

3.1 代码实现思路(文字描述)

一句话:把业务规则与界面彻底分开,让"最难出错的部分"可以被测试。

  • 三层结构:core.js(纯逻辑层,不依赖 DOM,浏览器 / Node 双环境加载)→ app.js(界面层:路由、渲染、事件)→ index.html(骨架)。业务规则只存在于 core.js,界面层不写业务判断,因此单元测试可以直接覆盖全部核心逻辑。
  • 路由:采用 hash 路由(#/list、#/item/:id、#/publish、#/mine),双击 HTML 即可用,无需服务器。
  • 不可变更新:状态流转 / 增删改一律返回新数组,原数组不被原地修改,配合"重新渲染"模式,界面状态永远和数据一致,不会出现"改了数据页面没跟上"。
  • 数据管线:搜索(多关键词 AND + 高亮)→ 筛选(类型 / 分类 / 区域 / 状态)→ 排序(未归还优先 + 时间序),三层串联为一条 query() 管线。
  • 持久化:localStorage 存 JSON;不可用时自动降级内存存储,保证任何环境下页面可用。
  • 安全:所有用户输入拼接进 HTML 前必须经过 escapeHtml 转义,防止发布内容注入脚本。

3.2 关键流程图 / 数据流图

① 系统数据流图

flowchart LR U[用户] --> UI[index.html 界面] UI --> APP[app.js 界面层<br>路由 / 渲染 / 事件委托] APP --> CORE[core.js 逻辑层<br>校验 / 检索 / 筛选 / 排序<br>状态流转 / 统计] CORE --> STORE[(localStorage<br>lost-found/items/v1)] CORE -.不可用时降级.-> MEM[(内存存储)] TEST[test/core.test.js<br>91 个单元测试] -.直接调用.-> CORE

② 核心业务流程(发布 → 浏览/搜索 → 详情 → 联系 → 更新状态)

flowchart TD A[首页信息流] --> B{浏览或搜索} B -- 搜索关键词 --> C[query 管线<br>搜索→筛选→排序] C --> A B -- 没找到想要的 --> D[发布信息] D --> E[表单校验<br>必填/格式/长度] E -- 不通过 --> F[逐项标红+提示] F --> E E -- 通过 --> G[createItem 构造记录] G --> H[写入 localStorage<br>发布成功弹层] H --> A B -- 找到相关物品 --> I[详情页] I --> J[联系 TA / 复制联系方式] J --> K[线下当面交接] K --> L{找回了?} L -- 是 --> M[我的发布 → 标记已归还] M --> N[changeStatus 不可变更新<br>列表/详情/筛选计数同步] N --> A L -- 否 --> A

③ 状态流转图

stateDiagram-v2 [*] --> 寻找中: createItem 发布 寻找中 --> 已归还: 发布者标记(changeStatus) 已归还 --> 寻找中: 撤销误操作(changeStatus) 寻找中 --> [*]: 发布者删除(removeItem) 已归还 --> [*]: 发布者删除(removeItem)

3.3 重要代码片段与解释

片段 1:搜索 → 筛选 → 排序的查询管线(core.js)

/** 列表页的完整管线:先搜、再筛、最后排序 */
function query(items, options) {
  var o = options || {};
  var result = searchItems(items, o.keyword);   // 多关键词 AND 匹配
  result = filterItems(result, {                // 类型/分类/区域/状态
    type: o.type, category: o.category,
    area: o.area, status: o.status
  });
  return sortItems(result, o.sort);             // 未归还优先 + 时间序
}

解释:三个环节各是一个纯函数、各自可单测,query 只负责把它们串起来。任何一环出问题,单元测试能直接定位到函数,而不是从整页渲染里猜。

片段 2:状态流转的不可变更新(core.js)

function changeStatus(items, id, status, now) {
  if (status !== STATUS.OPEN && status !== STATUS.DONE) return null;
  // 找到目标下标;不存在返回 null
  var next = list.slice();                       // 复制数组
  next[idx] = Object.assign({}, list[idx],
    { status: status, updatedAt: d.toISOString() });
  return next;                                   // 只返回新数组,不改原数据
}

解释:"已归还"是本次作业最容易出流程 bug 的地方(谁来改、从哪改、别人在哪看到结果)。我们约定:只有发布者能改(界面上只有"我的信息"才出现改状态按钮),改动走不可变更新 + 全量重渲染,列表卡片、详情、侧栏计数、找回率一次性同步。

片段 3:XSS 防护(core.js)

function escapeHtml(value) {
  return str(value).replace(/[&<>"']/g, function (c) {
    return { '&': '&amp;', '<': '&lt;', '>': '&gt;',
             '"': '&quot;', "'": '&#39;' }[c];
  });
}

解释:发布的内容是任意同学的输入,标题里写 <img onerror=...> 就能在别人页面上执行脚本。因此所有用户输入在拼接进 innerHTML 之前都必须过这一道。高亮函数也是先转义再匹配,保证转义实体不会被关键词切开。

片段 4:localStorage 失效兜底(core.js)

function pickStorage(preferred) {
  var candidates = [];
  if (preferred) candidates.push(preferred);
  try { if (typeof localStorage !== 'undefined') candidates.push(localStorage); }
  catch (e) { /* 忽略 */ }
  candidates.push(memoryStorage());   // 内存兜底
  for (var i = 0; i < candidates.length; i++) {
    try {
      var probe = '__lf_probe__';
      candidates[i].setItem(probe, '1');
      candidates[i].removeItem(probe); // 真实写入探测
      return candidates[i];
    } catch (e) { /* 换下一个 */ }
  }
  return memoryStorage();
}

解释:Chrome 在 file:// 下、隐私模式下 localStorage 可能不可用或抛异常。用一次真实写入探测,失败就自动降级,保证助教在任何环境双击 HTML 都不会白屏。


四、附加特点设计与展示

特点 1:四维筛选 + 实时计数 + 一键重置

  • 意义:信息多了之后"浏览"就退化成"翻页",按类型 / 分类 / 区域 / 状态筛选,让"我想找图书馆的耳机"变成三次点击。
  • 实现思路:侧栏每个筛选项后显示当前数量(countBy 统计),选中即成为筛选条件,顶部出现可单独移除的"筛选胶囊"。
  • 成果展示:【待截图:首页选中"区域:图书馆"后的界面,注意计数与胶囊】

特点 2:一键复制联系方式 + 类型自动识别

  • 意义:联系发布者是流程的终点,手抄手机号 / 微信号最容易出错。识别出联系方式类型并一键复制,直接减少"加错人"。
  • 实现思路:classifyContact 按 手机号 → QQ → 微信 → 邮箱 的顺序正则识别(顺序本身有测试锁住);复制走 navigator.clipboard,file:// 下不可用时自动退回 execCommand 兜底。
  • 成果展示:【待截图:详情页"联系 TA"弹层 + 复制成功 toast】

特点 3:照片上传自动压缩(最长边 720px)

  • 意义:手机照片 3~5MB,两三张就会撑爆 localStorage 的 5MB 配额导致整站存不进去。压缩后单张约几十 KB。
  • 实现思路:FileReader → Image → Canvas 等比缩放 → toDataURL('image/jpeg', 0.72),压缩发生在存入之前。
  • 成果展示:【待截图:发布页已上传 2 张照片的预览】

特点 4:「我的发布」看板:找回率统计 + 清空

  • 意义:发布者需要一个"我发出的信息现在什么状态"的入口;找回率让"状态维护"这件事有了正反馈。
  • 实现思路:stats 函数统计 我发布的 / 寻找中 / 已归还 / 找回率(四舍五入百分比)。
  • 成果展示:【待截图:"我的发布"页统计条】

(另有:搜索关键词高亮、空结果引导、安全提示、多关键词 AND 搜索等,一并算作人性化设计,见功能清单。)


五、目录说明和使用说明

(详见 GitHub 仓库 README,与博客同步摘录)

├── index.html           首页入口(唯一需要打开的页面)
├── css/style.css        全部样式
├── js/core.js           纯逻辑层(校验/检索/状态流转/持久化),可被单测直接引入
├── js/seed.js           首次打开自动载入的演示数据
├── js/app.js            界面交互层(路由/渲染/事件/表单/图片压缩)
└── test/
    ├── index.html       单元测试报告页(Chrome 直接打开即跑)
    ├── core.test.js     91 个测试用例
    └── mini-test.js     零依赖测试框架(API 仿 Jest)

测试人员如何运行:

  1. 主程序:Chrome 双击 index.html(无任何环境要求,首次打开自动载入演示数据);
  2. 单元测试:Chrome 打开 test/index.html,自动运行 91 个用例并显示报告;
  3. 数据说明:数据存在浏览器 localStorage(键 lost-found/items/v1),F12 Console 执行 localStorage.removeItem('lost-found/items/v1') 后刷新可恢复演示数据。

六、单元测试

6.1 测试工具与简易教程

选用工具:自研零依赖框架 mini-test.js(约 200 行),API 仿 Jest。

为什么不用 Jest / Mocha:本作业硬性要求"下载全部文件后用 Chrome 直接打开 html 就出预期结果"。引入 npm 工具链意味着助教必须先装 Node、跑 install,反而抬高验收门槛。自研框架让测试页双击即跑;且 API 与 Jest 对齐,日后迁移只需换运行器,用例一行不改。

简易教程(3 分钟上手):

// 1. 打开 test/index.html 自动运行;要新增用例,编辑 test/core.test.js
describe('被测函数:谁', function () {
  it('什么输入,期望什么结果', function () {
    expect(C.searchItems(sampleItems(), '校园卡')).toHaveLength(1);
    expect(C.classifyContact('13800138000')).toBe('手机号');
    expect(C.validateItem({}).valid).toBe(false);
  });
});
// 2. 保存后刷新 test/index.html 即重新运行
// 3. 支持的断言:toBe / toEqual / toBeTruthy / toBeFalsy / toBeNull /
//    toContain / toHaveLength / toBeGreaterThan / toThrow / toBeTypeOf,均可 .not 取反

6.2 部分测试代码及说明

测试的函数:validateItem(表单校验,白盒覆盖每个字段的每个分支)

describe('validateItem:发布表单校验', function () {
  it('物品名称超长时报错(边界值:刚好 24 字通过,25 字失败)', function () {
    var exactly24 = new Array(C.MAX_LEN.title + 1).join('伞');
    expect(exactly24).toHaveLength(24);
    expect(C.validateItem(validForm({ title: exactly24 })).valid).toBe(true);
    var over25 = exactly24 + '伞';
    expect(C.validateItem(validForm({ title: over25 })).valid).toBe(false);
  });

  it('分类传了不存在的 key 也应报错(防止前端被篡改)', function () {
    expect(C.validateItem(validForm({ category: 'not-a-real-cat' })).errors.category)
      .toBe('请选择物品分类');
  });

  it('多个字段同时出错时,应一次性全部收集', function () {
    var r = C.validateItem({ type: 'lost', title: '', category: '', area: '', place: '', contact: '' });
    expect(Object.keys(r.errors).length).toBeGreaterThan(4);
  });
});

测试的函数:searchItems(防住真实踩过的坑)

it('按地点命中:搜「图书馆」应找到地点在图书馆的那条', function () {
  // 这条专门防一个真实踩过的坑:只匹配标题时搜地点搜不到
  expect(ids(C.searchItems(sampleItems(), '图书馆'))).toEqual(['A2']);
});

测试的函数:changeStatus(状态流转,不可变更新的核心)

it('寻找中 → 已归还,并刷新更新时间', function () {
  var next = C.changeStatus(sampleItems(), 'A1', C.STATUS.DONE, new Date('2026-10-11T12:00:00.000Z'));
  expect(C.findById(next, 'A1').status).toBe(C.STATUS.DONE);
  expect(C.findById(next, 'A1').updatedAt).toBe('2026-10-11T12:00:00.000Z');
  expect(C.findById(next, 'A1').createdAt).toBe('2026-10-10T10:00:00.000Z');
});

6.3 测试数据构造思路

  • 设计方法:以白盒为主——先读 core.js 每个函数的每个分支(空值 / 边界值 / 非法值 / 正常值),保证每个分支至少一个用例;再结合等价类划分(如联系方式按"手机号类 / QQ 类 / 微信类 / 邮箱类 / 非法类"各取代表值)和边界值分析(标题 24/25 字、地点 30/31 字)。
  • 固定样本数据:sampleItems() 造 3 条属性互异的记录,保证每条用例的期望值可以手算(如排序 ['A2','A1','A3']),而不是"测了但说不出对不对"。
  • 防测试人员刁难:专门构造了一批"不按套路出牌"的输入——
    • null / undefined / 空数组 / 全空白字符串(所有函数不得抛异常);
    • 篡改分类 key、传非法状态值(前端可被 F12 改,逻辑层必须拒绝);
    • 注入 <img onerror>、<script>(转义后不得成为标签);
    • 模拟 localStorage 配额写满 / 每次调用都抛异常(必须自动降级不白屏);
    • 损坏的 JSON、非数组 JSON(load 返回 null 而非崩溃)。
  • 自评:91 个用例覆盖全部 12 个套件、全部导出函数,配合运行一次仅需数毫秒,可随时重跑,能满足本项目的测试要求;未覆盖 DOM 交互层(app.js),该层以人工走查补充(见下节异常记录)。

七、GitHub 签入记录

功能划分与提交对应(每个功能做完、可运行后至少一次 commit):

commit 内容
feat: 页面骨架与整体布局 顶栏 / 侧栏 / 内容区 / 弹层结构
feat: 核心逻辑层 core.js 校验 / 检索 / 筛选 / 排序 / 状态流转 / 持久化
feat: 界面交互层 app.js 路由 / 渲染 / 事件 / 表单 / 图片压缩
feat: 演示数据 seed.js 首次打开载入演示数据
test: 零依赖测试框架与 91 个单元测试 mini-test.js + core.test.js + 报告页
docs: README 目录说明与使用说明 README.md

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

# 问题描述 做过哪些尝试 是否解决 有何收获
1 测试报告页在浏览器打开白屏 逐行排查发现 test/index.html 里引用了 test/mini-test.js——页面本身就在 test/ 目录下,相对路径应为 mini-test.js,写错成了 test/test/... ✅ 已解决 相对路径的基准是当前页面所在目录而非项目根目录;跨目录引用要站在被打开文件的视角检查
2 测试用例用了 toBeGreaterThan,框架未实现该断言,1 例失败 在 mini-test.js 中补充该 matcher(与 Jest 语义一致),并更新框架文档注释 ✅ 已解决 断言库要先"列清单"再写用例;框架与用例同步演进
3 用户输入直接拼 innerHTML,存在 XSS 风险 统一加 escapeHtml 转义层,高亮函数改为先转义再匹配;写专门测试锁住 ✅ 已解决 前端"纯本地"不代表无安全边界,输入都是不可信的
4 Chrome file:// / 隐私模式下 localStorage 不可用或抛异常 用一次真实写入探测,失败自动降级内存存储 ✅ 已解决 验收环境不可控,容错要兜到最坏情况
5 手机照片太大,2~3 张就写爆 localStorage 5MB 配额 Canvas 等比压缩到 720px、JPEG 0.72 再存 dataURL ✅ 已解决 存储配额是"隐藏需求",上传功能必须测真实照片
6 发布页切换"寻物/招领"时,已填内容被重渲染清空 切换前先快照表单值,重建后回填 ✅ 已解决 重渲染与用户输入是敌人,凡是会重建 DOM 的交互都要考虑输入保留
7 卡片内"改状态"按钮点击时误触发了卡片的"打开详情" 事件委托里用 closest('[data-act]') 从内到外找最近的动作元素,并 preventDefault ✅ 已解决 委托模式下"按钮嵌在可点击卡片里"必须显式处理冒泡


九、过程总结(PSP 分析)

  • 具体设计超时 50%(120→180 分钟):低估了状态流转"谁来改、从哪改、别人在哪看到"这一连串问题的设计成本,画了两版状态流转图才定稿;
  • 编码超时 29%(480→620 分钟):图片压缩、存储降级、XSS 防护等"隐藏需求"不在最初的功能列表里,是编码中发现的返工点;
  • 测试超时 33%(120→160 分钟):为"测试人员会怎么刁难"补了一批异常输入用例(空值、篡改 key、损坏 JSON、配额写满),比预想耗时;
  • 改进计划:下次把"环境容错"和"安全"在需求分析阶段就列为显式任务;测试用例与编码同步写而非写完补。
posted @ 2026-10-12 14:52  林富强  阅读(13)  评论(0)    收藏  举报