Files
juzi/h5/assets/mock/smoke-test.js
T
Ubuntu ba3b346fce feat: 报告页 V2.2 检测可视化 + 评分引擎审计 + 真值回归/e2e 闭环
- server.js: face_maps 改为白名单 5 图层完整 dataURL 透传(REPORT_MAPS,按魔数判 mime),marks 坐标随 result 原样透传;实测 return_marks 不支持毛孔/黑头等扩展标记
- skin-score.js: 按需求文档 V2.2 逐字审计修正 9 处(黑眼圈 dark_circle_severity、oil_desc/spot_flag §7.2 拼接、skintone 中文映射、轮廓预警档前缀、痘痘 heavy_note、50+精华全文、综合文案数值格式),契约引用 V2.0→V2.2
- app.js/index.html/css: 报告页 6 分类检测可视化(轮廓=正脸实拍、皱纹=正脸压暗+皱纹增强线、光滑=正脸+色斑/痣/痤疮标记、肤色=色沉图、敏感=红区图、痘痘=正脸+闭口/痤疮标记),数据驱动 VIZ_CAT 配置,优雅降级
- h5/test/: Excel 两组三视图真值回归 84/84(含案例2 AY 列扁平化数据重建 fixture)
- h5/assets/mock/: 真实案例1 报告页 fixture(坐标与照片同比例缩放),?mock=1&screen=report 直渲
- e2e: Chrome 无头闭环 55/55(mock 全流程/直渲/真实 API 降级链路),截图存档 preview/e2e-20260926
- 自测: skin-score 83/83,fixture 冒烟 35/35
2026-09-26 22:35:02 +08:00

130 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ─── 报告页可视化 mock 冒烟(node,零依赖):node smoke-test.js ───
断言:① fixture 的 dataURL 可解析(mime 与魔数一致、宽 1280)
② marks 坐标缩放后全部落在照片范围内(坐标空间=照片空间)
③ app.js 纯函数段(vizCollectMarks/vizScaleMarks)对 fixture 提取/换算正确
④ fixture.result 能驱动 SkinScore.buildViewModel 出完整报告 */
'use strict';
var fs = require('fs');
var path = require('path');
var pass = 0, fail = 0;
function ok(cond, msg) {
if (cond) { pass++; console.log(' ✓ ' + msg); }
else { fail++; console.error(' ✗ FAIL: ' + msg); }
}
/* ─── 无依赖图片尺寸解析:PNG 读 IHDR,JPEG 扫 SOF ─── */
function imgSize(buf) {
if (buf[0] === 0x89 && buf[1] === 0x50) { // PNG:89 50 4E 47
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20), fmt: 'png' };
}
if (buf[0] === 0xFF && buf[1] === 0xD8) { // JPEG:FF D8,SOF0-15(除 C4/C8/CC)
var off = 2;
while (off + 9 < buf.length) {
if (buf[off] !== 0xFF) { off++; continue; }
var marker = buf[off + 1];
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
return { w: buf.readUInt16BE(off + 7), h: buf.readUInt16BE(off + 5), fmt: 'jpeg' };
}
off += 2 + buf.readUInt16BE(off + 2);
}
}
return null;
}
function parseDataURL(u) {
var m = /^data:(image\/(?:jpeg|png));base64,([A-Za-z0-9+/=]+)$/.exec(u || '');
if (!m) return null;
return { mime: m[1], buf: Buffer.from(m[2], 'base64') };
}
/* ─── ① fixture 加载与 dataURL 解析 ─── */
console.log('== ① fixture dataURL 解析 ==');
var window = {};
new Function('window', fs.readFileSync(path.join(__dirname, 'report-fixture.js'), 'utf8'))(window);
var fx = window.MOCK_FIXTURE;
ok(fx && fx.result && Array.isArray(fx.photos), 'window.MOCK_FIXTURE 结构完整(result/photos)');
var photo = parseDataURL(fx.photos[0]);
var photoSize = photo && imgSize(photo.buf);
ok(!!photo, 'photos[0] 是合法 dataURL');
ok(photoSize && photoSize.fmt === 'jpeg' && photo.mime === 'image/jpeg', '照片 jpeg 魔数与 mime 一致');
ok(photoSize && photoSize.w === 1280 && photoSize.h === 960, '照片尺寸 1280×960(实测 ' + (photoSize ? photoSize.w + '×' + photoSize.h : '?') + ')');
ok(fx.photos[1] === null && fx.photos[2] === null, '侧脸位为 null');
var maps = fx.result.face_maps || {};
['red_area', 'brown_area', 'texture_enhanced_lines', 'texture_enhanced_pores', 'texture_enhanced_blackheads'].forEach(function (k) {
var p = parseDataURL(maps[k]);
var sz = p && imgSize(p.buf);
ok(!!p && !!sz, 'face_maps.' + k + ' dataURL 可解析(' + (sz ? sz.fmt : '失败') + ')');
ok(sz && sz.w === 1280 && sz.h === 960, 'face_maps.' + k + ' 已缩放到 1280×960');
ok(p && sz && p.mime === 'image/' + sz.fmt, 'face_maps.' + k + ' mime 与魔数一致(' + (p && p.mime) + ')');
});
/* ─── ② marks 坐标全部落在照片范围内 ─── */
console.log('== ② marks 坐标范围 ==');
var coordCount = 0, outCount = 0;
(function walk(node) {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) return node.forEach(walk);
var ks = node;
if (typeof ks.left === 'number' && typeof ks.top === 'number' && typeof ks.width === 'number' && typeof ks.height === 'number') {
coordCount++;
if (ks.left < 0 || ks.top < 0 || ks.left + ks.width > 1280 || ks.top + ks.height > 960) outCount++;
return;
}
if (typeof ks.x === 'number' && typeof ks.y === 'number') {
coordCount++;
if (ks.x < 0 || ks.x > 1280 || ks.y < 0 || ks.y > 960) outCount++;
return;
}
Object.keys(ks).forEach(function (k) { if (k !== 'face_maps') walk(ks[k]); });
})(fx.result);
ok(coordCount > 100, 'result 内含坐标点/矩形 ' + coordCount + ' 个(原图 4000×3000 → ×0.32)');
ok(outCount === 0, '全部坐标在 1280×960 照片范围内(越界 ' + outCount + ' 个)');
var fr = fx.face_rectangle;
ok(fr && fr.left >= 0 && fr.top >= 0 && fr.left + fr.width <= 1280 && fr.top + fr.height <= 960,
'face_rectangle 已缩放且在范围内(' + JSON.stringify(fr) + ')');
/* ─── ③ app.js 纯函数段抽离断言 ─── */
console.log('== ③ app.js 纯函数(viz-pure 锚点抽离) ==');
var appSrc = fs.readFileSync(path.join(__dirname, '..', '..', 'js', 'app.js'), 'utf8');
var b = appSrc.indexOf('/* viz-pure-begin'), e = appSrc.indexOf('/* viz-pure-end');
ok(b >= 0 && e > b, 'app.js 含 viz-pure-begin/end 锚点');
var pure = new Function(appSrc.slice(b, e) + '; return { collect: vizCollectMarks, scale: vizScaleMarks };')();
var SMOOTH_MARKS = [{ key: 'brown_spot', color: '#B26A00' }, { key: 'mole', color: '#282828' }, { key: 'acne', color: '#D30C0C' }];
var ACNE_MARKS = [{ key: 'closed_comedones', color: '#E8A13D' }, { key: 'acne', color: '#D30C0C' }, { key: 'acne_pustule', color: '#D30C0C' }, { key: 'acne_nodule', color: '#8E0000' }];
var sm = pure.collect(SMOOTH_MARKS, fx.result);
var am = pure.collect(ACNE_MARKS, fx.result);
ok(sm.length === 2, '光滑:提取到 brown_spot+acne 两组(mole count=0 被跳过),实得 ' + sm.length + ' 组');
ok(sm[0].polygons.length === 37 && sm[0].rects.length === 37, 'brown_spot 37 处 polygon/rectangle 全保留');
ok(am.length === 2 && am[0].rects.length === 6, '痘痘:closed_comedones 6 处 + acne 3 处(pustule/nodule 为空跳过)');
ok(sm[0].color === '#B26A00', '颜色样式随配置透传');
var half = pure.scale(sm, 0.5);
ok(half[0].rects[0].left === sm[0].rects[0].left / 2 && half[0].polygons[0][0][0] === sm[0].polygons[0][0][0] / 2,
'vizScaleMarks 坐标按比例换算(0.5×)');
ok(half[0].rects[0].left + half[0].rects[0].width <= 1280 && half[0].polygons[0].every(function (pt) { return pt[0] <= 1280 && pt[1] <= 960; }),
'缩放后坐标仍在照片范围内');
ok(pure.collect(SMOOTH_MARKS, null).length === 0 && pure.collect(null, fx.result).length === 0, '缺 result/缺配置 → 空集合(优雅降级)');
var noKey = pure.collect([{ key: 'not_exist', color: '#000' }], fx.result);
ok(noKey.length === 0, '标记类型缺失 → 跳过不报错');
/* ─── ④ fixture 驱动真实评分管线 ─── */
console.log('== ④ SkinScore.buildViewModel(fixture) ==');
var SkinScore = require(path.join(__dirname, '..', '..', 'js', 'skin-score.js'));
var vm = SkinScore.buildViewModel(fx.result, fx.side, { realAge: 30, skinType: '中性肌肤' });
var catKeys = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne'];
var textsOK = true, scoreOK = true;
catKeys.forEach(function (k) {
var c = vm.cats[k];
if (!c || typeof c.text !== 'string' || /undefined|NaN/.test(c.text)) textsOK = false;
if (c.P == null || typeof c.S !== 'number') scoreOK = false;
});
ok(scoreOK, '6 分类全部出分(案例1 数据完整)');
ok(textsOK, '话术无 undefined/NaN');
ok(typeof vm.total === 'number' || vm.dims.firm.S != null, '综合评分/维度分可用');
console.log('\n' + (fail ? '✗ 失败 ' + fail + ' 项,' : '✓ 全部通过,') + '共 ' + (pass + fail) + ' 项断言');
process.exit(fail ? 1 : 0);