From 5f7c851022bf3be36000c4729e04dbd52704a482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 12:47:49 +0800 Subject: [PATCH] fix(home): restore account wallet and settings access --- ...20261013-combined-trial-update-c42d19a7.md | 14 +- README.md | 2 +- src/components/account/HomeAccountMenu.tsx | 100 +++++++++++++ src/pages/ModuleSelection/index.tsx | 13 +- tests/e2e/main-navigation.spec.ts | 114 +++++++++++++++ tests/unit/home-account-menu.test.tsx | 134 ++++++++++++++++++ 6 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 src/components/account/HomeAccountMenu.tsx create mode 100644 tests/unit/home-account-menu.test.tsx diff --git a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md index 4eb84fd6..4161d70c 100644 --- a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md +++ b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md @@ -8,12 +8,24 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-combined-trial-update-c42d19a7 - Base commit: 8289f27f3e53ef455b656f1c43d2620d235d5014 - Owner: codex -- Status: Ready for Integration +- Status: Planning ## Scope - Assemble and validate completed homepage and Agent changes, then update the existing local temporary application with both Main and Renderer while retaining its profile and login. - Keep the current temporary application as the target; no production backend deployment, repository push, primary checkout change or worktree cleanup. +- Follow-up: restore access to the existing account controls on the standalone module homepage and update the current trial. + +## Homepage Account Follow-up · 2026-10-13 + +- Concurrent / Planning Gates: Passed on same-task resume. Successful `start` and exact registry task/worktree/branch/feature/base match verified; reread active record, retained unchanged startup/domain/accepted decision context and 31-peer assessment. Fetch confirms origin/main still bf39f40; no new peer semantic dependency. Current verified runtime is product 7c51bb4, Main PID 9968 / port 58003. +- User reports the homepage lacks the pictured balance/recharge/profile/settings/logout controls. Source review confirms /module-select is outside MainLayout, and these controls only exist in Sidebar. Old be53e44, primary10da and latest main also lack a homepage menu; this assembly did not remove the Sidebar implementation. +- Plan: add a compact homepage account button/menu using existing UserAvatar, PointWallet and the page's existing profile hook/dialog; settings and logout use existing routes/auth action. Keep wallet mounted through menu closure and remount on account change. Preserve three-module layout, module permissions, drag region and mandatory-profile behavior. +- Verify real wallet fixtures and menu interactions in unit/Electron tests, plus small/desktop layouts; rebuild and update Renderer only if complete Main/preload/utility byte comparison permits, keeping the verified session/process and current focus. +- Do not open a real recharge order, modify profile, logout the real user or introduce another billing/auth authority during validation. +- Implementation verified: homepage account footer/menu, real PointWallet balance/preoccupation/recharge UI, existing profile dialog, settings route and logout action. Wallet remains mounted across menu closure; account identity keys isolate balances. Homepage uses a scrolling viewport and correct responsive padding so its footer remains reachable. +- Verification: 25 focused unit tests (homepage 5 / module navigation 9 / wallet 11), typecheck, scoped lint and full build passed. New fixture Electron account scenario and the three-card responsive scenario passed; screenshots at 1440 and 375 visually inspected. Initial account fixture failure was an absent fake session, corrected using the real versioned storage contract, without changing the product's authentication guard. +- Entire dist-electron and resources independently match the running 7c51bb4 snapshot byte-for-byte. No Main/shared/resource/package source changes. Renderer-only application update pending; source commit below will identify the build. ## Project Context Loaded diff --git a/README.md b/README.md index cf0dfc14..ac645270 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。 -Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。三个入口在桌面等宽并排、插画在上文字在下,小窗口改为纵向排列的横向插画卡片;Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。三个入口在桌面等宽并排、插画在上文字在下,小窗口改为纵向排列的横向插画卡片;Agents 标语为“打造你想象中的AI助手”。首页左下角保留账户入口,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 diff --git a/src/components/account/HomeAccountMenu.tsx b/src/components/account/HomeAccountMenu.tsx new file mode 100644 index 00000000..ddcfda3f --- /dev/null +++ b/src/components/account/HomeAccountMenu.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from 'react'; +import { ChevronUp, LogOut, Settings, UserCircle2 } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { UserAvatar } from '@/components/profile/UserAvatar'; +import { getAccountInitial } from '@/components/profile/user-avatar-utils'; +import { DisclosureContent } from '@/components/ui/disclosure'; +import { useAuthStore } from '@/stores/auth'; +import { PointWallet } from './PointWallet'; + +export function HomeAccountMenu({ accountName, avatarUrl, onOpenProfile }: { + accountName: string; + avatarUrl?: string | null; + onOpenProfile: () => void; +}) { + const user = useAuthStore(state => state.user); + const logout = useAuthStore(state => state.logout); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [loggingOut, setLoggingOut] = useState(false); + const [error, setError] = useState(''); + const trigger = useRef(null); + const menu = useRef(null); + const initial = getAccountInitial(accountName); + + useEffect(() => { + if (!open) return; + const outside = (event: MouseEvent) => { + if (event.target instanceof Node && !trigger.current?.contains(event.target) && !menu.current?.contains(event.target)) setOpen(false); + }; + const escape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + setOpen(false); + trigger.current?.focus(); + }; + document.addEventListener('mousedown', outside); + document.addEventListener('keydown', escape); + return () => { + document.removeEventListener('mousedown', outside); + document.removeEventListener('keydown', escape); + }; + }, [open]); + + const focusItem = (last = false) => requestAnimationFrame(() => { + if (trigger.current?.getAttribute('aria-expanded') !== 'true') return; + const items = menu.current?.querySelectorAll('[role="menuitem"]:not(:disabled)'); + if (items?.length) items[last ? items.length - 1 : 0].focus(); + }); + + if (!user) return null; + + return
+ + { + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const items = [...(menu.current?.querySelectorAll('[role="menuitem"]:not(:disabled)') ?? [])]; + const current = items.indexOf(document.activeElement as HTMLButtonElement); + const index = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 + : (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length; + items[index]?.focus(); + }}> +
+ +

{accountName}

词元点数账户

+
+ +
+ + + +
+ {error &&

{error}

} +
+
; +} diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index 80fe3c57..a342ce41 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -6,6 +6,7 @@ import moduleGameImage from '@/assets/module-game.webp'; import moduleRobotImage from '@/assets/module-robot.webp'; import logoWordmarkSource from '@/assets/makelore-wordmark-source.png'; import { UserProfileDialog } from '@/components/profile/UserProfileDialog'; +import { HomeAccountMenu } from '@/components/account/HomeAccountMenu'; import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; import { aiModules, isAiModuleAllowed, type AiModuleId } from '@/lib/ai-modules'; import { getAuthUserDisplayName } from '@/lib/auth-user-display'; @@ -29,6 +30,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea userProfile, profileRequired, profileSyncError, + syncProfileNow, } = useCurrentUserProfile(authRequired); const [profileDialogOpen, setProfileDialogOpen] = useState(false); const userDisplayName = getUserProfileDisplayName(userProfile) || getAuthUserDisplayName(authUser); @@ -48,8 +50,8 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea }; return ( -
-
+
+
+ {authenticated && authUser &&
+ { + setDismissedSyncError(null); setProfileDialogOpen(true); + void syncProfileNow().catch(() => undefined); + }} /> +
}
{ + test('keeps the home account wallet, profile, settings and logout available without creating a payment', async ({ launchElectronApp }, testInfo) => { + const app = await launchElectronApp({ skipSetup: true }); + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + const applicationUrl = page.url(); + await page.goto('about:blank'); + await app.evaluate(({ ipcMain, BrowserWindow }, summary) => { + BrowserWindow.getAllWindows()[0].setSize(1440, 900); + const state = { loggedIn: true, requests: [] as { method: string; path: string }[] }; + Object.assign(globalThis, { homeAccountFixture: state }); + const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } }); + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => { + const path = request.path ?? ''; + const method = (request.method ?? 'GET').toUpperCase(); + state.requests.push({ method, path }); + if (path === '/api/auth/session/sync') return result({ success: true, session: state.loggedIn ? { accessToken: 'home-account-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } : null }); + if (path === '/api/auth/me') return result({ success: true, user: state.loggedIn ? { username: 'child-wallet', userId: 'home-user', tenantId: null, deptId: null, authorities: [] } : null, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + if (path === '/api/auth/logout' && method === 'POST') { state.loggedIn = false; return result({ success: true }); } + if (path === '/api/works/user/agent-profile' && method === 'GET') return result({ success: true, profile: { display_name: '小麦', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } }); + if (path === '/api/works/billing/me' && method === 'GET') return result({ success: true, data: summary }); + if (path.startsWith('/api/works/billing/recharge/orders?') && method === 'GET') return result({ success: true, data: { items: [] } }); + if (path.startsWith('/api/works/billing/') || path === '/api/works/user/agent-profile') throw new Error('Unexpected home account write ' + method + ' ' + path); + return result({ success: true }); + }); + }, billingSummary()); + // The setup-skipping fixture starts as a guest. Hydrate only this isolated + // Renderer with a fake persisted session before the auth store is created. + await page.addInitScript(() => { + const now = Date.now(); + localStorage.setItem('niancode-auth', JSON.stringify({ version: 4, state: { + authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', + accessToken: 'home-account-fixture', tokenType: 'Bearer', + expiresAt: now + 3600000, lastActiveAt: now, canRefresh: false, legacyRefreshToken: null, + user: { username: 'child-wallet', userId: 'home-user', tenantId: null, deptId: null, authorities: [] }, + moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true }, + } })); + // Setup-skipping mode also suppresses the home profile sync. Restore the + // isolated account's cached profile in the store's persisted shape. + localStorage.setItem('niancode-user-agent-profiles', JSON.stringify({ version: 1, state: { + profilesByUserId: { 'home-user': { displayName: '小麦', age: null, gender: 'undisclosed', avatarUrl: null, + shareAgeWithAgents: false, shareGenderWithAgents: false, analysisEnabled: true, + pendingSync: false, version: 1, updatedAt: '2026-10-13T00:00:00Z' } }, + } })); + }); + await page.goto(applicationUrl); + const trigger = page.getByTestId('home-account-menu-trigger'); + const menu = page.getByTestId('home-account-menu'); + await expect(trigger).toContainText('小麦'); + await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3); + const footer = await trigger.evaluate(element => element.closest('footer')!.getBoundingClientRect().top); + const cards = await page.getByTestId('ai-module-options').boundingBox(); + expect(footer).toBeGreaterThanOrEqual(cards!.y + cards!.height); + await trigger.click(); + await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点'); + await expect(menu.getByText('永久有效 · 预占 5 点', { exact: true })).toBeVisible(); + await expect(menu.getByRole('menuitem', { name: '词元点数与充值', exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('home-account-menu-desktop.png') }); + await page.keyboard.press('Escape'); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + await expect(trigger).toBeFocused(); + await trigger.click(); + await page.getByRole('heading', { name: '今天,想创作什么?', exact: true }).click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + await trigger.click(); + await menu.getByRole('menuitem', { name: '词元点数与充值', exact: true }).click(); + const wallet = page.getByTestId('point-wallet-dialog'); + await expect(wallet).toBeVisible(); + await expect(wallet.getByTestId('point-recharge-recipient')).toHaveText('充值到:小麦(child-wallet)'); + await expect(wallet.getByRole('button', { name: /500 点\s*¥10.00/ })).toBeEnabled(); + await wallet.getByText('我的可用点数', { exact: true }).click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + await expect(wallet).toBeVisible(); + await wallet.getByRole('button', { name: '关闭词元点数', exact: true }).click(); + await expect(wallet).toHaveCount(0); + + await trigger.click(); + await menu.getByRole('menuitem', { name: '个人资料', exact: true }).click(); + const profile = page.getByRole('dialog', { name: '个人资料', exact: true }); + await expect(profile.getByLabel(/名字/)).toHaveValue('小麦'); + await profile.getByRole('button', { name: '关闭个人资料', exact: true }).click(); + await trigger.click(); + await menu.getByRole('menuitem', { name: '设置', exact: true }).click(); + await expect(page).toHaveURL(/\/settings$/); + await page.evaluate(() => { window.location.hash = '#/module-select'; }); + await expect(trigger).toContainText('小麦'); + + await app.evaluate(({ BrowserWindow }) => { const window = BrowserWindow.getAllWindows()[0]; window.setMinimumSize(360, 600); window.setSize(375, 800); }); + await expect.poll(() => page.evaluate(() => innerWidth)).toBe(375); + await trigger.click(); + await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点'); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + const menuBox = await menu.boundingBox(); + expect(menuBox!.x).toBeGreaterThanOrEqual(0); + expect(menuBox!.x + menuBox!.width).toBeLessThanOrEqual(375); + await page.screenshot({ path: testInfo.outputPath('home-account-menu-375.png') }); + await menu.getByRole('menuitem', { name: '退出登录', exact: true }).click(); + await expect(page).toHaveURL(/\/login$/); + await expect(trigger).toHaveCount(0); + const requests = await app.evaluate(() => (globalThis as typeof globalThis & { homeAccountFixture: { requests: { method: string; path: string }[] } }).homeAccountFixture.requests); + const billingReads = requests.filter(request => request.path.startsWith('/api/works/billing/')); + expect(billingReads.length).toBeGreaterThan(0); + expect(billingReads.every(request => request.method === 'GET')).toBe(true); + expect(requests.filter(request => request.path === '/api/works/user/agent-profile').every(request => request.method === 'GET')).toBe(true); + expect(requests.filter(request => request.path === '/api/auth/logout' && request.method === 'POST')).toHaveLength(1); + } finally { + await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined); + await closeElectronApp(app); + } + }); + test('opens remembered Code projects and keeps the module return entry in chat instead of project settings', async ({ electronTestResources, launchElectronApp, diff --git a/tests/unit/home-account-menu.test.tsx b/tests/unit/home-account-menu.test.tsx new file mode 100644 index 00000000..8ac090dc --- /dev/null +++ b/tests/unit/home-account-menu.test.tsx @@ -0,0 +1,134 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { ModuleSelection } from '@/pages/ModuleSelection'; +import { useAuthStore } from '@/stores/auth'; +import { useUserProfileStore } from '@/stores/user-profile'; +import { billingSummary, permanentBalance } from '../fixtures/permanent-points'; + +const api = vi.hoisted(() => ({ fetch: vi.fn() })); +vi.mock('@/lib/host-api', () => ({ hostApiFetch: api.fetch })); + +let profileName: string; +function profileResponse() { + return { success: true, profile: { display_name: profileName, age: null, gender: null, avatar_url: null, + share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, + completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } }; +} +function LocationProbe() { + const location = useLocation(); + return {location.pathname}; +} +function renderHome() { + return render( + } /> + } /> + ); +} +const writes = () => api.fetch.mock.calls.filter(([, init]) => init?.method && init.method !== 'GET'); + +beforeEach(() => { + vi.resetAllMocks(); profileName = '小麦'; + useAuthStore.setState({ initialized: true, loading: false, error: null, accessToken: 'home-fixture', tokenType: 'Bearer', + expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false, legacyRefreshToken: null, + user: { username: 'child-wallet', userId: 'home-user', tenantId: null, deptId: null, authorities: [] }, + moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + useUserProfileStore.setState({ profilesByUserId: {}, syncStatesByUserId: {} }); + api.fetch.mockImplementation(async (path: string) => { + if (path === '/api/works/user/agent-profile') return profileResponse(); + if (path === '/api/works/billing/me') return { success: true, data: billingSummary() }; + if (path.startsWith('/api/works/billing/recharge/orders?')) return { success: true, data: { items: [] } }; + if (path === '/api/auth/logout') return { success: true }; + throw new Error('Unexpected home account fixture request ' + path); + }); +}); +afterEach(cleanup); + +it('shows the authenticated account and opens the real personal wallet with read requests only', async () => { + renderHome(); + const trigger = await screen.findByTestId('home-account-menu-trigger'); + await waitFor(() => expect(trigger).toHaveTextContent('小麦')); + expect(screen.getAllByTestId(/^ai-module-option-/)).toHaveLength(3); + fireEvent.click(trigger); + const menu = screen.getByTestId('home-account-menu'); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(await within(menu).findByTestId('sidebar-total-token-points')).toHaveTextContent('75 点'); + expect(within(menu).getByText('永久有效 · 预占 5 点', { exact: true })).toBeInTheDocument(); + fireEvent.click(within(menu).getByRole('menuitem', { name: '词元点数与充值', exact: true })); + const dialog = await screen.findByTestId('point-wallet-dialog'); + await waitFor(() => expect(within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ })).toBeEnabled()); + expect(within(dialog).getByTestId('point-recharge-recipient')).toHaveTextContent('充值到:小麦(child-wallet)'); + expect(api.fetch).toHaveBeenCalledWith('/api/works/billing/recharge/orders?offset=0', undefined); + fireEvent.mouseDown(within(dialog).getByText('我的可用点数', { exact: true })); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(dialog).toBeVisible(); + expect(writes()).toEqual([]); +}); + +it('opens the existing profile dialog and navigates to settings without saving profile changes', async () => { + renderHome(); + await screen.findByText('小麦,欢迎回来!'); + fireEvent.click(screen.getByTestId('home-account-menu-trigger')); + fireEvent.click(within(screen.getByTestId('home-account-menu')).getByRole('menuitem', { name: '个人资料', exact: true })); + const profile = await screen.findByRole('dialog', { name: '个人资料', exact: true }); + expect(within(profile).getByLabelText(/名字/)).toHaveValue('小麦'); + fireEvent.click(within(profile).getByRole('button', { name: '关闭个人资料', exact: true })); + fireEvent.click(screen.getByTestId('home-account-menu-trigger')); + fireEvent.click(within(screen.getByTestId('home-account-menu')).getByRole('menuitem', { name: '设置', exact: true })); + expect(await screen.findByTestId('location-path')).toHaveTextContent('/settings'); + expect(writes()).toEqual([]); +}); + +it('closes the menu on Escape and outside clicks while preserving the wallet instance', async () => { + renderHome(); + const trigger = await screen.findByTestId('home-account-menu-trigger'); + fireEvent.click(trigger); + const menu = screen.getByTestId('home-account-menu'); + const wallet = await within(menu).findByTestId('sidebar-point-balance'); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(menu).toHaveAttribute('aria-hidden', 'true'); + fireEvent.click(trigger); + fireEvent.mouseDown(screen.getByRole('heading', { name: '今天,想创作什么?', exact: true })); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(trigger); + expect(within(menu).getByTestId('sidebar-point-balance')).toBe(wallet); + expect(writes()).toEqual([]); +}); + +it('remounts the wallet for another account and ignores the previous delayed balance', async () => { + let finish!: (value: { success: boolean; data: ReturnType }) => void; + const base = api.fetch.getMockImplementation()!; + let firstBalance = true; + api.fetch.mockImplementation((path: string, init) => { + if (path === '/api/works/billing/me') { + if (firstBalance) { firstBalance = false; return new Promise(resolve => { finish = resolve; }); } + return Promise.resolve({ success: true, data: billingSummary({ own_points: permanentBalance({ total_remaining: '2.00' }) }) }); + } + return base(path, init); + }); + renderHome(); + fireEvent.click(await screen.findByTestId('home-account-menu-trigger')); + await waitFor(() => expect(firstBalance).toBe(false)); + profileName = '小红'; + await act(async () => useAuthStore.setState({ user: { username: 'second-wallet', userId: 'second-user', tenantId: null, deptId: null, authorities: [] } })); + const trigger = screen.getByTestId('home-account-menu-trigger'); + await waitFor(() => expect(trigger).toHaveTextContent('小红')); + if (trigger.getAttribute('aria-expanded') !== 'true') fireEvent.click(trigger); + await waitFor(() => expect(within(screen.getByTestId('home-account-menu')).getByTestId('sidebar-total-token-points')).toHaveTextContent('2 点')); + await act(async () => finish({ success: true, data: billingSummary({ own_points: permanentBalance({ total_remaining: '999.00' }) }) })); + expect(screen.queryByText('999 点', { exact: true })).not.toBeInTheDocument(); + expect(writes()).toEqual([]); +}); + +it('logs out only the fixture account and hides the home account entry for signed-out users', async () => { + renderHome(); + fireEvent.click(await screen.findByTestId('home-account-menu-trigger')); + fireEvent.click(within(screen.getByTestId('home-account-menu')).getByRole('menuitem', { name: '退出登录', exact: true })); + expect(await screen.findByTestId('location-path')).toHaveTextContent('/login'); + expect(useAuthStore.getState().user).toBeNull(); + expect(writes()).toEqual([['/api/auth/logout', expect.objectContaining({ method: 'POST' })]]); + cleanup(); + renderHome(); + expect(screen.queryByTestId('home-account-menu-trigger')).not.toBeInTheDocument(); +});