Files
juzi/taobao/test/frontend.test.cjs
xsl 9eec4369da feat(taobao): add native mini program with shared backend
Preserve H5 flows while adding Taobao identity, native capture, reports and isolated local development. Fix canceled province selection and expired-session logout. Record passing local regressions, two pre-existing H5 assertion failures and pending platform validation.
2026-10-19 20:14:31 +08:00

146 lines
10 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('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 () => {} },
'../../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,
'../../utils/permissions': { settings: async () => ({ 'scope.camera': true }), state: (s, key) => s[key], ensure: async () => {} },
'../../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]);
}
}
});