结对作业二:校园失物招领小程序实现

结对作业二:校园失物招领小程序实现

这个作业属于哪个课程 H202601软件工程与软件工程实践
这个作业要求在哪里 2026秋软件工程结对作业(第二次之程序实现)
这个作业的目标 实现前一次的原型模型:把「福大拾光」校园失物招领原型做成可运行的 Web 应用,完成"发布—浏览/搜索—查看详情—联系发布者—更新状态"的核心流程
结对同学博客 翁齐文
本作业博客链接 结对作业二:校园失物招领小程序实现
GitHub 仓库地址 102402137-102402139
成员1 102402137 翁齐文
成员2 102402139 徐张睿

结对表已在班级群统计表中填写,GitHub 项目地址已同步填写(fork + Pull Request 流程见第七节)。


一、具体分工

成员 学号 主要负责
翁齐文 102402137 仓库搭建与分支/PR 流程、数据层 store.js(校验、增删改查、搜索筛选排序、分页、状态维护、智能配对、统计)、首页与搜索页、整体 UI 与交互迭代、README
徐张睿 102402139 发布页 / 详情页 / "我的"页、单元测试用例设计与编写(test/ 129 条)、功能走查与边界情况排查、博客整理

两人都参与了需求讨论、界面走查与代码复审;徐张睿 fork 仓库后在分支上完成功能,通过 Pull Request 合并(见第七节),翁齐文负责 review 与合并。


二、PSP 表格

PSP2.1 阶段 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 40
· Estimate · 估计这个任务需要多少时间 30 40
Development 开发 480 620
· Analysis · 需求分析(包括学习新技术) 40 60
· Design Spec · 生成设计文档 30 30
· Design Review · 设计复审 20 20
· Coding Standard · 代码规范 10 10
· Design · 具体设计 60 80
· Coding · 具体编码 280 380
· Code Review · 代码复审 40 50
· Test · 测试(自测、修 bug、提交) 40 60
Reporting 报告 90 110
· Test Report · 测试报告 30 40
· Size Measurement · 计算工作量 10 10
· Postmortem & Process Improvement Plan · 事后总结并提出过程改进计划 50 60
合计 600 770

偏差分析与过程改进:

  • 超出预估最多的是 Coding(280 → 380):一是"看起来很简单"的交互其实很费时间,比如卡片点击区域、发布成功页的回跳、编辑模式与新增模式共用一套表单;二是中文关键词高亮和悬浮预览的边界处理(右边放不下要翻到左边)反复调了几轮。
  • Analysis(40 → 60) 超时,是因为中途砍了一轮范围:最初想把"浏览记录、收藏"也做进去,讨论后发现这些功能解决不了"物归原主"这个核心问题,两个人也没时间做完,于是按老师的提醒收敛到"发布、搜索、详情、状态维护 + 少量附加特点"。
  • Test(40 → 60) 超时主要是补边界用例:写完测试才发现"标题 30 字 / 31 字""QQ 12 位 / 13 位"这类临界值有一处判断写反了,如果没有边界用例就会漏掉。
  • 下次改进:把"交互走查"单独列成一个任务并预留时间,而不是算在 Coding 里;先写一版可运行的最小骨架,再往上加特点,避免返工。

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

3.1 代码实现思路

整个项目按三层组织,层与层之间单向依赖:
fig1

数据层 store.js:唯一碰数据的地方。对外暴露 add / updateItem / getById / updateStatus / remove / query / paginate / summarize / findMatches / getStats / getSearchHistory / getDraft 等函数,内部负责 localStorage 读写与数据格式。它不引用任何 DOM 对象,判断浏览器还是 Node 靠的是结尾这段环境判断:

if (typeof module !== 'undefined' && module.exports) module.exports = Store;  // Node:可以被单元测试 require
else global.Store = Store;                                                    // 浏览器:挂到 window

所以同一份业务代码既能在 Chrome 里跑,又能被 node --test 直接引入做单元测试,一行都不用改。

公共层 common.js:放跨页面复用的纯函数。比如把一条物品数据渲染成卡片的 cardHtml(),首页、搜索页、我的页三处共用;再比如关键词高亮 highlight()、悬浮预览 initCardPreview()、头像渲染 avatarHtml()、一键复制 copyText()。

页面层:每个页面只做三件事——调 Store 拿数据、用 App 渲染成 HTML、给按钮绑事件。页面之间通过 URL 参数通信(detail.html?id=xxx&from=my,from 决定详情页的"返回"跳回哪里;search.html?kw=雨伞 支持直接带关键词打开)。

校验属于数据层的一部分:Store.validate(raw) 返回 { ok, errors, item },发布与编辑复用同一套规则,因此不存在"发布能过、编辑反而能绕过校验"的漏洞。

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

(1)发布一条信息的时序(含表单校验与智能配对)

fig2

校验失败走 alt [校验失败] 分支(顶部红框列错误,不写入数据),校验通过走 alt [校验通过] 分支(unshift + save() 落盘,再查一次智能配对,最后进发布成功页)。

(2)搜索 / 筛选的数据流(一个 query() 搞定所有组合条件)

fig3

地点用的是子串模糊匹配:点"图书馆"能命中"图书馆三楼自习区""图书馆门口"等所有包含该词的记录,不需要和原文一字不差。

(3)信息状态流转(可逆,发布者本人操作)

fig4

状态字段只有 active / resolved 两个取值,updateStatus() 对其它取值直接返回 false,避免出现第三种状态导致界面判断漏分支。

3.3 重要代码片段与解释

片段一:智能配对

function titleOverlap(a, b) {
  a = (a || '').toLowerCase(); b = (b || '').toLowerCase();
  for (var i = 0; i < a.length - 1; i++) {
    var g = a.substr(i, 2);          // 取相邻两字的二元组
    if (b.indexOf(g) >= 0) return true;
  }
  return false;
}

function findMatches(newItem) {
  var opposite = newItem.type === 'lost' ? 'found' : 'lost';
  return query({ type: opposite }).filter(function (it) {
    if (it.id === newItem.id) return false;
    if (it.status !== 'active') return false;
    if (it.category !== newItem.category) return false;
    return titleOverlap(newItem.title, it.title);
  }).slice(0, 3);
}

解释:不做站内聊天,也能把"丢的人"和"捡的人"接上。逻辑很克制——必须是相反类型(寻物 ↔ 招领)、同一分类、标题有两字重合、且仍在进行中,才推荐。titleOverlap 用中文二元组粗匹配,"蓝色折叠雨伞"和"藏蓝色折叠雨伞"会命中"折叠/叠雨/雨伞"这些公共片段;不取整串相等(太严格),也不算编辑距离(实现复杂、收益小)。

片段二:关键词高亮——先转义再包 <mark>,防 XSS

function highlight(text, keyword) {
  var s = (text == null) ? '' : String(text);
  var kw = (keyword == null) ? '' : String(keyword).trim();
  if (!kw) return escapeHtml(s);
  var lower = s.toLowerCase(), target = kw.toLowerCase();
  var out = '', from = 0, idx;
  while ((idx = lower.indexOf(target, from)) !== -1) {
    out += escapeHtml(s.slice(from, idx)) +
      '<mark class="hl">' + escapeHtml(s.slice(idx, idx + kw.length)) + '</mark>';
    from = idx + kw.length;
  }
  return out + escapeHtml(s.slice(from));
}

解释:搜索结果要把命中的字高亮,但物品标题是用户输入的内容,直接拼 HTML 会被 XSS(比如标题写成 <img src=x onerror=alert(1)>)。这里的关键是每一段原文都先过 escapeHtml 再插入 <mark>,连命中片段本身也不例外,保证"高亮"这个功能本身不会变成漏洞入口。同时支持一段文字多处命中、大小写不敏感。

片段三:搜索 + 筛选 + 排序一个函数搞定

function query(opt) {
  opt = opt || {};
  var kw = (opt.keyword || '').trim().toLowerCase();
  var items = getAll(), out = [];
  for (var i = 0; i < items.length; i++) {
    var it = items[i];
    if (opt.type && opt.type !== 'all' && it.type !== opt.type) continue;
    if (opt.category && opt.category !== 'all' && it.category !== opt.category) continue;
    // 地点用模糊包含:选"图书馆"能命中"图书馆三楼自习区"
    if (opt.location && opt.location !== 'all' && it.location.indexOf(opt.location) === -1) continue;
    if (opt.status && opt.status !== 'all' && it.status !== opt.status) continue;
    if (kw) {
      var hay = (it.title + ' ' + it.description + ' ' + it.location + ' ' + it.category).toLowerCase();
      if (hay.indexOf(kw) === -1) continue;
    }
    out.push(it);
  }
  var sort = SORTS.indexOf(opt.sort) === -1 ? 'newest' : opt.sort;
  out.sort(function (a, b) {
    var ra = a.status === 'resolved' ? 1 : 0, rb = b.status === 'resolved' ? 1 : 0;
    if (ra !== rb) return ra - rb;                       // 已解决的一律沉底
    if (sort === 'views') return (b.views || 0) - (a.views || 0);
    if (sort === 'oldest') return a.createdAt - b.createdAt;
    return b.createdAt - a.createdAt;                    // 默认最新发布在前
  });
  return out;
}

解释:五个筛选维度、三种排序,全部收敛到一个 query() 里,页面只负责传一个 opt 对象。这样加筛选条件(比如后来加的"地点")不用改页面逻辑;非法 sort 参数统一回退到 newest,不会因为前端传了怪值就崩。

片段四:卡片悬浮预览 + 视口边界处理

function move(x, y) {
  var w = box.offsetWidth, h = box.offsetHeight;
  var left = x - w / 2;                 // 水平方向以鼠标为中心
  var top = y - h - 16;                 // 默认浮在鼠标上方
  if (left < 8) left = 8;               // 左边越界 → 贴左
  if (left + w > window.innerWidth - 8) left = window.innerWidth - w - 8;   // 右边越界 → 贴右
  if (top < 8) top = y + 16;            // 上方空间不够 → 翻到鼠标下方
  box.style.left = left + 'px';
  box.style.top = top + 'px';
}

解释:浮层最容易出的问题不是"弹不出来",而是贴着屏幕边缘时被截断、跑到视口外。这里做了三步收敛:水平方向先按鼠标居中,再分别用左右边界钳制;垂直方向优先放在鼠标上方(不挡住卡片内容),上方空间不足时翻到下方。另外浮层设了 pointer-events: none,鼠标不会被它"接住"导致闪烁。


四、附加特点设计与展示

4.1 智能配对提醒

① 意义

传统失物招领页面只是被动地"把信息列出来",丢的人和捡的人还是要自己一条条翻。我们加了一个主动搭桥的小功能:当用户发布完一条信息时,系统自动找一找有没有人刚好捡到了 / 丢了相似的东西,然后在成功页提示一句"你可能要找的是这条,去联系看看"。

它的意义在于:不做聊天、不做地图,就把"失主"和"拾主"这两个本来互不相识的人第一次主动关联起来。对用户来说,从"我发完就只能干等"变成了"刚发完就有人推荐了一条可疑信息";这也是我们对"怎么减少无效联系"的回答——与其让大家互相乱问,不如系统先帮你筛一遍。

② 实现思路

配对条件刻意收得很窄,避免乱推荐:

条件 说明
类型相反 寻物(lost)只匹配招领(found),反之亦然——同一方向的两条信息不可能是同一件物品
分类相同 都选了"雨具"才可能是同一把伞
仍在进行中 已标记"已找到 / 已归还"的不再推荐
标题有两字重合 中文二元组粗匹配,"蓝色折叠雨伞" ↔ "藏蓝色折叠雨伞"命中"折叠/叠雨"

满足以上条件的信息最多取 3 条,显示在发布成功页。

③ 代码片段

function titleOverlap(a, b) {
  a = (a || '').toLowerCase(); b = (b || '').toLowerCase();
  for (var i = 0; i < a.length - 1; i++) {
    var g = a.substr(i, 2);              // 相邻两字组成的二元组
    if (b.indexOf(g) >= 0) return true;
  }
  return false;
}

function findMatches(newItem) {
  var opposite = newItem.type === 'lost' ? 'found' : 'lost';
  return query({ type: opposite }).filter(function (it) {
    if (it.id === newItem.id) return false;
    if (it.status !== 'active') return false;
    if (it.category !== newItem.category) return false;
    return titleOverlap(newItem.title, it.title);
  }).slice(0, 3);
}

解释:titleOverlap 不取整个标题做相等比较(那样太严格,"蓝色雨伞"和"蓝色折叠雨伞"就匹配不上),也不算编辑距离(实现复杂),而是把标题切成所有相邻两字的片段,任一公共片段命中即认为相似。中文两字是最自然的语义单元,对"伞 / 卡 / 耳机"这类短标题特别合适。

④ 成果展示

publish-match

4.2 物品分类 + 校园地点双重筛选(附排序与加载更多)

① 意义

光靠关键词搜索,用户脑子里得先想出"该搜哪个词"。但很多时候人是按场景找东西的——"我今天在图书馆丢的""在食堂附近捡的"。我们在搜索框下方加了两组一键筛选胶囊:

  • 分类:证件卡 / 数码电子 / 生活用品 / 雨具 / 包袋 / 随身物品 / 图书资料 / 其他
  • 地点:图书馆 / 食堂 / 教学楼 / 体育馆 / 宿舍 / 校车站 / 实验楼 / 操场

点一下就能筛,不用打字。地点筛选用模糊包含匹配:点"图书馆"能命中"图书馆三楼自习区""图书馆门口"等所有包含该词的记录,不用和原文一字不差。它和关键词、寻物/招领 tab、最新/最热排序可以叠加使用;条数多时列表分页显示,底部给"加载更多(还有 N 条)"。

② 实现思路

筛选逻辑集中在 Store.query(),每加一个条件就是一条 continue(代码见 3.3 片段三)。胶囊按钮的选项不是写死在 HTML 里的,而是从 Store.CATEGORIES 和 Store.HOT_LOCATIONS 两个常量渲染出来的,改一处常量,首页和搜索页同步更新。

③ 代码片段

// 地点筛选:点胶囊 = 选地点关键词(选项来自常量,不写死在 HTML)
function renderLocChips() {
  var box = document.getElementById('locChips');
  var html = '<span class="chips-label">地点</span>' +
    '<span class="chip' + (state.location === 'all' ? ' active' : '') +
    '" data-loc="all">全部</span>';
  Store.HOT_LOCATIONS.forEach(function (l) {
    html += '<span class="chip' + (state.location === l ? ' active' : '') +
      '" data-loc="' + App.escapeHtml(l) + '">' + App.escapeHtml(l) + '</span>';
  });
  box.innerHTML = html;
}

④ 成果展示

index

4.3 卡片悬浮预览

① 意义

列表里一屏只有几条信息,想知道"这条到底是不是我的东西",只能点进详情页再返回,来回几次很累。我们让鼠标停在卡片上就直接浮出一张简介卡(照片 / 标题 / 描述 / 地点 / 时间),看完移开就消失,不用离开列表就能快速筛掉明显不相关的信息,找到目标再点进去。

② 实现思路

  • 预览容器 #cardPreview 在 common.js 初始化时创建一次,后面只改内容与位置,不反复创建 DOM;
  • 事件绑在 document 上(而不是每个列表各自绑一遍),这样首页、搜索页、我的发布三处的卡片共用同一套预览逻辑,页面脚本一行都不用改;
  • 卡片渲染时带上了 data-id 属性,预览时用 Store.getById(card.dataset.id) 取完整数据(列表里只有摘要,详情字段要现取);
  • 位置计算:水平居中后左右钳制、垂直优先上方、上方不够翻到下方(见 3.3 片段四);
  • 浮层 pointer-events: none,不会被鼠标接住导致抖动;鼠标移出卡片(用 relatedTarget 判断是否还在卡片内)立即隐藏。

③ 代码片段

// 卡片渲染时带上 data-id,供悬浮预览取数据
'<a class="card" href="' + href + '" data-id="' + escapeHtml(item.id) + '">'

// 悬浮预览:鼠标停在卡片上时浮出简介/照片
function initCardPreview() {
  var box = document.getElementById('cardPreview');
  if (!box) { box = document.createElement('div'); box.id = 'cardPreview'; document.body.appendChild(box); }

  function show(id, x, y) {
    var it = (typeof Store !== 'undefined' && Store.getById) ? Store.getById(id) : null;
    if (!it) { box.style.display = 'none'; return; }
    var photo = it.photo ? '<img class="pv-photo" src="' + it.photo + '" alt="物品照片">' : '';
    box.innerHTML =
      photo +
      '<div class="pv-title">' + typeBadge(it) + ' ' + escapeHtml(it.title) + '</div>' +
      '<div class="pv-desc">' + escapeHtml(it.description || '(暂无描述)') + '</div>' +
      '<div class="pv-meta">📍 ' + escapeHtml(it.location || '—') + ' · ' + escapeHtml(it.time || '') + '</div>';
    box.style.display = 'block';
    move(x, y);
  }

  document.addEventListener('mouseover', function (e) {
    var card = e.target.closest('.card');
    if (!card) return;
    show(card.getAttribute('data-id'), e.clientX, e.clientY);
  });
  document.addEventListener('mousemove', function (e) {
    if (box.style.display === 'block') move(e.clientX, e.clientY);
  });
  document.addEventListener('mouseout', function (e) {
    var card = e.target.closest('.card');
    if (card && !card.contains(e.relatedTarget)) box.style.display = 'none';
  });
}
initCardPreview();

解释:预览内容里的标题、描述、地点全部经过 escapeHtml —— 悬浮预览是"第二个渲染入口",如果这里忘了转义,XSS 一样会从这个入口进来。

④ 成果展示

hover-preview

4.4 联系方式一键复制

① 意义

详情页里电话、QQ、微信本来就是给人联系失主用的,但手机/电脑上要"长按选中 → 复制 → 切到微信粘贴"很绕。我们在每条联系方式后面放一个"复制"按钮,点一下就进剪贴板并提示"已复制到剪贴板",对方拿到号码直接加好友,少一步操作摩擦。

② 实现思路

现代浏览器有 navigator.clipboard,但本项目是双击 HTML 在 file:// 下打开的,这种协议下浏览器往往不暴露 Clipboard API。所以做了降级:优先用 navigator.clipboard,不可用就创建一个隐藏的 <textarea> 用 document.execCommand('copy') 兜底。

③ 代码片段

function copyText(text) {
  function fallback() {
    var ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed'; ta.style.opacity = '0';
    document.body.appendChild(ta);
    ta.select();
    try { document.execCommand('copy'); return true; }
    finally { document.body.removeChild(ta); }
  }
  if (navigator.clipboard && navigator.clipboard.writeText) {
    return navigator.clipboard.writeText(text).then(function () { return true; }, fallback);
  }
  return Promise.resolve(fallback());
}

④ 成果展示

detail

4.5 其他贴心小设计

小设计 解决什么问题
搜索历史(最近 8 条,可单删/清空) 找东西往往是反复搜的,不用每次重新打字
关键词高亮 一屏好几条信息,直接看到"命中的是哪几个字",确认快
发布草稿自动保存(400ms 防抖) 填了一半误关页面/刷新,回来会问"要恢复吗",照片因为体积大不入草稿
头像上传 支持上传一张图片作为头像(canvas 居中裁剪成 120px 方图存为 dataURL,用 avatarHtml() 圆形显示),不想上传也可以用内置 emoji 头像
空状态与异常兜底 搜不到、筛不出、点进已删除的详情页、必填项没填、localStorage 被写坏,都有明确提示,不白屏
"我的"页统计与个人资料 我的发布数 / 已完成数 / 总浏览量一眼可见,发布时自动带出联系方式

4.6 失物招领最终成果简要展示

首页(筛选 + 排序 + 加载更多) 搜索页(历史 + 高亮)
index search
详情页(联系 + 状态维护) 发布页(表单 + 校验)
detail publish
我的(统计 + 头像上传) 悬浮预览
my hover-preview

五、目录说明与运行方法

5.1 目录结构

102402137-102402139/
├── index.html              # 首页:浏览 + 搜索框 + 类型/分类/地点筛选 + 排序 + 加载更多
├── search.html             # 搜索页:关键词搜索 + 热门搜索 + 搜索历史 + ?kw= 深链接
├── publish.html            # 发布/编辑页:寻物招领表单 + 校验 + 照片/图标 + 草稿 + 发布成功页
├── detail.html             # 详情页:物品详情 + 联系方式一键复制 + 发布者状态维护/编辑/删除
├── my.html                 # 我的:个人资料卡 + 编辑弹窗(昵称/头像上传/学院/校区/联系方式)+ 我的发布
├── css/
│   └── style.css           # 全部样式(居中手机式应用列,含悬浮预览等)
├── js/
│   ├── store.js            # 【数据层】localStorage 增删改查、校验、搜索/筛选/排序/分页、
│   │                       #   状态维护、统计、智能配对、搜索历史、草稿;兼容浏览器与 Node
│   ├── common.js           # 【公共层】HTML 转义、关键词高亮、时间格式化、卡片渲染、
│   │                       #   悬浮预览、一键复制、toast
│   ├── index.js            # 首页逻辑
│   ├── search.js           # 搜索页逻辑
│   ├── publish.js          # 发布/编辑表单逻辑
│   ├── detail.js           # 详情页逻辑
│   └── my.js               # 「我的」页逻辑
├── test/                   # 单元测试(不影响网页运行,助教可选择性执行)
│   ├── helpers.js          # 测试辅助:内存 localStorage 桩、构造合法数据的工厂函数
│   ├── store.test.js       # 数据层 Store 测试(71 条)
│   ├── common.test.js      # 公共函数测试(27 条)
│   ├── flow.test.js        # 业务主流程集成测试(8 条)
│   ├── pages.test.js       # 页面静态一致性检查(10 条,无需浏览器)
│   └── dom.flow.test.js    # DOM 级集成测试(10 条,可选,需 jsdom,未装自动跳过)
├── package.json            # 只有 test 脚本,没有任何运行依赖
├── .gitignore              # 忽略 node_modules 等
└── README.md               # 目录说明与使用说明(与本节目的一致)

组织思路:

  1. js/store.js 是唯一数据入口,所有页面通过它读写;数据层不碰 DOM,将来换后端只需改这一层。
  2. js/common.js 放跨页面复用的纯函数(转义、高亮、时间、卡片渲染、悬浮预览、复制),避免复制粘贴。
  3. 每个页面对应一个同名 .js,职责单一:取数据 → 渲染 → 绑事件。
  4. 脚本都是普通 <script>,没有模块化 import,所以 file:// 直接双击也能跑。
  5. test/ 与网页运行完全解耦:不装 Node、不跑测试也不影响 Chrome 打开 index.html。

5.2 测试人员如何运行

第 1 步:拿到代码

git clone https://github.com/wqwabc/102402137-102402139.git

或在 GitHub 页面 Code → Download ZIP 后解压。

第 2 步:打开网页(无需任何环境)

  1. 保持上面的目录结构不变;
  2. 用谷歌浏览器 Chrome 双击打开根目录下的 index.html;
    • 项目按 file:// 协议即可正常工作(没有用 fetch / ES module);
    • 若浏览器对本地文件限制较严,也可用 VS Code 的 Live Server 以 http:// 打开,效果一致;
  3. 首次打开会自动写入 6 条演示数据(寻物/招领、进行中/已归还等状态都有,其中 3 条带内置示例图片),首页立刻有内容;
  4. 统一用 Chrome,避免不同浏览器在日期控件、localStorage 上的行为差异。

第 3 步:按这个顺序走一遍(约 5 分钟)

步骤 操作 预期结果
① 发布 首页点「我丢了东西 / 我捡到东西」→ 填必填项 → 立即发布 发布成功页,显示编号与摘要;若有相关信息会给出"智能配对"提示
② 校验 不填必填项直接发布 表单顶部出现红色错误清单,不白屏
③ 搜索 输入「雨伞」「校园卡」 列表实时过滤,命中字黄色高亮;搜不到有空状态提示 + "清空全部筛选"
④ 筛选 点分类 / 地点胶囊、寻物招领 tab、最新 / 最热 列表内容随之变化,可与关键词叠加
⑤ 悬浮预览 鼠标停在任意卡片上 卡片旁浮出简介浮层(照片 / 标题 / 描述 / 地点 / 时间),靠近屏幕边缘会自动翻转贴边,移开即消失
⑥ 详情 点任意卡片 进入详情:分类 / 地点 / 时间 / 编号 / 描述 / 照片
⑦ 联系 详情页点「联系失主 / 拾取人」 显示联系方式,点「复制」提示"已复制到剪贴板"
⑧ 状态 打开自己发的那条,底部「发布者管理」 可标记已找到 / 已归还、撤回、编辑、删除
⑨ 我的 底部导航进「我的」 个人资料卡 + 我的发布 + 总浏览量;点铅笔可改昵称 / 头像(内置 emoji 或上传照片)/ 学院 / 校区 / 联系方式

提示:数据保存在浏览器 localStorage 中,清除浏览器数据会清空;换电脑演示时重新录几条数据即可(本作业不做后台,数据本来就不跨设备)。


六、单元测试

6.1 选用的测试工具、学习过程与简易教程

选用工具:Node.js 自带的 node:test + node:assert/strict。

为什么不用 Mocha / Jest:本项目是零依赖的纯静态页面,引入测试框架就要让助教 npm install 一整套依赖;而作业里也提到"单元测试不必上传"。用 node:test 的好处是 不装任何东西、一条命令就能跑,也不会因为网络或版本问题跑不起来。它的 describe / it / beforeEach 写法与 Mocha 基本一致,将来要迁到 Mocha 只需改一行 require。

我们是怎么学的:先看 Node 官方文档里 node:test 的最小示例(describe + it + assert),再回到项目里,对着 store.js 的每个导出函数列"它应该满足哪些规则",一条规则写一个 it。写完一个文件的用例就跑一次,看红绿。

自己写一个单元测试的最小教程(5 步):

# 第 1 步:确认 Node >= 18(自带 node:test)
node -v

# 第 2 步:让业务模块在 Node 里能被 require
#   store.js 结尾已经做了环境判断:
#   if (typeof module !== 'undefined' && module.exports) module.exports = Store;
#   else global.Store = Store;   // 浏览器
#   同一份代码,浏览器和 Node 都能用,业务逻辑一行不用改。

# 第 3 步:补一个 localStorage 桩(Node 里没有 localStorage)
#   见 test/helpers.js,用一个 Map 实现 getItem/setItem/removeItem/clear

# 第 4 步:写用例 test/mytest.test.js
#   const { describe, it } = require('node:test');
#   const assert = require('node:assert/strict');
#   const Store = require('../js/store.js');
#   describe('发布校验', () => {
#     it('标题 31 字应当被拒绝', () => {
#       assert.equal(Store.validate({ title: '伞'.repeat(31), ... }).ok, false);
#     });
#   });

# 第 5 步:运行
node --test test/mytest.test.js

运行全部测试:

npm test
# 等价于(不需要安装任何依赖):
node --test test/store.test.js test/common.test.js test/flow.test.js test/pages.test.js

测试隔离:每条用例开始前都换一个全新的内存版 localStorage 并 Store._reset(),所以用例之间互不污染,可以任意顺序、反复执行——这也是回归测试能每天跑的前提。

function createLocalStorage() {
  var map = new Map();
  return {
    getItem:    function (k) { return map.has(String(k)) ? map.get(String(k)) : null; },
    setItem:    function (k, v) { map.set(String(k), String(v)); },
    removeItem: function (k) { map.delete(String(k)); },
    clear:     function () { map.clear(); }
  };
}

6.2 部分单元测试代码与说明

const { describe, it, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const h = require('./helpers');
const Store = h.freshStore();

beforeEach(() => { h.installLocalStorage(); Store._reset(); });

describe('Store.validate —— 发布信息校验', () => {
  it('合法数据:返回 ok,并自动补全 id / 编号 / 发布者 / 状态', () => {
    const res = Store.validate(h.validRaw());
    assert.equal(res.ok, true);
    assert.match(res.item.code, /^LF\d{11}$/);          // 编号格式 LF + 日期 + 序号
    assert.equal(res.item.publisher, Store.CURRENT_USER_ID);
    assert.equal(res.item.status, 'active');
  });

  it('标题长度边界:30 字合法、31 字非法', () => {
    assert.equal(Store.validate(h.validRaw({ title: '伞'.repeat(30) })).ok, true);
    assert.equal(Store.validate(h.validRaw({ title: '伞'.repeat(31) })).ok, false);
  });

  it('三种联系方式至少填一种', () => {
    assert.equal(Store.validate(h.validRaw({
      contactPhone: '', contactQq: '', contactWechat: ''
    })).ok, false);
    assert.ok(Store.validate(h.validRaw({ contactPhone: '', contactWechat: 'wx_abc' })).ok);
  });
});

describe('query 排序 —— 进行中在前,已解决沉底', () => {
  it('已标记归还的信息排到后面', () => {
    h.useItems(Store, [
      h.item({ id: 'a', status: 'resolved', createdAt: 100 }),
      h.item({ id: 'b', status: 'active',   createdAt: 200 })
    ]);
    assert.deepEqual(Store.query().map(function (x) { return x.id; }), ['b', 'a']);
  });
});

测了哪些函数:

测试文件 用例数 覆盖的函数 / 规则
store.test.js 71 validate 表单校验、add / updateItem / updateStatus / remove、query 搜索筛选排序、paginate 分页、summarize 统计、findMatches 配对、getStats、搜索历史、发布草稿、exportData / importData / resetAll、个人资料
common.test.js 27 escapeHtml、highlight 高亮、fmtTime 时间格式化、状态/类型徽标、cardHtml 卡片渲染、moreHtml 加载更多
flow.test.js 8 业务主流程贯通:发布 → 浏览/搜索 → 详情 → 联系 → 标记状态 → 编辑/删除 → 备份迁移
pages.test.js 13 静态一致性 + 回归保护:JS 用到的 id 在 HTML 里是否存在、脚本/样式表路径、页面互链是否 404、编码是否 UTF-8;以及"头像上传必须用 readAsDataURL""common.js 不能在模块顶层碰 document""存储 Key 变更时测试要一起改"这三条曾经踩过的坑
dom.flow.test.js 10 可选(需 jsdom):真跑页面,模拟点击 / 输入,验证筛选、排序、加载更多、搜索历史、发布表单、草稿恢复、详情页状态维护
合计 129 远超作业要求的"至少 5 / 10 个测试用例"

6.3 构造测试数据的思路 & 怎么防"刁难"

1)等价类划分 + 边界值分析

每个字段先分"合法 / 非法 / 缺失"三类,再对长度类字段取 n 与 n+1 两个边界点:

字段 合法 非法 / 边界
手机号 13800138000 12345678901(第二位非法)、1380013800(10 位)、13800138000a(含字母)
物品名称 30 字 31 字
地点 50 字 51 字
QQ 5 位、12 位 4 位、13 位、含字母
搜索历史 8 条 第 9 条要挤掉最旧的一条

2)白盒覆盖分支

store.js 里每个 if 都至少有一条用例走到"真"和"假"两个方向。例如 updateStatus 只接受 active / resolved,就分别测:合法值、非法值 'done'、以及一个根本不存在的 id。

3)坏数据兜底

localStorage 被写成 {坏JSON、记录缺字段、null、全是空格、不是数组、导入文件缺 items —— 断言"不抛异常 + 有兜底值 + 不破坏已有数据"。

4)面向"将来测试人员会怎么挑刺"

  • 可能拿特殊字符试 XSS → escapeHtml 与 highlight 都有专门用例,断言输出里不出现 <img;
  • 可能说"搜索只匹配了标题吧" → 用例明确覆盖描述、地点、分类字段,并写死预期条数;
  • 可能换台电脑 / 清掉浏览器数据 → 测了"首次访问自动生成演示数据"和"导出再导入";
  • 可能点一个已被删除的详情页 → dom.flow.test.js 里有"id 不存在时给友好提示"的用例;
  • 不写 assert.ok(true) 这种假测试,每条断言都对着一条具体的业务规则(例如"发布成功后草稿必须被清除")。

6.4 可选的 DOM 集成测试(jsdom)

test/dom.flow.test.js 用 jsdom 把页面真的跑起来(执行页面脚本、模拟点击 / 输入)。它是可选依赖:没装 jsdom 时整个文件自动跳过(输出里显示 ﹣ 未安装 jsdom…),其余 119 条用例照常运行。

npm install --no-save jsdom      # 只装到本地 node_modules,不会写进依赖
npm run test:dom                 # 或 npm test(装了 jsdom 会自动一起跑)

它覆盖:首页 tab / 分类 / 地点筛选与关键词高亮、超过一页时的「加载更多」、搜索历史读写与删除、?kw= 深链接直接出结果、发布表单校验失败与成功页、草稿自动保存与恢复、详情页联系方式展示与「标记为已找到」、id 不存在时的兜底、我的页统计与恢复演示数据。

测试评估:这套用例满足了本程序的主要测试要求——数据层(最核心、最容易出错的一层)做到了分支级覆盖;页面层用静态一致性检查 + 可选的 DOM 集成测试兜住了"画了界面但点了没反应""链接写错 404""提交 UTF-16 导致乱码"这类问题。不足是没有做真实浏览器(Chrome)的端到端自动化,样式的视觉问题仍然依赖人工走查。


七、GitHub 签入记录

提交历史(主仓库 main):

github-commits-main

3567f08 fix: 修复单测跑不起来、头像上传无反应等 4 处问题        ← 徐张睿(PR #2)
9c9d24d docs: 优化README.md                                  ← 翁齐文
2d36f49 docs: 更新README                                     ← 翁齐文
fe76040 feat: 增加悬浮预览的功能(鼠标悬停预览物品详情)        ← 翁齐文
7ec593d feat: 优化UI                                        ← 翁齐文
29e54eb Merge pull request #1 from VioletQAQ666/main        ← 合并 PR
aa71afa docs: 重写 README(功能清单、目录说明、运行与单测教程) ← 徐张睿
8e0c03a test: 新增 126 条零依赖单元测试(node:test)           ← 徐张睿
6f328e5 feat(ui): 页面接入地点筛选、排序、加载更多、关键词高亮与数据管理
fa5a053 feat(data): 数据层新增地点筛选/排序/分页、搜索历史、发布草稿与数据备份
d38e1be feat: 优化界面UI                                    ← 更早的历史提交

协作用的是标准 fork + Pull Request 流程:

github-pr

  • 102402137(翁齐文)创建主仓库 wqwabc/102402137-102402139;
  • 102402139(徐张睿)fork 出 VioletQAQ666/102402137-102402139,在本地按功能拆分提交,每完成一个功能(数据层 → 页面接入 → 单元测试 → 文档)就 push 一次;
  • 阶段性完成后向主仓库发起 Pull Request,由翁齐文 review 差异后合并到主仓库 main。

commit 信息的写法:采用 类型(范围): 做了什么 的格式(feat / fix / docs / test),并在正文里分条列出"改了哪些文件、为什么这么改",方便 review 时对照差异。


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

# 问题 做过的尝试 是否解决 收获
1 照片以 base64 存 localStorage 导致超限 先直接存原图,发大图后抛 QuotaExceededError,之后换 FileReader + canvas 压缩 ✅ 压到最长边 480px、JPEG 质量 0.72 后再存;草稿不入照片 上传类功能必须先想体积上限,别等报错才处理
2 悬浮预览在屏幕边缘被截断(浮层跑出视口) 一开始固定放在卡片一侧 → 改为跟随鼠标 → 再加"水平左右钳制、上方空间不够就翻到鼠标下方"的收敛逻辑 ✅ 见 3.3 片段四 浮层一定要算视口边界,不能假设屏幕够宽
3 file:// 下 navigator.clipboard 不可用,点"复制"没反应 查资料确认是协议限制(非安全上下文拿不到 Clipboard API),加 execCommand 降级 ✅ 两种环境都能复制 本地双击打开和线上部署的运行环境不一样,要各测一遍
4 原 README 是 UTF-16 编码,部分编辑器/页面打开是乱码 先用 Set-Content/记事本另存都不彻底,最后统一把所有文本文件转成 UTF-8 无 BOM,并在 pages.test.js 里加了一条"禁止 UTF-16、禁止替换字符"的断言 ✅ 现在 GitHub、Chrome、VS Code 都正常 编码问题要用测试守住,靠人眼检查容易漏
5 首页信息一多,一屏滚不到底 先全量渲染 → 改成 Store.paginate() 每页 6 条 + "加载更多(还有 N 条)" ✅ 分页逻辑单独写了 5 条边界用例 纯展示逻辑也值得抽成函数并单测,边界(页数越界、非法每页条数)比正常路径更容易出错
6 两人对"要不要做收藏/浏览记录"意见不一致,返工了一轮 拉了一次需求对齐:把功能分成"必须/可选/明确不做"三列,按"是否服务于物归原主"排序 ✅ 砍掉收藏与浏览记录,把时间投到状态维护与测试上 开工前先把范围写成一张表双方确认,比事后争论便宜得多
7 合并队友的"悬浮预览"后,单元测试整个文件跑不起来 npm test 报 ReferenceError: document is not defined;排查发现 common.js 在模块顶层直接调用了 initCardPreview(),浏览器里没问题,Node 里 require 就炸,common.test.js 的 27 条用例全部加载不了 ✅ 改成 if (typeof document !== 'undefined') initCardPreview();,并在 pages.test.js 加了一条回归断言 浏览器与 Node 共用一份代码时,"顶层副作用"是最隐蔽的地雷:写完要问自己一句"这段代码在 Node 里跑会怎样"
8 新做的"上传头像"选了照片完全没反应 走查时点开选图,选完界面毫无变化;排查发现 FileReader 用的是 readAsText(file),图片被当成文本读,img.onload 永远不触发(写法本身没错,只是读取方式不匹配) ✅ 改成 readAsDataURL(file) 后正常,并加了回归用例钉住 这类"静默失败"没有报错、没有提示,只能靠真机走查发现;凡是"点了没反应",多半是异步回调根本没进
9 队友把存储 Key 从 v1 升到 v2(为了让老数据不干扰新结构),DOM 测试却还在往 v1 里写数据,用例全红 一开始以为是页面坏了,逐个 ID 排查后才发现数据根本没被读到 ✅ DOM 测试同步改成 v2,并加了一条"Key 变了测试要一起改"的断言 存储 Key、接口字段这类"契约"变更后,要全局搜一遍调用方;测试用例也是调用方

九、评价队友

值得学习的地方

这次结对中,搭档在测试走查和边界情况上帮了大忙。一开始我只顾着把"发布成功"这条主流程跑通,是他对着界面一条一条点,找出了好几个我漏掉的异常路径:必填项留空直接点发布会不会白屏、搜索一个根本不存在的词会不会空白一片、点进一条已经被删掉的详情页会不会报错。后来他把这些路径全部固化成了单元测试与 DOM 测试用例——"把偶然发现的问题变成以后每次都会自动检查的用例",这一点是我以前写作业时从来没做过的,也是这次收获最大的地方。

需要改进的地方

需求对齐可以更早一点。中间有一次他直接按自己的想法加了一个功能,结果和我们之前砍范围的决定冲突,返工了一轮;如果下次开工前先把"这次做什么、明确不做什么"列成一张表双方确认,能少走弯路。另外我们在 commit 粒度上也有过分歧,后来统一成"每完成一个可独立验证的功能提交一次"才好起来。

整体配合很顺畅,下次继续参考这个分工方式。

posted @ 2026-10-09 10:46  VioletQAQ  阅读(10)  评论(0)    收藏  举报