Files
juzi/h5/test/report-bottom-figma.mjs

88 lines
8.0 KiB
JavaScript

// Figma 1:65 visual layout, synthetic reports; no backend writes or paid 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';
const{chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href);
const browser=await chromium.launch();const artifacts=process.env.H5_ARTIFACT_DIR;
if(artifacts)fs.mkdirSync(artifacts,{recursive:true});
try{
const p=await browser.newPage({viewport:{width:375,height:1400},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(fileURLToPath(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.__bottomTest=function(age,legacy){
var raw=JSON.parse(JSON.stringify(MOCK_FIXTURE.result));
if(age==null)delete raw.skin_age;else raw.skin_age={value:age};
var vm=SkinScore.buildViewModel(raw,MOCK_FIXTURE.side,{realAge:30});
var presentation=JSON.parse(JSON.stringify(ReportUI.build(vm,raw)));
if(legacy&&presentation.product){presentation.product.name='旧版名称';presentation.product.image='missing-legacy.png';}
var before=JSON.stringify(presentation);
renderReport(vm,MOCK_FIXTURE.photos[0],null,{result:raw,presentation:presentation});showScreen('s-report');
return {before:before,after:JSON.stringify(presentation),tier:presentation.product&&presentation.product.tier};
};
})();`)}));
await p.goto((process.env.H5_BASE_URL||'http://127.0.0.1:8080/')+'?mock=1&screen=report');
await p.evaluate(()=>document.fonts.ready);
const names=['焕活光泽 透亮紧致','恢复弹性 紧实利落','强韧屏障 轮廓向上'];
for(const[age,tier]of[[24,1],[40,2],[60,3]]){
const state=await p.evaluate(age=>window.__bottomTest(age,true),age);assert.equal(state.tier,tier);assert.equal(state.before,state.after);
await p.waitForFunction(()=>document.querySelector('#product-image').complete&&document.querySelector('#product-image').naturalWidth===481);
assert.equal(await p.locator('#product-name').textContent(),names[tier-1]);
assert.equal(await p.locator('.card-product:visible').count(),1);
assert.equal(await p.locator('.report-care-products .card-product').count(),1);
assert.equal(await p.locator('.report-care-products .card-heading').textContent(),'日常护理');
assert.equal(await p.locator('.product-section-heading').textContent(),'推荐产品');
assert.equal(await p.locator('.care-summary').count(),0);
assert((await p.locator('.report-disclaimer').textContent()).includes('仅根据您最近的测肤记录推荐'));
for(const width of[320,375,414,480]){
await p.setViewportSize({width,height:1400});await p.locator('.card-product').scrollIntoViewIfNeeded();
const geo=await p.evaluate(()=>{
const rect=s=>{const r=document.querySelector(s).getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height,right:r.right,bottom:r.bottom};};
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*18)<.1,'bottom heading top whitespace matches dimension headings');
assert(Math.abs(spacing.bottom-spacing.u*18)<.1,'bottom heading bottom whitespace matches dimension headings');
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}));
assert(Math.abs(careGap.gap-careGap.u*24.0625)<.1,'care/product whitespace reduced by 30%');
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');
assert(Math.abs(geo.product.w/geo.product.h-945/511)<.01);
assert(Math.abs(geo.image.w/geo.image.h-481/511)<.01);
assert(Math.abs(geo.badge.w/geo.badge.h-101/77)<.01);
assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
}
await p.setViewportSize({width:375,height:1400});await p.locator('.card-product').scrollIntoViewIfNeeded();
if(artifacts){
const top=await p.locator('.prod-title').evaluate(e=>e.getBoundingClientRect().top);
const end=await p.locator('.report-disclaimer').evaluate(e=>e.getBoundingClientRect().bottom);
await p.screenshot({path:path.join(artifacts,'report-bottom-'+tier+'.png'),clip:{x:0,y:Math.max(0,top-8),width:375,height:end-Math.max(0,top-8)+8}});
}
}
await p.evaluate(()=>window.__bottomTest(null,false));
assert.equal(await p.locator('.card-product').isVisible(),false);assert(await p.locator('#product-unavailable').isVisible());
assert(await p.locator('.card-care').isVisible());assert(await p.locator('.prod-title').isVisible());
assert.equal(await p.locator('#product-image').getAttribute('src'),null);
// Long actual advice must push the product down rather than overlap it.
await p.evaluate(()=>{window.__bottomTest(24,false);document.querySelector('#care-list').textContent='测试长建议。'.repeat(300);});
const safe=await p.evaluate(()=>document.querySelector('#care-list').getBoundingClientRect().bottom<=document.querySelector('.product-section-heading').getBoundingClientRect().top);assert(safe);
assert.deepEqual(errors,[]);console.log('PASS bottom: 3 selected products, 4 widths, exact image ratios, merged card, titles/disclaimer order, legacy recommendation immutable, missing/long content');
}finally{await browser.close();}