fix(h5): center score stack, lock profile scroll and gate turn guidance
This commit is contained in:
+40
-7
@@ -107,19 +107,52 @@
|
||||
#dims-note .dims-note-l1 { font-size: 12px; line-height: 1.5; }
|
||||
#dims-note .dims-note-l2 { font-size: 11px; line-height: 1.5; margin-top: 3px; }
|
||||
|
||||
/* Labels share a top baseline outside the dial; center the score digits, not
|
||||
digits plus unit, inside the yellow ring. */
|
||||
.sum-score, .sum-age { flex-direction: column; align-items: center; justify-content: flex-start; gap: calc(6 * var(--u)); }
|
||||
.sum-age { align-self: stretch; }
|
||||
.sum-score > .stat-label, .sum-age > .stat-label { font-size: 12px; line-height: 1.5; }
|
||||
/* Center the complete score stack inside the ring, with the age label exactly
|
||||
25 CSS px above its number. Units do not offset the score digits. */
|
||||
.sum-score, .sum-age { flex-direction: column; align-items: center; justify-content: center; gap: 0; }
|
||||
.sum-age { align-self: stretch; gap: 25px; }
|
||||
.ring-center { gap: 6px; }
|
||||
.ring-center .stat-label, .sum-age > .stat-label { margin: 0; font-size: 12px; line-height: 1.5; color: var(--ink); }
|
||||
.score-dial, .age-dial { position: relative; width: calc(108 * var(--u)); height: calc(108 * var(--u)); }
|
||||
.age-dial { display: flex; align-items: center; justify-content: center; }
|
||||
.age-dial .stat-age { margin-top: 0; }
|
||||
.age-dial { height: auto; display: flex; align-items: center; justify-content: center; }
|
||||
.age-dial .stat-age { margin-top: 0; display: flex; align-items: baseline; }
|
||||
.age-dial .stat-age b { display: block; line-height: 1; }
|
||||
#stat-score { position: relative; }
|
||||
#stat-score b { display: block; letter-spacing: 0; }
|
||||
#stat-score i { position: absolute; left: 100%; bottom: 3px; margin-left: 2px; }
|
||||
.card-care .card-heading { text-align: left; }
|
||||
|
||||
/* The profile is a fixed page, not a scroll container. Keep normal-height PSD
|
||||
spacing; shrink only vertical whitespace/photo height on shorter viewports.
|
||||
Wheel pickers are mounted outside this screen and retain their own gestures. */
|
||||
#s-profile {
|
||||
overflow: hidden; overscroll-behavior: none; touch-action: none;
|
||||
--profile-space: min(var(--u), max(.3px, calc((100vh - 420px) / 306)));
|
||||
}
|
||||
@supports (height: 100dvh) {
|
||||
#s-profile { --profile-space: min(var(--u), max(.3px, calc((100dvh - 420px) / 306))); }
|
||||
}
|
||||
#s-profile .profile-body {
|
||||
height: 100%; min-height: 0; display: flex; flex-direction: column;
|
||||
padding-top: max(env(safe-area-inset-top), calc(34 * var(--profile-space)));
|
||||
padding-bottom: max(env(safe-area-inset-bottom), calc(40 * var(--profile-space)));
|
||||
}
|
||||
#s-profile .profile-body > * { flex-shrink: 0; }
|
||||
#s-profile .page-title { margin-bottom: calc(22 * var(--profile-space)); }
|
||||
#s-profile .field { margin-bottom: calc(19 * var(--profile-space)); }
|
||||
#s-profile .field-label { margin-bottom: calc(9 * var(--profile-space)); }
|
||||
#s-profile .profile-skin-field { flex: 0 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
#s-profile .skin-card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; padding-top: calc(21 * var(--profile-space)); padding-bottom: calc(24 * var(--profile-space)); }
|
||||
#s-profile .skin-axis { flex-shrink: 0; margin-bottom: calc(18 * var(--profile-space)); }
|
||||
#s-profile .skin-stage { flex: 1 1 auto; min-height: 0; display: flex; }
|
||||
#s-profile .skin-carousel { flex: 1; min-width: 0; min-height: 0; display: flex; touch-action: none; }
|
||||
#s-profile .skin-track { flex: 0 0 100%; min-width: 0; min-height: 0; }
|
||||
#s-profile .skin-item img { flex-shrink: 1; min-height: 0; object-fit: contain; }
|
||||
#s-profile .skin-name { flex-shrink: 0; margin-top: calc(16 * var(--profile-space)); }
|
||||
#s-profile .skin-desc { flex-shrink: 0; width: calc(150 * var(--u)); min-height: calc(2 * 1.55 * 11.5px); }
|
||||
#s-profile .skin-dots { flex-shrink: 0; margin-top: calc(22 * var(--profile-space)); }
|
||||
#s-profile .btn-submit { margin-top: calc(25 * var(--profile-space)); }
|
||||
|
||||
.detail-viewport { overflow: hidden; touch-action: pan-y; transition: height .32s ease; }
|
||||
.detail-viewport.dragging { transition: none; }
|
||||
.detail-panels { display: flex; align-items: flex-start; transition: transform .32s cubic-bezier(.22,.61,.36,1); }
|
||||
|
||||
+2
-2
@@ -80,7 +80,7 @@
|
||||
<input type="hidden" id="f-gender">
|
||||
<input type="hidden" id="f-region">
|
||||
|
||||
<div class="field">
|
||||
<div class="field profile-skin-field">
|
||||
<label class="field-label">您的日常肤质</label>
|
||||
<div class="skin-card">
|
||||
<div class="skin-axis">
|
||||
@@ -173,13 +173,13 @@
|
||||
<!-- 总分卡:左环形综合评分 / 右肌肤年龄 + 与实际年龄对比 -->
|
||||
<div class="card card-summary">
|
||||
<div class="sum-score">
|
||||
<span class="stat-label">综合评分</span>
|
||||
<div class="score-dial">
|
||||
<svg class="ring" viewBox="0 0 120 120" aria-hidden="true">
|
||||
<circle class="ring-bg" cx="60" cy="60" r="52"/>
|
||||
<circle class="ring-fg" id="ring-fg" cx="60" cy="60" r="52"/>
|
||||
</svg>
|
||||
<div class="ring-center">
|
||||
<span class="stat-label">综合评分</span>
|
||||
<span class="stat-value" id="stat-score"><b>--</b><i>分</i></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+13
-1
@@ -304,6 +304,11 @@
|
||||
|
||||
var carousel = $('#skin-carousel');
|
||||
skinSwipe = SwipeTrack.create(carousel, track, { count: SKINS.length, onSelect: goSkin });
|
||||
// Limit the iOS rubber-band fallback to the fixed profile itself. The mobile
|
||||
// wheel pickers live under body, so their vertical scrolling is untouched.
|
||||
$('#s-profile').addEventListener('touchmove', function (e) {
|
||||
if (e.cancelable) e.preventDefault();
|
||||
}, { passive: false });
|
||||
function cancelSkinDrag() { skinSwipe.cancel(); }
|
||||
carousel.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); goSkin(skinIndex + (e.key === 'ArrowRight' ? 1 : -1)); }
|
||||
@@ -766,7 +771,14 @@
|
||||
m.classList.toggle('no-face', cls === 'no-face');
|
||||
m.classList.toggle('ok', cls === 'ok');
|
||||
}
|
||||
/* camera-hint-stage-pure-begin */
|
||||
function captureStageHint(text, target, hasFrontPhoto) {
|
||||
if ((target === 'front' || !hasFrontPhoto) && (text === '请向左转头' || text === '请向右转头')) return '请正对屏幕';
|
||||
return text;
|
||||
}
|
||||
/* camera-hint-stage-pure-end */
|
||||
function setHint(text, ok, silent) {
|
||||
text = captureStageHint(text, CAM_STEPS[camStep].target, !!photos[0]);
|
||||
var h = $('#cam-hint');
|
||||
if (!text) { h.classList.remove('show', 'ok'); lastHint = ''; voice.dropHint(); return; }
|
||||
if (text !== lastHint) {
|
||||
@@ -949,7 +961,7 @@
|
||||
$('#cam-next').classList.remove('show');
|
||||
applyMirror();
|
||||
resetDetectState();
|
||||
voice.play(VOICE.step[camStep]); // 步骤引导语音
|
||||
if (camStep === 0 || photos[camStep - 1]) voice.play(VOICE.step[camStep]); // 前一步合格照片已保存,才播下一步引导
|
||||
}
|
||||
|
||||
var camSeq = 0; // 流程令牌:退出拍照/开始拍照时递增,使未决的快门/分析回调失效
|
||||
|
||||
+3
-8
@@ -158,8 +158,8 @@
|
||||
/**
|
||||
* check(points, target, mirrored) -> { ok, hint }
|
||||
* target: 'front' | 'left' | 'right'
|
||||
* 'front' : |yaw|<=15 且 |pitch|<=18、|roll|<=15;不 ok 时给方向提示
|
||||
* (UI 反馈 2026-10:动态提示「请向左转头」「请略微抬头」等,取代此前只提示回正)
|
||||
* 'front' : |yaw|<=15 且 |pitch|<=18、|roll|<=15;角度不合格只提示回正,
|
||||
* 不在正脸拍摄完成前播侧脸转头指令。
|
||||
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-70°,-30°]),|pitch|<=20
|
||||
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [30°,70°]),|pitch|<=20
|
||||
* ok=true 时 hint='保持不动';输入非法(含 estimate 返回 null 的退化特征点)时 ok=false、hint='请正对屏幕'。
|
||||
@@ -183,12 +183,7 @@
|
||||
if (Math.abs(sYaw) <= FRONT_YAW_MAX && Math.abs(pitch) <= FRONT_PITCH_MAX) {
|
||||
return { ok: true, hint: HINT_KEEP, yaw: sYaw };
|
||||
}
|
||||
// 按超出阈值比例更大的一项引导回正:头偏向自己左侧(yaw>0)→请向右转头;抬头过多(pitch>0)→请略微低头
|
||||
var yawOver = Math.abs(sYaw) / FRONT_YAW_MAX, pitchOver = Math.abs(pitch) / FRONT_PITCH_MAX;
|
||||
if (yawOver >= pitchOver) {
|
||||
return { ok: false, hint: sYaw > 0 ? HINT_TURN_RIGHT : HINT_TURN_LEFT, yaw: sYaw };
|
||||
}
|
||||
return { ok: false, hint: pitch > 0 ? HINT_LOWER : HINT_RAISE, yaw: sYaw };
|
||||
return { ok: false, hint: HINT_FRONT, yaw: sYaw };
|
||||
}
|
||||
|
||||
if (target === 'left' || target === 'right') {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { execSync } from 'node:child_process';
|
||||
import { pathToFileURL, fileURLToPath } 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 browser = await chromium.launch({ headless: true });
|
||||
const browser = await chromium.launch({ headless: true, args: ['--autoplay-policy=no-user-gesture-required'] });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
|
||||
page.setDefaultTimeout(60000);
|
||||
@@ -19,6 +19,37 @@ try {
|
||||
await page.route('**/sample-' + name, route => route.fulfill({ contentType: 'image/jpeg', body }));
|
||||
}
|
||||
await page.addInitScript(() => {
|
||||
// Record actual decoded audio starts, not preload requests. Include the
|
||||
// visible capture step so an early "turn left" instruction is detectable.
|
||||
const ids = new WeakMap(), buffers = new WeakMap(), originalFetch = window.fetch;
|
||||
window.__voice = [];
|
||||
window.fetch = function(url, options) {
|
||||
const match = String(url).match(/voice\/([a-z0-9-]+)\.mp3/);
|
||||
return originalFetch.call(this, url, options).then(response => {
|
||||
if (match) {
|
||||
const original = response.arrayBuffer.bind(response);
|
||||
response.arrayBuffer = () => original().then(data => { ids.set(data, match[1]); return data; });
|
||||
}
|
||||
return response;
|
||||
});
|
||||
};
|
||||
const OriginalAudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
function AudioRecorder(...args) {
|
||||
const context = new OriginalAudioContext(...args), decode = context.decodeAudioData.bind(context);
|
||||
context.decodeAudioData = (data, ok, error) => decode(data, buffer => { buffers.set(buffer, ids.get(data)); if (ok) ok(buffer); }, error);
|
||||
const create = context.createBufferSource.bind(context);
|
||||
context.createBufferSource = () => {
|
||||
const source = create(), start = source.start.bind(source);
|
||||
source.start = (...args) => {
|
||||
window.__voice.push({ id: buffers.get(source.buffer), at: Date.now(), duration: source.buffer.duration * 1000, step: document.querySelector('#cam-step').textContent });
|
||||
return start(...args);
|
||||
};
|
||||
return source;
|
||||
};
|
||||
return context;
|
||||
}
|
||||
AudioRecorder.prototype = OriginalAudioContext.prototype;
|
||||
window.AudioContext = window.webkitAudioContext = AudioRecorder;
|
||||
const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 600;
|
||||
let currentImage, offsetX = 0;
|
||||
function draw() {
|
||||
@@ -39,7 +70,7 @@ try {
|
||||
currentImage = image; offsetX = nextOffset; draw();
|
||||
} };
|
||||
navigator.mediaDevices.getUserMedia = async () => {
|
||||
await window.__cameraFixture.set('C3_180.jpg');
|
||||
await window.__cameraFixture.set('C3_135.jpg'); // invalid front: must not prompt a next-step turn
|
||||
return canvas.captureStream(10);
|
||||
};
|
||||
});
|
||||
@@ -78,6 +109,12 @@ try {
|
||||
};
|
||||
new MutationObserver(() => { window.__hints.push(document.querySelector('#cam-hint').textContent); if (window.__hints.length > 20) window.__hints.shift(); }).observe(document.querySelector('#cam-hint'), { childList: true, subtree: true });
|
||||
});
|
||||
await page.evaluate(() => window.__cameraFixture.set('C3_135.jpg'));
|
||||
await page.waitForFunction(() => window.__evaluations.some(row => row.target === 'front' && !row.ok && row.hint === '请正对屏幕'));
|
||||
await page.waitForFunction(() => window.__voice.some(row => row.id === 'hint-front'));
|
||||
assert((await page.locator('#cam-step').textContent()).includes('1/3'), 'invalid frontal angle never advances');
|
||||
assert.equal(await page.evaluate(() => window.__voice.some(row => /^hint-turn-|^step[23]-|^shot-done$/.test(row.id))), false, 'no turn/next-step/completion audio before a successful front photo');
|
||||
await page.evaluate(() => window.__cameraFixture.set('C3_180.jpg'));
|
||||
try { await page.waitForFunction(() => document.querySelector('#cam-step').textContent.includes('2/3')); }
|
||||
catch (error) {
|
||||
console.log(await page.evaluate(async () => {
|
||||
@@ -88,6 +125,11 @@ try {
|
||||
ratio: window.__dbgRatio, faces: faces.map(d => ({ score: d.detection.score, pose: HeadPose.estimate(d.landmarks.positions, false), box: d.detection.box })), holds: window.__holdSamples, hints: window.__hints };
|
||||
}).then(value => JSON.stringify(value, null, 2)), errors); throw error;
|
||||
}
|
||||
await page.waitForFunction(() => window.__voice.some(row => row.id === 'step2-rightface'));
|
||||
const firstVoice = await page.evaluate(() => window.__voice);
|
||||
const completion = firstVoice.find(row => row.id === 'shot-done'), turn = firstVoice.find(row => row.id === 'step2-rightface');
|
||||
assert(completion && turn && completion.step.includes('1/3') && turn.step.includes('2/3'));
|
||||
assert(turn.at >= completion.at + completion.duration - 100, 'turn instruction waits for captured-front completion speech to finish');
|
||||
await page.waitForFunction(() => window.__evaluations.some(row => row.target === 'right' && !row.ok));
|
||||
assert((await page.locator('#cam-step').textContent()).includes('2/3'), 'front photo cannot pass the side gate');
|
||||
await page.evaluate(() => window.__cameraFixture.set('C3_135.jpg'));
|
||||
@@ -112,5 +154,5 @@ try {
|
||||
assert(body.capture_diagnostics.views.right.yaw >= 30);
|
||||
assert(await page.evaluate(() => document.querySelector('#cam-video').srcObject === null), 'camera released after success');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: actual local models + automatic three-view capture + final JPEG revalidation; front/wrong-direction frames blocked locally; all three approved samples recorded; camera released; no paid call or backend writes');
|
||||
console.log('PASS: actual local models + automatic three-view capture + final JPEG revalidation; invalid front stays on step 1 with neutral audio, no early turn; actual decoded shot-done finishes before side guidance; wrong-direction frames blocked; approved samples/camera cleanup; no paid call/backend writes');
|
||||
} finally { await browser.close(); }
|
||||
|
||||
@@ -29,9 +29,15 @@ const check = (d, changes) => Quality.evaluate(d, { ...options, ...changes });
|
||||
assert(check(face()).ok);
|
||||
assert(!check(null).ok); assert(!check(face(), { count: 2 }).ok);
|
||||
for (const yaw of [-14, 14]) assert(check(face(yaw)).ok, 'former strict front limit now passes');
|
||||
for (const yaw of [-16, 16]) assert(!check(face(yaw)).ok);
|
||||
for (const yaw of [-16, 16, -45, 45]) {
|
||||
assert(!check(face(yaw)).ok);
|
||||
assert.equal(check(face(yaw)).hint, '请正对屏幕', 'front correction must not ask for a side view before capture');
|
||||
assert.equal(check(face(yaw), { mirrored: true }).hint, '请正对屏幕');
|
||||
}
|
||||
for (const pitch of [-17, 17]) assert(check(face(0, pitch)).ok);
|
||||
for (const pitch of [-20, 20]) assert(!check(face(0, pitch)).ok);
|
||||
for (const pitch of [-20, 20]) assert.equal(check(face(0, pitch)).hint, '请正对屏幕');
|
||||
assert.equal(check(face(0), { target: 'right' }).hint, '请向左转头', 'side step retains its proper turn instruction');
|
||||
assert.equal(check(face(0), { target: 'left' }).hint, '请向右转头');
|
||||
assert(check(face(0, 0, 12)).ok, 'normal tilt now passes');
|
||||
assert.equal(check(face(0, 0, 16)).hint, '请勿歪头');
|
||||
for (const yaw of [25, 72]) assert(!check(face(yaw), { target: 'right' }).ok);
|
||||
|
||||
@@ -6,7 +6,14 @@ const path = require('node:path');
|
||||
const source = fs.readFileSync(path.join(__dirname, '../js/app.js'), 'utf8');
|
||||
function extract(name) { return source.split('/* ' + name + '-begin */')[1].split('/* ' + name + '-end */')[0]; }
|
||||
const context = {};
|
||||
vm.runInNewContext(extract('camera-outline-pure') + extract('camera-haptic-pure'), context);
|
||||
vm.runInNewContext(extract('camera-outline-pure') + extract('camera-haptic-pure') + extract('camera-hint-stage-pure'), context);
|
||||
for (const hint of ['请向左转头', '请向右转头']) {
|
||||
assert.equal(context.captureStageHint(hint, 'front', false), '请正对屏幕');
|
||||
assert.equal(context.captureStageHint(hint, 'front', true), '请正对屏幕');
|
||||
assert.equal(context.captureStageHint(hint, 'right', false), '请正对屏幕');
|
||||
assert.equal(context.captureStageHint(hint, 'right', true), hint);
|
||||
}
|
||||
assert.equal(context.captureStageHint('请靠近一点', 'front', false), '请靠近一点');
|
||||
const geometry = { window: {} };
|
||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../js/camera-geometry.js'), 'utf8'), geometry);
|
||||
const original = JSON.stringify(geometry.window.CameraGeometry);
|
||||
|
||||
@@ -33,7 +33,7 @@ try {
|
||||
await page.route('**/*', route => {
|
||||
const req = route.request();
|
||||
if (req.method() !== 'GET') { blockedWrites.push(new URL(req.url()).pathname); return route.abort(); }
|
||||
if (/\.woff2?(\?|$)/.test(req.url())) return route.abort();
|
||||
if (!process.env.H5_PROFILE_FONTS && /\.woff2?(\?|$)/.test(req.url())) return route.abort();
|
||||
if (req.url().endsWith('/api/auth/session')) return route.fulfill({ json: { ok: true, user } });
|
||||
return route.continue();
|
||||
});
|
||||
@@ -66,6 +66,7 @@ try {
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
await enter();
|
||||
if (process.env.H5_PROFILE_FONTS) await page.evaluate(() => document.fonts.ready);
|
||||
let blank = await values();
|
||||
assert.equal(blank.birth, ''); assert.equal(blank.gender, ''); assert.equal(blank.region, ''); assert.equal(blank.skin, 0);
|
||||
await pick('#pick-birth', [62, 10]); // current year minus 37, November
|
||||
@@ -133,11 +134,56 @@ try {
|
||||
const size = await geometry(); assert(Math.abs(await offset() + 3 * size.width) < 2, 'resize keeps selection aligned at ' + width);
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'no horizontal page overflow');
|
||||
}
|
||||
for (const [width, height] of [[320, 568], [375, 667], [375, 726], [390, 664], [414, 896], [480, 726], [375, 440]]) {
|
||||
await page.setViewportSize({ width, height }); await page.waitForTimeout(400);
|
||||
const layout = await page.evaluate(() => {
|
||||
const profile = document.querySelector('#s-profile'), submit = document.querySelector('.btn-submit');
|
||||
const skin = document.querySelector('.skin-card').getBoundingClientRect();
|
||||
const image = document.querySelector('.skin-item img').getBoundingClientRect();
|
||||
const desc = document.querySelector('.skin-desc').getBoundingClientRect();
|
||||
const viewport = document.querySelector('#skin-carousel').getBoundingClientRect();
|
||||
const textBottom = Math.max(...[...document.querySelectorAll('.skin-desc')].map(el => {
|
||||
const range = document.createRange(); range.selectNodeContents(el);
|
||||
return Math.max(range.getBoundingClientRect().bottom, el.getBoundingClientRect().bottom);
|
||||
}));
|
||||
return { overflow: getComputedStyle(profile).overflowY, touch: getComputedStyle(profile).touchAction,
|
||||
client: profile.clientHeight, scroll: profile.scrollHeight, top: profile.scrollTop,
|
||||
submit: submit.getBoundingClientRect().bottom, image: image.height, desc: desc.bottom, skin: skin.bottom, textBottom, viewportBottom: viewport.bottom };
|
||||
});
|
||||
assert.equal(layout.overflow, 'hidden'); assert.equal(layout.touch, 'none');
|
||||
assert(layout.scroll <= layout.client + 1 && layout.submit <= height, 'all profile controls fit without clipping/scroll: ' + JSON.stringify({ width, height, ...layout }));
|
||||
assert(layout.image > 25 && layout.desc <= layout.skin, 'short screens retain skin illustration and description');
|
||||
assert(layout.textBottom <= layout.viewportBottom + 1, 'all five skin descriptions fit inside the clipped carousel: ' + JSON.stringify({ width, height, ...layout }));
|
||||
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, 'profile-fixed-' + width + '-' + height + '.png') });
|
||||
}
|
||||
const point = await geometry();
|
||||
await touch('touchStart', point.x, point.y);
|
||||
await touch('touchMove', point.x + 2, point.y + 70);
|
||||
await touch('touchEnd', 0, 0);
|
||||
await page.mouse.wheel(0, 300); await page.waitForTimeout(100);
|
||||
assert.equal(await page.locator('#s-profile').evaluate(el => el.scrollTop), 0, 'touch/wheel cannot pull the page down');
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollTop), 0);
|
||||
await dragBy(-70); assert.equal((await values()).skin, 4, 'horizontal swipe still works after vertical gesture');
|
||||
await dragBy(70); assert.equal((await values()).skin, 3);
|
||||
}
|
||||
// The screen's scroll lock must not prevent the separate picker from moving.
|
||||
await page.click('#pick-birth'); await page.waitForTimeout(400);
|
||||
assert.equal(await page.locator('.ms-mobile-select.ms-show').evaluate(el => !!el.closest('#s-profile')), false);
|
||||
const wheel = page.locator('.ms-mobile-select.ms-show .ms-wheel').first(), wheelBox = await wheel.boundingBox();
|
||||
const wheelValue = await page.evaluate(() => window.__pickers['#pick-birth'].getCurValue());
|
||||
const wx = wheelBox.x + wheelBox.width / 2, wy = wheelBox.y + wheelBox.height / 2;
|
||||
await touch('touchStart', wx, wy);
|
||||
await touch('touchMove', wx, wy - 40); await touch('touchMove', wx, wy - 80);
|
||||
await touch('touchEnd', 0, 0); await page.waitForTimeout(300);
|
||||
assert.notDeepEqual(await page.evaluate(() => window.__pickers['#pick-birth'].getCurValue()), wheelValue, 'birth wheel still responds to real vertical touch');
|
||||
await page.click('.ms-mobile-select.ms-show .ms-cancel'); await page.waitForTimeout(300);
|
||||
await enter(); assert.equal((await values()).skin, 3, 'skin selection also survives reload');
|
||||
await page.click('[data-action="submit-profile"]');
|
||||
await page.waitForSelector('#p-photoguide.show');
|
||||
assert.deepEqual(errors, []); assert.deepEqual(blockedWrites, [], 'no backend writes or paid requests');
|
||||
console.log('PASS: actual profile defaults/wheels/reload/account isolation; real mobile touch follows finger/snap/flick/edges/cancel/vertical scroll; arrows/dots/keyboard; three responsive widths; submission; no JS errors/backend writes');
|
||||
console.log('PASS: actual profile defaults/wheels/reload/account isolation; real mobile finger-follow/snap/flick/edges/cancel; fixed profile/no vertical movement/controls fit in seven viewport sizes; real birth-wheel vertical touch still works; arrows/dots/keyboard/submission; no JS errors/backend writes');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (child && child.exitCode == null) { const stopped = once(child, 'exit'); child.kill(); await stopped; }
|
||||
|
||||
@@ -33,21 +33,21 @@ try {
|
||||
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', '--']) {
|
||||
for (const score of ['0', '75', '82', '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'),
|
||||
return { dial: rect('.score-dial'), score: rect('#stat-score b'), title: rect('.ring-center .stat-label'),
|
||||
ageTitle: rect('.sum-age > .stat-label'), age: rect('#stat-age b'),
|
||||
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(Math.abs((layout.title.y + layout.score.bottom) / 2 - layout.dial.y - layout.dial.height / 2) < 1, 'complete label + score stack vertically centered');
|
||||
assert(layout.title.y > layout.dial.y && layout.score.bottom < layout.dial.bottom, 'both label and score stay inside the circle');
|
||||
assert(Math.abs(layout.age.y - layout.ageTitle.bottom - 25) < 1, 'age label-to-number gap is exactly 25 CSS px');
|
||||
assert.equal(layout.careAlign, 'left');
|
||||
}
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
@@ -127,5 +127,5 @@ try {
|
||||
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');
|
||||
console.log('PASS: whole score stack centered inside circle/age gap 25px/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(); }
|
||||
|
||||
Reference in New Issue
Block a user