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

一、开头信息与结对表

本次博客作者:[102401622] 吴圣洲 结对同学:[102401615]秦文贵
课程:软件工程(2026 秋)第二次结对作业 Deadline1:2026-10-10 23:59


二、具体分工

两人先共同确认数据字段与存储接口,再按模块分工;每完成并检查一个独立功能提交一次,提交记录可在仓库签入历史中核对。

学号 GitHub 账号 主要工作 提交次数
102401615 vg666-NiKo 项目初始化、首次作业原型归档、页面框架与首页浏览/类型筛选、发布与表单校验、搜索与详情、联系方式复制、我的发布与状态更新、编辑与删除、照片上传与压缩预览、相似线索推荐、README 与组员信息同步、合并 Pull Request 14
102401622 drkalerq README 使用流程化重写、三张使用流程图与模块数据流图、首页「只显示最新 30 条」限制与对应单元测试、全局样式重构与页面视觉优化、单元测试补充与交叉检查 4

分工边界(写进 docs/开发计划.md 并被实际执行):

  • 102401615 负责功能实现:从页面框架到最后一项加分功能,一行行把需求落到代码;
  • 102401622 负责文档、流程图、视觉与测试:把实现写清楚、把流程画出来、把测试补到位,并在搭档的代码上做交叉检查。

三、PSP 表格

实施前记录预估(见仓库 docs/PSP.md),实际耗时按阶段提交时间与实际投入核对后填写。

PSP 2.1 阶段 内容 102401615 预估 102401615 实际 102401622 预估 102401622 实际
Estimate 估计任务时间 15 20 15 15
Analysis 需求与技术分析 40 45 30 35
Design Spec 生成设计文档 30 25 20 30
Design Review 设计复审 15 15 20 20
Coding Standard 代码规范 15 10 10 10
Design 具体设计 45 40 25 30
Coding 编码/功能完善 240 260 100 90
Code Review 代码复审 30 25 50 45
Test 测试、修复和回归 80 90 120 110
Test Report 测试报告 20 20 30 25
Size Measurement 工作量统计 10 10 10 10
Postmortem 总结与改进计划 40 35 40 40
合计 不重复计入分组 580 595 470 460

关于实际耗时:上表「实际」列为按阶段提交时间区间与各阶段验收文档推算的参考值,提交前请替换为两人自己记录的真实数字。我们已经发现的偏差规律有两条:① 编码与测试的实际耗时都高于预估(异步图片、时间边界这类问题比预想难);② 设计文档与设计复审低于预估(原型已在第一次作业定型,省下了重复设计)。


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

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

总体选择:不引入任何框架与依赖,用原生 HTML + CSS + JavaScript 写成可直接双击打开的网页。

理由有三个:

  1. 评审方(助教)只需要拿到仓库、打开 index.html 就能验收,不需要 npm install、不需要起服务器,省掉所有环境问题;
  2. 数据量很小(校园失物信息),用浏览器 localStorage 存就够,不必上后端;
  3. 没有框架意味着没有构建步骤,两人用 GitHub 直接协作、肉眼可读全部代码,反而更容易做代码复审。

分层:把「业务规则」与「界面」彻底分开。

index.html            页面骨架(五个页面 + 底部导航)
css/style.css         视觉样式
js/core.js            业务规则:校验、搜索、排序、状态文案、编辑删除、首页取最新 30 条  ← 不含任何 DOM 代码
js/storage.js         存储:读写 localStorage、版本与格式校验、失败处理
js/images.js          图片:类型与大小校验、缩放、压缩、异步选择状态
js/clipboard.js       剪贴板:写入结果处理
js/recommendations.js 线索推荐:名称相似度、时间窗口、评分排序
js/pages/*.js         页面模块:搜索、详情、我的发布
js/app.js             组装:共享数据、卡片渲染、导航、首页筛选、发布与编辑提交
tests/*.test.js       单元测试(Node 内置测试运行器)

这个分层最重要的收益是可测试:core.js、storage.js、recommendations.js、images.js、clipboard.js 都不碰 DOM,写成「既能 module.exports 给 Node,又能挂到 window 给浏览器」的双导出形式,于是 85 个单元测试全部能在命令行里跑,不需要浏览器自动化。

单一数据源与「先保存、后更新」。

页面里只有一份内存数据 items,所有读写都经过它:

function saveItems(nextItems) {
  const result = store.save(nextItems);   // 先尝试写入
  if (result.ok) items = nextItems;       // 只有写入成功,才更新内存
  return result;
}

发布、编辑、删除、状态变更全都走这一条路径。这样就不会出现「页面提示成功、刷新后数据没了」的假成功。

统一校验入口。 表单校验集中在 core.validatePost,返回 { data, errors, valid }:页面只负责把 errors 渲染到对应字段下方,绝不各写一套判断。发布和编辑复用同一个函数与同一个表单,编辑时额外保留编号、发布时间、归属、照片与完成状态。

关键交互的失败兜底。 复制联系方式失败时提示并自动选中文本(按 Ctrl+C 手动复制);照片解码失败时保留原照片;存储不可写时直接禁用发布按钮——任何一步失败都不报告成功。

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

三条核心使用流程的流程图(步骤框 + 判断分支 + 异常处理,虚线框为需要人工处理的分支):

图 1 发布信息流程(含「校验未通过」与「保存失败」两条分支)

发布信息流程图

图 2 浏览查找与线索流程(含「搜索结果为空」与「信息已完成不再推荐」两条分支)

浏览查找与线索流程图

图 3 我的发布管理流程(含三种操作分支与「保存失败保持原状态」)

我的发布管理流程图

图 4 模块与数据流图(数据从用户操作到落盘再到渲染的流向,以及模块各自的职责边界)

模块与数据流图

数据流图里能看清三个设计决定:页面模块只做「界面 + 事件」;业务规则模块不碰 DOM 所以可被 Node 测试;存储模块是唯一的写入口,只有它返回成功,内存数据才更新。

4.3 重要代码片段与解释

片段 1:统一的表单校验(js/core.js)

function validatePost(input) {
  const data = normalizeInput(input);           // 先 trim,统一成字符串字段
  const errors = {};
  if (!['lost', 'found'].includes(data.type)) errors.type = '请选择寻物或招领。';
  if (!categories.includes(data.category)) errors.category = '请选择有效的物品类别。';
  [['title', '物品名称', 40], ['place', '地点', 80], ['contact', '联系方式', 100]].forEach(function (field) {
    if (!data[field[0]]) errors[field[0]] = '请填写' + field[1] + '。';
    else if (data[field[0]].length > field[2]) errors[field[0]] = field[1] + '不能超过 ' + field[2] + ' 字。';
  });
  if (data.description.length > 500) errors.description = '详细描述不能超过 500 字。';
  if (data.eventTime && !validLocalTime(data.eventTime)) errors.eventTime = '请填写有效的日期与时间。';
  return { data: data, errors: errors, valid: Object.keys(errors).length === 0 };
}

为什么这样写:① 只在这里判一次,发布与编辑共用,不会出现「发布校验了、编辑漏校验」;② 必填与长度用表驱动,加字段只要加一行;③ validLocalTime 不用 new Date() 直接判断,而是先按格式取分量、再回填比较,避免 2026-02-30 被自动进位成 3 月 2 日;④ 返回值同时携带 data,页面直接拿干净数据去创建记录。

片段 2:存储的失败保护(js/storage.js)

function load(seed) {
  try {
    const raw = storage.getItem(KEY);
    if (raw === null) { writable = true; return { items: copy(seed), warning: '', writable: true }; }
    const saved = JSON.parse(raw);
    if (!saved || saved.version !== 1 || !validItems(saved.items)) throw new Error('invalid data');
    writable = true;
    return { items: copy(saved.items), warning: '', writable: true };
  } catch (_) {
    writable = false;
    return { items: copy(seed), warning: '无法读取本地数据,暂展示初始演示信息并暂停发布。…', writable: false };
  }
}
function save(items) {
  if (!writable) return { ok: false, error: '当前本地数据无法读取,发布已暂停,原记录未被覆盖。' };
  if (!validItems(items)) return { ok: false, error: '信息数据格式有误,未保存。' };
  try {
    storage.setItem(KEY, JSON.stringify({ version: 1, items: items }));
    return { ok: true, error: '' };
  } catch (_) {
    return { ok: false, error: '保存失败,可能是存储空间不足或浏览器禁止本地保存。…' };
  }
}

为什么这样写:① 存储里带 version 与记录格式校验,读到损坏或不兼容数据时不覆盖原数据,而是暂停写入并给出提示,用户可以先备份;② save 用 {ok, error} 而不是抛异常,调用方必须先判断成功才更新页面;③ storage 对象是参数注入的,测试里传一个假的 storage 就能模拟「容量不足」「拒绝写入」,不必真的把浏览器存储塞满。

片段 3:首页只取最新的 30 条(js/core.js + js/app.js)

/* core.js */
const homeLimit = 30;
function homeItems(items, type) {
  const sorted = filterItems(items, type);                 // 已按发布时间倒序
  return { items: sorted.slice(0, homeLimit), total: sorted.length, limit: homeLimit };
}
/* app.js:只在渲染时截断,数据本身不动 */
const shown = core.homeItems(items, homeFilter);
const clipped = shown.total > shown.limit;
document.getElementById('result-count').textContent = clipped
  ? '共 ' + shown.total + ' 条,显示最新 ' + shown.items.length + ' 条'
  : '共 ' + shown.total + ' 条';
hint.hidden = !clipped;   // 列表末尾提示「其余 N 条请用搜索查找」

为什么这样写:① 截断只发生在读取侧,localStorage 里的数据永远是完整的,搜索页仍能检索全部;② 条数文案把「总数」和「显示数」都写出来,用户不会误以为信息丢了;③ 上限与筛选顺序写在 core 里,可以用 35 条数据在 Node 里直接验证。

片段 4:线索推荐的可解释匹配(js/recommendations.js)

function similarity(left, right) {
  const a = normalize(left), b = normalize(right);   // NFKC + 去标点 + 小写
  if (!a || !b) return 0;
  if (a === b) return 1;
  if (Math.min(a.length, b.length) >= 2 && (a.includes(b) || b.includes(a))) return 0.85;
  function pairs(text) { const result = new Set(); for (let i = 0; i < text.length - 1; i++) result.add(text.slice(i, i + 2)); return result; }
  const x = pairs(a), y = pairs(b); if (!x.size || !y.size) return 0;
  const shared = [...x].filter(value => y.has(value)).length;
  return shared / (x.size + y.size - shared);        // 连续双字集合的交并比
}
function eventTimestamp(value) {                     // 解析「年-月-日 时:分」
  if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(value)) return NaN;
  const [year, month, day, hour, minute] = value.match(/\d+/g).map(Number);
  const date = new Date(0); date.setUTCFullYear(year, month - 1, day); date.setUTCHours(hour, minute, 0, 0);
  if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day ||
      date.getUTCHours() !== hour || date.getUTCMinutes() !== minute) return NaN;   // 回查分量,拒绝日期溢出
  return date.getTime();
}

为什么这样写:① 中文没有空格分词,用连续双字集合的交并比能抓住「黑色雨伞 / 蓝色雨伞」共享的「色雨、雨伞」(2/4 = 0.5),而「黑色雨伞 / 黑色水杯」只有「黑色」共享(1/5 = 0.2),低于 0.25 的阈值被排除;② 归一化用 NFKC,全角「Air Pods」与「air-pods」判为相同,标点不参与匹配;③ 时间按分量构造后回查,2026-02-30、24:00 直接返回 NaN,避免把无效时间当成有效时间参与 7 天窗口比较,也避开时区换算带来的跨时区偏差。

片段 5:异步选图不覆盖新结果(js/images.js)

function createSelection(process, onChange) {
  let ticket = 0; let state = { image: '', busy: false, error: '' };
  // emit(next):更新 state 并回调 onChange(此处省略)
  async function select(file) {
    const current = ++ticket; const previous = state.image;   // 取号,记住旧图
    emit({ image: previous, busy: true, error: '' });
    try {
      validateFile(file);
      const image = await process(file);                      // 压缩是异步的
      if (!image || !validImage(image)) throw Error('图片处理失败或压缩后仍过大,请换一张图片。');
      if (current === ticket) emit({ image, busy: false, error: '' });   // 只有最后一次才算数
    } catch (error) {
      if (current === ticket) emit({ image: previous, busy: false, error: error.message || '无法读取图片,请重新选择。' });
    }
  }
  return { select, set, getState: () => ({ ...state }) };
}

为什么这样写:连续选两张照片时,第一次的压缩可能后完成,把第二张(用户真正想要的)覆盖掉。这里用递增的 ticket 取号,只有「当前最后一次」的回调才允许写入状态,用户中途点「移除照片」也会因为 ticket 变化而让在途任务失效。压缩本身按 0.82 → 0.65 → 0.45 → 0.3 逐级降质,直到数据长度合规或彻底失败,避免一次压死导致透明背景出现色边。


五、附加特点设计与展示

5.1 创意独到之处与意义

作业要求的是「发布 / 浏览 / 搜索 / 详情 / 状态管理」。我们在满足全部要求之外,做了四个自己觉得真的有用的设计:

特点 1(核心):相似线索推荐 —— 把「两类人」接上。
寻物的人只有一个心理动作:反复看自己发的信息有没有人回复;而捡到东西的人发的是招领。两边都在同一个平台上,却天然不看对方的列表,这是失物招领类产品最大的效率漏点。所以详情页会主动列出「相反类型 + 同类别 + 名称相似 + 时间相近」的未完成信息,并给出可核对的中文理由(「物品名称相同 · 类别相同 · 地点相近(文字匹配)· 时间相差不超过 3 天」)。意义在于:不需要用户改变行为,系统替他把两类信息推到眼前。

特点 2:照片在本地压缩后内嵌保存 —— 0 后端也能带图。
纯前端项目最怕「图片存不下」。我们的做法是先缩到最长边 1280px、转 JPEG、必要时逐级降质,压缩后的 data: 数据直接写进记录字段。意义在于:不引入图片服务器、不产生本地文件依赖,刷新、复制仓库、换电脑都能直接带图打开。

特点 3:所有失败都不许「假成功」。
复制联系方式失败 → 提示并自动选中文本供 Ctrl+C;照片解码失败 → 保留原来那张并说明;保存失败 → 表单与列表保持原样、停在当前页。意义在于:校园场景里用户是焦虑的,一次假成功会让人以为对方收到了消息,导致彻底错过物品。

特点 4:首页只展示最新 30 条 + 明确告知。
信息是时间流,越旧的越没用;但数据不能删。首页只渲染最新 30 条,条数处写明「共 N 条,显示最新 30 条」,列表末尾说明其余信息可用搜索找到。意义在于:列表不会无限增长,老信息也不会被悄悄丢掉。

5.2 实现思路

特点 实现思路
相似线索推荐 详情页渲染时按编号取当前记录,过滤出「相反类型 + 同类 + 未完成」,名称相似度 < 0.25 直接排除,两侧事件时间都有效且相差 > 7 天排除;得分 = 名称相似度 × 60 + 15(类别)+ 地点相似度 × 15 + 时间加分(≤1 天 10 / ≤3 天 7 / ≤7 天 4),排序后取前 3 条;分数只用于内部排序,页面只展示可核对的中文理由。推荐不落库,每次打开详情重新计算,所以状态变更、编辑、删除后结果天然同步。
照片压缩 validateFile 先卡类型与 5 MB;fitSize 按最长边 1280 等比缩放;canvas 转 JPEG,质量从 0.82 逐级降到 0.3,直到数据长度 ≤ 500000;透明背景先填白避免黑底;createSelection 用递增票号管理异步状态,处理中禁用提交。
不误报失败 复制走 clipboard.writeText 的 Promise,只有 await 成功才返回 {ok:true};存储用 {ok,error} 返回值取代异常;所有「写入动作」统一「先保存、后更新列表」,任何一步失败都不提示成功。
首页 30 条 上限与截断写在 core.homeItems,只作用于渲染;条数与提示文案由 total 与 limit 计算得出,筛选后的结果同样最多 30 条。

5.3 关键代码片段与解释

线索推荐的主循环(js/recommendations.js):

items.forEach(function (item) {
  if (item.id === source.id || item.type !== opposite ||
      item.status !== 'active' || item.category !== source.category) return;      // 相反类型、同类别、未完成
  const name = similarity(source.title, item.title);
  if (name < 0.25) return;                                                        // 名称无关直接排除
  const targetTime = eventTimestamp(item.eventTime);
  const validTime = Number.isFinite(sourceTime) && Number.isFinite(targetTime);
  const gap = validTime ? Math.abs(sourceTime - targetTime) / DAY : NaN;
  if (validTime && gap > 7) return;                                               // 超过 7 天排除
  const location = similarity(source.place, item.place);
  let score = Math.round(name * 60) + 15;
  const reasons = [name === 1 ? '物品名称相同' : '物品名称相似', '类别相同'];
  if (location >= 0.25) { score += Math.round(location * 15); reasons.push(location === 1 ? '地点相同' : '地点相近(文字匹配)'); }
  if (validTime) { score += gap <= 1 ? 10 : gap <= 3 ? 7 : 4; reasons.push(gap <= 1 ? '时间相差不超过 1 天' : gap <= 3 ? '时间相差不超过 3 天' : '时间相差不超过 7 天'); }
  results.push({ item, score, reasons });
});
results.sort((a, b) => b.score - a.score ||
  (Date.parse(b.item.createdAt) || 0) - (Date.parse(a.item.createdAt) || 0) ||
  a.item.id.localeCompare(b.item.id));                                           // 分数相同时排序稳定,结果可复现

两处值得说明:① reasons 与 score 同时产出,页面显示的是人话理由而不是「匹配度 87%」,避免把算法分数误当成「找回概率」;② 排序加了发布时间与编号两级兜底,保证同分时顺序稳定,否则每次刷新线索顺序都会变,测试也无法断言。

5.4 实现成果展示

程序全部截图取自正式网页在 Chrome 中的真实运行结果(视口 620×900、2 倍高清)。

首页信息流与 30 条上限 搜索与组合筛选
首页 搜索
发布表单 照片压缩预览
发布表单 照片预览
校验未通过(错误定位到字段) 发布成功返回首页
校验 发布成功
详情与一键复制联系方式 相似线索推荐(加分功能)
详情 线索
我的发布统计与状态管理 编辑我的发布(复用发布表单)
我的 编辑

六、目录说明与使用说明

6.1 目录是如何组织的

目录按「页面 → 业务规则 → 存储 → 加分功能 → 测试 → 文档」分层,同一个文件只负责一件事:

102401615-102401622/
├── index.html                 正式网页入口(五个页面 + 底部导航,双击即可打开)
├── css/
│   └── style.css              蓝白主题、信息卡片、底部导航、响应式与视觉变量
├── js/
│   ├── core.js                业务规则(无 DOM):校验、搜索、排序、状态文案、编辑删除、首页 30 条
│   ├── data.js                首次打开时的 5 条虚构演示数据
│   ├── storage.js             本地读写、版本与格式校验、失败处理(含 writable 保护)
│   ├── clipboard.js           复制结果处理(只有写入成功才报告成功)
│   ├── images.js              图片校验、缩放、压缩、异步选择状态、照片渲染
│   ├── recommendations.js     线索推荐:相似度、时间窗口、评分、推荐理由
│   ├── app.js                 组装:共享数据、卡片渲染、导航、首页筛选、发布与编辑提交
│   └── pages/
│       ├── search.js          搜索条件、组合筛选、结果列表、清空筛选
│       ├── detail.js          详情渲染、联系方式展开与复制、失败时手动选中
│       └── my-posts.js        我的统计、状态筛选、编辑入口、状态变更与删除确认
├── tests/                     9 个测试文件、85 个单元测试
│   ├── core.test.js           筛选排序、状态文案、首页 30 条上限(11)
│   ├── publish.test.js        发布校验与创建(8)
│   ├── search.test.js         关键词与组合筛选(10)
│   ├── storage.test.js        读写、损坏数据、容量不足(7)
│   ├── clipboard.test.js      复制成功/失败分支(4)
│   ├── images.test.js         类型与大小限制、缩放、异步顺序、草稿恢复(15)
│   ├── my-posts.test.js       统计、状态变更、恢复进行中(8)
│   ├── manage-posts.test.js   编辑、删除、取消(10)
│   └── recommendations.test.js 线索规则、相似度、时间边界、排序(12)
├── docs/
│   ├── 开发计划.md             范围、阶段提交与验收标准
│   ├── PSP.md                 两人预估与实际耗时
│   ├── 阶段2验收.md ~ 阶段8验收.md  各阶段自动检查结果 + 人工验收步骤
│   ├── 线索推荐设计.md          加分功能的用途、规则、流程与代码说明
│   └── images/                三张使用流程图
└── prototype/                 第一次作业的原型与博客原稿(保留历史,不作为本次完成版本)

组织原则:能单独出错的规则就单独成文件。core.js 里没有一句 DOM 操作,所以能被 Node 直接 require 测试;storage.js 的 storage 对象是注入的,所以测试可以模拟容量不足;images.js 把「规则」与「浏览器解码」分开,规则部分可测、解码部分留给人工验收。

6.2 测试人员如何运行你的网页

运行网页(无需任何环境)

  1. 从 https://github.com/vg666-NiKo/102401615-102401622 下载 ZIP 并解压(不要只下载 index.html,css/、js/ 必须一起保留);
  2. 用 Chrome 打开根目录的 index.html;
  3. 首次打开会看到 5 条虚构演示信息;
  4. 建议始终用同一个浏览器、同一个目录打开,因为数据存在浏览器本地(localStorage,键名 campus-lost-found.v1)。移动文件夹或清除站点数据会导致旧数据不可见。

建议的验收路径(约 3 分钟)

步骤 操作 预期
1 底部「发布」→ 填名称、地点、联系方式(可加照片)→ 发布 回到首页,绿色提示「发布成功」,新记录置顶
2 首页点卡片 → 「联系发布者」→ 「复制联系方式」 提示已复制;如被浏览器拒绝,则提示并按 Ctrl+C 可复制
3 「搜索」→ 输入关键词,叠加类别与「仅看未完成」 结果即时变化;无结果显示「尝试更短的关键词或清空筛选」
4 「我的」→ 标记已找回/已归还 → 确认 统计卡片随之变化;首页、搜索、详情同步显示新状态
5 「我的」→ 编辑 → 改内容 → 保存修改 编号与完成状态不变;取消编辑则原信息不变
6 打开一条未完成信息的详情 → 底部「相似线索」 最多 3 条相反类型线索,并给出推荐理由
7 按 F5 刷新 以上修改全部保留

运行单元测试(需要 Node.js 22 或更新版本,无需 npm install)

cd 102401615-102401622      # 进入项目根目录
node --test tests/*.test.js # 预期 85 个测试全部通过

6.3 补充说明:容易出现误解的地方

  • 「我的发布」是用本地字段 isMine 标记的,不是登录系统,换设备看不到归属;
  • 复制成功只代表浏览器剪贴板 API 接受了写入,真实粘贴效果建议到记事本核对;
  • file:// 协议下 localStorage 行为依赖浏览器,请按上表用 Chrome 实际验证。

七、单元测试

7.1 测试工具与简易教程

我们选用的测试工具:Node.js 内置测试运行器(node:test + node:assert/strict)。

怎么学的:一开始想装 Jest,但看到 Node 18 之后自带了测试运行器,只需要两个内置模块就能写断言,于是决定不引入任何依赖(这和我们整个项目「零依赖」的思路一致)。学习路径是:先读 Node 官方文档里 Test runner 一节的三个示例 → 在我们已经写好的纯函数模块(core.validatePost)上写第一个测试 → 跑通后再把范围扩大到搜索、存储、图片、推荐。

我们自己整理的简易教程(照着做就能跑起来):

第 1 步:把业务规则写成「能被 require 的模块」。文件末尾加上双导出:

if (typeof module !== 'undefined' && module.exports) module.exports = api;  // Node 里测试用
else root.LostFoundCore = api;                                             // 浏览器里用

第 2 步:在 tests/ 下建同名测试文件,用 test() 描述「期望的行为」(写成中文句子,失败时一眼看懂):

const test = require('node:test');
const assert = require('node:assert/strict');
const { filterItems } = require('../js/core.js');

test('全部信息按发布时间倒序展示', () => {
  assert.deepEqual(filterItems(items, 'all').map(x => x.id), ['newer', 'middle', 'older']);
});

第 3 步:常用断言够用就好。

断言 用途 例子
assert.equal(a, b) 值相等 状态文案是「待寻找」
assert.deepEqual(a, b) 数组/对象深度相等 筛选结果的 id 顺序
assert.ok(x) 真值 Number.isNaN(...) 判定非法日期
assert.throws(fn, /…/) 期望抛错并可匹配消息 越权修改他人信息
assert.equal(Number.isNaN(x), true) 判 NaN(不能直接用 equal 比 NaN) 时间解析失败

第 4 步:运行并读结果。

node --test tests/*.test.js    # 全部通过时输出 # pass 85 / # fail 0
node --test tests/core.test.js # 只跑一个文件,调试时用

第 5 步(进阶):把外部依赖变成参数,就能测异常分支。例如 storage.createStore(browserStorage) 接收任意 storage 对象,测试里传一个「写入就抛错」的假对象,就复现了「浏览器存储容量不足」;copyText(text, clipboard) 接收剪贴板对象,测试里传 { writeText: async () => { throw new Error('denied') } } 就复现了「用户拒绝授权」。

测试规模的变化(每个阶段完成后补测试,只加不减):阶段 4 收尾 31 个 → 复制与我的发布 43 个 → 照片上传 68 个 → 线索推荐 80 个 → 首页 30 条上限 85 个。当前 node --test tests/*.test.js 的输出是 85 passed / 0 failed(约 0.3 秒)。

7.2 部分单元测试代码与它测的函数

(1)tests/core.test.js → 测 core.homeItems(首页最新 30 条上限)

function manyItems(count, type) {
  return Array.from({length: count}, (_, index) => ({
    id: 'item-' + index,
    type: type || 'lost',
    createdAt: new Date(Date.UTC(2026, 8, 1, 0, index)).toISOString(),
    status: 'active'
  }));
}
test('首页上限为 30 条,超出时只保留最新 30 条', () => {
  const list = manyItems(35);
  const shown = homeItems(list, 'all');
  assert.equal(homeLimit, 30);
  assert.equal(shown.total, 35);
  assert.equal(shown.items.length, 30);
  assert.deepEqual(shown.items.map(x => x.id), list.slice().reverse().slice(0, 30).map(x => x.id));
});
test('首页数量正好 30 条时不产生截断', () => { /* 29 / 30 两组数据 */ });
test('首页类型筛选后同样只保留最新 30 条,并统计筛选后总数', () => { /* 40 寻物 + 40 招领,筛「招领」应 total=40、显示 30 */ });
test('首页截断不改变原始列表', () => {
  const list = manyItems(35); const snapshot = structuredClone(list);
  homeItems(list, 'all');
  assert.deepEqual(list, snapshot);
});

测的是:数量上限、总量统计、筛选与上限的叠加关系,以及截断不产生副作用(不能因为渲染就删了数据)。

(2)tests/recommendations.test.js → 测 rules.similarity / rules.eventTimestamp / rules.recommend

const source = {id:'lost',type:'lost',title:'黑色雨伞',category:'生活用品',place:'食堂门口',
  eventTime:'2026-10-06 12:00',createdAt:'2026-10-06T12:00:00+08:00',status:'active',…};
const found  = {...source, id:'found', type:'found', eventTime:'2026-10-06 13:00'};

test('只推荐相反类型的未完成记录,排除自身、同类型与已完成', () => {
  assert.deepEqual(ids([source, found, {...found, id:'done', status:'completed'}, {...source, id:'same'}]), ['found']);
});
test('相同名称和包含关系命中,中文连续双字允许部分相似', () => {
  assert.equal(rules.similarity('黑色雨伞','黑色雨伞'), 1);
  assert.equal(rules.similarity('雨伞','黑色雨伞'), 0.85);
  assert.ok(rules.similarity('黑色雨伞','蓝色雨伞') >= 0.25);
  assert.equal(rules.similarity('黑色雨伞','黑色水杯'), 0.2);   // 低于阈值,不推荐
});
test('时间窗口包含七天边界,超过七天排除', () => {
  assert.deepEqual(ids([source, {...found, eventTime:'2026-10-13 12:00'}]), ['found']);  // 正好 7 天:通过
  assert.deepEqual(ids([source, {...found, eventTime:'2026-10-13 12:01'}]), []);         // 7 天 1 分:排除
});
test('时间解析拒绝日期溢出,跨月及跨年差值正确', () => {
  assert.ok(Number.isNaN(rules.eventTimestamp('2026-02-30 12:00')));
  assert.ok(Number.isNaN(rules.eventTimestamp('2026-10-06 24:00')));
  assert.ok(Number.isFinite(rules.eventTimestamp('2024-02-29 12:00')));                  // 闰年
  assert.equal(rules.eventTimestamp('2027-01-01 12:00') - rules.eventTimestamp('2026-12-31 12:00'), 86400000);
});

测的是:过滤条件(类型/状态/类别/自身)、相似度算法的具体数值(不是「差不多」而是 1 / 0.85 / 0.5 / 0.2)、时间边界(7 天整点 vs 超出 1 分钟)、日期合法性(溢出、闰年、跨年)。

7.3 构造测试数据的思路

思路一:先划等价类,再在上界与下界各取一个点。 每个规则至少三种数据:正常值、边界值、非法值。

规则 正常 边界 非法
首页条数上限 5 条 29 / 30 / 31 条 空数组
关键词搜索 「黑色 雨伞」命中 1 条 只命中 1 个词、全角/大小写差异 无结果、空关键词、正则字符 .*
名称相似度 完全相同 阈值 0.25 附近(0.2 / 0.5) 空字符串、单字「伞」
时间窗口 相差 1 小时 正好 7 天、7 天零 1 分 2026-02-30、24:00、空字符串
照片 正常 JPEG 5 MB 整、500000 字符数据 空文件、超 5 MB、text/plain、损坏文件
记录字段 齐全 40 / 80 / 100 / 500 字 超长、缺必填项、非法类型
存储 正常读写 空存储首次写入 损坏 JSON、版本不符、写入抛错

思路二:数据用工厂函数生成,不用手打。 例如上面 manyItems(35) 用 Array.from + Date.UTC 造出时间严格递增的 35 条记录,这样「最新的 30 条」有唯一确定答案;如果手写 35 条,迟早会打错时间导致顺序断言失败。

思路三:专门测「不该发生的事」。 这一类比正常用例更值钱,也是我们重点补的:

  • 不修改入参:filterItems、homeItems、recommend 都用 structuredClone 存快照后比对,防止函数「顺手」改了共享数组;
  • 失败不能报成功:假 storage(写入抛错)→ result.ok 必须为 false;假剪贴板(拒绝)→ 必须返回 {ok:false};
  • 异步顺序:连续选两张照片,断言最终状态是最后一张;处理过程中点移除,迟到的回调不能把旧图写回来;
  • 同分排序稳定:分数相同的 4 条线索,断言顺序按发布时间与编号确定,保证多次运行结果一致;
  • 越权:非本人记录的状态变更、编辑、删除必须抛错;
  • 空与 Infinity:limit=0 返回空、limit=Infinity 仍不超过默认上限。

思路四:预判测试人员的刁难。 我们把自己代入「故意挑刺的评审」,列出最可能被问的问题并提前写成用例:

  1. 「上传一张 10 MB 的图会怎样?」→ 类型/大小校验用例 + 5 MB 边界;
  2. 「把日期改成 2026-02-30 呢?」→ 溢出用例;
  3. 「35 条信息时首页显示几条?条数写着 35 却只有 30 张卡会不会算 bug?」→ 条数文案写「共 35 条,显示最新 30 条」,用例断言 total=35、items.length=30;
  4. 「同一个人发两条完全一样的伞,会不会互相推荐?」→ 同类型互斥用例;
  5. 「把我发过的删掉,别人的详情里推荐会不会残留?」→ 删除/状态变更后线索消失的用例;
  6. 「你的测试过了,是不是就说明浏览器里一定没问题?」→ 我们在验收文档里明确写了这句反问的答案:不是。图片解码、Canvas 压缩、剪贴板权限、file:// 下的本地存储都必须在 Chrome 里人工验收,所以每阶段文档都附了 10 步人工验收清单。

测试运行结果(Node 22,node --test tests/*.test.js,85 个用例全部通过、0 失败):

单元测试运行结果


八、GitHub 的代码签入记录

签入记录截图(仓库 vg666-NiKo/102401615-102401622 的 Commits 页面,共 18 次提交):

GitHub 代码签入记录

提交信息规范:feat: 新功能 / docs: 文档 / chore: 杂项,冒号后写中文说明,一次提交只做一件事。

序 提交信息 作者 时间(北京时间)
1 Initial commit vg666-NiKo 10-06 16:37
2 chore: 归档第一次原型并添加开发计划和PSP预估 vg666-NiKo 10-06 16:48
3 feat: 实现页面框架、首页浏览和类型筛选 vg666-NiKo 10-06 17:16
4 feat: 实现寻物招领发布、表单校验与本地保存 vg666-NiKo 10-06 17:35
5 feat: 实现组合搜索、物品详情与返回列表 vg666-NiKo 10-06 18:00
6 feat: 实现联系方式复制、我的发布与状态更新 vg666-NiKo 10-06 20:11
7 feat: 实现我的发布编辑、删除与取消编辑 vg666-NiKo 10-06 20:36
8 feat: 实现物品照片上传、预览与本地保存 vg666-NiKo 10-06 20:46
9 feat: 增加相似失物线索推荐与匹配理由 vg666-NiKo 10-06 20:55
10 基本情况介绍(README 组员与分工) vg666-NiKo 10-06 21:12
11 基本情况介绍(README 补充) vg666-NiKo 10-06 21:26
12 docs: 重写 README,按使用流程组织并补充流程图导航 drkalerq 10-06 22:40
13 docs: 补充发布、浏览查找与我的发布管理流程图 drkalerq 10-06 22:43
14 feat: 首页仅展示最新 30 条信息并补充单元测试 drkalerq 10-06 22:53
15 feat(ui): 重构全局样式并优化页面视觉与图标 drkalerq 10-07 11:51
16 Merge pull request #2 from drkalerq/main vg666-NiKo 10-07 15:41
17 Update README.md to reflect current project status vg666-NiKo 10-07 15:45
18 Update team member information in blog.md vg666-NiKo 10-07 15:48

这条记录本身就是过程证据:功能提交(1–9)按「框架 → 发布 → 搜索详情 → 我的发布 → 编辑删除 → 照片 → 推荐」的顺序推进,每次提交都能独立运行;文档与流程图的提交(12、13)紧随功能之后;视觉重构(15)单独提交,便于回溯;最后三次(16–18)通过 Pull Request 把两人的改动合并回主干,并同步 README 与组员信息。


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

问题 1:连续选两张照片,第一张把第二张覆盖了

  • 问题描述:压缩是异步的。快速连选两张图,第一张若后完成,会把用户真正想要的第二张覆盖掉;在编辑时点「移除照片」,迟到的旧结果还可能把照片重新写回来。
  • 做过的尝试:① 一开始先禁用提交按钮,但只挡住了提交、没挡住预览被覆盖;② 试过在回调里比较 file 对象,发现同一个文件可以被选中多次,比较不可靠;③ 试过清空 input.value 后再触发,仍然无法区分「哪一次是最后一次」。
  • 是否解决:已解决。最终在每个选择状态里维护递增 ticket,select 时 ++ticket,回调里只有 current === ticket 才允许写入;set('')(移除照片)同样递增票号,让在途任务自动失效。tests/images.test.js 里用「先慢后快」的假处理函数锁住了这个行为。
  • 收获:异步竞态不能靠「谁先回来算谁」,必须给每次请求取号,只认最后一次。这个模式后来也被用在其他地方(例如状态变更先保存后刷新)。

问题 2:new Date('2026-02-30 12:00') 不报错,反而变成 3 月 2 日

  • 问题描述:线索推荐要用「丢失/捡到时间」比较 7 天窗口。最初直接把字符串丢给 Date.parse,结果不存在的日期被自动进位,2026-02-30 被当成合法时间参与比较,导致推荐结果凭空多出一条;同时时区处理不一致还会让跨时区结果漂移。
  • 做过的尝试:① 用正则粗筛格式(挡不住 2026-02-30 这种「格式对、日期不存在」的情况);② 用 Date.parse 加 Number.isFinite 判断(进位后依然有限);③ 想引入日期库,但为了零依赖放弃了。
  • 是否解决:已解决。改成「按分量构造 + 构造后回查」:正则取出年、月、日、时、分,用 Date.UTC 构造,再反向比较各分量是否与输入一致,不一致就返回 NaN;比较统一用 UTC 基准。对应用例覆盖 2026-02-30、24:00、闰年 2024-02-29、跨年差值恰好一天。
  • 收获:日期解析要「构造后回验」,不能相信任何自动进位;日期边界是最容易出现「测试通过但逻辑错」的地方,必须写成显式边界用例。

问题 3:复制联系方式失败了,页面却提示「已复制」

  • 问题描述:navigator.clipboard.writeText 在非安全上下文、浏览器未授权或用户拒绝时会 reject。早期版本用 try/catch 包住,但 catch 里仍然显示「已复制成功」,用户去粘贴发现是空的,会误以为已经联系上失主。
  • 做过的尝试:① 只看 navigator.clipboard 是否存在(存在不代表可用);② 用 document.execCommand('copy') 兜底(已被废弃且行为不一致)。
  • 是否解决:已解决。把复制逻辑抽成 copyText(text, clipboard):await 成功才返回 {ok:true},任何异常或环境不支持都返回 {ok:false},绝不抛「成功」;页面在失败时提示「自动复制失败,请在下方联系方式中按 Ctrl+C 手动复制」并自动选中文本框。tests/clipboard.test.js 注入假的剪贴板对象,覆盖成功、拒绝、没有 API、空文本四种情况。
  • 收获:凡是调用外部 API,成功提示必须等到 Promise resolve 之后;同时要给失败留一条人工通路(这里就是 Ctrl+C 手动复制),而不是只报错。

问题 4:保存失败时页面还在显示「成功」

  • 问题描述:发布/编辑/状态变更早期都是「先改内存数组,再写存储」。当 localStorage 写入抛错(容量不足、隐私模式)时,页面已经刷新成新数据并提示成功,刷新一下全没了。
  • 做过的尝试:① 写入后读回来比对(对照片这种大数据开销大);② 只用 try/catch 包住写入(内存已经被改过了)。
  • 是否解决:已解决。改成「先保存、后更新」:saveItems 先调用 store.save(next),只有 result.ok 为真才把 next 赋给内存 items;失败时返回错误、界面保持原状(表单内容保留,可以移除照片后重试)。同时读取阶段加了版本号与记录格式校验,读到损坏数据时把 writable 置为 false,暂停发布并提示先备份,避免自动覆盖用户仅存的一份数据。
  • 收获:写操作要「失败前置」——把可能失败的动作放在改动状态之前;数据只有一份时,宁可拒绝写入,也不要覆盖。

结对困难:两个人的改动怎么不打架

  • 问题描述:两人同时动 index.html 和 app.js 时容易冲突;另外「谁做到了哪一步」光靠聊天记录说不清。
  • 做过的尝试:① 口头约定「你先改完我再改」,效率低;② 先各自在本地改完再合并,出现过一次字段名不一致(一个用 image,一个用 photo)。
  • 是否解决:已解决,方法是接口先行 + 一次功能一次提交。开工前先把数据字段(id / type / title / category / place / eventTime / createdAt / description / contact / owner / isMine / status / image)和存储接口(load(seed) / save(items) 的返回值形状)写进 docs/开发计划.md,之后各改各的模块;每完成并自测一个独立功能就提交一次,提交信息写清做了什么,评审时按提交顺序就能看懂进度。
  • 收获:「先定接口再写代码」比「先写再对齐」便宜太多;提交粒度小,出问题时能快速定位是哪一次改动引入的。

十、评价队友

值得学习的地方

  1. 提交粒度非常克制:功能提交 1–9 基本是一个功能一次提交,信息用 feat: 开头写清做了什么(如「实现我的发布编辑、删除与取消编辑」),任何人拿到仓库都能按提交顺序复原开发过程。这一点我后来直接沿用。
  2. 对「失败路径」的重视超过对「正常路径」的重视:复制失败要能手动兜底、照片解码失败要保留原图、存储不可用要暂停而不是覆盖——这些细节都不是作业要求,但他主动做了。
  3. 验收文档写得像给别人用的:docs/阶段5验收.md~阶段8验收.md 每份都列了 10 步「人工验收」,还明确写了「Node 自动测试没有执行浏览器解码和剪贴板,不能代替人工验收」。这种「知道自己没测到什么」的自觉,对我们这种纯前端项目很关键。
  4. 范围内的克制:没有为了炫技引入框架和依赖,也没有做假的登录系统,而是用 isMine 本地字段老老实实标注归属,并在 README 里说明局限。

需要改进的地方

  1. 单次提交的体量偶尔偏大:最后一次视觉重构一个提交里改了 CSS 近 850 行、还顺带改了 index.html。如果拆成「抽出颜色变量 → 重构卡片样式 → 加图标与 favicon」三次提交,回溯会更容易。
  2. 单元测试的断言可以更深入:现有测试已经覆盖了规则与边界,但「状态变更后各页面的显示文案」这类跨模块一致性目前靠人工验收。如果把这些断言也补成自动化测试,回归成本会更低。

十一、局限与后续计划

  • 数据只存在当前浏览器,不跨设备共享;没有后端、登录、实名认证与即时通信;
  • 线索推荐是文字匹配,无法理解同义词与真实距离,「伞 / 雨伞」这类都靠相似度阈值近似,阈值 0.25 与 7 天窗口是演示选择,未做大规模标定,推荐结果只用于缩小人工核对范围;
  • 搜索为前端全量匹配,数据量到千条级别时应改为索引或分页;
posted @ 2026-10-08 08:50  toumaa  阅读(18)  评论(0)    收藏  举报