Files
juzi/h5/test/report-persistence.mjs
T

264 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Isolated HTTP + browser integration. Upstream Face++ is fixture-backed; no paid call.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
import http from 'node:http';
import { spawn, execSync } from 'node:child_process';
import { once } from 'node:events';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const ReportUI = require('../js/report-ui.js');
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-report-test-'));
const wechat = require('./wechat-fixture.cjs')(path.join(dataRoot, 'accounts'))[0];
const fx = {};
vm.runInNewContext(fs.readFileSync(path.join(h5, 'assets/mock/report-fixture.js'), 'utf8'), { window: fx });
const fixture = fx.MOCK_FIXTURE;
const child = spawn(process.execPath, ['-e', `
const fs=require('fs'), vm=require('vm'), w={};
vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w});
const f=w.MOCK_FIXTURE;
if(process.env.H5_ACNE_MAX_TEST==='1') {
const keys=['closed_comedones','acne','acne_pustule','acne_nodule','acne_mark'];
const set=(target,counts)=>keys.forEach((k,i)=>target[k]={count:counts[i]});
set(f.result,[2,0,0,0,3]);f.result.score_info.acne_score=100;
f.side.left.left_acne_info={score:0};set(f.side.left.left_acne_info,[5,1,0,0,1]);
f.side.right.right_acne_info={score:0};set(f.side.right.right_acne_info,[3,1,0,0,4]);
}
global.fetch=async()=>({ok:true,status:200,json:async()=>({result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right})});
require('./server.js');
`], { cwd: h5, env: { ...process.env, PORT: '18089', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
SKIN_API_KEY: 'fixture-key', SKIN_API_SECRET: 'fixture-secret', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
let browser;
try {
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('test server startup timeout')), 10000);
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timeout); resolve(); } });
child.once('exit', code => { clearTimeout(timeout); reject(new Error('server exited: ' + code)); });
});
const base = 'http://127.0.0.1:18089/';
browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
await page.context().addCookies([{ name: 'juzi_session', value: wechat.token, url: base }]);
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto(base);
await page.waitForSelector('#s-splash.active');
const api = page.context().request;
const result = await (await api.post(base + 'api/skin-analyze', { data: { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0], profile: { real_age: 60, daily_skin_type: '干性肌肤' } } })).json();
assert.equal(result.ok, true);
assert.equal(result.report_saved, true);
assert(result.vm && result.presentation);
const expectedTotal = Math.round(result.vm.dims.firm.S * 4 + result.vm.dims.vita.S * 3 + result.vm.dims.stab.S * 3);
assert.equal(result.vm.total, expectedTotal);
assert.notEqual(expectedTotal, result.result.score_info.total_score, 'fixture distinguishes weighted score from API total');
assert(result.vm.skinAgeRaw < 32);
const expectedAge = Math.max(55, Math.min(65, result.vm.skinAgeRaw));
assert.equal(result.vm.skinAgeDisplay, expectedAge);
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);
assert.deepEqual(adminRecord.recommendation, result.vm.recommendation);
assert.equal(adminRecord.skin_age, result.vm.skinAgeRaw);
assert.equal(adminRecord.skin_age_display, result.vm.skinAgeDisplay);
assert.equal(adminRecord.total_score, expectedTotal);
assert.equal(adminRecord.total_score_rule, 'dimensions-433-v1');
assert.deepEqual(adminRecord.acne_scoring,result.vm.cats.acne.vars.acne_scoring);
assert.deepEqual(adminRecord.tone_scoring,result.vm.cats.tone.vars.tone_scoring);
if(process.env.H5_ACNE_MAX_TEST==='1') {
assert.deepEqual(result.vm.cats.acne.vars.acne_counts,{closed_comedones:5,acne:1,acne_pustule:0,acne_nodule:0,acne_mark:4});
assert.equal(adminRecord.acne_scoring.deduction,13);assert.equal(adminRecord.cats.acne.P,87);
}
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, expectedAge);
assert.deepEqual(list.items[0].recommendation, result.vm.recommendation);
assert.equal(list.items[0].total_score, expectedTotal);
const csv = await (await api.get(base + 'api/admin/export')).text();
assert(csv.includes('原始肌龄(推荐依据),前端展示肌龄'));
assert(csv.includes(result.vm.recommendation.version));
assert(csv.includes('综合得分(4:3:3)'));
assert.equal(csv.trim().split('\n')[1].split(',')[14],String(expectedAge));
assert.equal(csv.trim().split('\n')[1].split(',')[12], String(expectedTotal));
const snapshot = await (await api.get(base + 'api/reports/' + result.record_id)).json();
assert.equal(snapshot.report.photos.length, 3);
assert.deepEqual(snapshot.report.presentation, result.presentation);
assert.deepEqual(snapshot.report.payload.result.face_maps, result.result.face_maps);
assert.equal(snapshot.report.photos[0], fixture.photos[0]);
assert.equal(snapshot.report.vm.total, expectedTotal);
assert.equal(JSON.parse(fs.readFileSync(path.join(dataRoot, 'records.jsonl'), 'utf8').trim()).total_score, expectedTotal, 'new records persist weighted score');
const desktop = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
desktop.on('pageerror', e => errors.push(e.message));
await desktop.goto(base + 'admin.html');
const row = desktop.locator('#tbl-records tbody tr').first();
await row.waitFor();
assert.equal(await row.locator('td').nth(6).textContent(), String(expectedTotal));
assert((await row.textContent()).includes('原始 ' + result.vm.skinAgeRaw + '岁'));
assert((await row.textContent()).includes('展示 ' + expectedAge + '岁'));
assert((await row.textContent()).includes("1'me 主场精华 I(25+)"));
for (const key of ['firm', 'vita', 'stab']) {
assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + ReportUI.dimensionScore(result.vm.dims[key].S) + ' ' + ReportUI.levelMap[result.vm.dims[key].level]));
}
assert.deepEqual(await desktop.locator('#f-dimlevel option').allTextContents(), ['不限档位', '改善', '良好', '优秀']);
if (process.env.H5_ARTIFACT_DIR) {
fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true });
await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-list.png'), fullPage: true });
}
await desktop.selectOption('#f-dim', 'firm');
await desktop.selectOption('#f-dimlevel', adminRecord.dims.firm.level);
const filtered = desktop.waitForResponse(r => r.url().includes('/api/admin/records?'));
await desktop.click('#btn-search');
assert.equal((await (await filtered).json()).total, 1);
await row.click();
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('推荐依据'));
const detailText = await desktop.locator('#d-body').textContent();
assert(detailText.includes('综合得分 ' + expectedTotal));
assert(detailText.includes('紧塑力×4+透亮感×3+稳韧力×3'));
assert(detailText.includes('痘痘三视图计分明细'));
assert(detailText.includes('黑眼圈评分×20%'));
const acneAuditRows=desktop.locator('.d-sec').filter({hasText:'痘痘三视图计分明细'}).locator('tbody tr');
assert.equal(await acneAuditRows.count(),5);
for(const [i,t] of adminRecord.acne_scoring.types.entries()) {
assert.deepEqual(await acneAuditRows.nth(i).locator('td').allTextContents(),[t.name,...['front','left','right'].map(k=>t.views[k]==null?'-':String(t.views[k])),t.count==null?'-':String(t.count),String(t.weight),t.deduction==null?'-':String(t.deduction)]);
}
if (process.env.H5_ARTIFACT_DIR) await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-weighted-detail.png') });
assert(detailText.includes(result.vm.recommendation.reason));
assert(detailText.includes('前端展示肌龄 ' + expectedAge + '岁'));
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) {
await desktop.locator('.d-sec').filter({ hasText: '使用原始肌龄' }).scrollIntoViewIfNeeded();
await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-recommendation.png') });
}
await desktop.click('#btn-close');
await desktop.click('[data-tab="stats"]');
await desktop.waitForFunction(() => document.querySelector('#dim-dist').textContent.includes('透亮感'));
assert(!(await desktop.locator('#dim-dist').textContent()).includes('预警'));
// A separate browser cannot access this user's report, even knowing its ID.
const other = await browser.newContext();
assert.equal((await other.request.get(base + 'api/reports/' + result.record_id)).status(), 401);
await other.request.post(base + 'api/auth/browser', { data: { consent: true } });
assert.equal((await other.request.get(base + 'api/reports/' + result.record_id)).status(), 404);
for (const route of ['data/accounts/identities.json', 'lib/accounts.js', 'server.js', 'test/accounts.js', '%2edata/identities.json']) {
assert([403,404].includes((await api.get(base + route)).status()), route);
}
// Raw HTTP avoids URL clients normalizing away the malicious path under test.
for (const route of ['//data/accounts/identities.json', '/%2fdata/accounts/identities.json',
'/%5cdata/accounts/identities.json', '//lib/accounts.js', '/%2fserver.js', '/assets/../data/accounts/identities.json']) {
const status = await new Promise((resolve, reject) => {
http.get({ hostname: '127.0.0.1', port: 18089, path: route }, r => { r.resume(); resolve(r.statusCode); }).on('error', reject);
});
assert([403,404].includes(status), route);
}
await page.click('[data-action="history"]');
await page.click('[data-report-id]');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#stat-score b').textContent(), String(expectedTotal));
if (process.env.H5_ARTIFACT_DIR) {
await page.waitForTimeout(400); // Capture after the report's entrance fade.
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'report-weighted-total.png') });
}
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth));
const imagesBefore = await page.locator('.ditem-img').evaluateAll(images => images.map(i => i.src));
const copyBefore = await page.locator('.ditem-para').allTextContents();
await page.reload();
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="history"]');
await page.click('[data-report-id]');
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth));
assert.deepEqual(await page.locator('.ditem-img').evaluateAll(images => images.map(i => i.src)), imagesBefore);
assert.deepEqual(await page.locator('.ditem-para').allTextContents(), copyBefore);
assert.equal(await page.locator('#product-title').textContent(), result.presentation.product.title);
await page.goBack();
await page.waitForSelector('#s-history.active');
await page.goBack();
await page.waitForSelector('#s-splash.active');
// Simulate an old, immutable report whose original product was II, even though
// today's rule would choose I. Admin must reuse its saved frontend copy, too.
const recordFile = path.join(dataRoot, 'records.jsonl');
const record = JSON.parse(fs.readFileSync(recordFile, 'utf8').trim());
const snapshotFile = path.join(dataRoot, 'accounts/reports', record.owner_id, result.record_id + '.json');
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.total = 12; // Old stored upstream total must not control the current display.
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;
record.skin_age_display = 57;
record.total_score = 12;
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(expectedAge), 'historical raw age uses current ±5 display rule, not saved display');
assert.equal(await page.locator('#stat-score b').textContent(), String(expectedTotal), 'old H5 report uses saved dimensions, not saved upstream total');
const legacyList = await (await api.get(base + 'api/admin/records')).json();
assert.equal(legacyList.items[0].skin_age_display, expectedAge);
assert.equal(legacyList.items[0].total_score, expectedTotal);
const legacyDetail = await (await api.get(base + 'api/admin/records/' + result.record_id)).json();
assert.equal(legacyDetail.record.skin_age_display, expectedAge);
assert.equal(legacyDetail.record.total_score, expectedTotal);
const legacyCsv=(await (await api.get(base + 'api/admin/export')).text()).trim().split('\n')[1].split(',');
assert.equal(legacyCsv[12], String(expectedTotal));
assert.equal(legacyCsv[14], String(expectedAge));
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));
await desktop.goto(base + 'admin.html');
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('前端展示肌龄 ' + expectedAge + '岁'));
assert.equal(fs.readFileSync(snapshotFile, 'utf8'), storedBytes);
assert.equal(fs.readFileSync(recordFile, 'utf8'), recordBytes);
// An incomplete set must show no total everywhere, not fall back to the API score.
const missingVm = JSON.parse(JSON.stringify(legacy.vm)); missingVm.dims.stab.S = null;
const missingRec = JSON.parse(JSON.stringify(record)); missingRec.dims.stab.S = null;
fs.writeFileSync(snapshotFile, JSON.stringify({ ...legacy, vm: missingVm }));
fs.writeFileSync(recordFile, JSON.stringify(missingRec) + '\n');
await page.reload(); await page.waitForSelector('#s-splash.active');
await page.click('[data-action="history"]'); await page.click('[data-report-id]');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#stat-score b').textContent(), '--');
assert.equal((await (await api.get(base + 'api/admin/records')).json()).items[0].total_score, null);
assert.equal((await (await api.get(base + 'api/admin/records/' + result.record_id)).json()).record.total_score, null);
await desktop.goto(base + 'admin.html');
assert.equal(await desktop.locator('#tbl-records tbody tr').first().locator('td').nth(6).textContent(), '--');
assert.equal((await (await api.get(base + 'api/admin/export')).text()).trim().split('\n')[1].split(',')[12], '');
fs.writeFileSync(recordFile, recordBytes);
// Missing skin age must not leave the previous product visible in the H5.
delete legacy.payload.result.skin_age;
const SkinScore = require('../js/skin-score.js');
legacy.vm = SkinScore.buildViewModel(legacy.payload.result, legacy.payload.side, { realAge: 60 });
legacy.presentation = ReportUI.build(legacy.vm, legacy.payload.result);
fs.writeFileSync(snapshotFile, JSON.stringify(legacy));
await page.reload();
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="history"]');
await page.click('[data-report-id]');
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 {
if (browser) await browser.close();
child.kill('SIGTERM');
if (child.exitCode === null) await once(child, 'exit');
fs.rmSync(dataRoot, { recursive: true, force: true });
}