'use strict'; const { test } = require('node:test'), assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'); const SS = require('../../h5/js/skin-score'), UI = require('../../h5/js/report-ui'); const base = path.resolve(__dirname, '..'); function page(file, deps = {}, extra = {}) { let definition; const timers = []; const context = { Page(value) { definition = value; }, require(name) { if (name in deps) return deps[name]; return require(path.resolve(path.dirname(path.join(base, file)), name)); }, setTimeout(fn) { timers.push(fn); return timers.length; }, clearTimeout(id) { timers[id - 1] = null; }, Promise, console, Date, ...extra }; vm.runInNewContext(fs.readFileSync(path.join(base, file), 'utf8'), context); const instance = Object.assign({}, definition, { data: JSON.parse(JSON.stringify(definition.data)), setData(patch, cb) { for (const [key, value] of Object.entries(patch)) { const pieces = key.replace(/\[(\d+)\]/g, '.$1').split('.'); let target = this.data; for (const piece of pieces.slice(0, -1)) target = target[piece]; target[pieces.at(-1)] = value; } if (cb) cb(); } }); return { instance, timers, context }; } const flush = () => new Promise(resolve => setImmediate(resolve)); test('offline camera debug skips identity/profile, never uploads or plays remote voice, and clears previews on exit', async () => { let backendCalls = 0, voiceCalls = 0, exited = 0, authReads = 0; const voice = { play() { voiceCalls++; }, stop() {}, VOICE: { step: [] } }; const camera = page('pages/camera/camera.js', { '../../utils/development': { cameraDebugEnabled: () => true }, '../../utils/api': { user() { authReads++; return null; }, analyze() { backendCalls++; throw Error('debug must not upload'); } }, '../../utils/voice': voice, '../../utils/permissions': { settings: async () => ({}), state: () => undefined, ensure: async () => {}, cameraSupported: () => true }, '../../utils/image': { compressToBase64: async (photo, p) => { p.setData({ compressWidth: 1280, compressHeight: 960 }); return 'data:image/jpeg;base64,/9j/'; } } }, { getApp() { throw Error('debug must not read/write global report state'); }, my: { navigateBack() { exited++; }, redirectTo() { throw Error('debug must not redirect to login/report'); }, showToast() {} } }); const p = camera.instance; p.onLoad({ debug: 'camera' }); p.onReady(); p.onShow(); await flush(); assert.equal(p.data.debugMode, true); assert.equal(authReads, 0); for (const [i, photo] of ['front.jpg', 'right.jpg', 'left.jpg'].entries()) { p.setData({ previewUrl: photo }); p.onConfirm(); if (i < 2) camera.timers[i](); } await flush(); assert.equal(p.data.debugComplete, true); assert.deepEqual(Array.from(p.data.debugPhotos, v => v.key), ['front', 'right', 'left']); assert(p.data.debugPhotos.every(v => v.width === 1280 && v.height === 960)); assert.equal(backendCalls, 0); assert.equal(voiceCalls, 0); p.onCancel(); assert.equal(p.data.debugPhotos.length, 0); assert.equal(p.photos.length, 0); assert.equal(exited, 1); }); test('camera debug URL cannot bypass normal login when the development gate is off', () => { let redirected = 0; const p = page('pages/camera/camera.js', { '../../utils/development': { cameraDebugEnabled: () => false }, '../../utils/api': { user: () => null } }, { my: { redirectTo() { redirected++; } } }).instance; p.onLoad({ debug: 'camera' }); assert.equal(redirected, 1); assert.equal(p.data.debugMode, false); }); test('development entry requires an explicit flag, development environment and local direct transport', () => { const config = require('../config'), original = { ...config }, development = require('../utils/development'); try { Object.assign(config, { environment: 'development', cameraDebug: true, transport: 'direct', baseUrl: 'http://127.0.0.1:18180' }); assert.equal(development.cameraDebugEnabled(), true); for (const patch of [{ environment: 'production' }, { cameraDebug: false }, { transport: 'cloud' }, { baseUrl: 'https://skin.example.test' }]) { const saved = { ...config }; Object.assign(config, patch); assert.equal(development.cameraDebugEnabled(), false); Object.assign(config, saved); } } finally { Object.assign(config, original); } }); test('Taobao report uses current H5 totals, displayed age, names, seven views, care selection and product', () => { const report = require('../utils/report-model'); for (const realAge of [24, 44, 60]) { const result = structuredClone(SS.MOCK_RESULT), side = structuredClone(SS.MOCK_SIDE); const model = SS.buildViewModel(result, side, { realAge, skinType: '干性肌肤' }); const display = UI.build(model, result), bytes = JSON.stringify(model); const rendered = report.model({ result, side, vm: model, presentation: display }, ['front'], {}); assert.equal(rendered.total, SS.weightedTotal(model.dims)); assert.equal(rendered.skinAge, SS.reportSkinAge(model, result)); assert.deepEqual(rendered.dims.map(d => d.name), ['紧塑力', '透亮感', '稳韧力']); assert.deepEqual(rendered.views.map(v => v.key), ['contour', 'wrinkles', 'color', 'smooth', 'water', 'sens', 'acne']); assert.deepEqual(rendered.careAdvice, display.care); assert.equal(rendered.product?.tier, display.product?.tier); assert.equal(JSON.stringify(model), bytes, 'rendering does not mutate saved scores'); } assert.deepEqual(report.marks({ acne: { rectangle: [{ left: 10, top: 20, width: 20, height: 40 }] } }, 'acne', 100, 200)[0], { id: 0, left: 10, top: 10, width: 20, height: 20, color: '#D30C0C' }); assert.equal(report.mapURL('https://untrusted.example/fake-map'), ''); }); test('camera blocks duplicate shots/confirm, cancels late callbacks and submits correct left/right order with one shared canvas', async () => { let captured, calls = 0, submitted = 0, ordered, resolver, navigated = 0; const app = { globalData: { profile: { real_age: 30 } } }; const api = { user: () => ({ id: 'a' }), analyze: (photos) => { submitted++; ordered = photos; return new Promise(resolve => { resolver = resolve; }); } }; const voice = { play() {}, stop() {}, VOICE: { step: [], shot: '', analyzing: '', fail: '' } }; const camera = page('pages/camera/camera.js', { '../../utils/api': api, '../../utils/voice': voice, '../../config': { environment: 'development', nativeCamera: true }, '../../utils/permissions': { settings: async () => ({ 'scope.camera': true }), state: (s, key) => s[key], ensure: async () => {}, cameraSupported: () => true }, '../../utils/image': { compressToBase64: async src => src + '-base64' } }, { getApp: () => app, my: { createCameraContext(id) { assert.equal(id, 'skin-camera'); return { takePhoto(opts) { captured = opts; calls++; } }; }, showToast() {}, redirectTo() { navigated++; } } }); const p = camera.instance; p.onLoad(); p.onReady(); p.onShow(); await flush(); p.onShutter(); p.onShutter(); assert.equal(calls, 1); captured.success({ tempImagePath: 'front' }); captured.complete(); p.onConfirm(); p.onConfirm(); assert.equal(p.data.stepIndex, 1); camera.timers[0](); p.setData({ previewUrl: 'right' }); p.onConfirm(); camera.timers[1](); p.setData({ previewUrl: 'left' }); p.onConfirm(); p.onConfirm(); await flush(); assert.equal(submitted, 1); assert.deepEqual(Array.from(ordered), ['front-base64', 'left-base64', 'right-base64']); p.onUnload(); resolver({ result: {} }); await flush(); assert.equal(navigated, 0, 'late result cannot navigate after leaving camera'); assert.equal(app.globalData.payload, undefined); }); test('image pipeline resizes large images to at most 1280 and exports real JPEG before base64 reading', async () => { let module = { exports: {} }, dimensions, exported; const context = { module, Promise, my: { getImageInfo(opts) { opts.success({ width: 4000, height: 3000 }); }, createCanvasContext() { return { clearRect() {}, drawImage(src, x, y, w, h) { dimensions = [w, h]; }, draw(reserve, cb) { cb(); }, toTempFilePath(opts) { exported = opts; opts.success({ apFilePath: 'resized.jpg' }); } }; }, getFileSystemManager() { return { readFile(opts) { assert.equal(opts.filePath, 'resized.jpg'); opts.success({ data: '/9j/test' }); } }; } } }; vm.runInNewContext(fs.readFileSync(path.join(base, 'utils/image.js'), 'utf8'), context); const result = await module.exports.compressToBase64('raw.heic', { setData(data, cb) { cb(); } }); assert.deepEqual(dimensions, [1280, 960]); assert.equal(exported.fileType, 'jpg'); assert.equal(exported.quality, 0.88); assert.equal(result, 'data:image/jpeg;base64,/9j/test'); }); test('province changes reset cities, canceled choices do not leak into reopening, and profiles stay account scoped', () => { let saved; const api = { assetUrl: v => v, user: () => ({ id: 'account-a' }) }, app = { globalData: {} }; const p = page('pages/profile/profile.js', { '../../utils/api.js': api }, { getApp: () => app, my: { setStorageSync(value) { saved = value; }, navigateTo() {}, getStorageSync() { return {}; } } }).instance; p.onLoad(); p.onOpenRegion(); p.onPickerChange({ detail: { value: [2, 8] } }); assert.equal(p.data.pickerValue[1], 0); p.onPickerConfirm(); assert.equal(p.data.regionIndex[1], 0); const committedCities = Array.from(p.data.regionRange[1]); p.onOpenRegion(); p.onPickerChange({ detail: { value: [3, 2] } }); p.onPickerClose(); p.onOpenRegion(); assert.deepEqual(Array.from(p.data.pickerValue), [2, 0]); assert.deepEqual(Array.from(p.data.regionRange[1]), committedCities, 'reopened city list must match the committed province'); p.setData({ birth: '1996-01', age: 30, birthText: '1996年1月', genderText: '女' }); p.onStartCamera(); assert.equal(saved.key, 'juzi.taobao.profile.account-a'); assert.equal(app.globalData.profile.real_age, 30); }); test('native AXML event handlers resolve and all routes have JS/AXML/ACSS/JSON files', () => { const pages = require('../app.json').pages; for (const route of pages) { for (const ext of ['js', 'axml', 'acss', 'json']) assert(fs.existsSync(path.join(base, route + '.' + ext)), route + '.' + ext); const source = fs.readFileSync(path.join(base, route + '.js'), 'utf8'); const markup = fs.readFileSync(path.join(base, route + '.axml'), 'utf8'); for (const match of markup.matchAll(/(?:on[A-Z]\w*|catch[A-Z]\w*)="([A-Za-z]\w*)"/g)) { assert(new RegExp('\\b' + match[1] + '\\s*:\\s*function').test(source), route + ' missing ' + match[1]); } } });