229 lines
15 KiB
JavaScript
229 lines
15 KiB
JavaScript
'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('cold launch resets session without loading or creating a native audio context', () => {
|
|
let app, resets = 0, restores = 0;
|
|
vm.runInNewContext(fs.readFileSync(path.join(base, 'app.js'), 'utf8'), {
|
|
App(value) { app = value; },
|
|
require(name) {
|
|
assert.equal(name, './utils/api.js', 'startup must not load the voice module');
|
|
return { resetForLaunch() { resets++; }, hasSession: () => false, restore() { restores++; } };
|
|
}
|
|
});
|
|
app.onLaunch(); app.onShow();
|
|
assert.equal(resets, 1); assert.equal(restores, 0);
|
|
});
|
|
|
|
test('global app share uses the login header title and a public entry without report or session parameters', () => {
|
|
let app;
|
|
vm.runInNewContext(fs.readFileSync(path.join(base, 'app.js'), 'utf8'), {
|
|
App(value) { app = value; }, require(name) { return require(path.resolve(base, name)); }
|
|
});
|
|
const shared = app.onShareAppMessage();
|
|
const title = require('../utils/page-header').layout().title;
|
|
assert.equal(shared.title, title);
|
|
assert.equal(shared.desc, title);
|
|
assert.equal(shared.path, 'pages/login/login');
|
|
assert.deepEqual(Object.keys(shared).sort(), ['desc', 'path', 'title']);
|
|
});
|
|
|
|
test('every registered business page explicitly shares the apostrophe title and public login entry', () => {
|
|
const routes = JSON.parse(fs.readFileSync(path.join(base, 'app.json'), 'utf8')).pages;
|
|
const title = require('../utils/page-header').layout().title;
|
|
for (const route of routes) {
|
|
const p = page(route + '.js', { '../../utils/api': { assetUrl: value => value } }).instance;
|
|
p.data.profile = { name: 'private-profile' }; p.data.payload = { report: 'private-report' };
|
|
const result = p.onShareAppMessage({ from: 'menu' });
|
|
assert.equal(result.title, title, route); assert.equal(result.desc, title, route);
|
|
assert.equal(result.path, 'pages/login/login', route);
|
|
assert.deepEqual(Object.keys(result).sort(), ['desc', 'path', 'title']);
|
|
result.title = 'changed'; assert.equal(p.onShareAppMessage().title, title, 'each share returns a fresh object');
|
|
}
|
|
});
|
|
|
|
test('login first-screen images are packaged original H5 assets with no remote URL dependency', () => {
|
|
const p = page('pages/login/login.js', { '../../utils/api': {} }).instance;
|
|
let total = 0;
|
|
for (const key of ['bgUrl', 'brandUrl', 'guestIconUrl']) {
|
|
assert.match(p.data[key], /^\.\.\/\.\.\/assets\/startup\//);
|
|
const local = path.resolve(base, 'pages/login', p.data[key]);
|
|
const original = path.join(base, '../h5/assets/img/ui-20261009', path.basename(local));
|
|
const bytes = fs.readFileSync(local); total += bytes.length;
|
|
assert.deepEqual(bytes, fs.readFileSync(original), 'visual assets remain byte-for-byte identical');
|
|
}
|
|
assert(total < 140 * 1024);
|
|
});
|
|
|
|
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('gender wheel saves only on confirmation and canceled drafts never replace the committed value', () => {
|
|
const api = { assetUrl: v => v, user: () => ({ id: 'guest-a', kind: 'taobao_guest' }) };
|
|
const p = page('pages/profile/profile.js', { '../../utils/api.js': api }).instance;
|
|
p.onLoad();
|
|
p.onOpenGender();
|
|
assert.equal(p.data.pickerTitle, '性别'); assert.equal(p.data.picker, 'gender');
|
|
assert.deepEqual(Array.from(p.data.pickerValue), [0]);
|
|
p.onPickerClose(); assert.equal(p.data.genderText, '', 'cancel does not implicitly choose the first option');
|
|
p.onOpenGender(); p.onPickerChange({ detail: { value: [1] } });
|
|
assert.equal(p.data.genderText, '', 'scrolling changes only the draft');
|
|
p.onPickerConfirm(); assert.equal(p.data.genderText, '男'); assert.equal(p.data.picker, '');
|
|
p.onOpenGender(); assert.deepEqual(Array.from(p.data.pickerValue), [1]);
|
|
p.onPickerChange({ detail: { value: [0] } }); p.onPickerClose();
|
|
assert.equal(p.data.genderText, '男');
|
|
p.onOpenGender(); assert.deepEqual(Array.from(p.data.pickerValue), [1]);
|
|
p.onPickerChange({ detail: { value: [0] } }); p.onPickerConfirm();
|
|
assert.equal(p.data.genderText, '女');
|
|
});
|
|
|
|
test('restored gender initializes the wheel and confirmation leaves birth and region intact', () => {
|
|
const api = { assetUrl: v => v, user: () => ({ id: 'account-a' }) };
|
|
const p = page('pages/profile/profile.js', { '../../utils/api.js': api }, {
|
|
my: { getStorageSync: () => ({ data: { birth: '1996-01', genderText: '男', regionText: '北京' } }) }
|
|
}).instance;
|
|
p.onLoad(); p.onOpenGender(); assert.deepEqual(Array.from(p.data.pickerValue), [1]);
|
|
p.onPickerChange({ detail: { value: [0] } }); p.onPickerConfirm();
|
|
assert.equal(p.data.genderText, '女'); assert.equal(p.data.birth, '1996-01'); assert.equal(p.data.regionText, '北京');
|
|
});
|
|
|
|
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]);
|
|
}
|
|
}
|
|
});
|