feat(scoring): use independent tone hydration minima and focus-first care

This commit is contained in:
xsl
2026-10-15 14:40:59 +08:00
parent 77493bdff5
commit 60f0965c5a
26 changed files with 2315 additions and 1014 deletions
+32
View File
@@ -0,0 +1,32 @@
'use strict';
const assert=require('node:assert/strict'),SS=require('../js/skin-score'),UI=require('../js/report-ui'),Copy=require('../js/report-copy');
const keys=['contour','wrinkles','color','smooth','water','sens','acne'];
function sample(scores,focus='firm'){
const vm=SS.buildViewModel(structuredClone(SS.MOCK_RESULT),SS.MOCK_SIDE,{});
for(const k of ['contour','wrinkles','smooth','sens','acne']){vm.cats[k].P=scores[k];vm.cats[k].level=scores[k]==null?null:scores[k]<80?'预警':scores[k]<90?'改善':'良好';}
Object.assign(vm.cats.tone.vars,{melanin:scores.color,color_score:scores.color,water_score:scores.water,water_sev:0});
vm.cats.tone.vars.tone_scoring.color_score=scores.color;
vm.cats.acne.vars.acne_counts={closed_comedones:0,acne:0,acne_pustule:0,acne_nodule:0,acne_mark:0};
vm.cats.acne.vars.acne_cnt=0;
for(const dim of ['firm','vita','stab'])vm.dims[dim].P=focus==null?null:dim===focus?10:99;
const before=JSON.stringify(vm),ui=UI.build(vm,SS.MOCK_RESULT);assert.equal(JSON.stringify(vm),before);
return{vm,ui};
}
function check(values,focus,expected){const scores=Object.fromEntries(keys.map((k,i)=>[k,values[i]])),x=sample(scores,focus);assert.deepEqual(x.ui.care.map(v=>v.key),expected);assert(x.ui.care.length<=3);for(const c of x.ui.care){assert.equal(c.text,Copy.care[c.key][c.level].care+Copy.care[c.key][c.level].lifestyle);assert.equal(c.priority,c.dim===focus);}return x;}
check([20,10,30,90,95,92,99],'firm',['wrinkles','contour','color']);
check([30,95,10,90,20,99,98],'firm',['contour','color','water']);
check([90,30,10,20,95,96,99],'firm',['wrinkles','color','smooth']);
check([99,98,40,30,20,97,96],'vita',['water','smooth','color']);
check([95,96,10,20,30,90,99],'firm',['color','smooth','water']);
check([80,80,80,80,80,80,80],'stab',['sens','acne','contour']);
check([80,80,80,80,80,80,80],null,['contour','wrinkles','color']);
check([79.999,79.998,79.997,99,99,99,99],'firm',['wrinkles','contour','color']);
check([null,null,50,null,60,null,null],'firm',['color','water']);
check([null,null,null,null,null,null,null],null,[]);
const x=check([99,98,79.99,99,90,99,99],'vita',['color','water','wrinkles']);
assert.equal(x.ui.views.find(v=>v.key==='color').level,'改善');assert.equal(x.ui.views.find(v=>v.key==='water').level,'优秀');
assert.equal(x.ui.careRule,'seven-lowest-three-focus-first-v1');
const heavy=sample(Object.fromEntries(keys.map(k=>[k,95])),'stab');heavy.vm.cats.acne.vars.acne_counts.acne_nodule=1;heavy.vm.cats.acne.vars.acne_cnt=1;
assert.equal(UI.build(heavy.vm,{}).views.find(v=>v.key==='acne').level,'改善');
assert(!Copy.care.wrinkles['改善'].lifestyle.startsWith('保证睡眠'));
console.log('PASS care: global lowest3, focus-first selected only, two/one/no focus members, tie/document order, raw precision, missing, independent color/water, acne guard, exact copy and immutable scores');
+1
View File
@@ -19,6 +19,7 @@ for(const [P,level] of [[79.99,'改善'],[80,'良好'],[89.99,'良好'],[90,'优
else result.dark_circle_severity={value:severity};
const vm=SS.buildViewModel(result,SS.MOCK_SIDE,{});
vm.cats.tone.P=P;vm.cats.tone.level=P<80?'预警':P<90?'改善':'良好';
vm.cats.tone.vars.color_score=P; // Test independent color thresholds.
const before=JSON.stringify({vm,result});
const presentation=UI.build(vm,result),view=presentation.views.find(v=>v.key==='color');
const valid=Number.isInteger(severity)&&severity>=0&&severity<=3;
+2
View File
@@ -114,6 +114,8 @@ function main() {
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);
+3
View File
@@ -44,6 +44,9 @@ try{
assert(Math.abs(geo.heading.w-595/1080*width)<.1,'Figma text group width scales with preview');
const stabilityFont=await p.locator('.dimension-stability-title').evaluate(e=>parseFloat(getComputedStyle(e).fontSize));
assert(Math.abs(geo.headingFont-stabilityFont)<.01,'bottom title matches stability heading font size at every width');
const productFonts=await p.evaluate(()=>['#product-title','#product-name'].map(s=>parseFloat(getComputedStyle(document.querySelector(s)).fontSize)));
assert(Math.abs(productFonts[0]-14.467593)<.01,'restore original product title size, including small screens');
assert(Math.abs(productFonts[1]-12.013166)<.01,'gray subtitle enlarged proportionally');
assert(Math.abs((geo.heading.x+geo.heading.w/2)-(geo.care.x+geo.care.w/2)-width/1080)<.1,'whole title group centered, retaining 1px design offset');
assert(geo.product.bottom<=geo.care.bottom);assert(geo.disclaimer.y>=geo.care.bottom);
assert(geo.title.right<=geo.info.right+1&&geo.name.right<=geo.info.right+1,'text must not overlap image');
+3
View File
@@ -70,6 +70,9 @@ try {
assert.equal(adminRecord.total_score_rule, 'dimensions-433-v1');
assert.deepEqual(adminRecord.acne_scoring,result.vm.cats.acne.vars.acne_scoring);
assert.deepEqual(adminRecord.tone_scoring,result.vm.cats.tone.vars.tone_scoring);
assert.equal(adminRecord.vita_score_rule,'independent-color-water-smooth-min-v1');
assert.equal(Object.keys(adminRecord.sub_scores).length,7);
assert.equal(adminRecord.dims.vita.P,Math.min(...['color','water','smooth'].map(k=>adminRecord.sub_scores[k].P).filter(v=>v!=null)));
if(process.env.H5_ACNE_MAX_TEST==='1') {
assert.deepEqual(result.vm.cats.acne.vars.acne_counts,{closed_comedones:5,acne:1,acne_pustule:0,acne_nodule:0,acne_mark:4});
assert.equal(adminRecord.acne_scoring.deduction,13);assert.equal(adminRecord.cats.acne.P,87);
+1 -1
View File
@@ -10,7 +10,7 @@ function run(dc,options={}){
return{vm,r,v:vm.cats.tone.vars};
}
const a=run(50);near(a.v.color_score,75*.8+50*.2);near(a.vm.cats.tone.P,75);
assert.equal(a.vm.cats.tone.S,9.25);assert.equal(a.vm.dims.vita.S,9.25);
assert.equal(a.vm.cats.tone.S,9.25);assert.equal(a.vm.dims.vita.S,9.1);
assert(!UI.build(a.vm,a.r).views.find(v=>v.key==='color').text.includes('占肤色项20%'));
for(const dc of [0,100]){const x=run(dc);near(x.v.color_score,75*.8+dc*.2);near(x.vm.cats.tone.P,(x.v.color_score+80)/2);}
for(const dc of [null,undefined,-1,101,NaN,Infinity]){const x=run(dc);assert.equal(x.v.dark_circle_score,null);near(x.v.color_score,75);near(x.vm.cats.tone.P,77.5);}
+8
View File
@@ -0,0 +1,8 @@
const assert=require('node:assert/strict'),S=require('../js/skin-score');
function run(melanin,water,dark){const r=structuredClone(S.MOCK_RESULT);Object.assign(r.score_info,{melanin_score:melanin,water_score:water,dark_circle_score:dark});r.melasma={value:0};r.freckle={value:0};r.water={water_severity:3};return S.buildViewModel(r,S.MOCK_SIDE,{});}
for(const [m,w,d]of[[66,97,30],[99,20,99],[99,99,99],[null,97,null],[null,null,null]]){
const v=run(m,w,d),vars=v.cats.tone.vars,values=[v.cats.smooth.P,vars.color_score,vars.tone_scoring&&vars.tone_scoring.water_adjusted_score].filter(x=>typeof x==='number'&&Number.isFinite(x));
assert.equal(v.dims.vita.P,Math.min(...values));assert.equal(v.vitaScoreRule,'independent-color-water-smooth-min-v1');assert.equal(v.total,S.weightedTotal(v.dims));
}
assert(Math.abs(run(66,97,30).dims.vita.P-58.8)<1e-9);
console.log('PASS independent vita min: color/water/smooth bottleneck, missing inputs, version and weighted total');
+22
View File
@@ -0,0 +1,22 @@
'use strict';
const assert=require('node:assert/strict'),SS=require('../js/skin-score'),UI=require('../js/report-ui'),Copy=require('../js/report-copy');
function run(score,severity=3,pigment=66,dc=30){
const r=structuredClone(SS.MOCK_RESULT);Object.assign(r.score_info,{water_score:score,melanin_score:pigment,dark_circle_score:dc});
r.melasma={value:0};r.freckle={value:0};r.water={water_severity:severity,water_area:.03};
const vm=SS.buildViewModel(r,SS.MOCK_SIDE,{}),before=JSON.stringify({r,vm});
const presentation=UI.build(vm,r);assert.equal(JSON.stringify({r,vm}),before);
return{r,vm,presentation,water:presentation.views.find(v=>v.key==='water')};
}
const example=run(97);assert.equal(example.vm.cats.tone.P,77.9);assert.equal(example.water.P,97);assert.equal(example.water.level,'优秀');
assert(!/含水量偏低|缺水比较严重|水润度不足/.test(example.water.text));assert(example.water.text.includes('97'));
assert.deepEqual(example.water.advice,Copy.care.water['优秀']);
assert(!example.presentation.care.some(v=>v.name==='水分'),'healthy water97 should not displace a weaker item in global bottom three');
for(const[p,level]of[[0,'改善'],[79.99,'改善'],[80,'良好'],[89.99,'良好'],[90,'优秀'],[100,'优秀']]){
for(const pigment of[0,50,100]){const x=run(p,3,pigment,0);assert.equal(x.water.level,level);assert.equal(x.water.P,p);assert.deepEqual(x.water.advice,Copy.care.water[level]);}
}
const crossed=run(97,70);assert.equal(crossed.water.P,30);assert.equal(crossed.water.level,'改善');assert(crossed.water.text.includes('分数与缺水指标不一致'));assert.equal(crossed.vm.cats.tone.vars.tone_scoring.water_adjusted_score,30);
assert(run(50,70).water.text.includes('缺水程度较高'));
assert(!run(50,3).water.text.includes('程度较高'));
assert(!run(50,null).water.text.includes('严重度'));
for(const score of[null,undefined,-1,101,NaN,Infinity]){const x=run(score);assert.equal(x.water.valid,false);assert.equal(x.water.advice,null);assert(!x.presentation.care.some(v=>v.name==='水分'));}
console.log('PASS water: 97 vs combined77.9, independent water copy/advice, 80/90 boundaries, true severity, existing cross-check, missing/invalid, scoring immutable');