fix(h5): show raw skin age, support Harmony touch pickers and add WeChat logout

This commit is contained in:
xsl
2026-10-13 16:12:54 +08:00
parent 4e42e33994
commit e71368db6d
23 changed files with 1984 additions and 1325 deletions
+1
View File
@@ -69,6 +69,7 @@ try {
assert.equal(await page.locator('#p-login-success.show').count(), 0, 'guest entry never shows login success');
assert.equal(await page.locator('[data-action="switch-login"]').count(), 0, 'switch-login controls are removed');
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), '游客模式');
assert.equal(await page.locator('[data-action="logout-wechat"]:visible').count(), 0, 'guests have no logout control');
assert.equal(guestRequests.length, 0, 'guest entry performs no authentication request');
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.identity.v1')), null);
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.reports.v1')), null);
+104
View File
@@ -0,0 +1,104 @@
// Real app + actual touch/mouse dispatch with varying UA/capabilities.
// Chromium UA regression, not a substitute for HarmonyOS hardware verification.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn, execSync } from 'node:child_process';
import { once } from 'node:events';
import { fileURLToPath, pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const h5 = fileURLToPath(new URL('../', import.meta.url));
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-harmony-test-'));
let child, browser;
let base = process.env.H5_BASE_URL;
try {
if (!base) {
base = 'http://127.0.0.1:18116/';
child = spawn(process.execPath, ['server.js'], { cwd: h5, env: { ...process.env, PORT: '18116', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture' }, stdio: ['ignore', 'pipe', 'pipe'] });
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('test startup timeout')), 10000);
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timer); resolve(); } });
child.once('exit', code => { clearTimeout(timer); reject(Error('server exited ' + code)); });
});
}
browser = await chromium.launch({ headless: true });
const android = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36';
const desktop = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36';
const cases = [
{ name: 'Android touch', ua: android, touch: true },
{ name: 'OpenHarmony 5 touch', ua: 'Mozilla/5.0 (Phone; OpenHarmony 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 ArkWeb/4.1.6.1 Mobile', touch: true },
{ name: 'OpenHarmony 6 WeChat touch', ua: 'Mozilla/5.0 (Phone;OpenHarmony 6.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 ArkWeb/6.0.0.42 Mobile MicroMessenger/8.0', touch: true },
{ name: 'Desktop UA on touch hardware', ua: desktop, touch: true },
{ name: 'Desktop mouse without TouchEvent', ua: desktop, touch: false },
{ name: 'Android UA without touch hardware', ua: android, touch: false }
];
for (const fixture of cases) {
const context = await browser.newContext({ viewport: { width: 375, height: 726 }, isMobile: fixture.touch, hasTouch: fixture.touch, userAgent: fixture.ua });
const page = await context.newPage(), errors = [], writes = [];
page.setDefaultTimeout(30000);
page.on('pageerror', e => errors.push(e.message));
if (!fixture.touch) await page.addInitScript(() => { delete window.TouchEvent; });
await page.route('**/*', route => {
const req = route.request();
if (req.method() !== 'GET') { writes.push(req.url()); return route.abort(); }
if (/\.woff2?(\?|$)/.test(req.url())) return route.abort();
if (req.url().endsWith('/api/auth/session')) return route.fulfill({ json: { ok: true, user: { id: 'harmony-picker-fixture', kind: 'wechat' } } });
return route.continue();
});
await page.route('**/mobile-select.iife.min.js', async route => {
const response = await route.fetch();
await route.fulfill({ response, body: await response.text() + ';var OriginalMobileSelect=MobileSelect;window.__pickers={};MobileSelect=function(c){var p=new OriginalMobileSelect(c);window.__pickers[c.trigger]=p;return p;};' });
});
await page.goto(base, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#s-splash.active');
await page.click('[data-action="open-privacy"]'); await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
await page.waitForSelector('#s-profile.active');
const cdp = await context.newCDPSession(page);
for (const trigger of ['#pick-birth', '#pick-region']) {
await page.click(trigger); await page.waitForTimeout(350);
const events = await page.evaluate(t => __pickers[t].eventHandleMap.panel.event, trigger);
assert.deepEqual(events, fixture.touch ? ['touchstart', 'touchend', 'touchmove'] : ['mousedown', 'mousemove', 'mouseup'], fixture.name);
assert.equal(await page.locator('.ms-mobile-select.ms-show').evaluate(el => !!el.closest('#s-profile')), false, 'profile scroll lock cannot intercept picker touch');
for (const column of [0, 1]) {
if (trigger === '#pick-region' && column === 1) await page.evaluate(() => __pickers['#pick-region'].locatePosition(0, REGION_DATA.findIndex(p => p[0] === '浙江省')));
const before = await page.evaluate(t => JSON.stringify(__pickers[t].getCurValue()), trigger);
const wheel = page.locator('.ms-mobile-select.ms-show .ms-wheel').nth(column), box = await wheel.boundingBox();
const x = box.x + box.width / 2, y = box.y + box.height / 2;
if (fixture.touch) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] });
for (let i = 1; i <= 4; i++) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: y - i * 20, id: 1 }] });
await page.waitForTimeout(25);
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
} else {
await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.move(x, y - 80, { steps: 4 }); await page.mouse.up();
}
await page.waitForTimeout(250);
assert.notEqual(await page.evaluate(t => JSON.stringify(__pickers[t].getCurValue()), trigger), before, fixture.name + '/' + trigger + '/' + column + ' must move');
}
const chosen = await page.evaluate(t => __pickers[t].getCurValue(), trigger);
await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(250);
if (trigger === '#pick-birth') {
const value = parseInt(chosen[0]) + '-' + String(parseInt(chosen[1])).padStart(2, '0');
assert.equal(await page.locator('#f-birth').inputValue(), value);
} else {
const value = await page.locator('#f-region').inputValue();
assert(value.includes(chosen[0].value) && value.includes(chosen[1].value));
}
await page.click(trigger); await page.waitForTimeout(300);
assert.deepEqual(await page.evaluate(t => __pickers[t].getCurValue(), trigger), chosen, 'reopening retains confirmed values');
await page.click('.ms-mobile-select.ms-show .ms-cancel'); await page.waitForTimeout(250);
}
await page.click('#pick-gender'); await page.click('.ms-mobile-select.ms-show .ms-ensure');
await page.click('[data-action="submit-profile"]'); await page.waitForSelector('#p-photoguide.show');
assert.deepEqual(errors, []); assert.deepEqual(writes, []);
console.log('PASS ' + fixture.name + ': year/month/province/city drag, cascade, confirm/reopen, profile validation');
await context.close();
}
} finally {
if (browser) await browser.close();
if (child && child.exitCode == null) { const stopped = once(child, 'exit'); child.kill(); await stopped; }
fs.rmSync(dataRoot, { recursive: true, force: true });
}
+22
View File
@@ -0,0 +1,22 @@
'use strict';
const assert = require('node:assert/strict');
const SS = require('../js/skin-score');
for (const raw of [20, 24, 28, 40, 50, 24.6, null]) {
for (const realAge of [25, 30, 60, undefined]) {
const result = structuredClone(SS.MOCK_RESULT);
if (raw == null) delete result.skin_age; else result.skin_age = { value: raw };
const before = JSON.stringify(result);
const vm = SS.buildViewModel(result, SS.MOCK_SIDE, { realAge });
assert.equal(vm.skinAgeRaw, raw); assert.equal(vm.skinAgeDisplay, raw);
assert.equal(vm.ageClampNote, ''); assert.equal(SS.reportSkinAge(vm, result), raw);
assert.equal(JSON.stringify(result), before);
}
}
const legacy = { skinAgeRaw: 24, skinAgeDisplay: 57, realAge: 60 };
assert.equal(SS.reportSkinAge(legacy), 24);
assert.equal(legacy.skinAgeDisplay, 57, 'historical VM is not mutated');
assert.equal(SS.reportSkinAge({ skinAgeDisplay: 57 }, { skin_age: { value: 24 } }), 24);
assert.equal(SS.reportSkinAge({ skinAgeDisplay: 57 }), null, 'clamped display is never a raw-age fallback');
assert.equal(SS.reportSkinAge({ skinAgeRaw: null, skinAgeDisplay: 57 }), null);
assert.equal(SS.reportSkinAge({}, { skin_age: '24.6' }), 24.6);
console.log('PASS: raw age across actual-age/decimal/missing cases; old display ignored; legacy payload fallback; no mutation or invented age');
+1 -1
View File
@@ -54,7 +54,7 @@ for (const realAge of [24, 40, 60, undefined]) {
assert.equal(vm.recommendation.skinAge, 24);
assert(Math.abs(vm.recommendation.scores.firm - 7.69) < 1e-12);
assert.equal(UI.build(vm, raw).product.tier, 1);
if (realAge === 60) assert.equal(vm.skinAgeDisplay, 57); // Display isn't the recommendation input.
assert.equal(vm.skinAgeDisplay, 24); // Display and recommendation both use raw skin age.
count++;
}
delete raw.skin_age;
+10 -6
View File
@@ -47,8 +47,8 @@ try {
assert.equal(result.report_saved, true);
assert(result.vm && result.presentation);
assert(result.vm.skinAgeRaw < 32);
assert.equal(result.vm.skinAgeDisplay, 57);
assert.equal(result.vm.essence.tier, 1); // Raw age, not actual60 or display57.
assert.equal(result.vm.skinAgeDisplay, result.vm.skinAgeRaw);
assert.equal(result.vm.essence.tier, 1); // Raw age, never actual60.
assert.equal(result.presentation.product.tier, 1);
const adminRecord = (await (await api.get(base + 'api/admin/records/' + result.record_id)).json()).record;
assert.deepEqual(adminRecord.presentation, result.presentation);
@@ -58,7 +58,7 @@ try {
for (const key of ['firm', 'vita', 'stab']) assert.equal(adminRecord.dims[key].S, result.vm.dims[key].S);
const list = await (await api.get(base + 'api/admin/records')).json();
assert.equal(list.items[0].skin_age, result.vm.skinAgeRaw);
assert.equal(list.items[0].skin_age_display, 57);
assert.equal(list.items[0].skin_age_display, result.vm.skinAgeRaw);
assert.deepEqual(list.items[0].recommendation, result.vm.recommendation);
const csv = await (await api.get(base + 'api/admin/export')).text();
assert(csv.includes('原始肌龄(推荐依据),前端展示肌龄'));
@@ -74,7 +74,7 @@ try {
const row = desktop.locator('#tbl-records tbody tr').first();
await row.waitFor();
assert((await row.textContent()).includes('原始 ' + result.vm.skinAgeRaw + '岁'));
assert((await row.textContent()).includes('展示 57岁'));
assert((await row.textContent()).includes('展示 ' + result.vm.skinAgeRaw + '岁'));
assert((await row.textContent()).includes("1'me 主场精华 I(25+)"));
for (const key of ['firm', 'vita', 'stab']) {
assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + result.vm.dims[key].S.toFixed(1) + ' ' + ReportUI.levelMap[result.vm.dims[key].level]));
@@ -93,7 +93,7 @@ try {
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('推荐依据'));
const detailText = await desktop.locator('#d-body').textContent();
assert(detailText.includes(result.vm.recommendation.reason));
assert(detailText.includes('前端展示肌龄 57岁'));
assert(detailText.includes('前端展示肌龄 ' + result.vm.skinAgeRaw + '岁'));
for (const view of result.presentation.views) assert(detailText.includes(view.text));
for (const care of result.presentation.care) assert(detailText.includes(care.text));
if (process.env.H5_ARTIFACT_DIR) {
@@ -146,19 +146,21 @@ try {
const legacy = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
legacy.presentation.product = { ...ReportUI.products[1], image: 'assets/img/ui-20261009/product-35.png' };
legacy.presentation.views[0].text = '历史报告原文,不应按新文案重新计算。';
legacy.vm.skinAgeDisplay = 57; // Old snapshot retains the retired display clamp.
legacy.vm.essence = { name: '35+支撑精华', text: '旧版推荐', triggers: ['最差项约束'] };
delete legacy.vm.recommendation;
fs.writeFileSync(snapshotFile, JSON.stringify(legacy));
record.essence = legacy.vm.essence;
delete record.presentation;
delete record.recommendation;
delete record.skin_age_display;
record.skin_age_display = 57;
fs.writeFileSync(recordFile, JSON.stringify(record) + '\n');
const storedBytes = fs.readFileSync(snapshotFile, 'utf8');
const recordBytes = fs.readFileSync(recordFile, 'utf8');
await page.click('[data-action="history"]');
await page.click('[data-report-id]');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#stat-age b').textContent(), String(result.vm.skinAgeRaw), 'old saved display clamp must not override original skin age');
assert.equal(await page.locator('#product-title').textContent(), ReportUI.products[1].title);
assert.equal(await page.locator('#product-image').getAttribute('src'), ReportUI.products[1].image, 'refresh artwork crop, never the historical recommendation/copy');
assert((await page.locator('.ditem-para').allTextContents()).includes(legacy.presentation.views[0].text));
@@ -166,6 +168,7 @@ try {
await desktop.locator('#tbl-records tbody tr').first().click();
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('历史报告原文'));
assert((await desktop.locator('#d-body').textContent()).includes('旧版规则(历史记录)'));
assert((await desktop.locator('#d-body').textContent()).includes('前端展示肌龄 ' + result.vm.skinAgeRaw + '岁'));
assert.equal(fs.readFileSync(snapshotFile, 'utf8'), storedBytes);
assert.equal(fs.readFileSync(recordFile, 'utf8'), recordBytes);
// Missing skin age must not leave the previous product visible in the H5.
@@ -181,6 +184,7 @@ try {
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('.card-product').isVisible(), false);
assert.equal(await page.locator('#product-unavailable').isVisible(), true);
assert.equal(await page.locator('#stat-age b').textContent(), '--');
assert.deepEqual(errors, []);
console.log('PASS: raw-age recommendation; admin/H5 scores, ages, product, seven-view copy, care, CSV, filters; saved/reloaded and legacy immutable snapshots; missing-age UI; owner isolation/private files/back');
} finally {
+43
View File
@@ -72,6 +72,7 @@ try {
await page.click('[data-action="login-confirm"]');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
assert.equal(await page.locator('#s-splash').evaluate(e => e.inert), false);
assert.equal(await page.locator('#s-splash [data-action="logout-wechat"]').isVisible(), true);
}
for (let device = 0; device < 2; device++) {
const context = await browser.newContext({ ignoreHTTPSErrors: true, userAgent: 'Mozilla/5.0 MicroMessenger/8.0', viewport: { width: 375, height: 726 } });
@@ -165,6 +166,48 @@ try {
assert.equal(await page.evaluate(() => AccountClient.user()), null);
assert.equal(await page.locator('#p-login-success.show').count(), 0, 'session restore must not repeatedly open a success dialog');
}
if (device === 1) {
const reportFile = path.join(root, 'accounts/reports', user.id, saved.id + '.json');
const originalBytes = fs.readFileSync(reportFile, 'utf8');
const logout = page.locator('#s-history [data-action="logout-wechat"]');
assert.equal(await logout.textContent(), '退出登录');
page.once('dialog', dialog => { assert(dialog.message().includes('历史报告将保留')); dialog.dismiss(); });
await logout.click();
assert.equal((await api(page, 'auth/session')).data.user.id, user.id, 'cancel leaves session intact');
await page.route('**/api/auth/logout', route => route.fulfill({ status: 503, json: { ok: false, message: '退出测试失败' } }));
page.once('dialog', dialog => dialog.accept()); await logout.click();
await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('退出失败'));
assert.equal(await page.evaluate(() => AccountClient.user().id), user.id, 'failure does not fake local logout');
assert.equal((await api(page, 'auth/session')).data.user.id, user.id);
await page.unroute('**/api/auth/logout');
await page.waitForFunction(() => !document.querySelector('#s-history [data-action="logout-wechat"]').disabled);
let heldReport, markHeld;
const reportHeld = new Promise(resolve => { markHeld = resolve; });
await page.route('**/api/reports/' + saved.id, route => { heldReport = route; markHeld(); }, { times: 1 });
await page.click('[data-report-id="RwechatCrossDevice"]'); await reportHeld;
page.once('dialog', dialog => dialog.accept()); await logout.click();
await page.waitForSelector('#s-login.active');
assert.equal(await page.evaluate(() => AccountClient.user()), null);
assert.equal((await api(page, 'auth/session')).data.user, null);
assert.equal((await api(page, 'reports/' + saved.id)).status, 401);
assert.equal(await page.locator('#login-consent').isChecked(), false);
assert.equal(fs.readFileSync(reportFile, 'utf8'), originalBytes, 'logout retains exact historical file');
await page.goBack(); await page.waitForSelector('#s-login.active');
await page.click('[data-action="guest-experience"]'); await page.waitForSelector('#s-splash.active');
await page.click('[data-action="history"]'); await page.waitForSelector('#s-history.active');
await heldReport.fulfill({ json: { ok: true, report: JSON.parse(originalBytes) } });
await page.waitForTimeout(250);
assert.equal(await page.locator('#s-report.active').count(), 0, 'late WeChat report response cannot open under guest identity');
await page.unroute('**/api/reports/' + saved.id);
await page.click('[data-action="open-wechat-login"]');
await page.check('#login-consent'); await page.click('[data-action="login-wechat"]');
await page.waitForSelector('#s-splash.active'); await confirmLogin(page, 'wechat');
assert.equal(await page.evaluate(() => AccountClient.user().id), user.id);
await page.click('[data-action="history"]'); await page.waitForSelector('[data-report-id="RwechatCrossDevice"]');
assert.deepEqual((await api(page, 'reports/' + saved.id)).data.report, JSON.parse(originalBytes));
assert.equal(fs.readFileSync(reportFile, 'utf8'), originalBytes);
console.log('PASS WeChat logout: confirm/cancel/failure, server session revoked, back guarded, re-login retains exact report');
}
assert.deepEqual(errors, []);
await context.close();
}