Files
juzi/taobao/test/h5-interactions.test.cjs
T
xsl 774376fa16 feat: 完善淘宝小程序拍照、报告与登录流程
接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
2026-11-04 15:07:51 +08:00

288 lines
20 KiB
JavaScript

'use strict';
const { test } = require('node:test'), assert = require('node:assert/strict');
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
const swipe = require('../utils/swipe-gesture'), root = path.resolve(__dirname, '..');
function event(x, y, timeStamp, card = 'firm', view = 'contour') {
const touch = { identifier: 1, clientX: x, clientY: y };
return { timeStamp, touches: [touch], changedTouches: [touch], currentTarget: { dataset: { card, view } } };
}
function page(route, overrides = {}) {
let definition, currentUser = { id: 'ui-guest', kind: 'taobao_guest' };
const fixture = { window: {} };
vm.runInNewContext(fs.readFileSync(path.join(root, '../h5/assets/mock/report-fixture.js'), 'utf8'), fixture);
const app = { globalData: { profile: { real_age: 30, daily_skin_type: '干性肌肤' },
photos: ['front-photo', 'left-photo', 'right-photo'], payload: { ok: true, ...fixture.window.MOCK_FIXTURE } } };
const heights = [340, 520, 370, 440, 390, 410, 450], widths = [263, 263, 263];
const api = { assetUrl: v => v, user: () => currentUser, restore: async () => currentUser };
const platform = { getSystemInfoSync: () => ({ windowWidth: 375, windowHeight: 726 }),
redirectTo() {}, getStorageSync: () => ({}), getImageInfo: opts => opts.success({ width: 720, height: 1280 }),
createSelectorQuery() { return { selectAll() { return this; }, boundingClientRect() { return this; },
exec(callback) { callback([widths.map(width => ({ width })), heights.map(height => ({ height }))]); } }; },
...overrides };
vm.runInNewContext(fs.readFileSync(path.join(root, route + '.js'), 'utf8'), {
Page(value) { definition = value; }, getApp: () => app, my: platform, Date, Promise,
setTimeout: () => 1, clearTimeout() {}, require(name) {
return /\/utils\/api(?:\.js)?$/.test(name) ? api : require(path.resolve(root, path.dirname(route), name));
}
});
const p = Object.assign({}, definition, { data: JSON.parse(JSON.stringify(definition.data)),
setData(patch, callback) { for (const [key, value] of Object.entries(patch)) {
const parts = key.replace(/\[(\d+)\]/g, '.$1').split('.'); let object = this.data;
for (const part of parts.slice(0, -1)) object = object[part]; object[parts.at(-1)] = value;
} if (callback) callback(); } });
return { p, api, app, heights, widths, setUser: value => { currentUser = value; } };
}
async function report(overrides) {
const f = page('pages/report/report', overrides); f.p.onLoad(); await f.p.onShow(); f.p.onReady(); return f;
}
test('report disables Taobao container swipe-back on show and ready while card swipes remain usable', async () => {
const calls = [];
const f = await report({ tb: { enableSwipeBack(options) { calls.push(options.enable); } } });
assert.deepEqual(calls, [0, 0]);
const before = f.p.data.cards[0].index;
drag(f.p, f.p.data.cards[0].key, 240, 30);
assert.equal(f.p.data.cards[0].index, before + 1);
f.p.onHide(); await f.p.onShow();
assert.equal(calls.at(-1), 0, 'returning from a product or background reasserts the gesture setting');
});
test('missing or rejecting native swipe-back APIs cannot prevent a report from opening', async () => {
await report({ tb: {} });
await report({ tb: { enableSwipeBack() { throw Error('unsupported host'); } } });
await report({ tb: { enableSwipeBack() { return Promise.reject(Error('unsupported host')); } } });
});
function drag(p, key, start, end, canceled = false) {
p.onPanelTouchStart(event(start, 100, 1000, key));
p.onPanelTouchMove(event(end, 103, 1100, key));
p[canceled ? 'onPanelTouchCancel' : 'onPanelTouchEnd'](event(end, 103, 1110, key));
}
test('gesture adapter follows H5 horizontal lock, flick, short-drag snap-back and edge resistance', () => {
let state = swipe.start(event(100, 100, 0), 0, 263, 3, { startThreshold: 12, directionRatio: 2 / 3, maxOverscroll: 16 });
assert.equal(swipe.move(state, event(91, 101, 20)).dragging, false);
assert.equal(swipe.move(state, event(70, 102, 40)).offset, -30);
assert.equal(swipe.end(state, event(70, 102, 200)).index, 0, 'short slow drags snap back');
state = swipe.start(event(100, 100, 0), 0, 263, 3);
swipe.move(state, event(80, 100, 30));
assert.equal(swipe.end(state, event(80, 100, 40)).index, 1, 'fast flicks cross to the next panel');
state = swipe.start(event(100, 100, 0), 0, 263, 3, { maxOverscroll: 16 });
assert.equal(swipe.move(state, event(240, 100, 100)).offset, 16);
assert.equal(swipe.end(state, event(240, 100, 120)).index, 0);
state = swipe.start(event(100, 100, 0), 2, 263, 3, { maxOverscroll: 16 });
assert.equal(swipe.move(state, event(-40, 100, 100)).offset, -16);
assert.equal(swipe.end(state, event(-40, 100, 120)).index, 2);
});
test('vertical, canceled, malformed and multiple-touch gestures cannot change the selection', () => {
const state = swipe.start(event(100, 100, 0), 1, 263, 3);
assert.equal(swipe.move(state, event(102, 180, 100)).dragging, false);
assert.equal(swipe.move(state, event(10, 180, 200)).dragging, false, 'vertical lock is retained');
assert.equal(swipe.end(state, event(10, 180, 210)).index, 1);
const horizontal = swipe.start(event(100, 100, 0), 1, 263, 3);
swipe.move(horizontal, event(10, 100, 100));
assert.equal(swipe.end(horizontal, event(10, 100, 110), true).index, 1);
assert.equal(swipe.start({ touches: [] }, 0, 263, 3), null);
assert.equal(swipe.start(event(0, 0, 0), 0, 0, 3), null);
assert.equal(swipe.move(horizontal, { touches: [{ clientX: 1, clientY: 1 }, { clientX: 2, clientY: 2 }] }), null);
});
test('all three report groups swipe independently and tabs, layers and variable heights follow selection', async () => {
const f = await report(), p = f.p;
assert.equal(p.data.cards[0].height, 340);
p.onPanelTouchStart(event(180, 100, 1000)); p.onPanelTouchMove(event(80, 104, 1100));
assert.equal(p.data.cards[0].dragOffset, -100); assert.equal(p.data.cards[0].dragging, true);
f.heights[0] = 360; p.measurePanels(); assert.equal(p.data.cards[0].height, 340, 'height is held during drag');
p.onPanelTouchEnd(event(80, 104, 1120));
assert.equal(p.data.cards[0].index, 1); assert.equal(p.data.cards[0].height, 520);
assert.equal(p.data.cards[0].dragOffset, 0); assert.equal(p.data.viewIndex, 1);
assert.deepEqual(p.data.activeLayers, require('../utils/report-model').layers(p.payload.result, 'wrinkles'));
drag(p, 'vita', 180, 70); drag(p, 'vita', 180, 70); drag(p, 'stab', 180, 70);
assert.deepEqual(Array.from(p.data.cards, card => card.index), [1, 2, 1]);
drag(p, 'vita', 180, 70); assert.equal(p.data.cards[1].index, 2, 'last panel is bounded');
p.onTabTap({ currentTarget: { dataset: { card: 'vita', index: 0 } } });
assert.equal(p.data.cards[1].index, 0); assert.equal(p.data.cards[1].height, 370);
assert.equal(p.data.viewIndex, 2); assert.equal(p.data.cards[0].index, 1);
p.onUnload();
});
test('report vertical scrolling and canceled/multi-touch drags preserve tabs and never open zoom', async () => {
const { p } = await report();
p.onPanelTouchStart(event(100, 100, 0)); p.onPanelTouchMove(event(102, 210, 100));
p.onPanelTouchEnd(event(102, 210, 110)); assert.equal(p.data.cards[0].index, 0);
drag(p, 'firm', 180, 70, true); assert.equal(p.data.cards[0].index, 0);
p.onPanelTouchStart(event(100, 100, 0)); p.onPanelTouchMove(event(0, 100, 100));
p.onPanelTouchMove({ touches: [] }); assert.equal(p.data.cards[0].dragging, false);
assert.equal(p.data.cards[0].index, 0); assert.equal(p.data.zoom, null); p.onUnload();
});
test('image tap zoom preserves crop/annotations, ignores drag clicks and closes without losing card state', async () => {
const { p } = await report();
drag(p, 'firm', 180, 70);
p.onZoomPhoto(event(70, 100, 1200, 'firm', 'wrinkles')); assert.equal(p.data.zoom, null);
p.onPanelTouchStart(event(100, 100, 2000, 'firm', 'wrinkles'));
p.onPanelTouchEnd(event(100, 100, 2010, 'firm', 'wrinkles'));
p.onZoomPhoto(event(100, 100, 2010, 'firm', 'wrinkles'));
assert.equal(p.data.zoom.photo, 'front-photo'); assert.equal(p.data.zoom.dark, .55);
assert.equal(p.data.zoom.photoStyle, p.data.cards[0].views[1].photoStyle);
assert.deepEqual(p.data.zoom.layers, p.data.cards[0].views[1].layers);
p.onCloseZoom(); assert.equal(p.data.zoom, null); assert.equal(p.data.cards[0].index, 1);
p.onZoomPhoto(event(100, 100, 2100, 'firm', 'contour')); assert.equal(p.data.zoom, null, 'inactive images cannot zoom');
p.onUnload();
});
test('late side-image callbacks cannot reopen private zoom after leaving or changing identity', async () => {
const pending = [], f = await report({ getImageInfo(value) { pending.push(value); } }), p = f.p;
const e = event(100, 100, 0); e.currentTarget.dataset.side = 'right';
p.onZoomPhoto(e); pending.at(-1).success({ width: 720, height: 1280 });
assert.equal(p.data.zoom.photo, 'right-photo'); assert.equal(p.data.zoom.side, true);
p.onCloseZoom(); p.onZoomPhoto(e); const old = pending.at(-1);
p.onHide(); old.success({ width: 720, height: 1280 }); assert.equal(p.data.zoom, null);
await p.onShow(); p.onZoomPhoto(e); const changed = pending.at(-1);
f.setUser({ id: 'other-account', kind: 'taobao' }); changed.success({ width: 720, height: 1280 });
assert.equal(p.data.zoom, null); p.onUnload();
});
test('side zoom uses measured photo proportions even when getImageInfo fails, with no guessed 3:4 stretch', async () => {
const f = await report({ getImageInfo(options) { options.fail && options.fail({}); } }), p = f.p;
const e = event(100, 100, 0); e.currentTarget.dataset.side = 'left';
p.onSidePhotoLoad({ currentTarget: { dataset: { side: 'left', photo: 'left-photo' } }, detail: { width: 720, height: 1280 } });
p.onZoomPhoto(e);
assert.equal(p.data.zoom.fit, 'aspectFit');
const dimensions = p.data.zoom.photoStyle.match(/width:([\d.]+)%;height:([\d.]+)%/);
assert(dimensions); assert(Math.abs(Number(dimensions[1]) * 208.7 / (Number(dimensions[2]) * 184.5) - 720 / 1280) < .0001);
assert.doesNotMatch(p.data.zoom.frameStyle, /transform/);
assert.match(p.data.zoom.frameStyle, /width:375px;height:/);
p.onCloseZoom();
p.data.sides[0].naturalWidth = 0; p.data.sides[0].naturalHeight = 0;
p.onZoomPhoto(e); assert.equal(p.data.zoom.photoStyle, 'left:0;top:0;width:100%;height:100%;');
const generation = p.data.zoom.generation;
const loaded = { currentTarget: { dataset: { photo: 'left-photo', generation } }, detail: { width: 1080, height: 1920 } };
p.onZoomSideLoad(loaded); assert.match(p.data.zoom.photoStyle, /width:[\d.]+%;height:/);
p.onCloseZoom(); p.onZoomSideLoad(loaded); assert.equal(p.data.zoom, null);
p.onUnload();
});
test('side zoom displays a baked bitmap without a second native crop and discards stale exports', async () => {
const exports = [], draws = [];
const f = await report({ createCanvasContext() { return {
clearRect() {}, setFillStyle() {}, fillRect() {}, drawImage(...args) { draws.push(args); },
draw(keep, cb) { cb(); }, toDataURL(options) { exports.push(options); }
}; } }), p = f.p;
const e = event(100, 100, 0); e.currentTarget.dataset.side = 'left';
p.onZoomPhoto(e); assert.equal(exports.length, 1);
assert(Math.abs(draws[0][2] + draws[0][4] / 2 - 184.5 * 2 - draws[0][4] * .05) < 1e-8, 'actual contour canvas drawing moves down by 5 percent of photo height');
const originalWidth = p.data.sides[0].naturalWidth;
p.onZoomSideLoad({ currentTarget: { dataset: { photo: 'left-photo', generation: p.data.zoom.generation } }, detail: { width: 835, height: 738 } });
assert.equal(p.data.sides[0].naturalWidth, originalWidth, 'rendered/exported dimensions cannot replace original source dimensions');
exports[0].success('data:image/jpeg;base64,/9j/2Q=='); await new Promise(resolve => setImmediate(resolve));
assert.equal(p.data.zoom.bakedPhoto, 'data:image/jpeg;base64,/9j/2Q==');
const axml = fs.readFileSync(path.join(root, 'pages/report/report.axml'), 'utf8');
assert.match(axml, /src="\{\{zoom.bakedPhoto \|\| zoom.photo\}\}" mode="aspectFit"/);
assert.doesNotMatch(axml, /class="report-zoom-side[^>]*style="\{\{zoom.photoStyle\}\}"/);
p.onCloseZoom(); p.onZoomPhoto(e); assert.equal(exports.length, 2);
p.onHide(); exports[1].success('data:image/jpeg;base64,/9j/2Q=='); await new Promise(resolve => setImmediate(resolve));
assert.equal(p.data.zoom, null); p.onUnload();
});
test('side enlarged crop reduces fill by 20 percent and moves down by 5 percent across screen and photo sizes', async () => {
const { chromium } = require('playwright');
const browser = await chromium.launch({ headless: true });
const { p } = await report({ getImageInfo(options) { options.fail && options.fail({}); } });
p.data.cards[0].index = 1;
try {
const css = fs.readFileSync(path.join(root, '../h5/css/ui-20261012.css'), 'utf8');
const tab = await browser.newPage();
for (const viewport of [320, 375, 414]) for (const [width, height] of [[720, 1280], [1280, 720], [1000, 1000]]) {
p.applySidePhotoSize('left', 'left-photo', { width, height });
const e = event(100, 100, 0, 'firm', 'wrinkles'); e.currentTarget.dataset.side = 'left'; p.onZoomPhoto(e);
const style = p.data.zoom.photoStyle;
const svg = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"></svg>`).toString('base64');
await tab.setContent(`<style>${css}\n:root{--u:${viewport / 375}px}.mini{position:relative;width:${208.7 * viewport / 750}px;height:${184.5 * viewport / 750}px}.mini img{position:absolute}</style>
<div style="width:${208.7 * viewport / 750}px"><div class="contour-side-photo"><img src="data:image/svg+xml;base64,${svg}"></div></div>
<div class="mini"><img style="${style}" src="data:image/svg+xml;base64,${svg}"></div>`);
await tab.waitForFunction(() => [...document.images].every(i => i.naturalWidth > 0));
const geometry = await tab.evaluate(() => {
const image = document.querySelector('.contour-side-photo img'), frame = image.parentElement.getBoundingClientRect(), box = image.getBoundingClientRect();
const fill = Math.max(box.width / image.naturalWidth, box.height / image.naturalHeight) * .5 * .8;
const w = image.naturalWidth * fill, h = image.naturalHeight * fill;
const left = Math.max(frame.width - w, Math.min(0, box.left - frame.left + (box.width - w) / 2));
const top = (frame.height - h) / 2 + h * .05;
const mini = document.querySelector('.mini'), actual = mini.querySelector('img').getBoundingClientRect(), base = mini.getBoundingClientRect();
return { expected: [left, top, w, h], actual: [actual.left - base.left, actual.top - base.top, actual.width, actual.height] };
});
geometry.expected.forEach((value, i) => assert(Math.abs(value - geometry.actual[i]) < .15, JSON.stringify({ viewport, width, height, geometry })));
}
} finally { p.onUnload(); await browser.close(); }
});
test('side thumbnails reduce H5 fill by 10 percent while preserving framing anchors across photo and screen sizes', async () => {
const { chromium } = require('playwright');
const browser = await chromium.launch({ headless: true });
try {
const { p } = await report();
const css = fs.readFileSync(path.join(root, '../h5/css/ui-20261012.css'), 'utf8');
const tab = await browser.newPage();
for (const viewport of [320, 375, 414]) for (const [width, height] of [[720, 1280], [1280, 720], [1000, 1000]]) {
p.applySidePhotoSize('left', 'left-photo', { width, height });
const style = p.data.sides.find(side => side.key === 'left').photoStyle
.replace(/(-?[\d.]+)rpx/g, (_, value) => Number(value) * viewport / 750 + 'px');
const svg = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="100%" height="100%" fill="red"/></svg>`).toString('base64');
await tab.setContent(`<style>${css}\n:root{--u:${viewport / 375}px}#wrap{width:${208.7 * viewport / 750}px}.mini{position:relative;overflow:hidden;width:${208.7 * viewport / 750}px;height:${184.5 * viewport / 750}px}.mini img{position:absolute}</style>
<div id="wrap"><div class="contour-side-photo"><img src="data:image/svg+xml;base64,${svg}"></div></div>
<div class="mini"><img style="${style}" src="data:image/svg+xml;base64,${svg}"></div>`);
await tab.waitForFunction(() => [...document.images].every(image => image.naturalWidth > 0));
const geometry = await tab.evaluate(() => {
const image = document.querySelector('.contour-side-photo img'), frame = image.parentElement.getBoundingClientRect();
const box = image.getBoundingClientRect(), fill = Math.max(box.width / image.naturalWidth, box.height / image.naturalHeight);
const width = image.naturalWidth * fill, height = image.naturalHeight * fill;
const mini = document.querySelector('.mini'), actual = mini.querySelector('img').getBoundingClientRect(), base = mini.getBoundingClientRect();
return { expected: [box.left - frame.left + (box.width - width) / 2 + width * .05, box.top - frame.top + (box.height - height) / 2 + height * .05, width * .9, height * .9],
actual: [actual.left - base.left, actual.top - base.top, actual.width, actual.height] };
});
// H5 quantizes the small frame before the transform; the new fill is 90%.
// allow subpixel rounding, still well below a single CSS pixel.
geometry.expected.forEach((value, i) => assert(Math.abs(value - geometry.actual[i]) < .15,
`H5 crop mismatch at ${viewport}px for ${width}x${height}: ${JSON.stringify(geometry)}`));
}
p.onUnload();
} finally { await browser.close(); }
});
test('side-photo crop survives wrinkle-layer refresh and ignores stale or hidden image size callbacks', async () => {
const f = await report(), p = f.p;
const before = p.data.sides[0].photoStyle;
p.renderSides({ left: { result: { face_maps: {} } } });
assert.equal(p.data.sides[0].photoStyle, before);
p.applySidePhotoSize('left', 'old-photo', { width: 1280, height: 720 });
assert.equal(p.data.sides[0].photoStyle, before);
p.onHide(); p.applySidePhotoSize('left', 'left-photo', { width: 1280, height: 720 });
assert.equal(p.data.sides[0].photoStyle, before);
p.onUnload();
});
test('skin choices follow the finger and keep arrow selection, dots, cancellation and vertical gestures consistent', async () => {
const calls = [];
const { p } = page('pages/profile/profile', { createSelectorQuery: undefined,
tb: { enableSwipeBack(options) { calls.push(options.enable); } } });
p.onLoad(); await p.onShow(); p.onReady();
assert.deepEqual(calls, [0, 0]);
p.onSkinTouchStart(event(160, 100, 0)); p.onSkinTouchMove(event(80, 100, 100));
assert.equal(p.data.skinDragOffset, -80); assert.equal(p.data.skinDragging, true);
p.onSkinTouchEnd(event(80, 100, 120)); assert.equal(p.data.skinIndex, 1); assert.equal(p.data.skinPercent, 100);
p.onSkinTouchStart(event(160, 100, 0)); p.onSkinTouchMove(event(80, 100, 100)); p.onSkinTouchCancel();
assert.equal(p.data.skinIndex, 1); assert.equal(p.data.skinDragOffset, 0);
p.onSkinTouchStart(event(160, 100, 0)); p.onSkinTouchMove(event(150, 200, 100)); p.onSkinTouchEnd(event(50, 200, 120));
assert.equal(p.data.skinIndex, 1);
p.onSkinTouchStart(event(80, 100, 200)); p.onSkinTouchMove(event(220, 100, 250)); p.onSkinTouchEnd(event(220, 100, 260));
assert.equal(p.data.skinIndex, 0, 'right swipe still selects the preceding skin card');
p.onHide(); await p.onShow(); assert.equal(calls.at(-1), 0);
p.onSkinPrev(); assert.equal(p.data.skinIndex, 0);
p.onSkinPrev(); assert.equal(p.data.skinIndex, 0); p.onUnload();
});
test('normal homepage matches H5 content without development tool rows', () => {
const { p } = page('pages/splash/splash');
assert.equal(p.onCameraDebug, undefined); assert.equal(p.onMvpDebug, undefined); assert.equal(p.onDiagnose, undefined);
const markup = fs.readFileSync(path.join(root, 'pages/splash/splash.axml'), 'utf8');
assert.doesNotMatch(markup, /dev-entries|diag-panel|相机测试|连通性|权限诊断/);
});