结对编程作业(第二次)

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-分层架构图


3.2 关键实现的流程图与数据流图

① 核心业务流程图(用户视角)

从用户角度看,整个系统就是一个五步闭环。第 5 步「更新状态」尤其重要——它让信息从
「进行中」变为「已找到 / 已归还」,后来的人就不会再重复联系,这正是我们想解决的
「重复询问、无效联系」痛点。

配图1-核心业务流程图

② 数据流图(代码视角)

配图2-数据流图

写入链路:发布页表单 → 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> 标签
  });
}

这段有两个容易踩的坑,我们都处理了:

  1. 转义与高亮的顺序。必须先转义再高亮。反过来做的话,<mark> 标签本身会被转义成
    &lt;mark&gt;,高亮就失效了。
  2. 正则元字符。用户如果搜 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 实现成果展示

首页:搜索框、实时统计、类型切换、类别/状态筛选、排序、信息卡片列表。

截图1-首页

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

截图2-发布页

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

截图3-详情页

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

截图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 步)

  1. 下载 / 克隆本项目全部文件到本地任意目录;
  2. 用 Google Chrome 打开项目根目录下的 index.html;
  3. 首次打开会自动写入 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 实现,零编译、零构建,学习成本低,适合课程项目。

我们的学习过程

  1. 先读官方文档:Mocha 官网的 Getting Started 与 Chai 的 assert API 文档;
  2. 看教程打基础:参考廖雪峰的 JavaScript 教程与《测试框架 Mocha 实例教程》,
    理解 describe(一组测试)与 it(单个用例)的组织方式;
  3. 动手写第一个用例:先给最简单的 resolvedText() 写断言,跑通后再逐步扩展到复杂逻辑;
  4. 理解断言风格:从 assert.equal 开始,逐步使用 assert.deepEqual(比较对象)、
    assert.property / assert.notProperty(判断字段是否报错)、assert.include(包含子串);
  5. 理解白盒测试:结合代码分支(每个 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。

image


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

困难一:搜索与筛选组合时结果不一致

问题描述
最初我们把「按类型/类别/状态筛选」写在页面脚本里,「关键词搜索」写在 core.js 里。
两边各自实现一遍,结果出现「先搜索再筛选」和「先筛选再搜索」得到不同结果的情况——
原因是页面里的筛选漏了「已解决」状态的边界处理。

做过哪些尝试

  1. 尝试在页面里手动组合两个结果 → 重复代码变多,改一处忘一处,问题依旧;
  2. 尝试把筛选也搬进 core,但保持两个独立函数由页面自己调顺序 → 顺序仍可能写错;
  3. 最终在 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 回来是个空对象。

做过哪些尝试

  1. 尝试在测试里手动 eval 文件内容 → 太 hack,不可取;
  2. 尝试改用全局变量 → 在 Node 里没有 window,同样不行;
  3. 最终把 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:// 下会静默失败,表现为「点了复制按钮没反应」。

做过哪些尝试

  1. 一开始只用了 navigator.clipboard → 本地打开时失效;
  2. 加上 document.execCommand('copy') 降级 → 仍然失败,因为没有选中任何内容;
  3. 最终用「动态创建隐藏 textarea → select() → execCommand('copy') → 移除」的方案。

是否解决
已解决,并把这段逻辑封装成 LFUI.copyText(),全站统一使用。

有何收获
认识到「能跑」和「在目标环境下能跑」是两件事。作业明确规定了运行方式,
就必须针对那个方式验证,而不能只在开发服务器上测通了就认为没问题。

结对过程中的体会

  • 接口先行:我们在动手前先约定好了 core.js 要导出哪些函数、参数长什么样、
    返回什么。这样佘博海写页面时不需要等王清源把 core 写完,可以并行推进。
  • 分层带来了真正的并行:因为业务规则都在 core 里,页面层只要照着接口调用即可,
    两人几乎没有互相阻塞。
  • PR 是很好的 review 机会:合并前一起过了一遍 6 条提交,发现了一处文案不一致,
    在合并前就改掉了。

九、评价你的队友

对佘博海(102401137)的评价

值得学习的地方

  • 单元测试做得很扎实。31 个用例里,边界值和「刁难型」用例(空值、非法枚举、
    大小写、恶意脚本注入、不可变性)基本都是他补的,很多是我没想到的角度。
  • 能够及时从错误思路中抽离出来,灵活转换,不死磕。

需要改进的地方

  • 前期对 MVP 边界把握不够,一度想把「按地点筛选」也塞进本期,后来一起讨论后砍掉了。
    建议以后先明确本期范围再动手。
  • commit 粒度偶尔偏大,有一次把两个小改动合在一个提交里了,建议坚持「一个功能一次提交」。

自我反思

  • 核心逻辑抽离成纯函数这个决策是对的,但一开始没有把接口约定写下来,
    导致沟通成本偏高。
  • 下次应该先写一份简单的接口文档再开工。

posted @ 2026-10-07 16:23  稚知  阅读(23)  评论(0)    收藏  举报