feat(h5): refine report layout, add finger-following tabs and optimize images

This commit is contained in:
xsl
2026-10-12 22:36:44 +08:00
parent bcf198ca6f
commit 63c9e065a9
22 changed files with 2027 additions and 1535 deletions
+3 -3
View File
@@ -97,10 +97,10 @@ try {
const sent = page.waitForRequest(r => r.url().endsWith('/api/skin-analyze'));
await page.evaluate(() => window.__cameraFixture.set('C3_-135.jpg'));
const body = (await sent).postDataJSON();
assert.equal(await page.locator('#scan-photo').getAttribute('src'), body.front_image, 'real live waiting uses the captured front photo');
assert.equal(await page.locator('#scan-photo.mirror').count(), 1, 'front camera waiting keeps the preview mirror without changing the sent JPEG');
assert.equal(await page.locator('#scan-photo').count(), 0, 'live waiting does not display the captured face');
assert.equal(await page.locator('#scan-artwork').count(), 1, 'waiting retains original PSD head');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#scan-photo').getAttribute('src'), null, 'waiting photo reference is cleared after success');
assert.equal(await page.locator('#p-analyzing.show').count(), 0, 'waiting is cleared after success');
for (const key of ['front', 'left', 'right']) {
const row = body.capture_diagnostics.views[key];
assert.equal(row.mode, 'live'); assert.equal(row.direction_checked, true);
+3 -4
View File
@@ -61,7 +61,7 @@ try {
await page.goto(base);
await page.waitForSelector('#s-splash.active');
await page.waitForLoadState('networkidle');
assert(!requests.some(p => /\.woff2$|faceapi|scan-(waiting|base|face)|product-\d+\.webp/.test(p)), 'heavy/scanning assets must not compete with entry');
assert(!requests.some(p => /\.woff2$|faceapi|scan-(waiting|base|face)|product-\d+\.(webp|jpg)/.test(p)), 'heavy/scanning assets must not compete with entry');
await page.waitForFunction(() => document.querySelector('#s-splash .splash-slogan').naturalWidth === 718 && document.querySelector('.brand-lockup').naturalWidth === 517);
await screenshot('01-home');
await page.click('[data-action="open-privacy"]');
@@ -122,8 +122,7 @@ try {
});
assert(scanPixels[0].slice(0, 3).every(value => value > 245), 'original PSD bitmap is unchanged');
assert(scanPixels[1].slice(0, 3).every(value => value > 245), 'original PSD bitmap is unchanged');
assert.equal(await page.locator('#scan-photo').getAttribute('src'), pending.request().postDataJSON().front_image, 'real front photograph covers the white PSD placeholder');
assert(await page.locator('#scan-photo').evaluate(image => image.complete && image.naturalWidth > 0));
assert.equal(await page.locator('#scan-photo').count(), 0, 'white PSD head is not covered by user photograph');
assert.equal(await page.locator('.scan-beam').evaluate(e => getComputedStyle(e).animationName), 'report-scan');
assert.equal(await page.locator('#p-analyzing [data-action]').count(), 1, 'only cancel is actionable; PSD icons decorative');
for (const [width, height] of [[320, 726], [375, 726], [390, 844], [414, 896], [480, 726], [375, 440]]) {
@@ -173,7 +172,7 @@ try {
});
await page.goto(base + '?mock=1&screen=report'); await loadFonts();
await page.waitForFunction(() => document.querySelector('#product-image').naturalWidth === 504);
assert((await page.locator('#product-image').getAttribute('src')).endsWith('product-' + ({ 1: 25, 2: 35, 3: 50 }[tier]) + '.webp'));
assert((await page.locator('#product-image').getAttribute('src')).endsWith('product-' + ({ 1: 25, 2: 35, 3: 50 }[tier]) + '.jpg'));
for (const width of widths) {
await page.setViewportSize({ width, height: 844 });
await page.locator('.card-product').scrollIntoViewIfNeeded();
+130
View File
@@ -0,0 +1,130 @@
// Real mobile pointer dispatch on the actual report. Auth/analysis writes blocked.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
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 base = process.env.H5_BASE_URL || 'http://127.0.0.1:8080/';
const root = fileURLToPath(new URL('../../', import.meta.url));
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'design/ui-20261012/delivery-optimization-20261012.json')));
for (const asset of manifest.assets) {
for (const [file, expected] of [[asset.source, asset.source_sha256], [asset.file, asset.sha256]]) {
assert.equal(crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex'), expected);
}
assert(asset.bytes < asset.source_bytes * .9 && asset.psnr_db >= 40);
}
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
const errors = [], writes = [];
page.on('pageerror', e => errors.push(e.message));
await page.route('**/*', route => {
const req = route.request();
if (req.method() !== 'GET') { writes.push(req.url()); return route.abort(); }
if (req.url().endsWith('/api/auth/session')) return route.fulfill({ json: { ok: true, user: { id: 'report-touch-fixture', kind: 'browser' } } });
return route.continue();
});
await page.goto(base + '?mock=1&screen=report');
await page.waitForFunction(() => document.querySelector('#stat-score b').textContent !== '--' && [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth));
await page.evaluate(async () => { await Promise.all([document.fonts.load('500 40px MiSans'), document.fonts.load('400 12px MiSans')]); });
for (const width of [320, 375, 390, 414, 480]) {
await page.setViewportSize({ width, height: 844 });
await page.waitForTimeout(400);
for (const score of ['0', '75', '100', '--']) {
await page.locator('#stat-score b').evaluate((el, value) => { el.textContent = value; }, score);
const layout = await page.evaluate(() => {
const rect = selector => {
const r = document.querySelector(selector).getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height, bottom: r.bottom };
};
return { dial: rect('.score-dial'), score: rect('#stat-score b'), title: rect('.sum-score > .stat-label'),
ageTitle: rect('.sum-age > .stat-label'), age: rect('.age-dial'),
careAlign: getComputedStyle(document.querySelector('.card-care .card-heading')).textAlign };
});
assert(Math.abs(layout.score.x + layout.score.width / 2 - layout.dial.x - layout.dial.width / 2) < 1, 'score horizontally centered: ' + width + '/' + score);
assert(Math.abs(layout.score.y + layout.score.height / 2 - layout.dial.y - layout.dial.height / 2) < 1, 'score vertically centered');
assert(layout.title.bottom < layout.dial.y && layout.ageTitle.bottom < layout.age.y, 'labels outside above their values');
assert(Math.abs(layout.title.y - layout.ageTitle.y) < 1, 'labels share top alignment');
assert.equal(layout.careAlign, 'left');
}
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
}
await page.locator('#stat-score b').evaluate(el => { el.textContent = '75'; });
await page.setViewportSize({ width: 375, height: 726 });
const cdp = await page.context().newCDPSession(page), cards = page.locator('.card-detail');
const offset = card => card.locator('.detail-panels').evaluate(el => new DOMMatrix(getComputedStyle(el).transform).m41);
const selected = card => card.locator('.dtab.active').getAttribute('data-tab');
const touch = (type, x, y, timestamp) => cdp.send('Input.dispatchTouchEvent', {
type, ...(timestamp ? { timestamp } : {}), touchPoints: /touchEnd|touchCancel/.test(type) ? [] : [{ x, y, id: 1 }]
});
async function drag(card, dx, { dy = 0, cancel = false, pause = 0, flick = false, verify = false } = {}) {
const frame = card.locator('.dpanel.active .viz-frame');
await frame.scrollIntoViewIfNeeded(); await page.waitForTimeout(400);
const box = await frame.boundingBox(), x = box.x + box.width / 2, y = Math.min(500, box.y + box.height / 2);
const before = await offset(card), previous = await selected(card), now = flick ? Date.now() / 1000 : null;
await touch('touchStart', x, y, now);
await touch('touchMove', x + dx / 2, y + dy / 2, now && now + .02);
await touch('touchMove', x + dx, y + dy, now && now + .04);
if (verify) {
assert(Math.abs(await offset(card) - before - dx) < 3, 'report follows finger before release');
assert.equal(await selected(card), previous, 'selection commits only on release');
assert.equal(await card.locator('.detail-panels.dragging').count(), 1);
if (process.env.H5_ARTIFACT_DIR) {
fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'report-drag-' + await card.locator('.dtab').first().textContent() + '.png') });
}
}
if (pause) await page.waitForTimeout(pause);
await touch(cancel ? 'touchCancel' : 'touchEnd', 0, 0, now && now + .05);
await page.waitForTimeout(400);
assert.equal(await card.locator('.detail-panels.dragging').count(), 0);
assert.equal(await page.locator('#p-zoom.show').count(), 0, 'drag never opens zoom');
}
async function aligned(card) {
const state = await card.evaluate(el => {
const viewport = el.querySelector('.detail-viewport'), active = el.querySelector('.dpanel.active');
return { viewport: viewport.getBoundingClientRect().height, panel: active.offsetHeight,
delta: active.getBoundingClientRect().left - viewport.getBoundingClientRect().left,
selected: el.querySelectorAll('.dtab[aria-selected="true"][tabindex="0"]').length,
accessible: [...el.querySelectorAll('.dpanel')].filter(p => !p.inert && p.getAttribute('aria-hidden') === 'false').length };
});
assert(Math.abs(state.viewport - state.panel) < 2 && Math.abs(state.delta) < 2, 'selected panel fits/aligned: ' + JSON.stringify(state));
assert.equal(state.selected, 1); assert.equal(state.accessible, 1);
}
for (let i = 0; i < 3; i++) {
const card = cards.nth(i);
await drag(card, -100, { verify: true }); assert.equal(await selected(card), '1'); await aligned(card);
await drag(card, 25, { pause: 160 }); assert.equal(await selected(card), '1', 'short slow drag snaps back');
await drag(card, 100, { cancel: true }); assert.equal(await selected(card), '1', 'cancellation restores selection');
await drag(card, 2, { dy: -90 }); assert.equal(await selected(card), '1', 'vertical scrolling preserves tab');
await drag(card, 40, { flick: true }); assert.equal(await selected(card), '0', 'short fast flick goes back');
await drag(card, 100); assert.equal(await selected(card), '0', 'first edge cannot overshoot');
await card.locator('.dtab').last().click(); await page.waitForTimeout(400); await aligned(card);
await drag(card, -100); assert.equal(await selected(card), String(i === 1 ? 2 : 1), 'last edge cannot overshoot');
await card.locator('.dtab').last().focus(); await page.keyboard.press('Home'); await page.waitForTimeout(400); await aligned(card);
}
await drag(cards.nth(1), -100); await drag(cards.nth(1), -100); assert.equal(await selected(cards.nth(1)), '2');
assert.equal(await selected(cards.nth(0)), '0'); assert.equal(await selected(cards.nth(2)), '0', 'groups are independent');
const image = cards.nth(0).locator('.dpanel.active .ditem-img');
await image.click(); await page.waitForSelector('#p-zoom.show');
assert.equal(await page.locator('#zoom-img').getAttribute('src'), await image.getAttribute('src'));
await page.click('#p-zoom'); await page.waitForTimeout(400);
for (const width of [320, 390, 480]) {
await page.setViewportSize({ width, height: 844 }); await page.waitForTimeout(400);
for (const card of await cards.all()) await aligned(card);
}
for (const asset of manifest.assets) {
const response = await page.request.get(new URL(asset.file.replace(/^h5\//, ''), base).href);
assert.equal(response.status(), 200); assert(response.headers()['content-type'].startsWith('image/jpeg'));
assert.equal((await response.body()).length, asset.bytes);
}
if (process.env.H5_ARTIFACT_DIR) {
await page.setViewportSize({ width: 375, height: 726 });
await page.locator('#s-report').evaluate(el => { el.scrollTop = 0; });
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'report-summary.png') });
}
assert.deepEqual(errors, []); assert.deepEqual(writes, []);
console.log('PASS: score centered/labels above/care left in five widths; real finger-following three report groups, flick/snap/cancel/vertical/edges/keyboard/zoom/resize/height/ARIA; smaller JPEG hashes/dimensions/MIME; no JS errors/backend writes');
} finally { await browser.close(); }
@@ -16,7 +16,7 @@ try {
let pending, artworkFailure = false;
await page.route('**/*', route => {
const req = route.request();
if (req.url().endsWith('/api/auth/session')) return route.fulfill({ json: { ok: true, user: { id: 'scan-photo-fixture', kind: 'browser' } } });
if (req.url().endsWith('/api/auth/session')) return route.fulfill({ json: { ok: true, user: { id: 'scan-placeholder-fixture', kind: 'browser' } } });
if (req.url().endsWith('/api/skin-analyze')) { pending = route; return; }
if (req.method() !== 'GET') { unexpectedWrites.push(req.url()); return route.abort(); }
if (/\.woff2$|\/vendor\/faceapi\//.test(req.url())) return route.abort();
@@ -48,39 +48,43 @@ try {
await page.setInputFiles('#cam-file', path.join(samples, name));
}
await page.waitForSelector('#p-analyzing.show');
await page.waitForFunction(() => { const img = document.querySelector('#scan-photo'); return img.complete && img.naturalWidth > 0; });
assert(pending);
const sent = pending.request().postDataJSON();
assert.equal(await page.locator('#scan-photo').getAttribute('src'), sent.front_image, 'waiting face is the current FRONT photo, not the last side or a placeholder');
assert.equal(await page.locator('#scan-photo').count(), 0, 'waiting never inserts a user face');
assert.equal(await page.locator('#p-analyzing img[src^="data:"]').count(), 0);
assert.notEqual(sent.front_image, sent.left_image); assert.notEqual(sent.front_image, sent.right_image);
assert.equal(await page.locator('#scan-photo.mirror').count(), 0, 'uploaded/system camera photos must not be mirrored again');
assert.equal(await page.locator('#scan-photo').evaluate(img => getComputedStyle(img).objectFit), 'cover');
assert.equal(await page.locator('#scan-face').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(17, 17, 17)', 'loading/error does not expose white placeholder');
return sent;
}
function rejectPending(message) { const route = pending; pending = null; return route.fulfill({ json: { ok: false, message } }); }
const cleared = async () => {
assert.equal(await page.locator('#scan-photo').getAttribute('src'), null, 'face reference cleared on exit');
assert.equal(await page.locator('#scan-photo').count(), 0);
assert.equal(await page.locator('#p-analyzing.scan-running').count(), 0);
};
await profile();
assert.equal(await page.locator('#scan-photo').getAttribute('src'), null, 'no face before capture');
assert.equal(await page.locator('#scan-photo').count(), 0, 'no user photo element');
await capture();
await page.waitForSelector('#p-analyzing.scan-artwork-ready');
assert((await page.locator('#scan-face').evaluate(el => getComputedStyle(el).maskImage)).includes('scan-face-mask.png'));
assert.equal(await page.locator('.scan-beam').evaluate(el => getComputedStyle(el).animationName), 'report-scan');
const pixels = await page.locator('#scan-artwork').evaluate(image => {
const canvas = document.createElement('canvas'); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight;
const context = canvas.getContext('2d'); context.drawImage(image, 0, 0);
return [[540, 720], [540, 1200]].map(([x, y]) => [...context.getImageData(x, y, 1, 1).data]);
});
assert(pixels.every(pixel => pixel.slice(0, 3).every(value => value > 245)), 'original white head is preserved');
for (const [width, height] of [[320, 726], [375, 726], [390, 844], [414, 896], [480, 726], [375, 440]]) {
await page.setViewportSize({ width, height });
const photo = await page.locator('#scan-photo').boundingBox();
assert(Math.abs(photo.width - Math.min(width, 480)) < 1 && Math.abs(photo.height - height) < 1, 'same cover viewport as camera');
assert(Math.abs(photo.x - (width - photo.width) / 2) < 1 && Math.abs(photo.y) < 1, 'photo crop stays centered independently of PSD contain mask');
const art = await page.locator('#scan-artwork').boundingBox();
assert(art.width <= width + 1 && art.height <= height + 1);
assert(Math.abs(art.width / art.height - 1080 / 2091) < .001, 'PSD is contained without cropping');
assert(Math.abs(art.x - (width - art.width) / 2) < 1 && Math.abs(art.y - (height - art.height) / 2) < 1);
assert(await page.locator('.scan-cancel').isVisible());
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
}
await page.setViewportSize({ width: 375, height: 726 });
if (process.env.H5_ARTIFACT_DIR) {
fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'scan-photo.png') });
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'scan-placeholder.png') });
}
await page.mouse.click(187, 350);
assert(await page.locator('#p-analyzing').isVisible(), 'clicking the face does not cancel');
@@ -97,14 +101,13 @@ try {
await pending.fulfill({ json: success }); pending = null;
await page.waitForSelector('#s-report.active'); await cleared();
assert(await page.evaluate(() => window.__scanIterations.some(row => row.target === 'scan-beam' && row.elapsed >= 1)), 'fast success completes an actual cycle');
// Failed artwork/mask preserves the real photograph + status + cancel.
// Failed artwork/mask preserves a readable status and cancel, not a user photo.
artworkFailure = true;
await profile(); await capture();
assert.equal(await page.locator('#p-analyzing.scan-artwork-ready').count(), 0);
assert.equal(await page.locator('#scan-face').evaluate(el => getComputedStyle(el).maskImage), 'none');
assert.equal(await page.locator('.scan-status').evaluate(el => getComputedStyle(el).clipPath), 'none');
assert.equal(await page.locator('#scan-photo').evaluate(img => getComputedStyle(img).visibility), 'visible');
assert.equal(await page.locator('#scan-artwork').evaluate(img => getComputedStyle(img).visibility), 'hidden');
await page.click('[data-action="cancel-analysis"]'); await cleared(); await rejectPending('canceled');
assert.deepEqual(errors, []); assert.deepEqual(unexpectedWrites, []);
console.log('PASS: actual three-photo waiting shows current front JPEG; original orientation/cover/PSD mask/scan; six viewport sizes; photo cleanup after cancel/error/success; stale response; one real reduced-motion cycle; image/mask failure fallback; no JS errors/backend writes');
console.log('PASS: actual three-photo waiting restores original white PSD head, no user photograph; six viewport sizes; cancel/error/success cleanup; stale response; one real reduced-motion cycle; image/mask failure fallback; no JS errors/backend writes');
} finally { await browser.close(); }