2026秋软件工程结对作业(第二次之程序实现)
软件工程第二次结对作业
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 首页 - H202601软件工程与软件工程实践 - 福州大学 - 班级博客 - 博客园 |
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次之程序实现)- H202601软件工程与软件工程实践 - 班级博客 - 博客园 |
| 这个作业的目标 | 实现"校园失物招领"核心功能,并同时交付 Web 端与 Android App(APK) 两种呈现形式 |
| 姓名 | 翁斌 |
| 学号 | 102401632 |
| 合作伙伴 | 严科儒(102401633),协作者博客 |
| GitHub 仓库 | https://github.com/Clu3y/102401632-102401633 |
| APK 下载(v1.0.0) | https://github.com/Clu3y/102401632-102401633/releases/download/v1.0.0/app.apk |
本文的项目事实与成员分工依据严科儒的第二次结对作业博客同步。双端目录和展示素材对应共同仓库的最终交付版本;当前本地 lost-found/ 是 Web 端开发副本。
一、具体分工
| 成员 | 主要分工 |
|---|---|
| 翁斌(102401632) | 发布、发布成功、我的发布、个人资料页;状态流转与权限校验;图片本地压缩;样式与交互联调;回归测试与 README;Web 端响应式改造(桌面/平板/手机布局)、登录注册与会话门禁;博客撰写 |
| 严科儒(102401633) | 原型走查与需求梳理;本地数据层(seed.js / db.js);列表、搜索、详情页;单元测试用例设计与测试框架搭建;Android WebView 壳工程与 APK 打包、真机安装验证;项目结构整理 |
两人采用结对编程 + 模块认领结合:数据结构、接口契约、状态机、双端目录约定等核心设计一起讨论确定,各自负责的页面与端再分头实现,最后交叉走查、合并、补测试。
二、项目展示
项目同时交付两种呈现形式,二者共用同一套本地数据层与业务规则:
- Web 端(
web/):桌面 / 平板 / 手机自适应,Chrome 直接运行; - Android App 端(
mobile/,安装包mobile/apk/app.apk):由最初的手机形态页面经 WebView 壳打包,真机离线可运行。
2.1 Web 端
2.2 Android App 端(真机截图)
三、PSP 表格
| PSP2.1 | 阶段 | 预估耗时 | 实际耗时 |
|---|---|---|---|
| Planning | 计划 | 20 | 25 |
| Estimate | 估计任务所需时间 | 20 | 20 |
| Analysis | 需求分析(含学习新技术) | 90 | 110 |
| Design Spec | 生成设计文档 | 60 | 70 |
| Design Review | 设计复审 | 30 | 35 |
| Coding Standard | 制定代码规范 | 25 | 20 |
| Design | 具体设计(数据结构/接口契约/状态机) | 90 | 120 |
| Coding | 具体编码 | 300 | 360 |
| Code Review | 代码复审 | 60 | 80 |
| Test | 测试(自测、改代码、提交) | 120 | 180 |
| Test Report | 测试报告 | 40 | 50 |
| Size Measurement | 计算工作量 | 20 | 20 |
| Postmortem & Process Improvement Plan | 事后总结与改进计划 | 30 | 40 |
| 合计 | 905 | 1130 |
四、解题思路与设计实现
4.1 代码实现思路
题目要求实现"发布信息 → 浏览或搜索 → 查看详情 → 联系发布者 → 更新状态"的闭环。我们沿用第一次作业的原型,做成一个移动端风格的 Web 单页应用(hash 路由),页面包括:首页、搜索、发布、详情、发布成功、我的发布。
为降低下载后演示和复现的环境要求,最终双端交付采用浏览器内本地数据层:
- 页面、样式、交互、以及对后端接口的调用方式完全按真实前后端分离项目来写(统一的
LF.api、Promise、统一返回结构、camelCase、分页结构、状态码语义); - 但把"后端"以一个浏览器内本地数据层的形式实现:两端目录下的
js/data/local-api.js承载全部业务规则,js/data/db.js用localStorage持久化,js/data/seed.js提供开箱即用的 14 条演示数据; - 页面只依赖
LF.api的方法签名与返回结构,不感知数据来自网络还是本地。接入真实后端时可保留这一分层,但需要核对并适配账号、字段、分页与图片上传协议;本地账号和数据只属于当前浏览器及访问地址,不跨设备共享。
双端演进:先用 Web 页面模拟手机 App(mobile/,桌面浏览器里是居中的手机外壳);随后用一个极薄的原生 Android WebView 壳工程把 mobile/ 的前端源码原样打包成可安装、离线可运行的 APK(产物 mobile/apk/app.apk);最后又在同一套数据层与业务规则之上,完善出桌面 / 平板 / 手机自适应的响应式 Web 端(web/),并补充了独立登录注册页、会话管理与更丰富的素材。两端业务核心同构,差异集中在视图层与外壳。
整体分层如下(两端一致,仅根目录前缀不同):
页面 pages/*.js(首页/搜索/发布/详情/我的发布)
│ 只调用 LF.api.xxx(),返回 Promise
▼
js/utils/api.js(接口集合,适配层)
│ 当前转发给本地实现;接后端时转发给 fetch
▼
js/data/local-api.js(业务规则:校验/状态机/分页/搜索/统计/联系方式)
▼
js/data/db.js(localStorage 数据库:主键、编号、持久化)
▼
js/data/seed.js(首次打开写入的 14 条演示数据)
4.2 流程图
(1)总体数据流(分层与依赖)

(2)核心业务流程:发布 → 浏览/搜索 → 详情 → 联系 → 更新状态
(3)信息状态机(状态迁移测试的依据)

4.3 重要代码片段
① 适配层:页面不感知数据来源(两端 js/utils/api.js)
(function (LF) {
var api = LF.localApi;
// 本地数据服务的方法签名与原后端接口一一对应,直接转发即可。
LF.api = {
login: api.login,
getItems: api.getItems,
searchItems: api.searchItems,
getItemDetail: api.getItemDetail,
getItemContact: api.getItemContact,
createItem: api.createItem,
updateItemStatus: api.updateItemStatus,
getMyItems: api.getMyItems,
getCurrentUser: api.getCurrentUser,
updateCurrentUser: api.updateCurrentUser,
uploadImage: api.uploadImage
};
})(window.LF);
意义:把页面与数据来源分开,便于复用页面和集中维护业务规则;上述片段展示公共接口转发,Web 端账号接口由其账号模块补充,实际接入 HTTP 后端仍须完成协议适配。
② 状态机 + 所有权校验(js/data/local-api.js,对应接口 PATCH /items/{id}/status)
var ALLOWED_STATUS = {
lost: ['searching', 'recovered'],
found: ['pending_claim', 'returned']
};
function updateItemStatus(id, status) {
return delay(function () {
var me = requireUser(); // 必须登录
var item = findItem(id);
if (!item) throw new Error('信息不存在或已被删除');
if (item.publisherId !== me.id) throw new Error('只能操作本人发布的信息'); // 越权拦截
if (ALLOWED_STATUS[item.type].indexOf(status) === -1) {
throw new Error('非法的状态变更,请刷新后重试'); // 跨类型状态拦截
}
item.status = status; // 幂等:重复设置同一状态也成功
LF.db.save();
return { id: item.id, status: item.status,
statusText: STATUS_TEXT[item.status], updatedAt: LF.db.formatTs(new Date()) };
});
}
③ 搜索:关键词同时匹配名称、地点、描述、编号(对应 GET /items/search)
var keyword = (params.keyword || '').trim().toLowerCase(); // 先 trim
var list = currentState().items.filter(function (it) {
if (params.type && params.type !== 'all' && it.type !== params.type) return false;
if (!keyword) return true; // 空关键词 = 全部
var hay = [it.name, it.location, it.description || '', it.itemNo].join(' ').toLowerCase();
return hay.indexOf(keyword) > -1;
});
④ 信息编号生成(js/data/db.js):寻物 L+yyyyMMdd+序号,招领 F 前缀,按天计数
function nextItemNo(st, type, date) {
var prefix = type === 'lost' ? 'L' : 'F';
var key = prefix + ymd(date);
st.itemNoSeq[key] = (st.itemNoSeq[key] || 0) + 1;
return key + pad2(st.itemNoSeq[key]); // 例如 L2026100201
}
⑤ 图片不上传服务器:本地读取 + Canvas 压缩为 dataURL(local-api.js)
function fileToDataUrl(file, maxEdge, quality) {
return new Promise(function (resolve, reject) {
var reader = new FileReader();
reader.onload = function () {
var img = new Image();
img.onload = function () {
/* 等比缩放到最长边 900,铺白底后转 JPEG,避免 localStorage 被大图撑爆 */
var w = img.width, h = img.height;
if (w > h && w > maxEdge) { h = Math.round(h * maxEdge / w); w = maxEdge; }
else if (h >= w && h > maxEdge) { w = Math.round(w * maxEdge / h); h = maxEdge; }
var canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
var ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.src = reader.result;
};
reader.readAsDataURL(file);
});
}
4.4 Android App:把零后端网页装进 APK
App 端没有再写一套原生业务,而是用一个极薄的原生 WebView 壳承载同一份前端代码:
- 壳工程只有一个全屏
MainActivity,其中的WebView直接加载打包在内的本地资源file:///android_asset/www/index.html,并开启domStorageEnabled(localStorage 依赖它); - 构建时把
mobile/下的index.html、assets/、css/、js/复制到壳工程的app/src/main/assets/www/(不复制apk/、server.js、单元测试/),再用 Gradle 构建出 APK; - APK 打包与真机验证由严科儒负责;其博客记录使用 Android Gradle Plugin 8.7.2 构建;
- 因为页面与数据都在本机(localStorage),App 安装后离线即可运行,不依赖任何服务器;
- 安装包已发布到 GitHub Release(tag
v1.0.0,约 97 KB):测试同学在安卓手机上打开 app.apk 下载链接即可下载安装,无需克隆仓库。
4.5 Web 端增强
按分工,Web 端(web/)响应式、登录注册与会话门禁由翁斌负责,在同一套数据层之上面向桌面浏览做了增强:
- 响应式布局:
responsive.css/media.css按断点在桌面侧边导航、平板品牌栏、手机单栏 + 底部 Tab 之间切换; - 完整账号体系:新增
data/accounts.js与pages/auth.js独立登录 / 注册页,发布与"我的发布"作为受保护路由,未登录时先给占位页、再弹原地登录窗,登录成功回到原目标页;定时 / 窗口聚焦时检查会话,过期清理私人内容; - 体验细节:发布草稿保留、无障碍"跳到页面内容"链接、lucide 图标、实物配图与插画。
五、附加特点设计
| # | 特点 | 设计意义 | 实现方式 |
|---|---|---|---|
| 1 | 零后端、零安装、双击即跑 | Web 端无需配置 JDK 或数据库,降低演示环境要求 | 浏览器内本地数据层 + localStorage;页面依赖统一接口,接入后端时需实现协议适配 |
| 2 | 一套核心、双端交付(Web + 离线 APK) | 同一题目两种呈现形式都能验收:Chrome 打开是网页,装到手机是 App,且业务行为一致 | web/ 响应式网页;mobile/ 经原生 WebView 壳打包为 apk/app.apk,共用 LF.api 与本地数据层 |
| 3 | 图片本地压缩 | 不上传服务器也能发图,且避免大图撑爆浏览器 / WebView 存储 | FileReader + Canvas 等比缩放至 900px、转 JPEG dataURL |
| 4 | 联系方式一键复制 / 拨打、且按需可见 | 隐私保护:列表与详情都不暴露联系方式,主动点击"联系发布者"才拉取;微信一键复制、手机一键 tel: 拨号 |
getItemContact 独立接口 + Clipboard API(含降级方案) |
| 5 | 证件类隐私提示 + 敏感信息不直接展示 | 呼应原型隐私设计,降低学号/卡号泄露风险 | 详情页对 id_card 类别显示专门隐私提示,种子数据对卡号做了脱敏 |
| 6 | 演示数据一键重置 + 相对时间 | 任何时候打开都有完整可演示数据;"刚刚/x 分钟前"随时间自然 | seed 时间用"距当前分钟数"动态生成;提供 LF.localApi.resetDemo() |
| 7 | 完整的表单 / 状态校验与空结果引导 | 不只走"顺利路径":必填、未来时间、图片超限、无搜索结果、越权、非法状态都有提示 | 本地服务端二次校验 + 前端引导(推荐搜索、去发布) |
| 8 | Web 端登录注册与会话门禁(响应式) | 桌面端账号体验完整,受保护操作不丢失原目标页,会话过期有清理 | 独立 auth 页 + 路由门禁 + 原地登录弹窗 + 会话定时检查 + 草稿恢复 |
六、目录说明与使用说明
6.1 项目结构
以下为协作者博客所述的最终共同仓库结构。当前本地副本没有 web/、mobile/ 双端目录,不能直接把下列路径作为本地运行入口;本地 Web 版运行方式见 README.md。
lost-found/
├─ README.md # 双端总说明(目录与运行方式以它为准)
├─ LICENSE # MIT
├─ docs/
│ └─ screenshots/
│ ├─ web/ # Web 端展示截图
│ └─ mobile/ # App 真机截图
├─ web/ # ===== Web 端(响应式)=====
│ ├─ index.html # 单页应用入口
│ ├─ server.js # 零依赖静态服务器(可选,默认 8090)
│ ├─ README.md / LICENSE
│ ├─ assets/ # tabbar 图标、分类图、lucide 图标、实物配图与插画
│ ├─ css/ # global + 页面样式 + responsive/media/auth
│ ├─ js/
│ │ ├─ config.js / app.js # 配置 / hash 路由与启动
│ │ ├─ data/ # seed 演示数据 / db(localStorage) / accounts 账号 / local-api
│ │ ├─ utils/ # api 适配层、auth、ui、icons、media、draft、query、format
│ │ ├─ components/tabbar.js # 底部导航
│ │ └─ pages/ # auth 登录注册 / home / search / publish / detail / success / my-posts
│ ├─ docs/ # 开发与验证过程记录、回归截图(过程材料)
│ └─ 单元测试/ # 7 个测试文件
└─ mobile/ # ===== Android App 端(手机形态)=====
├─ index.html # 入口(桌面浏览器含手机状态栏外壳)
├─ server.js # 浏览器预览用静态服务器(建议 8091)
├─ README.md / LICENSE
├─ apk/
│ └─ app.apk # ★ Android 安装包(WebView 壳 + assets/www)
├─ assets/ # tabbar 图标、6 类分类图
├─ css/ # global(含手机外壳)+ 页面样式
├─ js/
│ ├─ config.js / app.js # 配置 / 路由与启动(首启匿名静默登录)
│ ├─ data/ # seed / db(localStorage) / local-api
│ ├─ utils/ # api 适配层、auth、ui、format、url
│ ├─ components/tabbar.js
│ └─ pages/ # home / search / publish / detail / success / my-posts
└─ 单元测试/ # 3 个测试文件
6.2 如何运行
下列步骤面向从共同仓库下载的双端版本。
Web 端(统一用 Google Chrome):
- 直接用 Chrome 打开
web/index.html; - 或在
web目录执行node server.js,浏览器访问http://localhost:8090。
App 端(二选一):
- 真机安装(作业 APP 交付物):在安卓手机上点击下载安装包 app.apk(v1.0.0),允许"安装未知来源应用"后安装,桌面出现「校园失物招领」图标,离线即可运行(仓库内
mobile/apk/app.apk为同一份安装包); - 电脑浏览器预览手机界面:在
mobile目录执行node server.js 8091(用 8091 避免与 Web 端 8090 冲突),访问http://localhost:8091,或直接双击mobile/index.html。
七、单元测试
7.1 测试工具选型、学习过程与简易教程
测试框架选型、环境搭建与核心业务用例设计由严科儒负责;翁斌负责回归测试及 Web 端增强相关工作。以下工具说明与示例用于解释项目测试方案。
选型:协作者博客记录,项目对比 Mocha、Jest 和 Node 内置方案后,选择 Node.js 内置测试运行器 node:test + 内置断言 node:assert/strict,理由:
- 零第三方依赖、零安装:Node ≥ 18 自带,
npm test一定能跑起来,不会因为网络装不上 Mocha/Jest 而让测试"在我电脑上能跑"; - 天然自动化:一条命令递归运行、退出码反映成败,可放进每日构建;
- 语法与 Mocha 一致(
describe / it / beforeEach,BDD 风格),我们从 Mocha 教程学到的用例组织、钩子、断言思想完全通用;将来迁移到 Mocha 只需改导入方式。
协作者记录的学习路径:先读《测试框架 Mocha 实例教程》理解断言、测试套件、钩子、异步测试、用例隔离;再查 Node 官方文档 node:test,确认其 describe/it/beforeEach 与 assert 用法;先用一个小例子跑通,再迁移到本项目,并封装为两端使用的 tests/helpers/env.js 环境适配工具。
项目测试方案简易教程:
-
环境:安装 Node 18+(命令行
node -v能看到版本即可),测试框架不用npm install。 -
写测试:新建
tests/xxx.test.js,文件名以.test.js结尾,Node 会识别为测试文件:const { describe, it, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); describe('被测模块', () => { it('应当满足某性质', () => { assert.equal(1 + 1, 2); // 同步断言 }); it('异步接口用 await', async () => { const data = await someAsync(); assert.ok(Array.isArray(data.records)); }); }); -
跑测试:
node --test tests/a.test.js tests/b.test.js,或在package.json配
"scripts": { "test": "node --test tests/*.test.js" }后执行npm test。 -
看结果:
# pass / # fail汇总,失败会给出期望值/实际值与代码位置;进程退出码非 0 表示有失败,可被 CI/每日构建识别。 -
用例隔离:每个用例在
beforeEach里构造全新的内存数据库,互不污染、可重复运行。
7.2 单元测试代码展示与被测函数说明
协作者博客记录的双端测试结果如下,正式的测试来源和验证范围,保留结果来自队友记录:
| 端 | 位置 | 测试文件数 | 用例数 | 结果 |
|---|---|---|---|---|
| App 端(手机形态) | mobile/单元测试/tests/ |
3 | 58 | # pass 58 / # fail 0 |
| Web 端(响应式) | web/单元测试/tests/ |
7 | 100 | # pass 100 / # fail 0 |
运行命令:
cd mobile/单元测试 && npm test # App 端:local-api / db / utils
cd web/单元测试 && npm test # Web 端:另含 accounts / auth-navigation / media / responsive-features
被测函数与对应文件:
| 测试文件 | 被测对象 | 覆盖的函数/接口 |
|---|---|---|
local-api.test.js(两端都有) |
本地业务服务 | login、getItems、searchItems、getItemDetail、getItemContact、createItem、updateItemStatus、getMyItems、getCurrentUser、updateCurrentUser、uploadImage |
db.test.js(两端都有) |
数据库工具 | detectContactType、formatTs、nextItemNo、save/load/resetDemo |
utils.test.js(两端都有) |
纯工具 | format.parseDate/formatDateTime*/formatRelative、urlUtil.resolveImageUrl(s) |
accounts.test.js(仅 Web) |
账号库 | 注册 / 登录 / 登出、重复账号与错误口令 |
auth-navigation.test.js(仅 Web) |
鉴权与导航门禁 | 未登录拦截、登录后回到目标页、会话过期 |
media.test.js(仅 Web) |
图片前置校验 | 图片类型 / 数量 / 大小限制 |
responsive-features.test.js(仅 Web) |
Web 增强逻辑 | 响应式相关纯逻辑与工具 |
示例 1:状态迁移 + 权限(白盒)
it('发布者可把寻物标记为已找回,文案正确并带更新时间', async () => {
const created = await LF.api.createItem(validPayload(LF));
const res = await LF.api.updateItemStatus(created.id, 'recovered');
assert.equal(res.status, 'recovered');
assert.equal(res.statusText, '已找回');
assert.ok(res.updatedAt);
});
it('权限:不能修改他人发布的信息', async () => {
await assert.rejects(LF.api.updateItemStatus(2, 'returned'), /本人|权限/);
});
it('非法状态迁移:寻物不能置为“已归还”', async () => {
const lost = await LF.api.createItem(validPayload(LF, { type: 'lost' }));
await assert.rejects(LF.api.updateItemStatus(lost.id, 'returned'), /状态/);
});
示例 2:分页边界值(pageSize 钳制 1~20、非法页码归一)
it('pageSize 超过 20 被钳制为 20,非法页码归一到第 1 页', async () => {
const big = await LF.api.getItems({ page: 1, pageSize: 999 });
assert.equal(big.pageSize, 20);
assert.equal(big.records.length, 14);
const zero = await LF.api.getItems({ page: 0, pageSize: 0 });
assert.equal(zero.page, 1);
assert.equal(zero.pageSize, 10);
});
示例 3:测试环境垫片(让浏览器脚本跑在 Node 里)
const sandbox = { console, Date, Math, JSON, Promise, localStorage: memoryStorage() };
sandbox.setTimeout = (fn) => { fn(); return 0; }; // 去掉模拟网络延时,保留 Promise 语义
sandbox.window = sandbox;
vm.createContext(sandbox);
FILES.forEach(f => vm.runInContext(fs.readFileSync(path.join(JS_ROOT, f),'utf8'), sandbox));
return { LF: sandbox.LF };
协作者博客展示的执行输出:
# App 端(mobile/单元测试)
# tests 58
# pass 58
# fail 0
# Web 端(web/单元测试)
# tests 100
# pass 100
# fail 0
7.3 测试数据构造思路,以及如何应对"刁难"
我们以白盒设计方法为主来构造用例,先读代码理清分支与边界,再逐条覆盖:
- 等价类划分:类型(失物/招领)、登录态(本人/他人/匿名)、联系方式(微信/手机号)、搜索(命中名称/地点/描述/无命中)。
- 边界值分析:分页
pageSize取 0、1、10、20、999;图片恰好/超过 3 张与 5MB;发生时间卡在"现在 +5 分钟"附近;编号同一天跨第 1、2 条;文本长度上下限。 - 状态迁移测试:沿状态机四条合法迁移各测一次,再故意走两条非法跨类型迁移;并验证重复设置同一状态的幂等性。
- 权限/安全测试:匿名拉联系方式、非发布者改状态都必须被拒绝;详情接口断言不返回
contactValue。 - 错误推测(主动刁难自己):关键词首尾空格、空关键词、搜不到结果、不存在的 id、漏填每一个必填项、不勾协议、未来时间、非法类别枚举、非法图片类型、刷新后数据是否还在。
- Web 端补充:重复注册 / 错误口令、未登录访问受保护页被拦截且登录后回到原目标页、会话过期清理、图片类型与大小前置校验。
数据策略:固定 14 条种子数据刻意覆盖 6 个类别、2 种类型、4 种状态(含已结束)和有无图片两种情况,保证列表/筛选/统计/推荐都有确定性结果;动态新增的数据则用 validPayload() 工厂函数生成、按需覆盖字段。每个用例在 beforeEach 中拿到全新的内存数据库,互不干扰、可反复运行,不依赖真实浏览器数据。
验证边界:单元测试用于检查业务规则、账号、权限及工具逻辑;Canvas 图片压缩和页面显示依赖浏览器环境,不能仅凭 Node 测试结果确认。协作者记录了 APK 真机走查,Web 端个人回归过程与结论仍应引用本人实际验证记录;正式的测试来源和验证范围,保留结果来自队友记录。
八、GitHub 签入记录
共同仓库:Clu3y/102401632-102401633。
个人 fork:5-bin/102401632-102401633。

九、遇到的异常 / 结对困难与解决
问题 1:没有后端时如何模拟一个"后端",又不让页面写死?
- 技术问题:如果各页面直接操作 localStorage,校验、状态机和统计规则容易分散,增加维护与测试成本。
- 解决:抽出
local-api.js作为唯一业务出口,页面只依赖LF.api的契约;api.js做适配层转发。 - 设计意义:集中业务规则便于测试,并为后续实现 HTTP 协议适配保留入口。
问题 2:图片在没有服务器/对象存储时怎么保存?
- 技术问题:原图转成 dataURL 后仍可能占用较多空间,localStorage 写入可能因配额限制而失败。
- 解决:FileReader 读入后用 Canvas 等比压缩到最长边 900px、铺白底转 JPEG dataURL,并对存储写入做 try/catch,给出"空间不足请减少图片"的友好提示。
- 设计意义:在本地存储方案中同时考虑图片体积和写入失败反馈。
问题 3:单元测试在 Node 里加载不了浏览器脚本。
此问题及其解决过程由严科儒在博客中记录,对应其负责的测试环境搭建。
- 尝试:直接
require前端文件会报window/localStorage is not defined;引入 jsdom 又要联网安装、违背"零依赖可跑"。 - 解决:用 Node 内置
vm造隔离全局,提供内存版localStorage,按index.html顺序加载脚本。 - 收获:分清"依赖 DOM 的视图层"和"不依赖 DOM 的业务层",把业务逻辑写成可在任意宿主运行的纯逻辑,是可测试性的关键。
问题 4:测试断言报"结构相同但引用不等"。
此问题由严科儒在博客中记录,不能写成本人的独立排查经历。
- 尝试:
deepStrictEqual比较 vm 沙箱里产生的数组/对象与测试里的字面量,值一样却失败。 - 定位:vm 是独立的 JS realm,沙箱内对象的原型(
Array.prototype/Object.prototype)与测试主上下文不同,严格深比较会校验原型。 - 解决:对跨 realm 的复合值,用
Array.from(...)转回主上下文数组、或逐字段assert.equal比较,而非整体deepStrictEqual。 - 收获:理解了 JS realm 与原型身份,断言失败时要读 expected/actual 与 operator,而不是怀疑业务逻辑。
问题 5:没有后端的网页,怎么装进 APK 并保证离线可用?
该问题由严科儒在搭建测试环境时排查解决。
- 担心:WebView 直接加载远程 URL 仍需要一台服务器,违背"零后端";
file:///本地资源下 hash 路由、localStorage、Canvas 压缩是否可用也要验证。 - 解决:写一个只有全屏 WebView 的原生壳,加载打包在内的
file:///android_asset/www/index.html,并显式开启 DOM storage;纯前端零依赖代码天然离线运行。打包后把 APK 内assets/www/解出与mobile/源码逐文件哈希比对,全部一致,再装到真机走查发布→搜索→联系→改状态的完整闭环。 - 收获:WebView 本质是浏览器内核,纯前端应用几乎可以零改造复用;发布二进制产物时要做"产物与源码一致性"校验,避免装出去的包和仓库代码不是同一份。
问题 6:手机形态页面演进为桌面响应式后,"需要登录"的操作如何不打断流程?
- 技术问题:登录流程需要保留用户的目标页面,发布页还需避免丢失正在填写的文字内容。
- 解决:把发布与"我的发布"设为受保护路由,未登录时先显示占位页并弹出原地登录窗,登录成功回到原目标 hash;发布内容做草稿保留;会话过期时清理私人内容并提示重新登录。
- 设计意义:登录门禁与导航、草稿和会话状态一起设计。
问题 7:演示记录的时间要随当前时间变化,测试数据又要保持稳定。
严科儒的博客记录:时间按相对当前的分钟数生成,类型、类别、状态及图片覆盖固定;测试每次创建独立内存数据。这一方案兼顾演示效果与可重复验证,由其负责的数据层和测试模块实现。
十、评价队友
- 值得学习的地方:严科儒把数据层与测试环境结合起来设计,既考虑演示数据的类别、类型和状态覆盖,也通过独立的测试数据检查业务规则。这种做法让页面开发有明确的数据依据,也方便后续回归。Android 端交付还包含本地资源打包、真机走查和源码一致性校验,值得我在今后的项目交付中参考。
- 需要改进的地方:后续协作中,建议我们把接口字段、返回结构、双端差异和验收标准提前整理成共同维护的文档。尤其是 APK 的构建步骤、环境版本和校验方法,可以写成可复现的操作说明,让另一位成员也能独立完成打包和核对。这样能减少交付对单个成员经验的依赖,也便于双方交叉检查。
十一、小结
本次共同交付包含响应式 Web 页面和离线 Android APK。按已发布分工,我负责发布相关页面、状态与权限规则、图片压缩和 Web 端响应式及账号体验;严科儒负责数据层、列表搜索详情、核心测试与 APK 交付。两端通过统一接口组织业务,各自保留界面和宿主差异。
收获:对我负责的 Web 端而言,页面开发需要同时考虑业务规则和操作过程。发布表单不仅要检查必填项,还要处理图片限制、保存失败和草稿恢复;登录不仅要拦截受限操作,还要保留用户原本要去的页面;状态更新既要检查发布者身份,也要限制合法状态。这些设计说明,完成主流程之后,还需要逐项检查失败、取消和重复操作时的行为。
项目也体现了分层设计的作用:页面通过 LF.api 调用业务,数据与规则集中维护,界面调整和逻辑测试因此有了较清楚的边界。两人协作时,明确接口、字段和状态约定,与划分页面分工同样重要。
不足与改进:现有单元测试主要覆盖业务层与工具逻辑,不能替代页面交互和实机验收。后续需要继续检查图片压缩、弹窗关闭、会话过期和请求未完成时切换页面等场景,并针对已有审查记录中的问题补充回归验证。在时间安排上,应优先保证核心流程和异常处理,再集中调整样式细节,同时为测试和交付整理预留时间。
后续计划:完善 Web 端异常场景的验证记录,补充双端构建与验收说明
浙公网安备 33010602011771号