feat: adopt Apple UI and four-tab mobile navigation

This commit is contained in:
陈煜 committed 2026-10-07 18:13:59 +08:00
1 parent daf9b458af
commit fb8cf1c938
20 files changed
+1662 -378

No files matched your search

+352
View File
@@ -0,0 +1,352 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
// Reusable UI regression with synthetic API data; never connects to a live ledger.
// Start Vite first. Set WORTHPATH_PLAYWRIGHT_MODULE if Playwright is not resolvable.
const { chromium } = require(process.env.WORTHPATH_PLAYWRIGHT_MODULE || 'playwright');
const baseUrl = process.env.WORTHPATH_UI_URL || 'http://127.0.0.1:5175';
const output =
process.env.WORTHPATH_UI_OUTPUT || fs.mkdtempSync(path.join(os.tmpdir(), 'worthpath-ui-'));
fs.mkdirSync(output, { recursive: true });
const day = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Hong_Kong' }).format(new Date());
const user = {
username: 'UI预览',
role: 'admin',
baseCurrency: 'CNY',
hiddenMenus: [],
showNotes: true,
idleMinutes: 0,
revealed: false,
requireHiddenPassword: false,
};
let failNext = false;
let accounts = ['日常账户', '储蓄账户', '信用卡'].map((name, index) => ({
id: `fixture-${index}`,
name,
kind: 'account',
side: index === 2 ? 'liability' : 'asset',
category: index === 2 ? 'credit_card' : 'bank',
currency: 'CNY',
amount: String((index + 1) * 100),
groupName: index === 2 ? '信用账户' : '日常',
hidden: false,
archived: false,
included: true,
outgoing: [],
notes: '',
converted: '100',
activityDate: index === 0 ? day : null,
}));
(async () => {
const browser = await chromium.launch({
headless: true,
channel: process.env.WORTHPATH_BROWSER_CHANNEL || 'msedge',
});
try {
const page = await browser.newPage({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
deviceScaleFactor: 1,
});
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/api/**', async (route) => {
const url = new URL(route.request().url());
const endpoint = url.pathname.replace('/api', '');
if (failNext && endpoint === '/positions') {
failNext = false;
await route.fulfill({ status: 400, json: { error: '浏览器验收错误' } });
return;
}
let body = {};
if (endpoint === '/auth/me') body = user;
else if (endpoint === '/settings') body = { ...user, rates: [], fxStatus: null };
else if (endpoint === '/overview')
body = {
date: day,
assets: '600',
liabilities: '300',
net: '300',
complete: true,
missing: [],
items: [],
baseCurrency: 'CNY',
revealed: false,
};
else if (endpoint === '/positions')
body = url.searchParams.get('kind') === 'account' ? accounts : [];
else if (endpoint === '/schedules/run') body = { executed: 0, errors: [], hasMore: false };
else if (endpoint === '/schedules') body = [];
else if (endpoint === '/admin/users') body = { items: [], total: 0 };
else if (endpoint === '/calendar')
body = {
items: Array.from({ length: 31 }, (_, i) => ({
date: day.slice(0, 7) + '-' + String(i + 1).padStart(2, '0'),
income: '0',
expense: '0',
complete: true,
})),
baseCurrency: 'CNY',
};
else if (['/trend', '/history', '/transfers', '/calendar/day'].includes(endpoint))
body = { items: [], nextCursor: null, revealed: false };
await route.fulfill({ json: body });
});
await page.goto(baseUrl);
await page.locator('.primary-nav').waitFor();
const nav = page.locator('.primary-nav');
assert.deepEqual(await nav.locator('button').allTextContents(), [
'总览',
'日历',
'账号',
'设置',
]);
const checkWidth = async (label) => {
await page
.locator('[role="status"]')
.filter({ hasText: '正在刷新数据' })
.waitFor({ state: 'hidden' });
const widths = await page.evaluate(() => ({
page: document.documentElement.scrollWidth,
viewport: innerWidth,
}));
assert.ok(
widths.page <= widths.viewport,
`${label} horizontal overflow: ${JSON.stringify(widths)}`,
);
};
await checkWidth('overview');
await nav.getByRole('button', { name: '账号', exact: true }).click();
await page.getByRole('heading', { name: '日常账户', exact: true }).waitFor();
await checkWidth('accounts');
await page.screenshot({
path: path.join(output, 'worthpath-mobile-accounts.png'),
fullPage: true,
});
await page.getByRole('button', { name: '快速记账', exact: true }).click();
assert.equal(
await page.getByRole('button', { name: '标记当天已记账', exact: true }).count(),
0,
);
await page.getByRole('button', { name: '隐藏当天有记录', exact: true }).click();
assert.equal(await page.getByRole('heading', { name: '日常账户', exact: true }).count(), 0);
await page.getByRole('button', { name: '显示当天有记录', exact: true }).click();
assert.equal(await page.getByRole('heading', { name: '日常账户', exact: true }).count(), 1);
for (const width of [320, 375, 390, 430, 768, 1440]) {
await page.setViewportSize({ width, height: 844 });
await checkWidth(`quick ${width}`);
}
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({
path: path.join(output, 'worthpath-mobile-quick.png'),
fullPage: true,
});
await page
.locator('.section-nav')
.getByRole('button', { name: '独立资产', exact: true })
.click();
assert.equal(await nav.locator('[aria-current="page"]').textContent(), '账号');
await page.locator('.section-nav').getByRole('button', { name: '债务', exact: true }).click();
await checkWidth('debt');
await nav.getByRole('button', { name: '设置', exact: true }).click();
await page.locator('.settings-category select').selectOption('privacy');
await checkWidth('settings');
await page.screenshot({
path: path.join(output, 'worthpath-mobile-settings.png'),
fullPage: true,
});
await page
.locator('.section-nav')
.getByRole('button', { name: '定时计划', exact: true })
.click();
await checkWidth('plans');
assert.equal(await nav.locator('[aria-current="page"]').textContent(), '设置');
await page
.locator('.section-nav')
.getByRole('button', { name: '管理员后台', exact: true })
.click();
await checkWidth('admin');
await nav.getByRole('button', { name: '日历', exact: true }).click();
await page.locator('.calendar-day').first().waitFor();
await checkWidth('calendar');
await page.screenshot({
path: path.join(output, 'worthpath-mobile-calendar.png'),
fullPage: true,
});
await page.emulateMedia({ reducedMotion: 'reduce' });
assert.equal(
await nav
.locator('button')
.first()
.evaluate((el) => getComputedStyle(el).transitionDuration),
'0s',
);
await page.emulateMedia({ reducedMotion: 'no-preference', colorScheme: 'light' });
await nav.getByRole('button', { name: '账号', exact: true }).click();
await page.getByRole('button', { name: '退出快速记账', exact: true }).click();
await page.getByRole('button', { name: '快速记账', exact: true }).click();
await page.locator('.position-card').first().click();
const modal = page.locator('dialog.native-modal[open]');
await modal.waitFor();
assert.equal(await modal.evaluate((el) => el.contains(document.activeElement)), true);
await page.keyboard.press('Escape');
await modal.waitFor({ state: 'hidden' });
assert.equal(await page.evaluate(() => document.documentElement.dataset.input), 'keyboard');
assert.equal(
await nav
.locator('button')
.first()
.evaluate((el) => getComputedStyle(el).transitionDuration),
'0s',
);
const demo = accounts;
const worst = accounts.map((account, i) => ({
...account,
name: [
'家庭长期退休储备与跨境投资专用账户(用于记录定期存款、货币基金、证券组合及年度家庭应急备用金,请勿与日常消费账户混用)'
.repeat(2)
.slice(0, 100),
'Christopher Alexander Montgomery III · Hong Kong investment reserve',
'J',
][i],
groupName: ['跨境投资与长期退休储备账户(不参与日常消费流水)', '消费', '信用'][i],
amount: ['9999999999999999.98765432', '-42.5', '0'][i],
notes:
i === 0
? 'https://example.com/workspaces/household/accounts/international-reserve?tab=transactions&filter=long-term'
: '',
}));
const fixtures = {
Demo: demo,
'Worst case': worst,
Empty: [],
One: [worst[0]],
'1,000 rows': Array.from({ length: 1000 }, (_, i) => ({
...demo[i % 3],
id: 'bulk-' + i,
name: '家庭账户 ' + (i + 1),
})),
};
// Dev-only fixture picker: injected into this isolated browser session, never production.
await page.exposeFunction('chooseFixture', async (name) => {
accounts = fixtures[name];
});
await page.addInitScript(() =>
document.addEventListener('DOMContentLoaded', () => {
const panel = document.createElement('div');
panel.id = 'fixture-picker';
panel.style.cssText =
'position:fixed;bottom:100px;left:50%;transform:translateX(-50%);z-index:9999;background:#ddd;padding:4px;border-radius:8px;display:flex;gap:3px;font:11px system-ui;max-width:95%;';
for (const name of ['Demo', 'Worst case', 'Empty', 'One', '1,000 rows']) {
const button = document.createElement('button');
button.textContent = name;
button.style.cssText =
'font:inherit;border:0;border-radius:5px;padding:6px;background:white;color:#1d1d1f;';
button.onclick = async () => {
await window.chooseFixture(name);
const url = new URL(location.href);
url.searchParams.set('data', name);
location.href = url;
};
panel.append(button);
}
document.body.append(panel);
}),
);
for (const [name, values] of Object.entries(fixtures)) {
accounts = values;
await page.evaluate(() => localStorage.setItem('worthpath.locale', 'zh-Hans'));
await page.goto(baseUrl + '/?data=' + encodeURIComponent(name));
await page.locator('.primary-nav').waitFor();
await page.locator('.primary-nav').getByRole('button', { name: '账号', exact: true }).click();
await page.getByRole('button', { name: '快速记账', exact: true }).click();
await page
.locator('[role="status"]')
.filter({ hasText: '正在刷新数据' })
.waitFor({ state: 'hidden' });
for (const width of [320, 390, 1440]) {
await page.setViewportSize({ width, height: 844 });
await checkWidth(name + ' ' + width);
}
assert.equal(await page.locator('.position-card').count(), values.length);
if (name === 'Worst case') {
for (const scheme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: scheme });
for (const locale of ['zh-Hans', 'zh-Hant', 'en']) {
await page.locator('.language-picker select').selectOption(locale);
await page.setViewportSize({ width: 320, height: 844 });
await checkWidth(scheme + ' ' + locale);
await page.evaluate(() => {
document.documentElement.style.fontSize = '200%';
});
await checkWidth('200% ' + scheme + ' ' + locale);
await page.evaluate(() => {
document.documentElement.style.fontSize = '';
});
}
}
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('#fixture-picker').evaluate((el) => {
el.style.visibility = 'hidden';
});
await page.screenshot({
path: path.join(output, 'worthpath-mobile-worst-dark.png'),
fullPage: true,
});
await page.locator('#fixture-picker').evaluate((el) => {
el.style.visibility = '';
});
}
}
console.log(
'PASS: native dialog focus/Escape, keyboard no motion; plausible schema-limit names and amounts, empty/one/1,000 records, light/dark, three locales, 200% text.',
);
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setEmulatedMedia', {
features: [{ name: 'prefers-reduced-transparency', value: 'reduce' }],
});
assert.equal(
await page.locator('.sidebar').evaluate((el) => getComputedStyle(el).backdropFilter),
'none',
);
await cdp.send('Emulation.setEmulatedMedia', {
features: [{ name: 'prefers-contrast', value: 'more' }],
});
assert.equal(
await page.locator('.topbar').evaluate((el) => getComputedStyle(el).backdropFilter),
'none',
);
assert.equal(
await page.locator('html').evaluate((el) => getComputedStyle(el).scrollbarWidth),
'auto',
);
await cdp.send('Emulation.setEmulatedMedia', { features: [] });
assert.equal(
await page.locator('html').evaluate((el) => getComputedStyle(el).scrollbarWidth),
'thin',
);
failNext = true;
await page.locator('.topbar [aria-label="刷新数据"]').click();
const toast = page.locator('.toast').last();
await toast.waitFor();
assert.equal(await toast.evaluate((el) => getComputedStyle(el).animationName), 'none');
await toast.getByRole('button').click();
await toast.waitFor({ state: 'hidden' });
console.log(
'PASS: reduced transparency, higher contrast and interruptible toast entry/dismissal.',
);
assert.deepEqual(errors, []);
console.log(
'PASS: four entries, child navigation, daily activity filtering, six viewport widths, calendar/settings/admin and reduced motion; zero browser errors. Screenshots use synthetic data.',
);
} finally {
await browser.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});