Merge hidden recharge entry across sidebar and standalone account menus
This commit is contained in:
commit
5d500a932f
8 files changed
+45
-83
No files matched your search
@@ -41,7 +41,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
|
|||||||
- AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。
|
- AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。
|
||||||
- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。下方麦洛品牌与“操作对话/作品”标签行对齐;更浅底色的“作品广场”与“新建项目”采用相同高度、圆角、边框与阴影,并位于品牌和新建项目之间,上下各留 10px;“我的项目”和项目列表紧接新建项目;项目配置及其资源子页不再重复显示此侧栏入口,保留配置页自身的返回操作。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。
|
- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。下方麦洛品牌与“操作对话/作品”标签行对齐;更浅底色的“作品广场”与“新建项目”采用相同高度、圆角、边框与阴影,并位于品牌和新建项目之间,上下各留 10px;“我的项目”和项目列表紧接新建项目;项目配置及其资源子页不再重复显示此侧栏入口,保留配置页自身的返回操作。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。
|
||||||
- AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。
|
- AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。
|
||||||
- 账号词元点数:左下角账户菜单显示本人永久余额和预占,提供充值、充值订单与分页收支记录。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长为孩子充值时登录孩子的账号,页面显示当前昵称和账号,点数进入该账号自己的钱包;无需将孩子身份改为家长。当前有效账号可给本人充值,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理,充值不会自动切换共享/个人来源。需配套部署 Works Square 当前账号充值资格更新(本次无需新迁移,原永久点数基础为 0094);本地模拟验收不代表真实支付通过。
|
- 账号词元点数:左下角账户菜单显示本人永久余额和预占;“词元点数与充值”入口暂时隐藏,充值、充值订单与分页收支记录的既有实现保留。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长为孩子充值时登录孩子的账号,页面显示当前昵称和账号,点数进入该账号自己的钱包;无需将孩子身份改为家长。当前有效账号可给本人充值,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理,充值不会自动切换共享/个人来源。需配套部署 Works Square 当前账号充值资格更新(本次无需新迁移,原永久点数基础为 0094);本地模拟验收不代表真实支付通过。
|
||||||
- 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。
|
- 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。
|
||||||
- 后台生命周期:Main 统一维护模块活动状态与任务租约。隐藏窗口、离开模块和关闭开发浏览器会释放非必要连接;生成、Code 执行、下载与发布构建持有租约并在完成后释放。已写入 Pi 的 prompt 或上下文整理即使确认超过 10 秒,也会继续持有运行所有权与后台租约,直到迟到响应、权威事件、明确失败或用户清理使其收敛;此时同一 Conversation 不接受重叠 mutation,其他 Conversation 不受影响。各模块的后台连接、轮询和子进程必须通过同一生命周期入口登记。
|
- 后台生命周期:Main 统一维护模块活动状态与任务租约。隐藏窗口、离开模块和关闭开发浏览器会释放非必要连接;生成、Code 执行、下载与发布构建持有租约并在完成后释放。已写入 Pi 的 prompt 或上下文整理即使确认超过 10 秒,也会继续持有运行所有权与后台租约,直到迟到响应、权威事件、明确失败或用户清理使其收敛;此时同一 Conversation 不接受重叠 mutation,其他 Conversation 不受影响。各模块的后台连接、轮询和子进程必须通过同一生命周期入口登记。
|
||||||
- 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。
|
- 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function HomeAccountMenu({ accountName, avatarUrl, onOpenProfile }: {
|
|||||||
<UserAvatar src={avatarUrl} initial={initial} className="h-9 w-9 shrink-0" />
|
<UserAvatar src={avatarUrl} initial={initial} className="h-9 w-9 shrink-0" />
|
||||||
<div className="min-w-0"><p className="truncate text-sm font-semibold">{accountName}</p><p className="mt-1 text-xs text-muted-foreground">词元点数账户</p></div>
|
<div className="min-w-0"><p className="truncate text-sm font-semibold">{accountName}</p><p className="mt-1 text-xs text-muted-foreground">词元点数账户</p></div>
|
||||||
</div>
|
</div>
|
||||||
<PointWallet key={user.userId ?? user.username} active={open} accountName={accountName} accountUsername={user.username} />
|
<PointWallet key={user.userId ?? user.username} active={open} showRechargeEntry={false} accountName={accountName} accountUsername={user.username} />
|
||||||
<div className="mt-1 grid gap-1">
|
<div className="mt-1 grid gap-1">
|
||||||
<button type="button" role="menuitem" className="flex min-h-10 items-center gap-2 rounded-md px-1 text-left text-sm hover:bg-surface-subtle"
|
<button type="button" role="menuitem" className="flex min-h-10 items-center gap-2 rounded-md px-1 text-left text-sm hover:bg-surface-subtle"
|
||||||
onClick={() => { setOpen(false); onOpenProfile(); }}><UserCircle2 className="h-4 w-4" />个人资料</button>
|
onClick={() => { setOpen(false); onOpenProfile(); }}><UserCircle2 className="h-4 w-4" />个人资料</button>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const money = (cents: number) => `¥${(cents / 100).toFixed(2)}`;
|
|||||||
const time = (value: string) => new Date(value).toLocaleString('zh-CN');
|
const time = (value: string) => new Date(value).toLocaleString('zh-CN');
|
||||||
const unresolved = (order: WorksRechargeOrder) => order.status === 'pending' || order.status === 'manual_review';
|
const unresolved = (order: WorksRechargeOrder) => order.status === 'pending' || order.status === 'manual_review';
|
||||||
|
|
||||||
export function PointWallet({ active = true, accountName = '当前登录账号', accountUsername }: { active?: boolean; accountName?: string; accountUsername?: string }) {
|
export function PointWallet({ active = true, accountName = '当前登录账号', accountUsername, showRechargeEntry = true }: { active?: boolean; accountName?: string; accountUsername?: string; showRechargeEntry?: boolean }) {
|
||||||
const [summary, setSummary] = useState<WorksBillingSummary | null>(null);
|
const [summary, setSummary] = useState<WorksBillingSummary | null>(null);
|
||||||
const [summaryError, setSummaryError] = useState(false);
|
const [summaryError, setSummaryError] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -169,7 +169,7 @@ export function PointWallet({ active = true, accountName = '当前登录账号',
|
|||||||
{isWorksTokenPointBalanceExhausted(own) && !usingShared && <p className="mt-1 text-[11px] text-muted-foreground">{summary?.can_recharge ? '点数已用尽,请由家长完成当前账号充值。' : '点数已用尽,当前账号暂不能充值,请联系支持。'}</p>}
|
{isWorksTokenPointBalanceExhausted(own) && !usingShared && <p className="mt-1 text-[11px] text-muted-foreground">{summary?.can_recharge ? '点数已用尽,请由家长完成当前账号充值。' : '点数已用尽,当前账号暂不能充值,请联系支持。'}</p>}
|
||||||
{summaryError && <button className="mt-1 underline" onClick={() => void refreshSummary()}>重试余额</button>}
|
{summaryError && <button className="mt-1 underline" onClick={() => void refreshSummary()}>重试余额</button>}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" role="menuitem" data-testid="sidebar-point-wallet-open" className="flex min-h-10 w-full items-center gap-2 rounded-md px-1 text-left text-sm hover:bg-surface-subtle" onClick={() => { setOpen(true); setTab('recharge'); setOffset(0); }}><Coins className="h-4 w-4" />词元点数与充值</button>
|
{showRechargeEntry && <button type="button" role="menuitem" data-testid="sidebar-point-wallet-open" className="flex min-h-10 w-full items-center gap-2 rounded-md px-1 text-left text-sm hover:bg-surface-subtle" onClick={() => { setOpen(true); setTab('recharge'); setOffset(0); }}><Coins className="h-4 w-4" />词元点数与充值</button>}
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto" data-testid="point-wallet-dialog">
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto" data-testid="point-wallet-dialog">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -864,7 +864,7 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-1 grid gap-0.5">
|
<div className="mt-1 grid gap-0.5">
|
||||||
{authUser && <PointWallet key={authUser.userId ?? authUser.username} active={accountMenuOpen} accountName={accountName} accountUsername={authUser.username} />}
|
{authUser && <PointWallet key={authUser.userId ?? authUser.username} active={accountMenuOpen} accountName={accountName} accountUsername={authUser.username} showRechargeEntry={false} />}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ test.describe('Makelore module navigation without setup flow', () => {
|
|||||||
await trigger.click();
|
await trigger.click();
|
||||||
await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点');
|
await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点');
|
||||||
await expect(menu.getByText('永久有效 · 预占 5 点', { exact: true })).toBeVisible();
|
await expect(menu.getByText('永久有效 · 预占 5 点', { exact: true })).toBeVisible();
|
||||||
await expect(menu.getByRole('menuitem', { name: '词元点数与充值', exact: true })).toBeVisible();
|
await expect(menu.getByRole('menuitem', { name: '词元点数与充值', exact: true })).toHaveCount(0);
|
||||||
await checkAccountGeometry(1440);
|
await checkAccountGeometry(1440);
|
||||||
await page.screenshot({ path: testInfo.outputPath('home-account-menu-desktop.png') });
|
await page.screenshot({ path: testInfo.outputPath('home-account-menu-desktop.png') });
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
@@ -184,17 +184,7 @@ test.describe('Makelore module navigation without setup flow', () => {
|
|||||||
await page.getByRole('heading', { name: '今天,想创作什么?', exact: true }).click();
|
await page.getByRole('heading', { name: '今天,想创作什么?', exact: true }).click();
|
||||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
await trigger.click();
|
await expect(page.getByTestId('point-wallet-dialog')).toHaveCount(0);
|
||||||
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 trigger.click();
|
||||||
await menu.getByRole('menuitem', { name: '个人资料', exact: true }).click();
|
await menu.getByRole('menuitem', { name: '个人资料', exact: true }).click();
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||||
|
|
||||||
type WalletFixture = { requests: string[]; paid: boolean; created: boolean };
|
type WalletFixture = { requests: string[]; credited: boolean };
|
||||||
type FixtureGlobal = typeof globalThis & { __pointWalletE2E: WalletFixture };
|
type FixtureGlobal = typeof globalThis & { __pointWalletE2E: WalletFixture };
|
||||||
|
|
||||||
test.describe('Permanent point wallet', () => {
|
test.describe('Permanent point wallet', () => {
|
||||||
test('shows the child recipient, reopens its frozen unpaid order, and refreshes only after confirmed credit', async ({ launchElectronApp }, testInfo) => {
|
test('hides recharge while retaining account controls and refreshing the balance', async ({ launchElectronApp }, testInfo) => {
|
||||||
const app = await launchElectronApp({ skipSetup: true });
|
const app = await launchElectronApp({ skipSetup: true });
|
||||||
try {
|
try {
|
||||||
const page = await getStableWindow(app);
|
const page = await getStableWindow(app);
|
||||||
@@ -12,21 +12,14 @@ test.describe('Permanent point wallet', () => {
|
|||||||
const applicationUrl = page.url();
|
const applicationUrl = page.url();
|
||||||
await page.goto('about:blank');
|
await page.goto('about:blank');
|
||||||
await app.evaluate(({ ipcMain }) => {
|
await app.evaluate(({ ipcMain }) => {
|
||||||
const state: WalletFixture = { requests: [], paid: false, created: false };
|
const state: WalletFixture = { requests: [], credited: false };
|
||||||
(globalThis as FixtureGlobal).__pointWalletE2E = state;
|
(globalThis as FixtureGlobal).__pointWalletE2E = state;
|
||||||
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
|
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
|
||||||
const points = () => ({
|
const points = () => ({
|
||||||
total: state.paid ? '500.00' : '0.00', used: '0.00', reserved: '0.00',
|
total: state.credited ? '500.00' : '0.00', used: '0.00', reserved: '0.00',
|
||||||
total_remaining: state.paid ? '500.00' : '0.00', entitlement_source: 'self',
|
total_remaining: state.credited ? '500.00' : '0.00', entitlement_source: 'self',
|
||||||
family_shared: false, can_recharge: true, shared_available: null, expires_at: null,
|
family_shared: false, can_recharge: true, shared_available: null, expires_at: null,
|
||||||
});
|
});
|
||||||
const order = () => ({
|
|
||||||
id: 'order-e2e', product_id: 'product-e2e', product_name: '500 词元点数',
|
|
||||||
status: state.paid ? 'succeeded' : 'pending', amount_cents: 1000, point_amount: '500.00',
|
|
||||||
currency: 'CNY', created_at: '2026-09-22T00:00:00Z',
|
|
||||||
paid_at: state.paid ? '2026-09-22T01:00:00Z' : null, manual_review_required: false,
|
|
||||||
qr_payload: state.paid ? null : 'weixin://wxpay/e2e-fixture-not-a-real-payment',
|
|
||||||
});
|
|
||||||
ipcMain.removeHandler('hostapi:fetch');
|
ipcMain.removeHandler('hostapi:fetch');
|
||||||
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => {
|
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => {
|
||||||
const path = request.path ?? '';
|
const path = request.path ?? '';
|
||||||
@@ -55,23 +48,9 @@ test.describe('Permanent point wallet', () => {
|
|||||||
can_recharge: true, payment_configured: true, signup_bonus: '100.00', points_per_yuan: 50,
|
can_recharge: true, payment_configured: true, signup_bonus: '100.00', points_per_yuan: 50,
|
||||||
recharge_products: [{
|
recharge_products: [{
|
||||||
id: 'product-e2e', name: '词元点数',
|
id: 'product-e2e', name: '词元点数',
|
||||||
amount_cents: state.created ? 2000 : 1000, point_amount: state.created ? '1000.00' : '500.00', currency: 'CNY',
|
amount_cents: 1000, point_amount: '500.00', currency: 'CNY',
|
||||||
}],
|
}],
|
||||||
} });
|
} });
|
||||||
if (path.startsWith('/api/works/billing/recharge/orders?')) return result({
|
|
||||||
success: true, data: { items: state.created ? [order()] : [] },
|
|
||||||
});
|
|
||||||
if (path === '/api/works/billing/recharge/orders' && method === 'POST') {
|
|
||||||
state.created = true;
|
|
||||||
return result({ success: true, data: order() });
|
|
||||||
}
|
|
||||||
if (path === '/api/works/billing/recharge/orders/order-e2e') return result({ success: true, data: order() });
|
|
||||||
if (path.startsWith('/api/works/billing/points/transactions?')) return result({
|
|
||||||
success: true, data: { has_more: false, items: state.paid ? [{
|
|
||||||
id: 'credit-e2e', kind: 'credit', description: '充值', status: 'credited',
|
|
||||||
points: '500.00', reserved_points: '0.00', created_at: '2026-09-22T01:00:00Z',
|
|
||||||
}] : [] },
|
|
||||||
});
|
|
||||||
return { ok: false, error: { message: 'Unexpected fixture request: ' + method + ' ' + path } };
|
return { ok: false, error: { message: 'Unexpected fixture request: ' + method + ' ' + path } };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -82,36 +61,29 @@ test.describe('Permanent point wallet', () => {
|
|||||||
await page.getByTestId('sidebar-member-menu-trigger').click();
|
await page.getByTestId('sidebar-member-menu-trigger').click();
|
||||||
await expect(page.getByTestId('sidebar-total-token-points')).toHaveText('0 点');
|
await expect(page.getByTestId('sidebar-total-token-points')).toHaveText('0 点');
|
||||||
await expect(page.getByText('点数已用尽,请由家长完成当前账号充值。')).toBeVisible();
|
await expect(page.getByText('点数已用尽,请由家长完成当前账号充值。')).toBeVisible();
|
||||||
await page.getByTestId('sidebar-point-wallet-open').click();
|
const menu = page.getByTestId('sidebar-account-menu');
|
||||||
const wallet = page.getByTestId('point-wallet-dialog');
|
await expect(menu.getByRole('menuitem', { name: '词元点数与充值' })).toHaveCount(0);
|
||||||
await expect(wallet).toContainText('1 元充值 50 点');
|
await expect(page.getByTestId('sidebar-point-wallet-open')).toHaveCount(0);
|
||||||
await expect(wallet.getByTestId('point-recharge-recipient')).toHaveText('充值到:小麦(child-wallet)');
|
await expect(page.getByTestId('point-wallet-dialog')).toHaveCount(0);
|
||||||
await expect(wallet).toContainText('请由家长登录孩子的账号完成付款');
|
await expect(menu.getByRole('menuitem', { name: '个人资料' })).toBeVisible();
|
||||||
await expect(wallet).not.toContainText('重置卡');
|
await expect(menu.getByRole('menuitem', { name: '设置' })).toBeVisible();
|
||||||
await wallet.getByRole('button', { name: /500 点\s*¥10.00/ }).click();
|
await expect(menu.getByRole('menuitem', { name: '退出登录' })).toBeVisible();
|
||||||
await expect(wallet.getByAltText('充值支付二维码')).toBeVisible();
|
await page.screenshot({ path: testInfo.outputPath('account-menu-recharge-hidden.png') });
|
||||||
await expect(page.getByTestId('point-recharge-checkout')).toContainText('¥10.00 · 500 点');
|
|
||||||
await expect(wallet.getByRole('button', { name: /1,000 点\s*¥20.00/ })).toHaveCount(0);
|
await page.getByTestId('sidebar-member-menu-trigger').click();
|
||||||
await page.screenshot({ path: testInfo.outputPath('pending-permanent-points.png') });
|
await app.evaluate(() => { (globalThis as FixtureGlobal).__pointWalletE2E.credited = true; });
|
||||||
|
await page.getByTestId('sidebar-member-menu-trigger').click();
|
||||||
|
await expect(page.getByTestId('sidebar-total-token-points')).toHaveText('500 点');
|
||||||
|
await expect(page.getByText('点数已用尽,请由家长完成当前账号充值。')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('sidebar-point-wallet-open')).toHaveCount(0);
|
||||||
|
|
||||||
// Reloading the Renderer must recover the existing server order using GET only.
|
|
||||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
await page.getByTestId('sidebar-member-menu-trigger').click();
|
await page.getByTestId('sidebar-member-menu-trigger').click();
|
||||||
await page.getByTestId('sidebar-point-wallet-open').click();
|
await expect(page.getByTestId('sidebar-total-token-points')).toHaveText('500 点');
|
||||||
await wallet.getByRole('button', { name: '查看原订单' }).click();
|
await expect(page.getByTestId('sidebar-point-wallet-open')).toHaveCount(0);
|
||||||
await expect(wallet.getByAltText('充值支付二维码')).toBeVisible();
|
|
||||||
await app.evaluate(() => { (globalThis as FixtureGlobal).__pointWalletE2E.paid = true; });
|
|
||||||
await wallet.getByRole('button', { name: '刷新此订单' }).click();
|
|
||||||
await expect(page.getByTestId('point-recharge-checkout')).toContainText('已到账');
|
|
||||||
await expect(wallet.getByAltText('充值支付二维码')).toHaveCount(0);
|
|
||||||
await expect(wallet).toContainText('500 点');
|
|
||||||
await wallet.getByRole('tab', { name: '收支记录' }).click();
|
|
||||||
await expect(wallet).toContainText('+500 点');
|
|
||||||
await expect(wallet).toContainText('已入账');
|
|
||||||
await page.screenshot({ path: testInfo.outputPath('credited-permanent-points.png') });
|
|
||||||
expect(await app.evaluate(() => (globalThis as FixtureGlobal).__pointWalletE2E.requests.filter(
|
expect(await app.evaluate(() => (globalThis as FixtureGlobal).__pointWalletE2E.requests.filter(
|
||||||
(request) => request === 'POST /api/works/billing/recharge/orders',
|
(request) => request.includes('/api/works/billing/recharge/') || request.includes('/api/works/billing/points/transactions'),
|
||||||
).length)).toBe(1);
|
))).toEqual([]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const page = app.windows().at(-1);
|
const page = app.windows().at(-1);
|
||||||
if (page && !page.isClosed()) {
|
if (page && !page.isClosed()) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
it('shows the authenticated account and opens the real personal wallet with read requests only', async () => {
|
it('shows the authenticated account balance and controls without a recharge entry', async () => {
|
||||||
renderHome();
|
renderHome();
|
||||||
const trigger = await screen.findByTestId('home-account-menu-trigger');
|
const trigger = await screen.findByTestId('home-account-menu-trigger');
|
||||||
await waitFor(() => expect(trigger).toHaveTextContent('小麦'));
|
await waitFor(() => expect(trigger).toHaveTextContent('小麦'));
|
||||||
@@ -56,14 +56,10 @@ it('shows the authenticated account and opens the real personal wallet with read
|
|||||||
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||||
expect(await within(menu).findByTestId('sidebar-total-token-points')).toHaveTextContent('75 点');
|
expect(await within(menu).findByTestId('sidebar-total-token-points')).toHaveTextContent('75 点');
|
||||||
expect(within(menu).getByText('永久有效 · 预占 5 点', { exact: true })).toBeInTheDocument();
|
expect(within(menu).getByText('永久有效 · 预占 5 点', { exact: true })).toBeInTheDocument();
|
||||||
fireEvent.click(within(menu).getByRole('menuitem', { name: '词元点数与充值', exact: true }));
|
expect(within(menu).queryByRole('menuitem', { name: '词元点数与充值', exact: true })).not.toBeInTheDocument();
|
||||||
const dialog = await screen.findByTestId('point-wallet-dialog');
|
expect(screen.queryByTestId('point-wallet-dialog')).not.toBeInTheDocument();
|
||||||
await waitFor(() => expect(within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ })).toBeEnabled());
|
for (const name of ['个人资料', '设置', '退出登录']) expect(within(menu).getByRole('menuitem', { name, exact: true })).toBeVisible();
|
||||||
expect(within(dialog).getByTestId('point-recharge-recipient')).toHaveTextContent('充值到:小麦(child-wallet)');
|
expect(api.fetch.mock.calls.filter(([path]) => path.includes('/billing/recharge/') || path.includes('/billing/points/transactions'))).toEqual([]);
|
||||||
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([]);
|
expect(writes()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -85,13 +85,17 @@ describe('Sidebar permanent point wallet', () => {
|
|||||||
fetchBillingSummaryMock.mockReset();
|
fetchBillingSummaryMock.mockReset();
|
||||||
fetchBillingSummaryMock.mockResolvedValue(billingSummary());
|
fetchBillingSummaryMock.mockResolvedValue(billingSummary());
|
||||||
});
|
});
|
||||||
it('opens the point wallet from the account menu without retired member controls', async () => {
|
it('keeps the balance and account controls while hiding the recharge entry', async () => {
|
||||||
await openAccount();
|
await openAccount();
|
||||||
await waitFor(() => expect(screen.getByTestId('sidebar-total-token-points')).toHaveTextContent('75 点'));
|
await waitFor(() => expect(screen.getByTestId('sidebar-total-token-points')).toHaveTextContent('75 点'));
|
||||||
expect(screen.queryByText(/重置卡|升级订阅|会员等级|本周剩余/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/重置卡|升级订阅|会员等级|本周剩余/)).not.toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByTestId('sidebar-point-wallet-open'));
|
expect(screen.getByTestId('sidebar-point-balance')).toHaveTextContent('永久有效 · 预占 5 点');
|
||||||
expect(screen.getByTestId('point-wallet-dialog')).toHaveTextContent('1 元充值 50 点');
|
expect(screen.queryByTestId('sidebar-point-wallet-open')).not.toBeInTheDocument();
|
||||||
expect(screen.getByTestId('point-recharge-recipient')).toHaveTextContent('测试用户(member@example.com)');
|
expect(screen.queryByRole('menuitem', { name: '词元点数与充值' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('point-wallet-dialog')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('menuitem', { name: '个人资料' })).toBeVisible();
|
||||||
|
expect(screen.getByRole('menuitem', { name: '设置' })).toBeVisible();
|
||||||
|
expect(screen.getByRole('menuitem', { name: '退出登录' })).toBeVisible();
|
||||||
});
|
});
|
||||||
it('shows a retryable failure without inventing a zero balance', async () => {
|
it('shows a retryable failure without inventing a zero balance', async () => {
|
||||||
fetchBillingSummaryMock.mockRejectedValue(new Error('offline'));
|
fetchBillingSummaryMock.mockRejectedValue(new Error('offline'));
|
||||||
|
|||||||
Reference in new issue
Block a user