'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(``).toString('base64'); await tab.setContent(`