2026秋软件工程第二次结对作业之程序实现——校园失物招领

一、概览信息

二、具体分工

成员 主要分工
冯安娜 建仓与项目骨架、核心业务逻辑(core.js)、数据持久化与渲染层(store.js / ui.js / app.js)、单元测试、README、GitHub Pages + CI、PSP 预估与实际、博客主笔
李思淇 fork 仓库与 PR 协作、独立走查回归、测试用例补充、README 与博客互审

两人共同:接口约定定稿、核心流程验收(发布→搜索→详情→联系→状态更新闭环)、提交前对照评分规则逐项检查。

三、PSP 表格

实施前先记录预估,实现后补实际耗时并做偏差分析。

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

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

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

本次作业要求「下载全部文件后用谷歌浏览器运行 html 文件就能展现预期结果」,我们据此做了三个关键决策:

  1. 技术选型:纯 HTML + CSS + 原生 JavaScript,零依赖、零构建。 不用框架、不用构建工具、不需要服务器和 npm install,双击 index.html 即可运行,最大程度降低测试人员复现成本。
  2. 数据持久化:localStorage + 项目命名空间。 没有后端,发布的信息必须存在浏览器里。注意一个隐蔽的坑:Chrome 下所有 file:// 页面共享同一个 origin,键名如果随便起(如 items)会与别人的项目数据串扰,因此键名统一带命名空间 lf_102401402_*。读取时做了两层防御:JSON 解析失败不覆盖原数据、localStorage 不可用(隐私模式)时降级为内存存储。
  3. 架构分层:业务规则与界面彻底分离。 全部数据逻辑收敛在 core.js(校验、搜索、筛选、状态机、相似度、格式化,纯函数、不碰 DOM);存储收敛在 store.js;渲染收敛在 ui.js;事件编排在 app.js。core.js 用 UMD 双导出(module.exports + window.Core),因此既能在浏览器加载,又能被 Node 直接 require 做单元测试——可测试性在架构层面就设计进去,而不是事后补。

数据流是单向的:

用户操作(点击/输入)
   ↓
app.js(事件编排,持有全局状态)
   ├─→ core.js(纯业务逻辑:校验/搜索/状态机/相似度)
   ├─→ store.js(localStorage 读写,损坏防御)
   └─→ ui.js(DOM 渲染,插入前 HTML 转义)
   ↓
界面更新 + 持久化

这样做的好处是关注点分离:改界面不碰逻辑,改逻辑不影响界面,core.js 的规则可以被 Node 单独验证。

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





4.3 重要的/有价值的代码片段及解释

片段一:UMD 双导出 ——「可测试」的关键

(function (root, factory) {
  if (typeof module === 'object' && module.exports) {
    module.exports = factory();      // Node 环境:require 拿到对象
  } else {
    root.Core = factory();           // 浏览器环境:挂到 window
  }
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';
  // 全部纯函数:validatePost / searchItems / sortItems / canManage /
  // completePost / revertPost / findSimilar / similarity / recommend /
  // formatTime / calcStats / escapeHtml / normalize ...
  return { /* 导出函数对象 */ };
});

解释:同样的逻辑,浏览器用经典 <script> 标签加载(避开 file:// 下 ES 模块被 CORS 拦截的坑),Node 用 require 加载做单元测试。这是「双击 html 能跑」与「Node 能测」能同时满足的原因。

片段二:统一校验入口(表驱动,发布/编辑复用)

var CATEGORIES = ['电子设备', '证件卡片', '钥匙', '服饰箱包', '书籍文具', '其他'];
var AREAS = ['教学区', '图书馆', '食堂', '宿舍区', '运动场', '其他'];

function validatePost(input) {
  var data = normalizeInput(input);            // 先 trim、统一成字符串字段
  var errors = {};
  if (data.type !== 'lost' && data.type !== 'found') errors.type = '请选择寻物或招领。';
  if (CATEGORIES.indexOf(data.category) === -1) errors.category = '请选择有效的物品类别。';
  if (AREAS.indexOf(data.area) === -1) errors.area = '请选择有效的区域。';
  [['title', '物品名称', 40], ['location', '具体地点', 80], ['contact', '联系方式', 100]]
    .forEach(function (f) {
      if (!data[f[0]]) errors[f[0]] = '请填写' + f[1] + '。';
      else if (data[f[0]].length > f[2]) errors[f[0]] = f[1] + '不能超过' + f[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 日(返回 NaN 拒绝);④ 返回值同时携带 data,页面直接拿干净数据去创建记录。

片段三:双层状态权限 ——「没有显示按钮」不是完整规则

function canManage(post, ownerId) {
  return Boolean(post && !post.demo && ownerId && post.ownerId === ownerId);
}

function completePost(posts, id, ownerId, now) {
  now = now || new Date();
  var post = posts.filter(function (p) { return p.id === id; })[0];
  if (!post) return { ok: false, error: '这条信息不存在。' };
  if (!canManage(post, ownerId)) return { ok: false, error: '只有发布者可以更新状态。' };
  if (post.status === 'resolved') return { ok: false, error: '这条信息已经完成,无需重复操作。' };
  var updated = posts.map(function (p) {
    return p.id === id ? Object.assign({}, p, { status: 'resolved', resolvedAt: now.toISOString() }) : p;
  });
  return { ok: true, posts: updated };
}

解释:界面只对本人显示操作按钮,但业务函数会再次检查权限,避免把「没有显示按钮」当作完整规则(防开发者工具篡改)。函数返回新数组,界面等保存成功后才使用新数据,从而避免「保存失败却显示已完成」的假成功。demo 演示数据同样不可修改。

片段四:组合搜索 + 已解决置底排序

function searchItems(items, opts) {
  opts = opts || {};
  // 先按原始关键词以空白分词,再逐词归一化(normalize 会去除空白,顺序不能反)
  var rawKw = String(opts.keyword || '').trim();
  var tokens = rawKw ? rawKw.split(/\s+/).map(normalize).filter(Boolean) : [];
  return items.filter(function (it) {
    if (opts.type && it.type !== opts.type) return false;
    if (opts.category && it.category !== opts.category) return false;
    if (opts.area && it.area !== opts.area) return false;
    if (opts.status && it.status !== opts.status) return false;
    if (tokens.length) {
      var hay = normalize([it.title, it.description, it.location, it.area, it.category].join(' '));
      if (!tokens.every(function (t) { return hay.indexOf(t) !== -1; })) return false;
    }
    return true;
  });
}

function sortItems(list) {
  return list.slice().sort(function (a, b) {
    var ra = a.status === 'resolved' ? 1 : 0;
    var rb = b.status === 'resolved' ? 1 : 0;
    if (ra !== rb) return ra - rb;            // 未解决在前
    var ta = ra ? (a.resolvedAt || '') : (a.createdAt || '');
    var tb = rb ? (b.resolvedAt || '') : (b.createdAt || '');
    return ta < tb ? 1 : (ta > tb ? -1 : 0);  // 组内时间倒序
  });
}

解释:关键词先按原始文本以空白分词、再逐词归一化(顺序不能反——normalize 会去除空白,先归一化就分不了词了),每个 token 要求全部命中(AND)而非任一命中;normalize 做 NFKC + 小写 + 去标点,中英文、全半角混输都能匹配;筛选条件全部省略时退化为返回全部。sortItems 让求助信息优先被看到,已解决的置底但不删除——既保留可查,又不打扰需要帮助的人;返回新数组避免页面间数据污染。

五、附加特点设计与展示

5.1 创意独到之处与意义

在满足题目核心功能之外,从「用户真实使用」的角度做了四个体验向设计:

特点 创意与意义
相似线索推荐 寻物的人反复看自己发的信息,捡到东西的人发招领,两边在同一个平台却天然不看对方的列表——这是失物招领类产品最大的效率漏点。详情页主动推荐「相反类型 + 同类别 + 名称相似」的未完成信息,并给出可核对的中文理由(「物品名称相似 · 类别相同」),不需要用户改变行为,系统替他把两类信息推到眼前
一键复制联系方式 联系方式是「联系发布者」的唯一载体,微信/QQ/手机号记错就前功尽弃。点一下复制,缩短「看到→联系」的路径
类别/区域/状态组合筛选 + 一键重置 只靠关键词搜不到「图书馆捡到的所有电子产品」;三维标签与关键词任意叠加,筛选复杂后一键重置
统计条 + 已解决置底 + 相对时间 统计条(全部/进行中/已完成/解决率)暗示「在这里发帖真的能找回东西」;「2 小时前/昨天/3 天前」比冷冰冰的日期更易读

5.2 实现思路

  • 相似线索推荐:similarity(a,b) 用连续双字集合的交并比——中文没有空格分词,「黑色雨伞/蓝色雨伞」共享「色雨、雨伞」(2/4=0.5),「黑色雨伞/黑色水杯」只有「黑色」(1/5=0.2),阈值 0.25 排除后者;normalize 用 NFKC,全角「Air」与半角「Air」判为相同。推荐在详情页打开时实时计算、不落库,状态变更后天然同步。
  • 一键复制:优先 navigator.clipboard.writeText,Promise 拒绝(file:// 等非安全上下文)时降级为隐藏 textarea + document.execCommand('copy');仍失败则明确提示手动复制路径,不显示虚假的「已复制」。
  • 组合筛选:筛选状态集中在 state 对象,任何操作只改 state 再统一 render(),渲染逻辑只有一份,不会出现「改了 A 忘了 B」的状态不同步。
  • 相对时间:formatTime(iso, now) 按分钟/小时/天分档,now 可注入便于单元测试。

5.3 重要的/有价值的代码片段及解释

相似度与推荐主循环

function similarity(a, b) {
  var x = normalize(a), y = normalize(b);
  if (!x || !y) return 0;
  if (x === y) return 1;
  if (Math.min(x.length, y.length) >= 2 && (x.indexOf(y) !== -1 || y.indexOf(x) !== -1)) return 0.85;
  var xp = bigrams(x), yp = bigrams(y);          // 连续双字集合
  if (!xp.length || !yp.length) return 0;
  var shared = xp.filter(function (g) { return yp.indexOf(g) !== -1; }).length;
  return shared / (xp.length + yp.length - shared);
}

function recommend(source, items) {
  var opposite = source.type === 'lost' ? 'found' : 'lost';
  var results = [];
  items.forEach(function (it) {
    if (it.id === source.id || it.type !== opposite ||
        it.status !== 'active' || it.category !== source.category) return;
    var name = similarity(source.title, it.title);
    if (name < 0.25) return;                     // 名称无关直接排除
    var reasons = [name === 1 ? '物品名称相同' : '物品名称相似', '类别相同'];
    results.push({ item: it, score: Math.round(name * 60) + 15, reasons: reasons });
  });
  return results.sort(function (a, b) { return b.score - a.score; }).slice(0, 3);
}

一键复制(带降级,不假成功)

function copyText(text, done) {
  function fallback() {
    var ta = document.createElement('textarea');
    ta.value = text;
    ta.style.cssText = 'position:fixed;left:-9999px;top:0;';
    document.body.appendChild(ta); ta.select();
    var ok = false;
    try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
    document.body.removeChild(ta);
    done(ok);
  }
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(text).then(
      function () { done(true); },
      function () { fallback(); }
    );
  } else { fallback(); }
}
// 调用侧:done(false) 时 Toast 提示「自动复制受限,请选中文字按 Ctrl+C」
  • 5.4 实现成果展示

在线演示:https://anna-khalil.github.io/102401402-052401304/

  • 【图 1:首页截图 —— 统计条 + 组合筛选 + 信息卡片列表】
  • 【图 2:详情页截图 —— 联系发布者弹窗 + 一键复制 + 相似线索推荐】
  • 【图 3:发布页截图 —— 逐字段校验红字提示 + 相似信息提醒弹窗】
  • 【图 4:我的发布截图 —— 标记已找到 / 已归还 + 删除二次确认】
  • 【图 5:搜索空状态截图 —— 无结果引导 + 一键重置】

六、目录说明和使用说明

6.1 目录组织

102401402-052401304/
├── index.html            # 入口:四视图单页(首页/详情/发布/我的发布)+ 底部 Tab + 弹窗
├── css/
│   └── style.css         # 全部样式(蓝白科技简约风,响应式,移动端风格 PC 居中)
├── js/
│   ├── core.js           # 核心纯业务逻辑(UMD 双导出:校验/搜索/筛选/状态机/相似度/格式化)
│   ├── data.js           # 8 条内置示例数据(demo 标记,不可修改状态)
│   ├── store.js          # localStorage 读写(命名空间键 + 版本校验 + 损坏防御 + 内存降级)
│   ├── ui.js             # 渲染层(卡片/弹窗/Toast/一键复制/HTML 转义)
│   └── app.js            # 应用入口(路由、事件绑定、状态管理、发布/搜索/状态闭环)
├── test/
│   └── core.test.js      # 单元测试(node:test,本地 `node --test test/` 运行)
├── docs/                 # PSP 记录、数据流图(Mermaid)、开发计划
├── .github/workflows/    # CI:每次 push 自动跑单元测试
└── README.md             # 目录说明 + 使用说明(评分者入口)

分层说明:core.js(业务规则,无 DOM)→ store.js(持久化)→ ui.js(界面)→ app.js(编排)。核心逻辑与界面分离,core.js 可被 Node 直接 require 做单元测试。

6.2 测试人员如何运行

  1. 下载 / git clone 本仓库全部文件到本地,保持目录结构不变;
  2. 用谷歌浏览器(Chrome) 双击打开 index.html;
  3. 无需安装环境、无需联网、无需后端(数据存浏览器 localStorage)。

核心流程走查建议:

  • 首页浏览 → 搜索「校园卡」(试试多关键词、无结果时看空状态引导);
  • 点卡片看详情 → 「联系发布者」弹窗 → 一键复制联系方式;
  • 底部「发布」填表单(试试留空必填项看红字提示、试试发布与已有信息相似的名字看提醒);
  • 发布成功 → 「我的发布」里「标记已找到/已归还」→ 回首页看卡片置灰沉底、统计条数字变化;
  • 试试标记演示数据(应无操作按钮,不可修改)。

注意事项:数据保存在本机浏览器的 localStorage 中,换浏览器/换电脑数据不同属正常现象;想重置示例数据按 F12 控制台执行 localStorage.clear() 后刷新。运行单元测试才需要 Node.js 18+。

七、单元测试

7.1 测试工具的选择、学习过程与简易教程

工具:Node.js 内置的 node:test(Node 18+ 自带)+ 内置 assert 断言库。

为什么选它:作业附录推荐了 Mocha 等框架,但它们需要 npm install 安装依赖;node:test 是 Node 官方内置模块,任何装了 Node 18+ 的机器上 node --test test/ 直接就能跑,与「纯原生 JS、零依赖」的技术路线一致。核心逻辑因为抽成了纯函数(core.js 不碰 DOM),只需 require('../js/core.js') 就能测,无需模拟浏览器。

学习过程:先读 Node 官方文档 Test runner 章节和廖雪峰老师的 JS 教程,写最小示例跑通「测试失败会怎样报错」,再按「先测核心逻辑、后补存储边界」的顺序逐步补全用例。

简易教程(给第一次接触单元测试的同学):

  1. 安装 Node.js 18+(官网下载 LTS 版,一路下一步);

  2. 在项目根目录建 test/ 文件夹,写 xxx.test.js:

    const test = require('node:test');
    const assert = require('node:assert/strict');
    const Core = require('../js/core.js');
    test('搜索能按名称命中', function () {
      const r = Core.searchItems(fixture(), { keyword: '保温杯' });
      assert.strictEqual(r.length, 1);
    });
    
  3. 在项目根目录执行 node --test test/,看到绿勾即通过;

  4. 修改代码后重复运行;可用 node --test --experimental-test-coverage test/ 查看覆盖率。

7.2 测试代码与说明

展示部分用例(完整 55 例全部通过,见仓库 test/core.test.js,本地 node --test 运行):

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

// 被测函数覆盖:validatePost / searchItems / sortItems / canManage /
// completePost / revertPost / findSimilar / similarity / formatTime / calcStats

test('validatePost:缺少必填项时逐字段报错', function () {
  const r = Core.validatePost({ type: 'lost', category: '钥匙', area: '图书馆' });
  assert.strictEqual(r.valid, false);
  assert.ok(r.errors.title && r.errors.location && r.errors.contact);
});

test('validatePost:名称超长被拒绝', function () {
  const r = Core.validatePost({ type: 'lost', title: 'x'.repeat(41), category: '其他', area: '其他', location: 'A', contact: 'B' });
  assert.strictEqual(r.valid, false);
  assert.ok(r.errors.title);
});

test('validatePost:2026-02-30 非法日期被拒绝(分量回查)', function () {
  const r = Core.validatePost({ type: 'lost', title: '伞', category: '其他', area: '其他', location: 'A', contact: 'B', eventTime: '2026-02-30 12:00' });
  assert.strictEqual(r.valid, false);
  assert.ok(r.errors.eventTime);
});

test('searchItems:多关键词空格分隔要求全部命中(AND)', function () {
  const r = Core.searchItems(fixture(), { keyword: '图书馆 保温杯' });
  assert.ok(r.every(it => it.title.indexOf('保温杯') !== -1 && it.location.indexOf('图书馆') !== -1));
});

test('searchItems:全角/半角、大小写归一化后匹配', function () {
  assert.strictEqual(Core.searchItems(fixture(), { keyword: 'air' }).length,
                     Core.searchItems(fixture(), { keyword: 'Air' }).length);
});

test('sortItems:未解决在前、已解决置底', function () {
  const r = Core.sortItems(fixture());
  const firstResolved = r.findIndex(it => it.status === 'resolved');
  assert.ok(firstResolved === -1 || r.slice(firstResolved).every(it => it.status === 'resolved'));
});

test('completePost:非发布者不可修改(双层权限兜底)', function () {
  const r = Core.completePost(fixture(), 'p1', 'someone-else');
  assert.strictEqual(r.ok, false);
  assert.ok(/发布者/.test(r.error));
});

test('completePost:demo 演示数据不可修改', function () {
  const r = Core.completePost(fixture(), 'p_demo1', fixture()[0].ownerId);
  assert.strictEqual(r.ok, false);
});

test('completePost:已完成信息不可重复标记', function () {
  const r = Core.completePost(fixture(), 'p_done1', 'owner-a');
  assert.strictEqual(r.ok, false);
  assert.ok(/已经完成/.test(r.error));
});

test('revertPost:改回进行中并清空 resolvedAt', function () {
  const r = Core.revertPost(fixture(), 'p_done1', 'owner-a');
  assert.strictEqual(r.ok, true);
  assert.strictEqual(r.posts.filter(p => p.id === 'p_done1')[0].status, 'active');
  assert.strictEqual(r.posts.filter(p => p.id === 'p_done1')[0].resolvedAt, null);
});

test('similarity:名称互相包含得高分,无关名称低于阈值', function () {
  assert.ok(Core.similarity('黑色雨伞', '蓝色雨伞') > Core.similarity('黑色雨伞', '黑色水杯'));
});

test('calcStats:统计总数与解决率', function () {
  const s = Core.calcStats(fixture());
  assert.strictEqual(s.total, fixture().length);
  assert.strictEqual(s.resolved + s.active, s.total);
});

7.3 构造测试数据的思路

白盒为主,按每个函数的正常、边界、异常三类输入设计:

  • 正常情况:每个函数的典型输入(搜索能命中、状态能正常转移、表单填全能通过);
  • 边界情况:空关键词(应返回全部)、40/41 字名称、空数组统计为 0、临界时间(一分钟内显示「刚刚」)、重复标记已完成项、多关键词组合;
  • 异常情况:非法状态转移、非发布者修改、null/非数组输入(返回空数组)、非法日期(2026-02-30 分量回查拒绝)、HTML 字符(转义后不破坏页面)、损坏 JSON(不覆盖原数据)。

对测试设计的自我评价:这 55 个用例覆盖了全部核心纯函数的主要分支(校验、搜索、排序、状态机、权限、相似度、统计、转义、存储防御),业务与存储层行覆盖率 core.js 97.6% / store.js 93.3%,能满足本程序测试要求;未覆盖的是纯 UI 层交互(Toast 文案、弹窗显示)和真实浏览器剪贴板权限差异,这部分依靠 Chrome 手工回归走查补足(见第 9 节)。

八、GitHub 代码签入记录截图

commit

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

问题 做过哪些尝试
file:// 下 ES 模块被 CORS 拦截,双击打开白屏 先逐行排查代码,后定位为 Chrome 对 file:// 协议模块加载的限制;改用经典 <script> 标签按序加载 + UMD 双导出 ✅ 交付「双击 html 能跑」的作业要避开 ES 模块;UMD 能同时满足浏览器与 Node 两套环境
localStorage 键名与他人项目串扰 键名加项目命名空间 lf_102401402_*;读取时版本号+格式校验,损坏 JSON 不覆盖原数据 ✅ file:// 下所有页面共享同一 origin,键名必须带命名空间
核心逻辑一开始混在 DOM 里无法测试 把业务规则抽成纯函数放 core.js,渲染层只做 HTML 生成 ✅ 纯函数 + 关注点分离是「可测试」的前提,也反向让代码更清晰
与队友接口约定不一致 动手前先定稿字段与函数签名(见协作文档第 5 节),改动先同步文档再改代码 ✅ 接口先行能省下大量返工

十、评价你的队友

值得学习的地方:李思淇同学从使用者操作顺序考虑问题的思路很值得学习——搜索无结果时有没有清空入口、联系方式能否方便复制、已完成信息还能从哪里找到,这些细节直接影响使用体验;作为独立走查者,她按 README 从零操作,正好模拟了助教视角,帮我们发现「以为能用、实际用不起来」的问题。

需要改进的地方:希望她能把走查发现的问题更早整理成可复现的记录(写明关键词/操作/预期/实际),这样定位更高效。

posted on 2026-10-09 17:11  Anna_2333  阅读(13)  评论(0)    收藏  举报