feat(h5): update skin scoring, age display and capture countdown

This commit is contained in:
xsl
2026-10-15 00:20:48 +08:00
parent d8c67762da
commit 60ae6c97ef
37 changed files with 5110 additions and 1632 deletions
+37
View File
@@ -0,0 +1,37 @@
'use strict';
const assert = require('node:assert/strict');
const SS = require('../js/skin-score'), UI = require('../js/report-ui');
const keys = ['closed_comedones','acne','acne_pustule','acne_nodule','acne_mark'];
const node = counts => Object.fromEntries(keys.map((key,i)=>[key,counts[i]===null?{}:{count:counts[i]}]));
function run(front,left,right,score=100){
const r=structuredClone(SS.MOCK_RESULT);Object.assign(r,node(front));r.score_info.acne_score=score;
const sides={left:{left_acne_info:{...node(left),score:0}},right:{right_acne_info:{...node(right),score:0}}};
const before=JSON.stringify({r,sides});const vm=SS.buildViewModel(r,sides,{});
assert.equal(JSON.stringify({r,sides}),before,'raw inputs unchanged');
return {vm,cat:vm.cats.acne,view:UI.build(vm,r).views.find(v=>v.key==='acne')};
}
const result=run([2,0,0,0,3],[5,1,0,0,1],[3,1,0,0,4]);
assert.deepEqual(Object.values(result.cat.vars.acne_counts),[5,1,0,0,4]);
assert.equal(result.cat.vars.acne_scoring.deduction,13);assert.equal(result.cat.P,87);
assert.equal(result.cat.vars.acne_cnt,10);assert.equal(result.cat.vars.acne_detail,'闭口粉刺5处、丘疹1处、痘印4处');
assert.deepEqual(result.cat.vars.acne_scoring.types[1].sources,['left','right']);
assert.equal(result.vm.dims.stab.S,9.61);assert(result.view.text.includes('丘疹1处'));
assert.equal(run([2,0,0,0,3],[5,1,0,0,1],[3,1,0,0,4],80).cat.P,70,'front score mapping still limits score');
const heavy=run([0,0,0,0,0],[0,0,1,0,0],[0,0,0,1,0]);
assert.equal(heavy.cat.P,90);assert.equal(heavy.view.level,'改善');
assert(heavy.view.text.includes('脓包1处')&&heavy.view.text.includes('结节1处'));
assert(heavy.view.text.includes('含脓包、结节'));
const empty=run([null,null,null,null,null],[null,null,null,null,null],[null,null,null,null,null]);
assert.equal(empty.cat.P,100);assert.equal(empty.cat.vars.acne_cnt,null);assert(empty.view.text.includes('未获取完整'));
assert.equal(empty.cat.vars.acne_scoring.deduction,null);
const partial=run([null,0,null,null,null],[null,null,null,null,null],[null,null,null,null,null]);
assert.equal(partial.cat.vars.acne_counts_complete,false);assert(partial.view.text.includes('部分类型数据缺失'));
assert.equal(run([null,null,null,null,null],[null,null,null,null,null],[null,null,null,null,null],null).cat.P,null);
const capped=run([100,100,100,100,100],[0,0,0,0,0],[0,0,0,0,0]);
assert.equal(capped.cat.P,50);assert.equal(capped.cat.vars.acne_scoring.raw_deduction,1450);assert.equal(capped.cat.vars.acne_scoring.deduction,50);
const r=structuredClone(SS.MOCK_RESULT);r.acne={rectangle:[{},{}]};
let vm=SS.buildViewModel(r,null,{});assert.equal(vm.cats.acne.vars.acne_counts.acne,2,'rectangle fallback');
for(const n of [-1,1.5,NaN,Infinity]){r.acne={count:n};vm=SS.buildViewModel(r,null,{});assert.equal(vm.cats.acne.vars.acne_counts.acne,null);}
const zero=run([0,0,0,0,0],[null,null,null,null,null],[null,null,null,null,null]);
assert.equal(zero.cat.P,100);assert.equal(zero.cat.vars.acne_counts_complete,true);assert(zero.view.text.includes('未检出明显皮损'));
console.log('PASS acne maxima per type, no sum/view selection, front-score mapping, heavy side lesions/copy, missing vs zero, cap50, source audit, invalid counts and unchanged inputs');
+64
View File
@@ -0,0 +1,64 @@
// Seeded temporary records only; no real account/report reads, writes or paid calls.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn, execSync } from 'node:child_process';
import { once } from 'node:events';
import { fileURLToPath, pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding:'utf8' }).trim() + '/playwright/index.mjs').href);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-beijing-test-'));
const times = ['2026-10-13T15:59:59.999Z', '2026-10-13T16:00:00.000Z', '2026-10-14T06:34:42.839Z', '2026-10-14T15:59:59.999Z', '2026-10-14T16:00:00.000Z', '2026-10-12T16:00:00.000Z'];
const records = times.map((created_at, i) => ({ record_id:'R20261014-TIME' + i, created_at, updated_at:created_at, detect_status:'成功',
dims:{ firm:{ S:8 }, vita:{ S:9 }, stab:{ S:8.5 } }, cats:{}, total_score:85, photos:{}, notes:[] }));
const file = path.join(root, 'records.jsonl');
const stored = records.map(r => JSON.stringify(r)).join('\n') + '\n'; fs.writeFileSync(file, stored);
const child = spawn(process.execPath, ['-e', "Date.now=()=>Date.parse('2026-10-13T16:30:00.000Z');require('./server.js')"], {
cwd:fileURLToPath(new URL('../', import.meta.url)),
env:{ ...process.env, TZ:'America/New_York', PORT:'18117', HOST:'127.0.0.1', JUZI_DATA_ROOT:root,
SKIN_API_KEY:'fixture', SKIN_API_SECRET:'fixture', PUBLIC_BASE_URL:'', WECHAT_APP_ID:'', WECHAT_APP_SECRET:'' },
stdio:['ignore','pipe','pipe']
});
let browser;
try {
await new Promise((resolve,reject) => {
const timer=setTimeout(()=>reject(Error('startup timeout')),10000);
child.stdout.on('data', b=>{ if(b.toString().includes('已启动')){clearTimeout(timer);resolve();} });
child.once('exit',code=>{clearTimeout(timer);reject(Error('server exited '+code));});
});
const base='http://127.0.0.1:18117/';
const read=async route=>{const r=await fetch(base+route);assert.equal(r.status,200);return r.json();};
const filtered=await read('api/admin/records?from=2026-10-14&to=2026-10-14');
assert.deepEqual(filtered.items.map(r=>r.record_id), [3,2,1].map(i=>records[i].record_id));
const early=await read('api/admin/records?to=2026-10-13');assert.equal(early.total,2);
const late=await read('api/admin/records?from=2026-10-15');assert.equal(late.total,1);
const exact=new URLSearchParams({from:times[2],to:'2026-10-14T14:34:42.839+08:00'});
assert.equal((await read('api/admin/records?'+exact)).total,1);
for(const route of ['api/admin/records?from=2026-02-30','api/admin/export?to=bad']) assert.equal((await fetch(base+route)).status,400);
const stats=await read('api/admin/stats');assert.equal(stats.today_total,3,'today is October14 in Beijing, not UTC October13');
assert.equal(stats.daily.at(-1).date,'2026-10-14');assert.equal(stats.daily.at(-1).total,3);assert.equal(stats.daily.at(-2).total,2);
const csv=await(await fetch(base+'api/admin/export?from=2026-10-14&to=2026-10-14')).text();
assert(csv.includes('创建时间(北京时间)'));assert(csv.includes('2026-10-14 00:00:00'));assert(csv.includes('2026-10-14 14:34:42'));assert(csv.includes('2026-10-14 23:59:59'));
assert(!csv.includes(records[0].record_id));assert(!csv.includes(records[4].record_id));
browser=await chromium.launch();const errors=[];
for(const timezoneId of ['America/Los_Angeles','UTC','Asia/Shanghai']){
const context=await browser.newContext({viewport:{width:1600,height:1000},timezoneId});
const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
await page.route('**/*',route=>{assert.equal(route.request().method(),'GET');return route.continue();});
await page.goto(base+'admin.html');await page.waitForSelector('#tbl-records tbody tr');
const row=page.locator('#tbl-records tbody tr').filter({hasText:records[2].record_id});
assert((await row.textContent()).includes('2026-10-14 14:34:42'));
await page.fill('#f-from','2026-10-14');await page.fill('#f-to','2026-10-14');
const response=page.waitForResponse(r=>r.url().includes('/api/admin/records?'));
await page.click('#btn-search');assert.equal((await(await response).json()).total,3);
await row.click();await page.waitForFunction(()=>document.querySelector('#d-body').textContent.includes('创建时间'));
const body=await page.locator('#d-body').textContent();assert(body.includes('创建时间 2026-10-14 14:34:42'));assert(body.includes('更新时间 2026-10-14 14:34:42'));
if(timezoneId==='Asia/Shanghai')await page.screenshot({path:'/tmp/juzi-admin-beijing-20261014.png'});
await context.close();
}
assert.deepEqual(errors,[]);assert.equal(fs.readFileSync(file,'utf8'),stored,'original UTC records remain byte-identical');
console.log('PASS Beijing admin: list/detail across browser timezones; date/ISO filters, CSV, midnight today/daily stats, invalid dates; UTC records unchanged');
}finally{
if(browser)await browser.close();child.kill('SIGTERM');if(child.exitCode===null)await once(child,'exit');
fs.rmSync(root,{recursive:true,force:true});
}
+18
View File
@@ -0,0 +1,18 @@
'use strict';
const assert = require('node:assert/strict');
const Time = require('../js/beijing-time');
assert.equal(Time.format('2026-10-14T06:34:42.839Z'), '2026-10-14 14:34:42');
assert.equal(Time.format('2026-10-14T14:34:42.839+08:00'), '2026-10-14 14:34:42');
assert.equal(Time.format('2026-10-14 14:34:42'), '2026-10-14 14:34:42');
assert.equal(Time.day('2026-10-13T15:59:59.999Z'), '2026-10-13');
assert.equal(Time.day('2026-10-13T16:00:00.000Z'), '2026-10-14');
assert.equal(Time.format(null), '-'); assert.equal(Time.format('bad'), '-');
const range = Time.range('2026-10-14', '2026-10-14');
assert.equal(new Date(range.start).toISOString(), '2026-10-13T16:00:00.000Z');
assert.equal(new Date(range.end).toISOString(), '2026-10-14T16:00:00.000Z');
assert.equal(Time.range('2024-02-29', '2024-02-29').end - Time.range('2024-02-29', '2024-02-29').start, 86400000);
for (const args of [['2026-02-29', null], ['2026-02-30', null], ['bad', null], ['2026-10-15', '2026-10-14']]) assert.throws(() => Time.range(...args));
const instant = Time.range('2026-10-14T06:34:42.839Z', '2026-10-14T14:34:42.839+08:00');
assert.equal(instant.end - instant.start, 1, 'full ISO end remains inclusive to the millisecond');
assert.deepEqual(Time.range(null, null), { start: -Infinity, end: Infinity });
console.log('PASS Beijing time: UTC/+08/zone-less formatting, midnight boundaries, leap day, invalid dates, ISO instants');
+35
View File
@@ -0,0 +1,35 @@
'use strict';
const assert=require('node:assert/strict');
const SS=require('../js/skin-score'),UI=require('../js/report-ui');
const descriptions=['无明显黑眼圈','仅存在轻度黑眼圈','存在中度黑眼圈','可见明显黑眼圈'];
const templates={
改善:'检测到当前肌肤色素分 88,存在色素沉着较显著,{note},整体透亮感不足。',
良好:'检测到当前肌肤色素分 88,局部有色素沉积;{note},整体状态稳定,局部透亮感稍弱。',
优秀:'检测到当前肌肤色素分 88,无明显色素沉积,{note};肤色均匀透亮。'
};
let checked=0;
for(const [P,level] of [[79.99,'改善'],[80,'良好'],[89.99,'良好'],[90,'优秀'],[100,'优秀']]){
for(const severity of [0,1,2,3,null,undefined,-1,4,1.5,'2',NaN,Infinity]){
const result=structuredClone(SS.MOCK_RESULT);
result.score_info.melanin_score=88;
// Opposite score/type must never dictate the severity phrase.
result.score_info.dark_circle_score=severity===3?100:0;
result.dark_circle={value:3};
if(severity===undefined)delete result.dark_circle_severity;
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?'改善':'良好';
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;
const expected=valid?templates[level].replace('{note}',descriptions[severity]):templates[level].replace(/\{note\}[,;]/,'');
assert.equal(view.level,level);assert.equal(view.text,expected);
assert(!view.text.includes('评分'));assert(!view.text.includes('20%'));assert(!/[{}]/.test(view.text));
assert.equal(JSON.stringify({vm,result}),before,'presentation never mutates scores or input');
checked++;
}
}
const missing=structuredClone(SS.MOCK_RESULT);delete missing.score_info.melanin_score;
const v=UI.build(SS.buildViewModel(missing,SS.MOCK_SIDE,{}),missing).views.find(v=>v.key==='color');
assert.equal(v.valid,false);assert(!v.text.includes('色素分'));
console.log('PASS color copy: '+checked+' level/severity combinations, score-independent severity, missing/invalid omission, boundaries and immutable scoring');
+53
View File
@@ -0,0 +1,53 @@
// Controlled audio ended events; real capture function, no camera/API writes.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {execSync} from 'node:child_process';
import {fileURLToPath,pathToFileURL} from 'node:url';
const {chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href);
const browser=await chromium.launch();
try {
const page=await browser.newPage();const errors=[];
page.on('pageerror',e=>errors.push(e.message));
const source=fs.readFileSync(fileURLToPath(new URL('../js/app.js',import.meta.url)),'utf8');
await page.route('**/api/**',r=>{assert.equal(r.request().method(),'GET');return r.fulfill({json:{ok:true,user:{kind:'browser'}}});});
await page.route('**/assets/voice/*.mp3',r=>r.fulfill({body:r.request().url().split('/').pop()}));
await page.route('**/js/app.js*',r=>r.fulfill({contentType:'text/javascript',body:source.replace(/\}\)\(\);\s*$/,`
window.__countdown = {
start: function(step) { camStep=step; currentScreen='s-cam'; camLive=true; shutterLock=false;
captureFromVideo=function(){window.__shots.push(step);return 'fixture';};
checkStill=function(){return Promise.resolve(null);};
onPhotoCaptured=function(){window.__completed.push(step);}; doCapture(); },
again:doCapture, cancel:function(){camSeq++; voice.stop(true);},
busy:function(){return shutterLock;}
};
})();`)}));
await page.addInitScript(()=>{
window.__audio=[];window.__shots=[];window.__completed=[];
window.AudioContext=class {
constructor(){this.state='running';this.destination={};}
decodeAudioData(ab,resolve){const b={id:new TextDecoder().decode(ab),duration:3};resolve(b);return Promise.resolve(b);}
createBufferSource(){return {connect(){},stop(){},start(){window.__audio.push(this);}};}
};
});
await page.goto(process.env.H5_BASE_URL||'http://127.0.0.1:8080/');
await page.locator('body').click({position:{x:2,y:2}});
await page.waitForTimeout(300);
for(let step=0;step<3;step++){
await page.evaluate(n=>window.__countdown.start(n),step);
await page.waitForFunction(n=>window.__audio.length===n,step+1);
assert.equal(await page.evaluate(()=>window.__audio.at(-1).buffer.id),'countdown-321.mp3');
await page.evaluate(()=>window.__countdown.again());
await page.waitForTimeout(350);
assert.equal(await page.evaluate(()=>window.__shots.length),step,'no capture before audio ended');
assert(await page.evaluate(()=>window.__countdown.busy()));
await page.evaluate(()=>window.__audio.at(-1).onended());
await page.waitForFunction(n=>window.__completed.length===n,step+1);
}
await page.evaluate(()=>window.__countdown.start(0));
await page.waitForFunction(()=>window.__audio.length===4);
await page.evaluate(()=>window.__countdown.cancel());
await page.waitForTimeout(700);
assert.equal(await page.evaluate(()=>window.__shots.length),3,'cancelled countdown never captures');
assert.deepEqual(errors,[]);
console.log('PASS: each of 3 shots waits for countdown ended; duplicate shutter ignored; cancellation safe');
}finally{await browser.close();}
+44
View File
@@ -0,0 +1,44 @@
// Explicit private case; render only, no backend mutations or paid API calls.
import assert from 'node:assert/strict';
import fs from 'node:fs';import path from 'node:path';
import {execSync} from 'node:child_process';
import {fileURLToPath,pathToFileURL} from 'node:url';
import {createRequire} from 'node:module';
const require=createRequire(import.meta.url),SS=require('../js/skin-score');
const dir=process.env.H5_PIGMENTATION_CASE_DIR;assert(dir,'set private case directory');
const raw=JSON.parse(fs.readFileSync(path.join(dir,'raw.json')));
const photo='data:image/jpeg;base64,'+fs.readFileSync(path.join(dir,'front.jpg')).toString('base64');
const vm=SS.buildViewModel(raw.result,{left:raw.left_side_result,right:raw.right_side_result},{realAge:24,skinType:'混合偏油肌肤'});
assert.equal(raw.result.brown_spot.count,81);assert.equal(raw.result.freckle.value,0);
const audit=vm.cats.tone.vars.tone_scoring;
assert(Math.abs(audit.color_score-(audit.pigment_adjusted_score*.8+audit.dark_circle_score*.2))<1e-9);
const {chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href);
const browser=await chromium.launch();
try {
const page=await browser.newPage({viewport:{width:375,height:812},isMobile:true,hasTouch:true});const errors=[];
page.on('pageerror',e=>errors.push(e.message));
await page.route('**/api/**',r=>{assert.equal(r.request().method(),'GET');return r.fulfill({json:{ok:true,user:{kind:'browser'}}});});
const source=fs.readFileSync(fileURLToPath(new URL('../js/app.js',import.meta.url)),'utf8');
await page.route('**/js/app.js*',r=>r.fulfill({contentType:'text/javascript',body:source.replace(/\}\)\(\);\s*$/,`
window.__renderCase=function(x){renderReport(x.vm,x.photo,null,x.raw);showScreen('s-report');};
})();`)}));
await page.goto(process.env.H5_BASE_URL||'http://127.0.0.1:8080/');
await page.evaluate(x=>window.__renderCase(x),{vm,photo,raw});
await page.locator('#viz-tone').scrollIntoViewIfNeeded();
await page.waitForFunction(()=>{const i=document.querySelector('#viz-tone');return i.naturalWidth&&!i.hidden&&i.dataset.overlayAlpha==='0.3';});
assert.equal(await page.locator('#tone-detections').count(),0,'detection status overlay removed');
assert.equal(await page.locator('#tone-map-note').count(),0,'technical caption removed');
assert(vm.cats.tone.vars.spot_flag.includes('褐色斑81处'));
const dcDescriptions=['无明显黑眼圈','仅存在轻度黑眼圈','存在中度黑眼圈','可见明显黑眼圈'];
assert((await page.locator('#tx-tone').textContent()).includes(dcDescriptions[raw.result.dark_circle_severity.value]));
assert(!(await page.locator('#tx-tone').textContent()).includes('占肤色项20%'));
await page.locator('#viz-tone').locator('xpath=ancestor::div[contains(@class,"card-detail")][1]').screenshot({path:path.join(dir,'pigmentation-81-color.png')});
// Display-only example, explicitly synthetic; real downloaded case is unchanged.
const example=structuredClone(vm);example.skinAgeRaw=40;example.realAge=25;example.skinAgeDisplay=40;
await page.evaluate(x=>window.__renderCase(x),{vm:example,photo,raw});
assert.equal(await page.locator('#stat-age b').textContent(),'30');
assert.equal(await page.locator('#stat-age-note').textContent(),'比实际年龄大5岁');
assert.equal(example.skinAgeRaw,40);
assert.deepEqual(errors,[]);
console.log(JSON.stringify({case:'R20261014-155810-LFB8',brownSpots:81,freckle:0,audit,toneP:vm.cats.tone.P,toneS:vm.cats.tone.S,vita:vm.dims.vita.S,total:vm.total,tests:'PASS; render only; original case unchanged'}));
}finally{await browser.close();}
+12 -4
View File
@@ -7,16 +7,24 @@ for (const raw of [20, 24, 28, 40, 50, 24.6, null]) {
if (raw == null) delete result.skin_age; else result.skin_age = { value: raw };
const before = JSON.stringify(result);
const vm = SS.buildViewModel(result, SS.MOCK_SIDE, { realAge });
assert.equal(vm.skinAgeRaw, raw); assert.equal(vm.skinAgeDisplay, raw);
assert.equal(vm.ageClampNote, ''); assert.equal(SS.reportSkinAge(vm, result), raw);
const expected = raw == null || realAge == null ? raw : Math.max(realAge - 5, Math.min(realAge + 5, raw));
assert.equal(vm.skinAgeRaw, raw); assert.equal(vm.skinAgeDisplay, expected);
assert.equal(Boolean(vm.ageClampNote), expected != null && expected !== raw);
assert.equal(SS.reportSkinAge(vm, result), expected);
assert.equal(JSON.stringify(result), before);
}
}
const legacy = { skinAgeRaw: 24, skinAgeDisplay: 57, realAge: 60 };
assert.equal(SS.reportSkinAge(legacy), 24);
assert.equal(SS.reportSkinAge(legacy), 55);
assert.equal(legacy.skinAgeDisplay, 57, 'historical VM is not mutated');
assert.equal(SS.reportSkinAge({ skinAgeDisplay: 57 }, { skin_age: { value: 24 } }), 24);
assert.equal(SS.reportSkinAge({ skinAgeDisplay: 57 }), null, 'clamped display is never a raw-age fallback');
assert.equal(SS.reportSkinAge({ skinAgeRaw: null, skinAgeDisplay: 57 }), null);
assert.equal(SS.reportSkinAge({}, { skin_age: '24.6' }), 24.6);
console.log('PASS: raw age across actual-age/decimal/missing cases; old display ignored; legacy payload fallback; no mutation or invented age');
for (const [raw, expected] of [[24,25],[25,25],[30,30],[35,35],[36,35]]) {
assert.equal(SS.reportSkinAge({skinAgeRaw:raw,realAge:30}),expected);
}
assert.equal(SS.reportSkinAge({skinAgeRaw:40,realAge:25}),30);
assert.equal(SS.reportSkinAge({skinAgeRaw:10,realAge:25}),20);
for(const raw of [NaN,Infinity,-1]) assert.equal(SS.reportSkinAge({skinAgeRaw:raw,realAge:25}),null);
console.log('PASS: age display clamps ±5, boundaries/decimal/missing; original age and historical snapshots unchanged');
+1 -1
View File
@@ -54,7 +54,7 @@ for (const realAge of [24, 40, 60, undefined]) {
assert.equal(vm.recommendation.skinAge, 24);
assert(Math.abs(vm.recommendation.scores.firm - 7.69) < 1e-12);
assert.equal(UI.build(vm, raw).product.tier, 1);
assert.equal(vm.skinAgeDisplay, 24); // Display and recommendation both use raw skin age.
assert.equal(vm.skinAgeDisplay, realAge == null ? 24 : Math.max(realAge - 5, Math.min(realAge + 5, 24))); // Display clamp does not change raw-age recommendation.
count++;
}
delete raw.skin_age;
+13
View File
@@ -102,6 +102,19 @@ function main() {
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');
}
var vm = SkinScore.buildViewModel(fixture.result, fixture.side, tr.profile);
// 下颌线角度 AP/AQ:fixture 保真校验(引擎消费角度后由 L 列逐字断言兜底)
+34 -8
View File
@@ -22,6 +22,13 @@ const child = spawn(process.execPath, ['-e', `
const fs=require('fs'), vm=require('vm'), w={};
vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w});
const f=w.MOCK_FIXTURE;
if(process.env.H5_ACNE_MAX_TEST==='1') {
const keys=['closed_comedones','acne','acne_pustule','acne_nodule','acne_mark'];
const set=(target,counts)=>keys.forEach((k,i)=>target[k]={count:counts[i]});
set(f.result,[2,0,0,0,3]);f.result.score_info.acne_score=100;
f.side.left.left_acne_info={score:0};set(f.side.left.left_acne_info,[5,1,0,0,1]);
f.side.right.right_acne_info={score:0};set(f.side.right.right_acne_info,[3,1,0,0,4]);
}
global.fetch=async()=>({ok:true,status:200,json:async()=>({result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right})});
require('./server.js');
`], { cwd: h5, env: { ...process.env, PORT: '18089', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
@@ -50,7 +57,8 @@ try {
assert.equal(result.vm.total, expectedTotal);
assert.notEqual(expectedTotal, result.result.score_info.total_score, 'fixture distinguishes weighted score from API total');
assert(result.vm.skinAgeRaw < 32);
assert.equal(result.vm.skinAgeDisplay, result.vm.skinAgeRaw);
const expectedAge = Math.max(55, Math.min(65, result.vm.skinAgeRaw));
assert.equal(result.vm.skinAgeDisplay, expectedAge);
assert.equal(result.vm.essence.tier, 1); // Raw age, never actual60.
assert.equal(result.presentation.product.tier, 1);
const adminRecord = (await (await api.get(base + 'api/admin/records/' + result.record_id)).json()).record;
@@ -60,16 +68,23 @@ try {
assert.equal(adminRecord.skin_age_display, result.vm.skinAgeDisplay);
assert.equal(adminRecord.total_score, expectedTotal);
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);
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);
}
for (const key of ['firm', 'vita', 'stab']) assert.equal(adminRecord.dims[key].S, result.vm.dims[key].S);
const list = await (await api.get(base + 'api/admin/records')).json();
assert.equal(list.items[0].skin_age, result.vm.skinAgeRaw);
assert.equal(list.items[0].skin_age_display, result.vm.skinAgeRaw);
assert.equal(list.items[0].skin_age_display, expectedAge);
assert.deepEqual(list.items[0].recommendation, result.vm.recommendation);
assert.equal(list.items[0].total_score, expectedTotal);
const csv = await (await api.get(base + 'api/admin/export')).text();
assert(csv.includes('原始肌龄(推荐依据),前端展示肌龄'));
assert(csv.includes(result.vm.recommendation.version));
assert(csv.includes('综合得分(4:3:3)'));
assert.equal(csv.trim().split('\n')[1].split(',')[14],String(expectedAge));
assert.equal(csv.trim().split('\n')[1].split(',')[12], String(expectedTotal));
const snapshot = await (await api.get(base + 'api/reports/' + result.record_id)).json();
assert.equal(snapshot.report.photos.length, 3);
@@ -85,10 +100,10 @@ try {
await row.waitFor();
assert.equal(await row.locator('td').nth(6).textContent(), String(expectedTotal));
assert((await row.textContent()).includes('原始 ' + result.vm.skinAgeRaw + '岁'));
assert((await row.textContent()).includes('展示 ' + result.vm.skinAgeRaw + '岁'));
assert((await row.textContent()).includes('展示 ' + expectedAge + '岁'));
assert((await row.textContent()).includes("1'me 主场精华 I(25+)"));
for (const key of ['firm', 'vita', 'stab']) {
assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + result.vm.dims[key].S.toFixed(1) + ' ' + ReportUI.levelMap[result.vm.dims[key].level]));
assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + ReportUI.dimensionScore(result.vm.dims[key].S) + ' ' + ReportUI.levelMap[result.vm.dims[key].level]));
}
assert.deepEqual(await desktop.locator('#f-dimlevel option').allTextContents(), ['不限档位', '改善', '良好', '优秀']);
if (process.env.H5_ARTIFACT_DIR) {
@@ -105,9 +120,16 @@ try {
const detailText = await desktop.locator('#d-body').textContent();
assert(detailText.includes('综合得分 ' + expectedTotal));
assert(detailText.includes('紧塑力×4+透亮感×3+稳韧力×3'));
assert(detailText.includes('痘痘三视图计分明细'));
assert(detailText.includes('黑眼圈评分×20%'));
const acneAuditRows=desktop.locator('.d-sec').filter({hasText:'痘痘三视图计分明细'}).locator('tbody tr');
assert.equal(await acneAuditRows.count(),5);
for(const [i,t] of adminRecord.acne_scoring.types.entries()) {
assert.deepEqual(await acneAuditRows.nth(i).locator('td').allTextContents(),[t.name,...['front','left','right'].map(k=>t.views[k]==null?'-':String(t.views[k])),t.count==null?'-':String(t.count),String(t.weight),t.deduction==null?'-':String(t.deduction)]);
}
if (process.env.H5_ARTIFACT_DIR) await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-weighted-detail.png') });
assert(detailText.includes(result.vm.recommendation.reason));
assert(detailText.includes('前端展示肌龄 ' + result.vm.skinAgeRaw + '岁'));
assert(detailText.includes('前端展示肌龄 ' + expectedAge + '岁'));
for (const view of result.presentation.views) assert(detailText.includes(view.text));
for (const care of result.presentation.care) assert(detailText.includes(care.text));
if (process.env.H5_ARTIFACT_DIR) {
@@ -181,13 +203,17 @@ try {
await page.click('[data-action="history"]');
await page.click('[data-report-id]');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#stat-age b').textContent(), String(result.vm.skinAgeRaw), 'old saved display clamp must not override original skin age');
assert.equal(await page.locator('#stat-age b').textContent(), String(expectedAge), 'historical raw age uses current ±5 display rule, not saved display');
assert.equal(await page.locator('#stat-score b').textContent(), String(expectedTotal), 'old H5 report uses saved dimensions, not saved upstream total');
const legacyList = await (await api.get(base + 'api/admin/records')).json();
assert.equal(legacyList.items[0].skin_age_display, expectedAge);
assert.equal(legacyList.items[0].total_score, expectedTotal);
const legacyDetail = await (await api.get(base + 'api/admin/records/' + result.record_id)).json();
assert.equal(legacyDetail.record.skin_age_display, expectedAge);
assert.equal(legacyDetail.record.total_score, expectedTotal);
assert.equal((await (await api.get(base + 'api/admin/export')).text()).trim().split('\n')[1].split(',')[12], String(expectedTotal));
const legacyCsv=(await (await api.get(base + 'api/admin/export')).text()).trim().split('\n')[1].split(',');
assert.equal(legacyCsv[12], String(expectedTotal));
assert.equal(legacyCsv[14], String(expectedAge));
assert.equal(await page.locator('#product-title').textContent(), ReportUI.products[1].title);
assert.equal(await page.locator('#product-image').getAttribute('src'), ReportUI.products[1].image, 'refresh artwork crop, never the historical recommendation/copy');
assert((await page.locator('.ditem-para').allTextContents()).includes(legacy.presentation.views[0].text));
@@ -195,7 +221,7 @@ try {
await desktop.locator('#tbl-records tbody tr').first().click();
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('历史报告原文'));
assert((await desktop.locator('#d-body').textContent()).includes('旧版规则(历史记录)'));
assert((await desktop.locator('#d-body').textContent()).includes('前端展示肌龄 ' + result.vm.skinAgeRaw + '岁'));
assert((await desktop.locator('#d-body').textContent()).includes('前端展示肌龄 ' + expectedAge + '岁'));
assert.equal(fs.readFileSync(snapshotFile, 'utf8'), storedBytes);
assert.equal(fs.readFileSync(recordFile, 'utf8'), recordBytes);
// An incomplete set must show no total everywhere, not fall back to the API score.
+20
View File
@@ -0,0 +1,20 @@
'use strict';
const assert=require('node:assert/strict');const SS=require('../js/skin-score'),UI=require('../js/report-ui');
const near=(a,b)=>assert(Math.abs(a-b)<1e-9, a+' !== '+b);
function run(dc,options={}){
const r=structuredClone(SS.MOCK_RESULT);Object.assign(r.score_info,{melanin_score:90,water_score:80,dark_circle_score:dc});
r.melasma={value:1};r.freckle={value:1};r.water={water_severity:20};
if(options.missingPigment)delete r.score_info.melanin_score;
if(options.missingWater)delete r.score_info.water_score;
const bytes=JSON.stringify(r),vm=SS.buildViewModel(r,SS.MOCK_SIDE,{});assert.equal(JSON.stringify(r),bytes);
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(!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);}
near(run(50,{missingPigment:true}).vm.cats.tone.P,65);
near(run(50,{missingWater:true}).vm.cats.tone.P,70);
assert.equal(a.v.tone_scoring.dark_circle_weight,.2);assert.equal(a.v.tone_scoring.pigment_weight,.8);
console.log('PASS tone: pigment80/dark-circle20, color/water50:50, spot deductions before weighting, zero/missing/invalid scores, audit, copy and immutable source');
+26
View File
@@ -0,0 +1,26 @@
// Solid-color canvas fixtures verify actual blended pixels, not only CSS flags.
import assert from 'node:assert/strict';import {execSync}from'node:child_process';import{pathToFileURL}from'node:url';
const{chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href);
const browser=await chromium.launch();
try{
const page=await browser.newPage({viewport:{width:375,height:726}});const errors=[];let mode='map';
page.on('pageerror',e=>errors.push(e.message));
await page.route('**/*',r=>{assert.equal(r.request().method(),'GET');return r.continue();});
await page.route('**/assets/mock/report-fixture.js',r=>r.fulfill({contentType:'text/javascript',body:`
function solid(color){var c=document.createElement('canvas');c.width=100;c.height=100;var x=c.getContext('2d');x.fillStyle=color;x.fillRect(0,0,100,100);return c.toDataURL('image/png');}
window.MOCK_FIXTURE={result:{score_info:{melanin_score:90,water_score:80,dark_circle_score:50,wrinkle_score:65},melasma:{value:1},freckle:{value:0},face_maps:${mode==='map'?"{brown_area:solid('#000')}" : '{}'}},side:{},photos:[solid('rgb(200,100,50)')]};
`}));
const base=process.env.H5_BASE_URL||'http://127.0.0.1:8080/';
const pixel=async()=>page.locator('#viz-tone').evaluate(async i=>{await i.decode();const c=document.createElement('canvas');c.width=i.naturalWidth;c.height=i.naturalHeight;const x=c.getContext('2d');x.drawImage(i,0,0);return [...x.getImageData(c.width/2,c.height/2,1,1).data].slice(0,3);});
await page.goto(base+'?mock=1&screen=report');
await page.waitForFunction(()=>{const i=document.querySelector('#viz-tone');return !i.hidden&&i.naturalWidth&&i.dataset.overlayAlpha==='0.3'});
const actual=await pixel();[140,70,35].forEach((v,i)=>assert(Math.abs(actual[i]-v)<=4,actual+' != 30% overlay'));
assert.equal(await page.locator('#viz-tone').evaluate(i=>getComputedStyle(i).opacity),'1','face image not globally faded');
assert.equal(await page.locator('#tone-detections').count(),0,'no detection status overlay');
assert.equal(await page.locator('#tone-map-note').count(),0,'no technical caption below color copy');
assert(!(await page.locator('#tx-tone').textContent()).includes('占肤色项20%'));
mode='missing';await page.reload();await page.waitForFunction(()=>{const i=document.querySelector('#viz-tone');return !i.hidden&&i.naturalWidth});
const plain=await pixel();[200,100,50].forEach((v,i)=>assert(Math.abs(plain[i]-v)<=4));
assert.equal(await page.locator('#tone-map-note').count(),0,'missing map does not add technical caption');
assert.deepEqual(errors,[]);console.log('PASS tone overlay: actual 30% pixel blending, original photo opaque, true flags/no invented boxes, missing map photo fallback, new dark-circle copy, no writes');
}finally{await browser.close()}
+53
View File
@@ -0,0 +1,53 @@
// Explicitly supplied private case directory; never check real photos/raw into Git.
import assert from 'node:assert/strict';
import fs from 'node:fs';import os from 'node:os';import path from 'node:path';
import {spawn,execSync} from 'node:child_process';import{once}from'node:events';
import{fileURLToPath,pathToFileURL}from'node:url';import{createRequire}from'node:module';
const require=createRequire(import.meta.url),SS=require('../js/skin-score'),UI=require('../js/report-ui');
const input=process.env.H5_WRINKLE_CASE_DIR;
assert(input,'set H5_WRINKLE_CASE_DIR to a private, explicitly downloaded case directory');
const raw=JSON.parse(fs.readFileSync(path.join(input,'raw.json')));
const profile={real_age:26,daily_skin_type:'干性肌肤'};
const projected=SS.buildViewModel(raw.result,{left:raw.left_side_result,right:raw.right_side_result},{realAge:26,skinType:profile.daily_skin_type});
assert.equal(raw.result.score_info.wrinkle_score,65);
assert.equal(projected.cats.wrinkles.P,65);assert.equal(projected.cats.wrinkles.S,8.95);
assert.equal(projected.dims.firm.S,8.95);assert.equal(projected.total,94);
assert.equal(projected.dims.vita.S,9.63);assert.equal(projected.dims.stab.S,9.91);
assert.equal(projected.cats.acne.vars.acne_counts.acne,1);
assert.equal(projected.cats.acne.vars.acne_counts.closed_comedones,1);
assert.equal(projected.cats.acne.vars.acne_scoring.deduction,3);
const root=fs.mkdtempSync(path.join(os.tmpdir(),'juzi-wrinkle-replay-'));
const user=require('./wechat-fixture.cjs')(path.join(root,'accounts'))[0];
const{chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href);
const child=spawn(process.execPath,['-e',"const fs=require('fs');const raw=JSON.parse(fs.readFileSync(process.env.H5_WRINKLE_CASE_DIR+'/raw.json'));global.fetch=async()=>({ok:true,status:200,json:async()=>raw});require('./server.js')"],{
cwd:fileURLToPath(new URL('../',import.meta.url)),env:{...process.env,PORT:'18118',HOST:'127.0.0.1',JUZI_DATA_ROOT:root,SKIN_API_KEY:'fixture',SKIN_API_SECRET:'fixture',PUBLIC_BASE_URL:'',WECHAT_APP_ID:'',WECHAT_APP_SECRET:''},stdio:['ignore','pipe','pipe']});
let browser;
try{
await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(Error('startup timeout')),10000);child.stdout.on('data',b=>{if(b.toString().includes('已启动')){clearTimeout(timer);resolve()}});child.once('exit',c=>{clearTimeout(timer);reject(Error('exited '+c))})});
const base='http://127.0.0.1:18118/';browser=await chromium.launch();const context=await browser.newContext({viewport:{width:375,height:726}});
await context.addCookies([{name:'juzi_session',value:user.token,url:base}]);
const images=Object.fromEntries(['front','left','right'].map(k=>[k+'_image','data:image/jpeg;base64,'+fs.readFileSync(path.join(input,k+'.jpg')).toString('base64')]));
const response=await context.request.post(base+'api/skin-analyze',{data:{...images,profile}});assert.equal(response.status(),200);const result=await response.json();
assert.equal(result.ok,true);assert.equal(result.vm.total,94);assert.equal(result.vm.dims.firm.S,8.95);
const admin=await(await context.request.get(base+'api/admin/records/'+result.record_id)).json();
assert.equal(admin.record.total_score,94);assert.equal(admin.record.cats.wrinkles.P,65);assert.equal(admin.record.dims.firm.S,8.95);assert.equal(admin.record.wrinkle_score_rule,'api-overall-v1');
assert.equal(admin.record.acne_scoring.types.find(t=>t.key==='acne').count,1);
const errors=[];const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
await page.goto(base);await page.click('[data-action="history"]');await page.click('[data-report-id]');await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#stat-score b').textContent(),'94');assert.equal(await page.locator('#dim-firm').textContent(),'9.0');
await page.waitForTimeout(500);await page.screenshot({path:path.join(input,'report-overall-wrinkles.png')});
await page.locator('#viz-tone').scrollIntoViewIfNeeded();
await page.waitForFunction(()=>{const i=document.querySelector('#viz-tone');return !i.hidden&&i.naturalWidth&&i.dataset.overlayAlpha==='0.3'});
assert(!(await page.locator('#tx-tone').textContent()).includes('占肤色项20%'));
assert.equal(await page.locator('#tone-detections').count(),0,'detection status overlay removed');
await page.locator('#viz-tone').locator('xpath=ancestor::div[contains(@class,"card-detail")][1]').screenshot({path:path.join(input,'tone-color-30.png')});
const desktop=await context.newPage({viewport:{width:1600,height:1000}});await desktop.setViewportSize({width:1600,height:1000});desktop.on('pageerror',e=>errors.push(e.message));
await desktop.goto(base+'admin.html');const row=desktop.locator('#tbl-records tbody tr').first();assert.equal(await row.locator('td').nth(6).textContent(),'94');
await row.click();await desktop.waitForFunction(()=>document.querySelector('#d-body').textContent.includes('综合得分'));
assert((await desktop.locator('#d-body').textContent()).includes('痘痘三视图计分明细'));
await desktop.screenshot({path:path.join(input,'admin-overall-wrinkles.png')});assert.deepEqual(errors,[]);
const auditSection=desktop.locator('.d-sec').filter({hasText:'痘痘三视图计分明细'});
await auditSection.scrollIntoViewIfNeeded();
await auditSection.screenshot({path:path.join(input,'admin-acne-three-view.png')});
console.log(JSON.stringify({case:'R20261014-171215-CYLF',interfaceWrinkles:65,wrinkles:8.95,firm:8.95,display:UI.dimensionScore(8.95),vita:9.63,stab:9.91,papules:1,closedComedones:1,countDeduction:3,total:94,tests:'PASS local replay only; no paid call/production writes'}));
}finally{if(browser)await browser.close();child.kill('SIGTERM');if(child.exitCode===null)await once(child,'exit');fs.rmSync(root,{recursive:true,force:true})}
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const assert = require('node:assert/strict');
const SS = require('../js/skin-score'), UI = require('../js/report-ui');
function sample(score) {
const r = structuredClone(SS.MOCK_RESULT);
r.score_info.wrinkle_score = score;
r.left_mouth_wrinkle_info = { wrinkle_score:100, wrinkle_severity_level:3 };
return r;
}
for (const p of [0, 23, 65, 80, 95, 100]) {
const r=sample(p), bytes=JSON.stringify(r), vm=SS.buildViewModel(r,SS.MOCK_SIDE,{});
assert.equal(vm.cats.wrinkles.P,p,'overall score used directly');
assert.equal(vm.cats.wrinkles.S,Math.round((7+3*p/100+1e-9)*100)/100);
assert.equal(vm.dims.firm.P,Math.min(vm.cats.contour.P,p));
assert.equal(vm.wrinkleScoreRule,'api-overall-v1');
assert.equal(vm.cats.wrinkles.vars.overall_wrinkle_score,p);
assert.equal(JSON.stringify(r),bytes,'raw data not mutated');
}
for (const score of [null, undefined, NaN, Infinity, -1, 101]) {
const r=sample(score);const vm=SS.buildViewModel(r,SS.MOCK_SIDE,{});
assert.equal(vm.cats.wrinkles.P,null,'no silent worst-region fallback');
}
const onlyOverall={ score_info:{ wrinkle_score:65 } };
assert.equal(SS.buildViewModel(onlyOverall,null,{}).cats.wrinkles.S,8.95,'regions not required');
assert.equal(SS.buildViewModel({wrinkle_score:{value:65}},null,{}).cats.wrinkles.P,65,'legacy scalar field alias');
const base=SS.buildViewModel(sample(65),SS.MOCK_SIDE,{});
const changed=sample(65);changed.left_mouth_wrinkle_info={wrinkle_score:0,wrinkle_severity_level:0};
assert.equal(SS.buildViewModel(changed,SS.MOCK_SIDE,{}).cats.wrinkles.P,base.cats.wrinkles.P,'region severity has no scoring effect');
assert.equal(UI.dimensionScore(8.95),'9.0');assert.equal(UI.dimensionScore(9.45),'9.5');
assert.equal(UI.dimensionScore(9.97),'10.0');assert.equal(UI.dimensionScore(null),'-');
console.log('PASS overall wrinkles: direct 0..100, no inversion/region caps, missing/invalid data, firm min, source immutability, half-up dimension display');