chen673
欢迎来到🍊的博客世界ㅇㅅㅇ

丢东西别慌,会自动牵线的失物招领站——软件工程第二次结对作业

项目 内容
课程 H2026软件工程与软件工程实践
作业要求 2026秋软件工程结对作业(第二次之程序实现)
作业目标 基于上次原型,结对实现失物招领Web/App程序。
学号&姓名 102401303陈铮、102401305卓滢
结对同学博客链接 102401305卓滢
本作业博客链接 102401303陈铮
GitHub项目地址 102401303-102401305

一、具体分工

我(102401303陈铮):UI 对齐:Figma 原型还原、启动页/登录页/首页视觉设计、校区系统样式、图片上传与卡片布局样式、整体红色主题色彩统一
队友(102401305卓滢):功能开发:核心页面构建(首页/详情/发布/我的发布/个人中心)、交互逻辑、举报/发布者主页/帖子编辑/排序等新功能开发
同时在合作过程中,我们在github上互相pr实现互审,可以在对方基础上检查bug并及时提出并修缮

二、PSP表格

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

总耗时预估 750 分钟,实际 1040 分钟,超了 290 分钟(大约 39%)。除了 Estimate 和 Size Measurement 两项,其余全线飘红……第一次正经做大作业,对工作量的敬畏心确实不够 orz。超得最多的是下面两块:
1. Coding:300 → 420(+120),最大头。 原因主要有三个:一是 UI 还原远比想象中磨人,Figma 上看着理所当然的间距、圆角和红色主题色,落到十几个页面上要反复对齐;中间还踩了两个玄学样式坑——CSS 文件缓存导致不同页面样式新旧不一致、水印定位规则把 sticky 导航栏顶消失了,光排查这两个问题就花了不少时间。二是中途加了新功能,举报、发布者主页、帖子编辑、排序都是开发过程中才补进来的,预估时根本没算这部分。三是 Git 事故,堆叠 PR 玩脱造成分支嵌套和游离提交,收拾仓库前前后后搭进去两三个小时(详见第八节),属于纯纯的计划外开销(。
2. Test:90 → 150(+60)。 原本以为写用例最费时间,真正卡住的反而是环境:源码是浏览器 IIFE 写法,直接挂 window、读写 localStorage,Node 里一跑满屏 undefined,只能先写 setup.js,用内存 Map 模拟 localStorage 再补上 window 和 document;formatRelativeTime 是内部函数没法直接测,又琢磨出通过 renderCard 的输出间接断言的办法。等模拟层搭好,18 个用例反而写得很快。

其余超支项:Code Review +20(结对互查比一个人抠得细,Git 问题也是在这个阶段集中爆发处理的);Reporting +20、Test Report +15(截图、画流程图、调整博客排版都比预想费时间)。省时间的只有 Estimate(-5)和 Size Measurement(-5),规模统计有目录和代码行数可以数,很快;至于"估计任务时间"本身估得太快嘛……实际结果也确实把我们打脸了(笑)。

复盘下来,偏差几乎全部来自"看不见的工作量":环境配置、中途加需求、工具翻车,真正写功能的时间和预估反而差得不多。下次做计划会专门给学习新工具和调试联调留出缓冲,不再只按"一路顺风顺水写代码"的理想状态估时间。

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

3.1代码实现思路

先说技术选型:没用任何框架,也没有构建工具,就是原生 HTML + CSS + JavaScript 三件套,双击 index.html 就能跑,数据全部存在浏览器 localStorage 里。选这个方案一半是够用,一半是怕配环境——队友拉下来零配置就能打开,对两个人结对来说真的能少很多沟通成本hhh。

代码分了三层:
页面层:9 个功能页面(首页 / 发布 / 详情 / 我的发布 / 我的收藏 / 消息通知 / 设置 / 匹配提醒 / 个人中心),每页一个 HTML 配一个同名 JS,JS 全部用 IIFE 包起来,各写各的变量,互不污染;
数据层 storage.js:全站只有它直接碰 localStorage。信息列表(items)、本机发布 id(mine)、收藏 id(favorites)、个人资料(profile)、举报记录(reports)各用各的存储 key,互不干扰。key 名上还带了版本号(现在是 lost_found_items_v4),这样以后示例数据的字段改了,老用户打开页面读到的也是新格式,不会直接白屏;
工具层 common.js:把跨页面复用的东西都攒在这——卡片渲染、类别图标映射、相对时间格式化、Toast 轻提示、弹层。首页、我的收藏、匹配提醒这些地方的卡片长得一模一样、行为也一致,秘密就是它们调的是同一个渲染函数,改一处全站生效,再也不用到处复制粘贴卡片代码。

3.2流程图

image

  • 系统核心流程为"发布信息 → 浏览/搜索 → 查看详情 → 联系发布者 → 更新状态"五步闭环(红色实线)
  • 用户发布时经表单校验与图片上传后落盘 localStorage;
  • 浏览页支持关键词、类别、校区三维筛选;
  • 详情页可一键复制联系方式。灰色虚线为三条特色联动:
  • 发布成功触发智能匹配(同类别、类型互补、排除本机发布)并可直达详情;
  • 详情页支持收藏持久化与防重复举报;
  • 发布者更新"已找到/已归还"后状态自动回流至列表,全员实时可见,避免重复询问。全流程纯前端实现,零后端依赖,下载即用。

3.3有价值代码片段

3.3.1收藏开关与复制联系方式(storage.js + common.js)

收藏用"返回布尔值"的开关式设计,详情页根据返回值即时切换按钮文案并 toast 反馈。复制联系方式时优先 Clipboard API,在 file://双击打开场景下自动降级为 textarea + execCommand,保证所有环境可用。

// storage.js — 收藏开关,返回布尔值供即时反馈
function toggleFavorite(id) {
  var ids = getFavoriteIds();
  var idx = ids.indexOf(id);
  if (idx === -1) { ids.push(id); writeJSON(FAVORITES_KEY, ids); return true; }
  ids.splice(idx, 1); writeJSON(FAVORITES_KEY, ids); return false;
}

// common.js — 复制联系方式,优先 Clipboard API,file:// 场景降级
function copyText(text, onOk, onFail) {
  if (navigator.clipboard) { navigator.clipboard.writeText(text).then(onOk, onFail); }
  else { // 降级:hidden textarea + document.execCommand
    var ta = document.createElement('textarea');
    ta.value = text; ta.setAttribute('readonly','');
    ta.style.cssText = 'position:fixed;top:-9999px;opacity:0;';
    document.body.appendChild(ta);
    ta.select(); document.execCommand('copy');
    document.body.removeChild(ta); onOk();
  }
}

3.3.2统一卡片渲染(common.js)

全站卡片格式统一收口:首页、收藏、匹配列表等 6 个页面共用这一个渲染函数,保证类别 emoji 图标、状态胶囊、校区·地点·相对时间单行 meta、图片懒加载等细节完全一致,新增页面只需传入数据即可复现。

function renderCard(item) {
  var statusClass = item.status === 'done' ? 'status-done' : 'status-active';
  var campus = Storage.itemCampus(item);
  var imgs = Array.isArray(item.images) ? item.images : [];
  var imgHtml = imgs.length ? '<div class="card-imgs">' + imgs.map(function (src) {
    return '<img src="' + src + '" alt="物品图片" loading="lazy">';
  }).join('') + '</div>' : '';
  return '<a class="card" href="detail.html?id=' + encodeURIComponent(item.id) + '">' +
    '<div class="card-icon">' + categoryIcon(item.category) + '</div>' +
    '<div class="card-body">' +
      '<div class="card-head"><h3>' + escapeHtml(item.title) + '</h3>' +
        '<span class="status ' + statusClass + '">' + statusText(item) + '</span></div>' +
      '<p class="card-desc">' + escapeHtml(item.description) + '</p>' + imgHtml +
      '<p class="card-meta">' + escapeHtml(campus) + ' · ' + escapeHtml(item.location) +
        ' · ' + escapeHtml(formatRelativeTime(item.time)) + '</p>' +
    '</div></a>';
}

四、附加特点设计与展示

4.1 UI方面

所有人严肃观看我的小巧思!(•̀ω•́)ゝ✧

4.1.1满满的福大元素~增加校园归属感hhh

本人在首页背景上方导航栏加入了晋江楼风景,主背景采用校徽,采用适当的透明度就可以做到既不影响阅读也不影响美观~
在发布成功后的页面背景采用福大红和福友阁
imageimage
在启动以及登陆界面也是采用了福大航拍美景的水彩版,下方放了校训,( ˘ω˘ )看到这条所有人背诵校训(bushi
imageimage

4.2功能方面

4.2.1实现多校区切换

为了方便不同校区的同学使用,不要在大量帖子中大海捞针,在首页右上角可快速选择校区,切换后可以看到对应校区发的帖子,同时发布界面的“地点-快速选择”区域也会根据校区的更改同时更改快捷选项~很细节吧啊哈哈哈哈!!!(≧▽≦)
imageimage
前一张为旗山校区,后一张为铜盘校区
imageimage

4.2.2增加了举报功能

可以在发布的帖子下面进行举报,防止有的人发一些无关信息或者冒领等不良行为,让我们一起打击坏蛋!不过同一浏览器对同一帖子只能举报一次,防止恶意刷举报
imageimage

4.2.3匹配提醒功能

有的同学可能发布了帖子之后不会天天守在手机前看,那么这个功能就正好解决了这个问题,当有人发布的失物招领/寻物启事与你的待寻物/丢失物类别可以匹配的上,那么后台就会给你推送提醒,即便黄花菜都凉了,你也可以直接点开提醒消息快速定位到帖子详情,金鱼来了也可以想起来了!(不过这个也只是一个demo,目前没有接后端
imageimage

4.2.4在个人界面可以快速浏览我的发布/我的收藏

忘记自己发了啥怎么办?没事!可以直接在个人主页查看之前自己发布过的帖子以及曾经留意收藏过的帖子
imageimageimage

4.2.5一键复制联系方式

在帖子联系方式那一栏点击“复制”可以快速复制到粘贴板哦
image

4.2.6发布者主页

使用者可以在任何一条帖子详情页找到发布者的信息,点开即可看到发布者主页,没准他发布的其他失物招领正好是你在找的呢?同时可以在他们的主页看看谁这么能丢东西!(bushi
imageimage

4.3有价值代码片段

4.3.1匹配规则核心(storage.js)

实现思路 :我们自己试用时发现,发完寻物帖根本不会天天回来刷列表,别人后来发的招领帖就这么错过了。干脆让系统主动来提醒。做法是在数据层写一个 findMatches,消息页遍历"我的发布",有匹配就生成一条带小铃铛的提醒;点提醒先跳到匹配列表看概况,再进详情——从"发现有人捡到"到"联系上对方",一路不用自己翻帖子。

/**
 * 匹配查询:与某条信息"类型互补、类别一致"的其他人信息。
 * 我的寻物帖 ↔ 他人的招领帖;我的招领帖 ↔ 他人的寻物帖。
 */
function findMatches(item) {
  return getItems().filter(function (other) {
    return other.id !== item.id &&      // 不是自己
      !isMine(other.id) &&              // 不是我发布的(避免自匹配)
      other.type !== item.type &&       // 类型互补:寻物↔招领
      other.category === item.category; // 类别一致(如都是"校园卡")
  });
}

消息页遍历"我的发布",对有匹配的帖子生成一条带箭头、可点击的提醒:

Storage.getMineItems().forEach(function (item) {
  var matches = Storage.findMatches(item);
  if (matches.length === 0) return;
  msgs.push({
    id: 'match-' + item.id, type: '匹配提醒', icon: '🔔',
    title: '你的' + UI.TYPE_TEXT[item.type] + '《' + item.title + '》有 ' + matches.length + ' 条匹配信息',
    matchId: item.id
  });
});

// 点击匹配提醒:标记已读并跳转匹配列表页,置顶对应分组
if (id.indexOf('match-') === 0) {
  location.href = 'matches.html?mine=' + encodeURIComponent(id.slice(6));
}

解释 :规则本身只有三行判断,说人话就是:寻物配招领(类型得互补)、类别要一样(校园卡配校园卡,配到钥匙就离谱了)、排除自己发的帖子。没有额外建表存匹配结果,每次实时 filter,也不用担心存的数据和列表对不上。消息 id 用 match- 前缀把我的帖子 id 带在身上,点击时切出来拼进 URL,matches 页拿到参数就把对应那一组顶到最上面——从哪条消息点进来的,就先看哪组。

4.3.2启动页/登录页引导与登录态记忆(auth.js)

实现思路 :想达到的效果:第一次打开的人有启动页到登录页的仪式感,老用户就别每次都被强制看一遍了。用两个 localStorage 标记分工:一个记"是不是第一次来",一个记"登没登录",由此分出三条路——新人走完整引导,已登录的显示"点击任意位置进入"直达首页,来过但没登录的也不再重复展示引导。

var FIRST_OPEN_KEY = 'lost_found_first_open_v1';
var AUTH_KEY = 'lost_found_auth_v1';
var SPLASH_DURATION = 2400;               // 启动页停留 2.4 秒
var ACCOUNT_RE = /^\d{8,12}$/;            // 学号 8-12 位(手机号同样满足)

function init() {
  // 不是首次打开:直接进入主界面
  if (localStorage.getItem(FIRST_OPEN_KEY)) return;
  localStorage.setItem(FIRST_OPEN_KEY, '1');

  showView(splash);
  var hasAuth = !!readAuth();
  skipEl.textContent = hasAuth ? '点击任意位置进入' : '点击任意处跳过';

  function gotoNext() {
    if (enteredNext) return;              // 防止定时器和点击重复触发
    enteredNext = true;
    if (hasAuth) enterApp();              // 已登录 → 直达首页
    else { hideView(splash); showView(login); }  // 未登录 → 登录页
  }

  var timer = setTimeout(gotoNext, SPLASH_DURATION);
  splash.addEventListener('click', function () {
    clearTimeout(timer);                  // 点击跳过则取消定时器
    gotoNext();
  });
}

解释 : 这里有个写出来才踩到的坑:跳转有两个触发源,2.4 秒定时器自动跳,和用户点击跳过。早期没加防护时两个一起触发,页面连着切两次,动画直接抽风。后来加了 enteredNext 守卫,进来过一次就拦住后面的所有调用,世界清净了。登录态靠 hasAuth 同时改文案和去向:已登录的人点一下就进首页,登录表单都不用看见。另外设置页的"清除缓存"会把这两个 key 一起清掉,给助教演示前点一下,就能重新当一次新用户~

4.3.3发布表单统一校验(publish.js)

实现思路 :发布页是全项目字段最多的:标题、类别、自定义类别、地点、时间、描述、联系方式、发布人……一开始差点给每个字段各写一套校验,写了两个就发现提示样式五花八门,自己都看不下去。最后用闭包收口成一个 check 函数,所有字段共用同一套"标红 + 自动聚焦到第一个出错的字段 + 动态提示原因"。

function validate(values) {
  var ok = true;
  var firstError = null;

  function check(field, cond, errorMsg) {
    if (!cond) {
      setError(field, firstError === null);          // 标红,第一个错误自动聚焦
      if (errorMsg && field === 'category') categoryError.textContent = errorMsg;
      if (firstError === null) firstError = field;
      ok = false;
    }
  }

  check('title', values.title.length >= 1 && values.title.length <= 30);

  // 类别必选;选"其他"时自定义类别需 1-10 个字
  if (!values.category) {
    check('category', false, '请选择物品类别');
  } else if (categorySelect.value === '其他') {
    var customLen = getFinalCategory().length;
    check('category', customLen >= 1 && customLen <= 10, '请输入自定义类别(1-10 个字)');
  }

  check('location', values.location.length >= 1 && values.location.length <= 50);

  var timeOk = false;
  if (values.time) {
    var t = new Date(values.time);
    timeOk = !isNaN(t.getTime()) && t.getTime() <= Date.now();  // 时间不能是未来
  }
  check('time', timeOk);

  check('description', values.description.length >= 5 && values.description.length <= 300);
  check('contact', values.contact.length >= 4 && values.contact.length <= 50);
  check('publisher', values.publisher.length >= 1 && values.publisher.length <= 20);

  return { ok: ok };
}

解释 : 几个自己比较满意的细节:

  • firstError 记住第一个没通过的字段,校验完直接把页面滚过去聚焦,不用在一整屏表单里自己找哪里红了;
  • 选了"其他"类别才会额外校验自定义输入(1-10 个字),错误文案也是动态传进去的;
  • 时间专门拦了未来时间——东西总不能是明天才丢的吧(;
  • 以后加新字段,补一行 check 就行,不用再复制粘贴一大段。

五、目录说明和使用说明

5.1 目录说明

lost-found/
├── splash.html       # 启动页(红色基调,自动跳转首页)
├── index.html        # 首页:列表、搜索、筛选
├── publish.html      # 发布页:表单发布寻物/招领信息
├── success.html      # 发布成功页:成功提示 + 返回首页/查看详情
├── detail.html       # 详情页:信息详情、收藏、复制联系方式、更新状态
├── profile.html      # 个人中心:资料、统计、快捷入口
├── mine.html         # 我的发布:本机信息管理(标记完成/删除)
├── favorites.html    # 我的收藏
├── messages.html     # 消息通知
├── settings.html     # 设置:昵称/头像/清除数据
├── css/
│   └── style.css     # 全局样式(红色基调,移动端优先,最大宽度 480px 居中)
├── js/
│   ├── storage.js    # 数据层:localStorage 读写、示例数据、收藏/个人资料
│   ├── common.js     # 公共层:DOM 工具、HTML 转义、Toast、卡片渲染、复制等
│   ├── index.js      # 首页逻辑:搜索与筛选
│   ├── publish.js    # 发布页逻辑:类型切换与表单校验
│   ├── success.js    # (内联在 success.html)发布成功页摘要渲染
│   ├── detail.js     # 详情页逻辑:详情渲染、收藏、复制、标记完成
│   ├── profile.js    # 个人中心逻辑:统计渲染
│   ├── mine.js       # 我的发布逻辑:标记完成、删除
│   ├── favorites.js  # 我的收藏逻辑
│   ├── messages.js   # 消息通知逻辑
│   └── settings.js   # 设置逻辑:昵称/头像/清除数据
├── assets/           # 预留资源目录(当前版本无外部图片资源)
└── README.md         # 项目说明

组织原则:按"页面级拆分" —— 每个页面对应一个独立 HTML + 一个同名 JS(IIFE 自执行,互不污染)
storage.js统一封装 localStorage 读写与匹配算法,common.js提供各页面复用的卡片渲染与工具函数,style.css一份样式全站共用;这样页面之间职责清晰,新增页面只需加一个 HTML + 一个 JS,引用公共模块即可。

5.2 测试人员如何运行

启动程序
(双击 index.html)
     ↓
启动页
(校徽+校训,点击任意处或等待2.4秒)
     ↓
登录页
(输入学号/手机号+密码登录,或点击「游客浏览」直接进入)
     ↓
首页
(默认旗山校区,可在右上角切换六大校区)
     ↓
┌────────────┬─────────────┬──────────────┐
↓            ↓             ↓              ↓
浏览帖子      搜索筛选       发布启事        查看详情
(Tab切换    (搜索框输入    (点底部红色    (点击卡片,
 寻物/招领    关键字或选择    「+」按钮,    可收藏/复制
 类别胶囊)    类别)         填写信息+上传   联系方式/举报)
                            图片后提交)
     ↓
个人中心(底部「我的」)
(管理我的发布:编辑/标记完成/删除;查看收藏与消息)
     ↓
设置
(通知开关、切换校区、清除缓存重置数据)

本项目为纯静态网页,进入lost-found 目录,用 Google Chrome双击index.html即可启动,无需安装。首次会经过启动页和登录页(可选择游客浏览跳过),进入首页后默认展示旗山校区的失物招领信息,可通过右上角切换六大校区。在首页可以浏览、搜索、筛选帖子,点击底部红色「+」按钮发布启事(支持上传最多 3 张图片),点击卡片查看详情并收藏或联系发布者。底部「我的」入口可管理自己的发布与收藏,在「设置」中可清除缓存重置所有数据。
(ps:本次的登录以及匹配提醒功能都只是一个纯前端的demo哦)

六、单元测试

6.1 测试工具的选用

本项目为纯前端 JavaScript 项目(无后端、无构建工具),我们选用的测试组合是:

工具 作用 选择理由
Mocha 测试框架(组织/执行用例) 生态最成熟,既能在 Node.js 也能在浏览器运行;describe/it 的 BDD 风格可读性好,适合初学者
Chai 断言库 expect 链式断言接近自然语言,报错信息清晰
Node.js + npm 运行环境 / 自动化入口 让单元测试可以用一条命令(npm test)在任何人的机器上运行,满足"测试自动化、每日构建可运行"的要求

学习过程
我们通过 JavaScript 基础入门教程理解测试思想,参考 Mocha 测试框架社区教程掌握 describe/it 与断言用法,结合 JavaScript 测试工具对比文章选定 Mocha + Chai 方案,并阅读软件测试与回归测试相关文献,认识到单元测试必须自动化、可每日运行。

简易教程:5 分钟跑通我们的单元测试

第 1 步:安装(项目根目录下,需先装 Node.js)

npm init -y
npm install --save-dev mocha chai

第 2 步:准备 Node 环境模拟层
我们的源码是浏览器 IIFE 写法(挂载到 window、读写 localStorage),Node 里没有这些浏览器全局对象;且 common.js 加载时会调用 document.addEventListener 注册导航高亮监听。因此需要一个 setup 文件同时模拟这三个对象:

// test/setup.js —— 在 Node 中模拟浏览器环境
global.window = global;

// 用内存 Map 实现与浏览器一致的 localStorage 接口
const store = new Map();
global.localStorage = {
  getItem: (k) => (store.has(k) ? store.get(k) : null),
  setItem: (k, v) => store.set(k, String(v)),
  removeItem: (k) => store.delete(k),
  clear: () => store.clear()
};

// 最小 document 模拟(common.js 加载时会注册 DOMContentLoaded 监听)
global.document = {
  addEventListener: function () {},
  querySelector: function () { return null; },
  querySelectorAll: function () { return []; }
};

require('../js/common.js');   // 挂载 window.UI
require('../js/storage.js');  // 挂载 window.Storage

第 3 步:写第一个测试

// test/common.test.js
require('./setup');
const { expect } = require('chai');

describe('UI.escapeHtml:HTML 转义', function () {
  it('应转义 <script> 标签,防止 XSS 注入', function () {
    expect(UI.escapeHtml('<script>alert(1)</script>'))
      .to.equal('&lt;script&gt;alert(1)&lt;/script&gt;');
  });
});

第 4 步:运行并自动化

npx mocha test/        # 运行全部测试

并在 package.json 中配置:

{ "scripts": { "test": "mocha test/" } }

之后任何人拉下代码,只要 npm install && npm test 一条命令即可运行全部单元测试;该命令可直接挂到每日构建/CI 流水线中,实现"每天都运行、有错立即发现"。

小提示:Windows PowerShell 默认执行策略会拦截 npm.ps1 脚本,使用 npm.cmd test 即可正常运行。

6.2 单元测试代码与被测函数说明

6.2.1 被测函数清单

我们从工具层与数据层中选出 7 个核心函数作为单元测试对象(涵盖系统最主要的业务规则与防御逻辑),全程未修改任何项目源码:

所属模块 被测函数 函数职责
common.js escapeHtml(str) 转义用户输入,防 XSS
common.js formatRelativeTime(value) 相对时间格式化(刚刚/X分钟前/…/日期),内部函数,经 renderCard 间接验证
common.js statusText(item) 状态文案(寻物中/已找到/招领中/已归还)
common.js renderCard(item) 信息卡片 HTML 渲染(间接验证入口)
storage.js findMatches(item) 匹配算法核心:类型互补 + 类别一致 + 排除本机发布
storage.js toggleFavorite(id) 收藏/取消收藏切换
storage.js addReport(id, reason) 举报记录,同一帖子禁止重复举报
storage.js itemCampus(item) / setCampus(c) 校区推断与切换(含非法值兜底)

6.2.2 部分单元测试代码展示

(1)formatRelativeTime —— 白盒分支覆盖(经 renderCard 间接验证,不改源码)

formatRelativeTime 是 common.js 的内部函数,未挂载到全局;而 renderCard 渲染卡片时会调用它并把相对时间拼进卡片 HTML,因此通过断言 renderCard 的输出即可覆盖其全部分支:

describe('UI.renderCard → formatRelativeTime:相对时间渲染', function () {
  const MIN = 60000, HOUR = 3600000, DAY = 86400000;

  function cardWithTime(time) {
    return UI.renderCard({
      id: 't1', type: 'lost', status: 'active', title: '测试物品',
      category: '钥匙', location: '图书馆', time: time,
      description: '测试描述', publisher: '测试者', images: []
    });
  }

  it('非法时间字符串应原样输出', function () {
    expect(cardWithTime('not-a-date')).to.include('not-a-date');
  });

  it('1 分钟内显示"刚刚"', function () {
    expect(cardWithTime(Date.now() - 30 * 1000)).to.include('刚刚');
  });

  it('59 分钟显示"59分钟前",60 分钟显示"1小时前"(边界)', function () {
    expect(cardWithTime(Date.now() - 59 * MIN)).to.include('59分钟前');
    expect(cardWithTime(Date.now() - 60 * MIN)).to.include('1小时前');
  });

  it('1 天整显示"昨天",3 天显示"3天前"', function () {
    expect(cardWithTime(Date.now() - DAY)).to.include('昨天');
    expect(cardWithTime(Date.now() - 3 * DAY)).to.include('3天前');
  });

  it('超过 7 天显示 yyyy-MM-dd 日期', function () {
    expect(cardWithTime(Date.now() - 10 * DAY)).to.match(/\d{4}-\d{2}-\d{2}/);
  });

  it('未来时间不应出现负数,按"刚刚"处理(防御分支)', function () {
    expect(cardWithTime(Date.now() + HOUR)).to.include('刚刚');
  });
});

(2)findMatches —— 匹配算法(核心业务规则)

describe('Storage.findMatches:匹配提醒算法', function () {
  beforeEach(function () {
    localStorage.clear();
    // 构造测试数据:1 条我的寻物帖 + 3 条他人帖子
    Storage.addItem({ type: 'lost', title: '丢校园卡', category: '校园卡',
      location: '图书馆', time: new Date().toISOString(),
      description: '黑色卡套,急用', contact: 'QQ12345', publisher: '我' });
    localStorage.setItem('lost_found_items_v4', JSON.stringify(
      JSON.parse(localStorage.getItem('lost_found_items_v4')).concat([
        { id: 'o1', type: 'found', category: '校园卡', title: '捡到校园卡' }, // 应匹配
        { id: 'o2', type: 'found', category: '钥匙',   title: '捡到钥匙' },   // 类别不同
        { id: 'o3', type: 'lost',  category: '校园卡', title: '也丢了卡' }    // 类型相同
      ])));
  });

  it('我的寻物帖应匹配到他人同类别招领帖', function () {
    const mine = Storage.getMineItems()[0];
    const ids = Storage.findMatches(mine).map(i => i.id);
    expect(ids).to.include('o1');
  });

  it('类别不同 / 类型相同的帖子不应被匹配', function () {
    const mine = Storage.getMineItems()[0];
    const ids = Storage.findMatches(mine).map(i => i.id);
    expect(ids).to.not.include('o2');
    expect(ids).to.not.include('o3');
  });

  it('不应匹配到帖子自身,也不应匹配本机发布的其他帖子', function () {
    const mine = Storage.getMineItems()[0];
    const ids = Storage.findMatches(mine).map(i => i.id);
    expect(ids).to.not.include(mine.id);
    Storage.getMineItems().forEach(m => expect(ids).to.not.include(m.id));
  });
});

(3)其余函数的部分用例

describe('UI.escapeHtml:XSS 防护', function () {
  it('null / undefined 不应报错,返回空串', function () {
    expect(UI.escapeHtml(null)).to.equal('');
    expect(UI.escapeHtml(undefined)).to.equal('');
  });
});

describe('UI.statusText:状态文案(判定组合覆盖)', function () {
  it('寻物 + 完成 → 已找到;招领 + 进行中 → 招领中', function () {
    expect(UI.statusText({ type: 'lost', status: 'done' })).to.equal('已找到');
    expect(UI.statusText({ type: 'found', status: 'active' })).to.equal('招领中');
  });
});

describe('Storage.toggleFavorite:收藏切换', function () {
  it('第一次切换为收藏(true),再次切换为取消(false)', function () {
    expect(Storage.toggleFavorite('seed-1')).to.equal(true);
    expect(Storage.toggleFavorite('seed-1')).to.equal(false);
  });
});

describe('Storage.addReport:举报防重复', function () {
  it('首次举报成功,重复举报返回 false', function () {
    expect(Storage.addReport('seed-1', '虚假信息')).to.equal(true);
    expect(Storage.addReport('seed-1', '虚假信息')).to.equal(false);
  });
});

describe('Storage 校区:推断与兜底', function () {
  it('有 campus 字段直接使用;旧数据 location 含"铜盘"推断为铜盘校区', function () {
    expect(Storage.itemCampus({ campus: '厦门校区', location: '图书馆' })).to.equal('厦门校区');
    expect(Storage.itemCampus({ location: '铜盘校区食堂' })).to.equal('铜盘校区');
    expect(Storage.itemCampus({ location: '图书馆' })).to.equal('旗山校区');
  });

  it('setCampus 传入非法校区名不生效,getCampus 兜底为旗山校区', function () {
    Storage.setCampus('不存在的校区');
    expect(Storage.getCampus()).to.equal('旗山校区');
  });
});

真实运行结果:执行 npm test,输出 18 passing,0 failing——18 个用例全部通过(截图见下)。
image

6.3 测试数据构造思路与自我评价

6.3.1 总体思路:白盒为主,黑盒为辅

按作业要求,以白盒测试用例设计方法为主——先读函数源码,画出分支结构,保证每条语句、每个分支至少被执行一次(语句覆盖 + 分支覆盖);再用黑盒的等价类划分和边界值分析补充用例。

以 formatRelativeTime 为例,源码共有 7 条输出分支(空值/非法值/刚刚/分钟前/小时前/昨天/天数/日期),我们针对每条分支各设计至少一个用例,并在分支边界处加测:

  • 等价类划分:把输入按"时间差"划为 8 个等价类(空、非法、<1分钟、159分钟、123小时、1天、2~6天、≥7天、未来时间),每个等价类取代表值;
  • 边界值分析:专挑分界线两侧取值——59 分钟 vs 60 分钟、23 小时 vs 24 小时、6 天 vs 7 天,这类"差一就错"的 off-by-one 错误最容易藏在边界上。

对 findMatches 这类业务规则函数,则把规则拆成三个独立判定条件(类型互补、类别一致、非本机发布),用"排列组合"构造正例与三类反例,保证每个条件单独为假时都能被识别出来。

6.3.2 测试用例汇总表(共 18 个用例)

编号 被测函数 用例类型 测试数据 预期结果
1 formatRelativeTime 反例(非法) 'not-a-date' 原样输出
2 formatRelativeTime 正例 30 秒前 '刚刚'
3 formatRelativeTime 边界 59 分钟前 '59分钟前'
4 formatRelativeTime 边界 60 分钟前 '1小时前'
5 formatRelativeTime 正例 1 天前 '昨天'
6 formatRelativeTime 正例 3 天前 '3天前'
7 formatRelativeTime 正例 10 天前 yyyy-MM-dd 格式
8 formatRelativeTime 刁难 未来时间(+1小时) '刚刚'(不出现负数)
9 escapeHtml 刁难(XSS) 全部转义为实体
10 escapeHtml 刁难 null / undefined 不报错,返回 ''
11 statusText 判定组合 lost+done / found+active '已找到' / '招领中'
12 findMatches 正例 我的寻物帖 vs 他人同类别招领帖 匹配成功
13 findMatches 反例 类别不同 / 类型相同 均不匹配
14 findMatches 反例 帖子自身 / 本机发布的其他帖子 均不匹配
15 toggleFavorite 正例+重复操作 同一 id 连续切换两次 true → false
16 addReport 刁难(重复提交) 同一帖子举报两次 第一次 true,第二次 false
17 itemCampus 分支覆盖 有campus字段 / 含"铜盘" / 普通地点 厦门 / 铜盘 / 旗山
18 setCampus 刁难(非法值) '不存在的校区' 不生效,兜底旗山校区

6.3.3 如何应对"测试人员的刁难"

我们预设了最苛刻的测试者会做的事,并逐条准备数据:

  1. 恶意注入 :在标题/描述里输入 < script > 、HTML 标签——靠 escapeHtml 用例保证页面不被注入;
  2. 脏数据 :直接往 localStorage 里写非 JSON 字符串、写空值——readJSON 的 try/catch 会回退默认值,对应用例验证不白屏;
  3. 时间捣乱 :把系统时间调到未来、传入非法日期字符串——验证"未来时间不出现负数""非法日期原样输出";
  4. 疯狂连点 :收藏按钮连点、举报重复提交——用"连续切换/重复举报"用例验证幂等性,数据不会错乱;
  5. 非法枚举值 :直接调用接口传入不存在的校区名——验证白名单兜底逻辑;
  6. 边界值 :专测 59/60 分钟、1 天/2 天这种"擦边"数据。

七、Github的代码签入记录截图

image

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

问题描述:
本次协作中,我们尝试使用"堆叠 PR(Stacked PR)"来管理代码依赖,即基于基础 UI 分支(pr1-ui)再拉取功能分支(pr2-feature)。然而由于我个人对 Git 分支模型经验不足TT,合并时产生了严重的嵌套与分支基线错位,导致本地和远程历史出现分叉,甚至产生了游离提交。这种过度设计直接引发了 PR 记录繁琐、合并逻辑混乱等沟通障碍。

做过哪些尝试:
我们最初考虑使用常规 rebase 梳理分支状态,但交错嵌套的历史已难以通过正常手段修复。为彻底切断错乱的 Git 历史,我们最终选择删除旧仓库、重新创建同名新仓库,并将最新代码作为初始状态重新提交(因此提交时间戳显得尤为相近,并非我们草草了事,请苍天辨忠奸 orz)。

是否解决:
已解决。我们果断放弃了复杂的堆叠分支模型,转而采用更契合两人协作的简单流程:直接基于 main 分支拉取功能分支,完成开发后再合并回 main。通过清空旧仓库、重建同名仓库,彻底抹除了交错的历史包袱,并重新规范了分支命名(如 cheng0673-dev)与权限管理。最终 main 分支成功包含了所有正确的代码。

有何收获:
这次经历让我们深刻体会到:协作流程的复杂度应当与项目规模相匹配,盲目套用大型团队的分支模型只会徒增沟通成本。重建仓库为后续开发找回了一个干净、低沟通成本的起点(不过应该没有人还会跟我一样傻了吧)。

九、评价我的队友

9.1值得学习的地方

超级无敌有耐心谢谢谢谢,就上一个标题的pr问题她就帮我一起解决了两三个小时,而且她在git以及功能、后端等比我更加专业,知识储备也更多,所以合作过程中大多数时候都是我在捅窟窿她给我缝、、通过跟她组队我也学到了很多功能实现的难易程度。同时有问必答,即便我问的不太聪明欸嘿嘿(´・ω・`),

9.2需要改进的地方

不过确实在美工UI设计方面的头脑还需加强啦,不过我们组队的初衷就是她负责更专业的功能我负责更视觉方面的UI设计hhh,感觉更需要改进的是我把吧哈哈。。

posted on 2026-10-06 10:03  chen673  阅读(71)  评论(0)    收藏  举报