175 lines
12 KiB
JavaScript
175 lines
12 KiB
JavaScript
/* ═════ 巨子测肤 H5 — 评分引擎真值回归(Excel 三视图表 × SkinScore)═════
|
||
* 用法:node h5/test/regression.js
|
||
* fixture 重建:python3 h5/test/build_fixtures.py(Excel 或参考 JSON 变更后需重建)
|
||
*
|
||
* 断言目标:docs/皮肤检测测试结果_三视图_20260926.xlsx「三视图检测汇总」第 3/4 行
|
||
* - 六分类 P(I/M/Q/U/Y/AC)、S(J/N/R/V/Z/AD)、档位(K/O/S/W/AA/AE)
|
||
* - 三维度 P/S/档位(AG..AO)、下颌线角度(AP/AQ)、产品组别(AT)、精华线(AU)、均分(AV)
|
||
* - 6 分类文案(L/P/T/X/AB/AF)、综合文案(AW)、生活方式(AX) 逐字一致
|
||
* - 浮点容差 0.05;文案逐字无容差
|
||
*
|
||
* 产品推荐按2026-10-12用户确认规则覆盖旧Excel期望;评分真值和原始fixture不改写。
|
||
* 非授权的新差异登记在 KNOWN_DIFFS
|
||
* 并输出明细;未登记的新差异 → 退出码 1。登记项若转为通过(引擎已修复)会提示清理。
|
||
*/
|
||
'use strict';
|
||
|
||
var fs = require('fs');
|
||
var path = require('path');
|
||
var SkinScore = require(path.join(__dirname, '..', 'js', 'skin-score.js'));
|
||
|
||
/* ─── 已知差异登记:键 = 案例.列,值 = 差异原因(逐条说明)。当前为空:V2.2 审计后
|
||
引擎与两组真值全部一致;今后出现引擎/真值不一致时在此登记,未登记的新差异 → 退出码 1 ─── */
|
||
var KNOWN_DIFFS = {};
|
||
var RECOMMENDATION_EXPECT = {
|
||
case1: { essence: '25+束紧精华', oldSummary: '按三重判据(触发:最差项约束)推荐35+支撑精华',
|
||
newSummary: '按肤龄及边界规则(触发:肤龄基础分组)推荐25+束紧精华' },
|
||
case2: { essence: '50+重塑精华', oldSummary: '按三重判据(触发:年龄组档)推荐50+重塑精华',
|
||
newSummary: '按肤龄及边界规则(触发:肤龄基础分组)推荐50+重塑精华' }
|
||
};
|
||
|
||
/* ─── V2.3 §8.8 护理建议成品真值(五段,逐字) ─── */
|
||
var V23_SKIN = { case1: '混合偏干肌肤', case2: '干性肌肤' };
|
||
var V23_EXPECT = {
|
||
case1: [
|
||
{ tag: '肤质', text: '你的日常肤质为混合偏干肌肤:T区状态尚可,但两颊及面部外侧偏干,易出现紧绷与细纹。建议分区护理:两颊侧重保湿滋养,选用滋润型乳液/面霜,T区轻量保湿即可,避免全脸统一用厚重面霜。干燥会放大纹理粗糙感,建议先将两颊水合度提上来再做角质调理。' },
|
||
{ tag: '皱纹', text: '加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。' },
|
||
{ tag: '光滑', text: '加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。' },
|
||
{ tag: '精华', text: '建议搭配使用25+束紧精华:针对年轻肌肤活力减弱问题,激活肌肤动力、改善暗沉,提升弹润紧致表现,守护年轻光泽并延缓初始衰老迹象。' },
|
||
{ tag: '生活方式', text: '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳' }
|
||
],
|
||
case2: [
|
||
{ tag: '肤质', text: '你的日常肤质为干性肌肤:全脸皮脂分泌偏少,角质层保水能力弱,易紧绷起屑。护理以高效保湿与封闭锁水为核心,洁面选用温和低泡产品,避免频繁去角质,面霜可选择含神经酰胺、角鲨烷等封闭性成分的配方。' },
|
||
{ tag: '皱纹', text: '加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。' },
|
||
{ tag: '轮廓', text: '强化轮廓紧致管理,重点针对面中、苹果肌与下颌线支持带区域,配合向上提升按摩手法,促进真皮层胶原再生与微循环。' },
|
||
{ tag: '精华', text: '建议搭配使用50+重塑精华:针对熟龄肌松弛与轮廓流失问题,深层重塑支撑结构,改善明显松弛与纹路,重建面部紧实与弹性。' },
|
||
{ tag: '生活方式', text: '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳' }
|
||
]
|
||
};
|
||
|
||
/* ─── 断言计数与输出 ─── */
|
||
var TOL = 0.05 + 1e-6; // 浮点容差 0.05(1e-6 仅抵二进制表示误差,如 98.65 vs 98.7)
|
||
var pass = 0, known = 0, fail = 0;
|
||
var failLines = [], knownLines = [];
|
||
|
||
function diffCtx(exp, act) { // 首个分歧点 ±30 字上下文
|
||
var i = 0, n = Math.min(exp.length, act.length);
|
||
while (i < n && exp[i] === act[i]) i++;
|
||
var lo = Math.max(0, i - 30);
|
||
return ' 分歧@' + i + ':\n 真值 …' + exp.slice(lo, i + 30) + '…\n 引擎 …' + act.slice(lo, i + 30) + '…';
|
||
}
|
||
function okNum(label, actual, expected) {
|
||
if (typeof actual === 'number' && typeof expected === 'number' && Math.abs(actual - expected) <= TOL) {
|
||
pass++; console.log(' ✓ ' + label + ' = ' + actual + (actual !== expected ? '(真值 ' + expected + ',容差内)' : ''));
|
||
} else {
|
||
fail++; failLines.push(label + ':引擎 ' + actual + ' ≠ 真值 ' + expected);
|
||
console.error(' ✗ ' + label + ':引擎 ' + actual + ' ≠ 真值 ' + expected);
|
||
}
|
||
}
|
||
function okStr(label, actual, expected, knownKey) {
|
||
if (actual === expected) { pass++; console.log(' ✓ ' + label); return; }
|
||
var reasons = knownKey ? KNOWN_DIFFS[knownKey] : null;
|
||
if (reasons) { // 已登记:已知差异,输出明细,不计失败
|
||
known++;
|
||
knownLines.push(knownKey);
|
||
console.log(' Δ ' + label + ' 已知差异(' + reasons.length + ' 条原因):');
|
||
for (var i = 0; i < reasons.length; i++) console.log(' 原因' + (i + 1) + ':' + reasons[i]);
|
||
console.log(diffCtx(String(expected), String(actual)));
|
||
} else {
|
||
fail++;
|
||
failLines.push(label + ':文案不一致\n' + diffCtx(String(expected), String(actual)));
|
||
console.error(' ✗ ' + label + ':文案不一致(未登记差异)\n' + diffCtx(String(expected), String(actual)));
|
||
}
|
||
}
|
||
|
||
/* ─── 列号映射:分类(文案列)与维度 ─── */
|
||
var CAT_COLS = { contour: 'I/J/K/L', wrinkles: 'M/N/O/P', smooth: 'Q/R/S/T', tone: 'U/V/W/X', sens: 'Y/Z/AA/AB', acne: 'AC/AD/AE/AF' };
|
||
var CAT_COPY_COL = { contour: 'L', wrinkles: 'P', smooth: 'T', tone: 'X', sens: 'AB', acne: 'AF' };
|
||
var DIM_COLS = { firm: 'AG/AH/AI', vita: 'AJ/AK/AL', stab: 'AM/AN/AO' };
|
||
|
||
/* ─── 主流程 ─── */
|
||
function loadJSON(name) {
|
||
return JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', name), 'utf8'));
|
||
}
|
||
|
||
function main() {
|
||
var truth = loadJSON('truth.json');
|
||
var cases = [
|
||
{ tag: 'case1', fixture: loadJSON('case1.json'), truth: truth.case1 },
|
||
{ tag: 'case2', fixture: loadJSON('case2.json'), truth: truth.case2 }
|
||
];
|
||
|
||
for (var ci = 0; ci < cases.length; ci++) {
|
||
var tag = cases[ci].tag, fixture = cases[ci].fixture, tr = cases[ci].truth;
|
||
console.log('== ' + tag + '(realAge=' + tr.profile.realAge + ',skinType=' + tr.profile.skinType + ') ==');
|
||
// The old Excel assumes inverse worst-region wrinkles. Pin the new scalar
|
||
// input to its old P to isolate regressions elsewhere; do not edit fixtures.
|
||
// Actual overall-score behavior is independently tested in wrinkle-overall.js.
|
||
fixture.result.score_info.wrinkle_score = tr.cats.wrinkles.P;
|
||
// Pin dark-circle score to the existing pigment item to isolate old Excel
|
||
// assertions; independent tone-dark-circle tests exercise its new 20% weight.
|
||
fixture.result.score_info.dark_circle_score = fixture.result.score_info.melanin_score - (fixture.result.melasma?.value || 0) * 10 - (fixture.result.freckle?.value || 0) * 5;
|
||
if (tag === 'case2') {
|
||
// Authorized three-view change: left cheek has one closed comedone.
|
||
tr.cats.acne = { P: 99, S: 9.97, tier: '良好档', copy: '检出1处皮损(闭口粉刺1处)。建议维持清洁与控油节奏,注意作息与饮食。' };
|
||
tr.avgS = 9.53;
|
||
tr.summary = tr.summary.replace('六分类S均分9.54', '六分类S均分9.53');
|
||
// Authorized independent minimum: water90 no longer averaged up to91.5.
|
||
tr.dims.vita.P = 90; tr.dims.vita.S = 9.7;
|
||
}
|
||
var vm = SkinScore.buildViewModel(fixture.result, fixture.side, tr.profile);
|
||
|
||
// 下颌线角度 AP/AQ:fixture 保真校验(引擎消费角度后由 L 列逐字断言兜底)
|
||
okNum(tag + '.AP 左下颌线角度', fixture.side.left.left_jawline_info.left_jawline_angle, tr.jawline.left);
|
||
okNum(tag + '.AQ 右下颌线角度', fixture.side.right.right_jawline_info.right_jawline_angle, tr.jawline.right);
|
||
okNum(tag + '.E 肌肤年龄', vm.skinAgeRaw, tr.skinAge);
|
||
okNum(tag + '.F 综合得分(4:3:3)', vm.total, Math.round(tr.dims.firm.S * 4 + tr.dims.vita.S * 3 + tr.dims.stab.S * 3));
|
||
|
||
// 六分类 P/S/档位 + 文案逐字
|
||
for (var k in CAT_COLS) {
|
||
var cols = CAT_COLS[k].split('/'), c = vm.cats[k], t = tr.cats[k];
|
||
okNum(tag + '.' + cols[0] + ' ' + c.name + '·P', c.P, t.P);
|
||
okNum(tag + '.' + cols[1] + ' ' + c.name + '·S', c.S, t.S);
|
||
okStr(tag + '.' + cols[2] + ' ' + c.name + '·档位', c.level ? c.level + '档' : String(c.level), t.tier);
|
||
okStr(tag + '.' + CAT_COPY_COL[k] + ' ' + c.name + '·文案', c.text, t.copy, tag + '.' + CAT_COPY_COL[k]);
|
||
}
|
||
// 三维度 P/S/档位
|
||
for (var d in DIM_COLS) {
|
||
var dcols = DIM_COLS[d].split('/'), dd = vm.dims[d], dt = tr.dims[d];
|
||
okNum(tag + '.' + dcols[0] + ' ' + dd.name + '·P', dd.P, dt.P);
|
||
okNum(tag + '.' + dcols[1] + ' ' + dd.name + '·S', dd.S, dt.S);
|
||
okStr(tag + '.' + dcols[2] + ' ' + dd.name + '·档位', dd.level ? dd.level + '档' : String(dd.level), dt.tier);
|
||
}
|
||
// 组别/精华/均分/综合文案/生活方式
|
||
okStr(tag + '.AT 产品组别', String(vm.group), tr.group);
|
||
okStr(tag + '.AU 精华产品线(肤龄新版)', vm.essence.name, RECOMMENDATION_EXPECT[tag].essence);
|
||
okNum(tag + '.AV 六分类S均分', vm.avgS, tr.avgS);
|
||
okStr(tag + '.AW 综合文案', vm.summary, tr.summary.replace(RECOMMENDATION_EXPECT[tag].oldSummary,
|
||
RECOMMENDATION_EXPECT[tag].newSummary), tag + '.AW');
|
||
okStr(tag + '.AX 生活方式', vm.lifestyle, tr.lifestyle);
|
||
|
||
// V2.3 §8 护理建议五段(真值:§8.8 样例;daily_skin_type 按样例取,与 summary 口径的肤质参数不同)
|
||
var vm23 = SkinScore.buildViewModel(fixture.result, fixture.side, { realAge: tr.profile.realAge, skinType: V23_SKIN[tag] });
|
||
okStr(tag + '.BA 护理建议·五段成品', JSON.stringify(vm23.careAdvice), JSON.stringify(V23_EXPECT[tag]));
|
||
}
|
||
|
||
// 登记项清理提示:已知差异键本轮全部命中为差异;若某键实际通过则提示移除登记
|
||
var knownHit = {}, i;
|
||
for (i = 0; i < knownLines.length; i++) knownHit[knownLines[i]] = 1;
|
||
for (var key in KNOWN_DIFFS) {
|
||
if (!knownHit[key]) console.log(' ◆ 提示:登记项 ' + key + ' 本轮通过(引擎或真值已变化),可从 KNOWN_DIFFS 移除');
|
||
}
|
||
|
||
console.log('----------------------------------------');
|
||
console.log('断言 ' + (pass + known + fail) + ' 条:通过 ' + pass + ',已知差异 ' + known + ',失败 ' + fail);
|
||
if (knownLines.length) console.log('已知差异明细:' + knownLines.join('、') + '(原因见上,待 skin-score.js 审计代理处理)');
|
||
if (failLines.length) {
|
||
console.error('失败明细:');
|
||
for (i = 0; i < failLines.length; i++) console.error(' ✗ ' + failLines[i]);
|
||
}
|
||
if (fail > 0) process.exit(1);
|
||
console.log(known > 0 ? '结论:全绿(仅剩已登记、已说明原因的已知差异)' : '结论:全绿');
|
||
}
|
||
|
||
main();
|