feat(report): add annotated wrinkle views and restore capture countdown

This commit is contained in:
xsl
2026-10-15 19:45:21 +08:00
parent 2d80f6dfd0
commit 757b96dfad
23 changed files with 396 additions and 75 deletions
+3 -3
View File
@@ -35,7 +35,7 @@ try {
return{ratio:f.height/i.height,top:i.top-f.top,width:i.width-f.width,other:other.height/i.height,overflow:getComputedStyle(img.parentElement).overflow};
});
assert(Math.abs(crop.ratio-.9)<.002);assert(Math.abs(crop.top)<1);assert(Math.abs(crop.width)<1);
assert(Math.abs(crop.other-1)<.002);assert.equal(crop.overflow,'hidden');
assert(Math.abs(crop.other-.9)<.002,'wrinkle front frame matches contour crop');assert.equal(crop.overflow,'hidden');
const offset=await page.locator('#ditem-img1').getAttribute('data-content-offset');
assert(offset!=null&&Number(offset)>=0&&Number(offset)<=.090001);
const fullFront=await page.locator('#ditem-img1').getAttribute('src');
@@ -49,8 +49,8 @@ try {
const m=new DOMMatrix(getComputedStyle(e).transform),r=e.getBoundingClientRect(),b=e.parentElement.getBoundingClientRect();
return{sx:m.a,sy:m.d,shift:m.f/b.height,top:r.top-b.top,bottom:r.bottom-b.bottom};
});
assert(Math.abs(fill.sx-1.3)<.001);assert(Math.abs(fill.sy-1.3)<.001);assert(Math.abs(fill.shift-.15)<.001);
assert(Math.abs(fill.top)<1&&fill.bottom>0,'zoomed fill covers frame without blank top edge');
assert(Math.abs(fill.sx-2.8)<.001);assert(Math.abs(fill.sy-2.8)<.001);assert(Math.abs(fill.shift-(-.25))<.001);
assert(fill.top<=.01&&fill.bottom>0,'zoomed fill covers frame without blank top edge');
}
const spacing=await page.locator('#contour-sides').evaluate(e=>{
const frame=document.querySelector('.contour-front-frame'),f=frame.getBoundingClientRect(),s=e.getBoundingClientRect();
+17 -4
View File
@@ -17,15 +17,16 @@ try {
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);},
again:doCapture, cancel:leaveCamera,
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);}
constructor(){this.destination={};}
get state(){return window.__mute?'suspended':'running';}
decodeAudioData(ab,resolve){const b={id:new TextDecoder().decode(ab),duration:.9};resolve(b);return Promise.resolve(b);}
createBufferSource(){return {connect(){},stop(){},start(){window.__audio.push(this);}};}
};
});
@@ -36,18 +37,30 @@ try {
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');
assert.equal(await page.locator('#cam-countdown').textContent(),'3');
assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),false);
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()));
assert.equal(await page.locator('#cam-countdown').textContent(),'2');
await page.waitForFunction(()=>document.querySelector('#cam-countdown').textContent==='1');
await page.evaluate(()=>window.__audio.at(-1).onended());
await page.waitForFunction(n=>window.__completed.length===n,step+1);
assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),true);
}
await page.evaluate(()=>window.__countdown.start(0));
await page.waitForFunction(()=>window.__audio.length===4);
await page.evaluate(()=>window.__countdown.cancel());
assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),true,'cancel immediately clears countdown');
await page.waitForTimeout(700);
assert.equal(await page.evaluate(()=>window.__shots.length),3,'cancelled countdown never captures');
await page.evaluate(()=>{window.__mute=true;window.__countdown.start(0);});
assert.equal(await page.locator('#cam-countdown').textContent(),'3');
await page.waitForTimeout(350);
assert.equal(await page.evaluate(()=>window.__shots.length),3,'silent devices still wait for visual countdown');
await page.waitForFunction(()=>window.__completed.length===4);
assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),true);
assert.deepEqual(errors,[]);
console.log('PASS: each of 3 shots waits for countdown ended; duplicate shutter ignored; cancellation safe');
console.log('PASS: all 3 shots show animated 3/2/1, wait for voice and visual completion; duplicate shutter ignored; cancellation clears digits; silent-device countdown');
}finally{await browser.close();}
+9
View File
@@ -92,6 +92,15 @@ const wrinkle = { key: 'wrinkles', text: '原始皱纹结论。\n分区明细(
assert.equal(ReportUI.customerText(wrinkle), '原始皱纹结论。');
assert(wrinkle.text.includes('分区明细'), 'historical snapshot not overwritten');
assert.equal(ReportUI.customerText({ key: 'contour', text: '轮廓清晰;侧脸角度数据未完整获取;其他结论。' }), '轮廓清晰;其他结论。');
for (const key of ['contour','wrinkles','color','smooth','water','sens','acne']) {
const missing={key,valid:false,text:'本次未检出有效数据,请重新拍摄后查看。'};
const before=JSON.stringify(missing);
assert.equal(ReportUI.customerText(missing),'');
assert.equal(JSON.stringify(missing),before,'retain backend diagnostic');
assert.equal(ReportUI.customerText({key,text:'本次未检出有效数据'}),'','legacy missing-data text hidden');
assert.equal(ReportUI.customerText({key,text:'正常结论。',valid:true}),'正常结论。');
}
assert.equal(ReportUI.customerText({key:'contour',text:'本次未获取轮廓有效数据。'}),'');
const raw = { left_side_result: { left_jawline_info: { left_jawline_quality: 3, left_jawline_angle: 110 } }, side_result: { right_side_result: { right_jawline_info: { right_jawline_quality: 1, right_jawline_angle: 115 } } } };
assert.equal(Diagnostics.assess(raw).status, 'invalid');
assert.equal(Diagnostics.assess({}).status, 'unavailable');
+3
View File
@@ -70,6 +70,9 @@ now += 150; state.onDetect(face); assert.equal(captures, 2, 'vibration failure m
for (const manual of [true, false]) {
let manualBuzzes = 0, scheduled = [];
const shutter = { shutterLock: false, camLive: true, capturing: false, camSeq: 1,
currentScreen:'s-cam', VOICE:{countdown:'countdown-321'},
voice:{dropHint:()=>{},play:(_,opts)=>opts.onStart(3000),afterCurrent:cb=>cb()},
startCaptureCountdown:(_,duration,done)=>done(),
isManualMode: () => manual, navigator: { vibrate: () => { manualBuzzes++; throw Error('blocked'); } },
clearTimeout: () => {}, nudgeTimer: null, hideNudge: () => {},
$: () => ({ classList: { add: () => {} } }), setTimeout: (_, ms) => scheduled.push(ms) };
+2 -2
View File
@@ -19,10 +19,10 @@ 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; let calls=0;
global.fetch=async()=>({ok:true,status:200,json:async()=>++calls===2?{error_message:'INVALID_IMAGE_FACE'}:
global.fetch=async(url,options)=>({ok:true,status:200,json:async()=>options.body.has('left_side_image_file')&&++calls===2?{error_message:'INVALID_IMAGE_FACE'}:
{request_id:'fixture',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: '18115', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
`], { cwd: h5, env: { ...process.env, NODE_ENV: 'test', PORT: '18115', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture' }, stdio: ['ignore', 'pipe', 'pipe'] });
let browser;
function diskSnapshot(dir = dataRoot, prefix = '') {
+1 -2
View File
@@ -114,8 +114,7 @@ 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;
// Restored original minimum: compare combined tone with smoothness.
}
var vm = SkinScore.buildViewModel(fixture.result, fixture.side, tr.profile);
+10
View File
@@ -41,6 +41,16 @@ try{
return{heading:rect('.prod-title'),care:rect('.card-care'),product:rect('.card-product'),info:rect('.prod-info'),title:rect('#product-title'),name:rect('#product-name'),image:rect('#product-image'),badge:rect('.prod-badge'),disclaimer:rect('.report-disclaimer'),align:getComputedStyle(document.querySelector('.prod-title')).textAlign,headingFont:parseFloat(getComputedStyle(document.querySelector('.prod-title')).fontSize)};
});
assert.equal(geo.align,'center');assert(geo.heading.bottom<=geo.care.y+1);
const spacing=await p.evaluate(()=>{
const u=document.querySelector('.viz-frame').getBoundingClientRect().width/215;
const title=getComputedStyle(document.querySelector('.prod-title'));
return{u,top:parseFloat(title.marginTop),bottom:parseFloat(title.marginBottom),dimensions:[...document.querySelectorAll('.dimension-title')].map(e=>{
const r=e.getBoundingClientRect();return{above:r.top-e.previousElementSibling.getBoundingClientRect().bottom,below:e.nextElementSibling.getBoundingClientRect().top-r.bottom};
})};
});
assert(Math.abs(spacing.top-spacing.u*13*1.3)<.1,'bottom heading top whitespace increased 30%');
assert(Math.abs(spacing.bottom-spacing.u*8.583333*1.3)<.1,'bottom heading bottom whitespace increased 30%');
for(const d of spacing.dimensions){assert(Math.abs(d.above-d.below)<.1,'dimension heading whitespace symmetric');assert(Math.abs(d.above-spacing.u*18)<.1,'all dimension headings use same gap');}
const divider=await p.locator('.product-section-heading').evaluate(e=>{const s=getComputedStyle(e,'::before');return{width:s.borderTopWidth,color:s.borderTopColor,left:s.left,right:s.right};});
assert.equal(divider.width,'1px');assert.equal(divider.color,'rgb(221, 221, 221)');assert.equal(divider.left,'0px');assert.equal(divider.right,'0px');
const careGap=await p.locator('.product-section-heading').evaluate(e=>({gap:parseFloat(getComputedStyle(e).marginTop),u:document.querySelector('.viz-frame').getBoundingClientRect().width/215}));
+14 -4
View File
@@ -29,10 +29,17 @@ const child = spawn(process.execPath, ['-e', `
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})});
let callCount=0;
global.fetch=async(url,options)=>{
callCount++;
const sideOnly=options.body.get('return_maps')==='texture_enhanced_lines';
if(sideOnly){if(options.body.has('left_side_image_file')||options.body.has('right_side_image_file'))throw Error('side wrinkle photo must be its own main image');}
if(callCount>3)throw Error('unexpected paid request count');
return{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,
SKIN_API_KEY: 'fixture-key', SKIN_API_SECRET: 'fixture-secret', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
SKIN_API_KEY: 'fixture-key', SKIN_API_SECRET: 'fixture-secret', NODE_ENV:'test', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
let browser;
try {
await new Promise((resolve, reject) => {
@@ -53,6 +60,8 @@ try {
assert.equal(result.ok, true);
assert.equal(result.report_saved, true);
assert(result.vm && result.presentation);
assert.equal(result.wrinkle_views.version,'independent-side-wrinkles-v1');
assert.equal(result.wrinkle_views.left.status,'ready');assert.equal(result.wrinkle_views.right.status,'ready');
const expectedTotal = Math.round(result.vm.dims.firm.S * 4 + result.vm.dims.vita.S * 3 + result.vm.dims.stab.S * 3);
assert.equal(result.vm.total, expectedTotal);
assert.notEqual(expectedTotal, result.result.score_info.total_score, 'fixture distinguishes weighted score from API total');
@@ -73,9 +82,9 @@ try {
for (const item of Object.values(adminRecord.sub_scores)) assert.equal(item.level,ReportUI.internalLevel(item.S));
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(adminRecord.vita_score_rule,'combined-tone-smooth-min-v2');
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)));
assert.equal(adminRecord.dims.vita.P,Math.min(...['tone','smooth'].map(k=>adminRecord.cats[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);
@@ -95,6 +104,7 @@ try {
const snapshot = await (await api.get(base + 'api/reports/' + result.record_id)).json();
assert.equal(snapshot.report.photos.length, 3);
assert.deepEqual(snapshot.report.presentation, result.presentation);
assert.deepEqual(snapshot.report.payload.wrinkle_views,result.wrinkle_views);
assert.deepEqual(snapshot.report.payload.result.face_maps, result.result.face_maps);
assert.equal(snapshot.report.photos[0], fixture.photos[0]);
assert.equal(snapshot.report.photos[1],fixture.photos[0]); // This HTTP test submits the same fixture for all three views.
+5 -4
View File
@@ -1,8 +1,9 @@
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));
const v=run(m,w,d),values=[v.cats.smooth.P,v.cats.tone.P].filter(x=>typeof x==='number'&&Number.isFinite(x));
assert.equal(v.dims.vita.P,Math.min(...values));assert.equal(v.vitaScoreRule,'combined-tone-smooth-min-v2');assert.equal(v.total,S.weightedTotal(v.dims));
assert.equal(v.dims.firm.P,Math.min(v.cats.contour.P,v.cats.wrinkles.P));assert.equal(v.dims.stab.P,Math.min(v.cats.sens.P,v.cats.acne.P));
}
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');
assert(Math.abs(run(66,97,30).cats.tone.P-77.9)<1e-9);
console.log('PASS restored dimension minima: combined tone/smooth, firm/stability unchanged, missing inputs, version and weighted total');
+11 -5
View File
@@ -8,15 +8,21 @@ function run(score,severity=3,pigment=66,dc=30){
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.equal(example.water.text,'角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。');
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,'改善'],[30,'改善'],[40,'良好'],[60,'良好'],[65,'优秀'],[79.99,'优秀'],[80,'优秀'],[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(20,70).water.text.includes('缺水程度较高'));
assert(!run(20,3).water.text.includes('程度较高'));
assert(!run(20,null).water.text.includes('严重度'));
const crossed=run(97,70);assert.equal(crossed.water.P,30);assert.equal(crossed.water.level,'改善');assert.equal(crossed.vm.cats.tone.vars.tone_scoring.water_adjusted_score,30);
assert.equal(crossed.water.text,'角质层含水量较低,缺水面积 3%、肌肤缺水比较严重 70,肌肤含水量偏低,水润度不足,表面偏干燥,容易出现干纹。');
assert.equal(run(40).water.text,'角质层含水量适中,缺水面积 3%、肌肤含水量处于健康水平,整体水润度良好。');
assert(run(20,3).water.text.includes('肌肤缺水比较严重 3'));
assert(!run(20,null).water.text.includes('肌肤缺水比较严重'));
const old=structuredClone(example.presentation);old.copyRule='wrinkles-excellent-acne-detected-v1';old.views.find(v=>v.key==='water').text='水分分 97;旧水分文案';
const savedBefore=JSON.stringify(old),updated=UI.forDisplay(example.vm,example.r,old);
assert.equal(updated.views.find(v=>v.key==='water').text,example.water.text);
assert.equal(JSON.stringify(old),savedBefore,'history snapshot not rewritten');assert.deepEqual(updated.product,old.product);
for(const v of updated.views.filter(v=>v.key!=='water'))assert.equal(v.text,old.views.find(x=>x.key===v.key).text,'other conclusions unchanged');
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: independent copy/advice, displayed 8/9 boundaries, true severity, existing cross-check, missing/invalid, scoring immutable');
+53
View File
@@ -0,0 +1,53 @@
// Deterministic API payloads; no network writes or upstream detection.
import assert from 'node:assert/strict';import fs from 'node:fs';
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 b=await chromium.launch();
try{
const p=await b.newPage({viewport:{width:390,height:1100},isMobile:true,hasTouch:true});const errors=[];p.on('pageerror',e=>errors.push(e.message));
await p.route('**/api/**',r=>{assert.equal(r.request().method(),'GET');return r.fulfill({json:{ok:true,user:{kind:'browser'}}});});
const source=fs.readFileSync(new URL('../js/app.js',import.meta.url),'utf8');
await p.route('**/js/app.js*',r=>r.fulfill({contentType:'text/javascript',body:source.replace(/\}\)\(\);\s*$/,`
window.__wrinkleTest=function(x){var fx=MOCK_FIXTURE,raw=JSON.parse(JSON.stringify(fx.result));
if(x.noFrontMap)delete raw.face_maps.texture_enhanced_lines;
var vm=SkinScore.buildViewModel(raw,fx.side,{});
renderReport(vm,fx.photos[0],null,{result:raw,side:fx.side,wrinkle_views:x.views,face_rectangle:fx.face_rectangle},[fx.photos[0],x.left,x.right]);
showScreen('s-report');setDetailTab(document.querySelector('.card-detail'),'1',true);
};})();`)}));
await p.goto((process.env.H5_BASE_URL||'http://127.0.0.1:8080/')+'?mock=1&screen=report');
const svg=body=>'data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240">'+body+'</svg>');
const left=svg('<rect width="320" height="240" fill="#e6bba2"/>'),right=svg('<rect width="320" height="240" fill="#dcb196"/>');
const map=color=>svg('<path d="M0 80 L320 80 M0 140 L320 140" stroke="'+color+'" stroke-width="8"/>');
const entry=color=>({status:'ready',result:{face_maps:{texture_enhanced_lines:map(color)}}});
for(const width of[320,375,414,480]){
await p.setViewportSize({width,height:1100});
await p.evaluate(x=>window.__wrinkleTest(x),{left,right,views:{left:entry('red'),right:entry('blue')}});
await p.waitForFunction(()=>['wrinkle-left','wrinkle-right'].every(id=>{const i=document.getElementById(id);return i.naturalWidth>0&&!i.closest('figure').hidden&&i.dataset.hasAnnotation==='true';}));
assert(await p.locator('#wrinkle-layer-note').evaluate(e=>e.hidden));
assert(await p.locator('#ditem-img2').evaluate(e=>e.dataset.hasAnnotation==='true'));
const layout=await p.evaluate(()=>{
const styles=e=>{const s=getComputedStyle(e);return{width:s.width,height:s.height,marginBottom:s.marginBottom,transform:s.transform,objectFit:s.objectFit,objectPosition:s.objectPosition};};
return{contour:styles(document.querySelector('#ditem-img1').parentElement),wrinkle:styles(document.querySelector('#ditem-img2').parentElement),frontA:styles(document.querySelector('#ditem-img1')),frontB:styles(document.querySelector('#ditem-img2')),sideA:styles(document.querySelector('#contour-left')),sideB:styles(document.querySelector('#wrinkle-left')),offsetA:document.querySelector('#ditem-img1').dataset.contentOffset,offsetB:document.querySelector('#ditem-img2').dataset.contentOffset};
});
assert.deepEqual(layout.contour,layout.wrinkle,'same front frame and spacing');
assert.deepEqual(layout.frontA,layout.frontB,'same front image size');
assert.deepEqual(layout.sideA,layout.sideB,'same side image scaling and positioning');
assert.equal(layout.offsetA,layout.offsetB,'same face crop offset; overlay moves with photo');
const leftSrc=await p.locator('#wrinkle-left').getAttribute('src'),rightSrc=await p.locator('#wrinkle-right').getAttribute('src');
assert.notEqual(leftSrc,left);assert.notEqual(rightSrc,right);assert.notEqual(leftSrc,rightSrc);
await p.getByRole('button',{name:'查看左脸皱纹检测大图'}).click();assert.equal(await p.locator('#zoom-img').getAttribute('src'),leftSrc);await p.locator('#p-zoom').click();
await p.getByRole('button',{name:'查看右脸皱纹检测大图'}).click();assert.equal(await p.locator('#zoom-img').getAttribute('src'),rightSrc);await p.locator('#p-zoom').click();
}
await p.evaluate(x=>window.__wrinkleTest(x),{left,right,views:{}});
await p.waitForFunction(()=>!document.querySelector('#wrinkle-left').closest('figure').hidden&&!document.querySelector('#wrinkle-right').closest('figure').hidden);
assert.equal(await p.locator('#wrinkle-left').getAttribute('src'),left,'no front-face map projected onto unannotated side');
assert(await p.locator('#wrinkle-layer-note').isVisible());
const marks={left_cheek_wrinkle_outline:[{x:10,y:10},{x:200,y:10},{x:200,y:80}]};
await p.evaluate(x=>window.__wrinkleTest(x),{left,right:null,noFrontMap:true,views:{left:{status:'ready',result:{wrinkle_mark:marks}}}});
await p.waitForFunction(()=>document.querySelector('#wrinkle-left').dataset.hasAnnotation==='true'&&document.querySelector('#ditem-img2').dataset.hasAnnotation==='true');
assert(await p.locator('#wrinkle-right').evaluate(e=>e.closest('figure').hidden&&!e.hasAttribute('src')));
await p.evaluate(x=>window.__wrinkleTest(x),{left:null,right:null,views:{}});
assert(await p.locator('#wrinkle-sides').evaluate(e=>e.hidden));assert(await p.locator('#wrinkle-layer-note').evaluate(e=>e.hidden));
assert.deepEqual(errors,[]);
console.log('PASS wrinkle three views: 4 widths, own overlays, annotated zoom, front overlay preserved, missing layer/photo, real-coordinate fallback, no false projection and stale clearing');
}finally{await b.close();}
+34
View File
@@ -0,0 +1,34 @@
'use strict';
const assert=require('node:assert/strict'), Views=require('../lib/wrinkle-views');
const side=label=>({request_id:label,result:{score_info:{wrinkle_score:1},face_maps:{texture_enhanced_lines:label+'-map',red_area:'do-not-forward'},wrinkle_mark:{left_cheek_wrinkle_outline:[{x:1,y:1},{x:2,y:1},{x:2,y:2}]}}});
const photos={left:Buffer.from('left photo').toString('base64'),right:Buffer.from('right photo').toString('base64')};
(async()=>{
const original={result:{score_info:{wrinkle_score:76}},left_side_result:{left_jawline_info:{left_jawline_angle:112}}};
const before=JSON.stringify(original),calls=[];
const options={key:'fixture',secret:'fixture',url:'https://fixture.invalid',sleep:async()=>{},now:()=>0,fetch:async(url,o)=>{
assert.equal(o.method,'POST');const form=o.body;
assert.equal(form.get('return_maps'),'texture_enhanced_lines');assert.equal(form.get('return_marks'),'wrinkle_mark');
assert(!form.has('left_side_image_file')&&!form.has('right_side_image_file'));
const file=form.get('image_file'),label=file.name.split('.')[0];calls.push(label);
assert.equal(await file.text(),label+' photo');
return{ok:true,status:200,json:async()=>side(label)};
}};
const views=await Views.collect(original,photos,options);
assert.deepEqual(calls,['left','right']);assert.equal(JSON.stringify(original),before);
assert.equal(views.left.status,'ready');assert.equal(views.right.status,'ready');
const report=Views.forReport(views);
assert.equal(report.left.result.face_maps.texture_enhanced_lines,'left-map');
assert.equal(report.right.result.face_maps.texture_enhanced_lines,'right-map');
assert(!report.left.result.score_info&&!report.left.result.face_maps.red_area);
const mixed=await Views.collect(original,photos,{...options,fetch:async(url,o)=>({ok:true,status:200,json:async()=>o.body.get('image_file').name==='left.jpg'?{error_message:'INVALID_IMAGE_FACE: test'}:side('right')})});
assert.equal(mixed.left.status,'unavailable');assert.equal(mixed.left.error_code,'INVALID_IMAGE_FACE');assert.equal(mixed.right.status,'ready');
assert(!Views.forReport(mixed).left.result);
const deadline=await Views.collect(original,photos,{...options,deadline:1,gapMs:4,fetch:()=>{throw Error('must not call');}});
assert.equal(deadline.left.error_code,'WRINKLE_TIME_BUDGET');assert.equal(deadline.right.error_code,'WRINKLE_TIME_BUDGET');
const missing=await Views.collect(original,{},options);assert.equal(missing.left.error_code,'MISSING_SIDE_PHOTO');
const reused=await Views.collect({left_side_result:side('cached').result},photos,{...options,fetch:async()=>({ok:true,json:async()=>side('right')})});
assert.equal(reused.left.source,'primary-side-result');assert.equal(reused.right.source,'independent-side-request');
const failed=await Views.collect(original,photos,{...options,fetch:async()=>{const e=new Error();e.name='TimeoutError';throw e;}});
assert.equal(failed.left.error_code,'WRINKLE_TIMEOUT');assert.equal(failed.right.error_code,'WRINKLE_TIMEOUT');
console.log('PASS wrinkle views: own photo requests, sequential order, separate maps, no score mutation/forwarding, native reuse, missing/API failure/timeout/budget and report-safe payload');
})().catch(e=>{console.error(e);process.exitCode=1;});