mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(billing): support Coding Plan usage and wallet fallback * fix(web): keep AMR send off billing preflight Workspace send no longer waits on advisory vela billing preflight; recovery still requests it. Realign zero-wallet P0 oracles and the credits-card wallet label. Generated-By: looper 0.15.0 (runner=fixer, agent=omp) * fix(billing): serialize recovery preflight and bound hung vela Skip overlapping AmrBalanceDialog recovery ticks and stop the watch after ten minutes even if a preflight is still pending. Give vela billing preflight a 60s runVelaCommand timeout so a hung CLI cannot pin recovery. Generated-By: looper 0.15.0 (runner=fixer, agent=omp) * fix(web): restore coding plan text contrast in billing card * feat(web): render Coding Plan windows and Go tier in the credits panel - CodingPlanUsage renders one row per preflight window (name, used %, remaining USD, reset countdown + absolute time) with loading/unavailable states; refreshes once on mount and once at resetsAt (no 30s polling, setTimeout delay clamped to the 32-bit limit). - Pure model in coding-plan-usage-model.ts; creditsPerUsd comes from `vela billing summary` through WorkspaceBillingSummary and falls back to 10,000 only when the backend omits it. - PlanWordmark/EntryNavRail accept the `go` tier (placeholder wordmark, design asset still pending). - i18n: 7 new billing keys + entry.billingTierGo across all 19 locales; real translations for the #8339 keys; drop unused codingPlanNone / codingPlanRemaining. - CLI wording: unstarted window reads "not started yet". * build(pack): pin vela-cli 0.1.4-test.0 and gate on billing preflight The packaged client needs `vela billing preflight` for the Coding Plan window rows. Bump the bundled CLI to the npm `test` prerelease that ships it and add a third capability gate in tools-pack: a CLI without the subcommand prints the parent `billing` help and exits 0, so the gate checks the help markers (preflight, --workspace-id, --model, --format) instead of the exit code. TEMPORARY: replace 0.1.4-test.0 with the stable 0.1.4 once it is released; do not merge with a `test` prerelease pinned. * fix(web): make Coding Plan rows legible on the inverted credits card The credits card is a hard-coded #202020/#fafafa inverted surface, but CodingPlanUsage.module.css read page-level --text-* / --bg-fill-* tokens, so on the card the window names rendered #202020 on #202020 (1.00:1) and the stat/reset lines sat below WCAG AA. Derive every colour from currentColor instead (name 100%, stat 72%, reset/note 60%, separator ~50%, track 36%, exhausted = --red mixed toward currentColor); measured in Chrome: name 15.6:1, stat 8.7:1, reset 6.4:1, track 3.2:1, exhausted 6.5:1. Keep the stat on one line (nowrap) and let the name ellipsize instead of wrapping "60% / used" onto two lines; drop the block's 12px side padding so it aligns with the card's own rows. Also add a minutes tier to the reset countdown: the last hour before a window resets used to show no countdown at all. New key billing.codingPlanResetsInMinutes in all 19 locales. * feat(web): rebuild the plan panel 1:1 from design PR #8364 Replace the improvised Coding Plan block with the design's panel, measured against docs/ui-previews/plan-panels/index.html in Chrome (computed styles and a 4x pixel diff of the Plus specimen): - Header: tier name + 16px wordmark, 升级 pill (管理 on the top tier). - One row 「7天额度免费用 · 已用 N% ›」 (7-day window only; rounded percent; 100% when spent, no red state) over a 5px #b1f38b track, then the separator and the 「钱包余额 · US$x.xx ›」 row. No headings, window names, remaining USD, reset time, note or unavailable line. - Team workspaces keep the pre-existing card unchanged (all team_* tiers); the quota row renders only for personal workspaces. - Free personal workspace: no quota row, but the header sells 升级 like the design (a personal workspace needs no billing permission; an account B reports without a plan upgrades as free). - Top-right pill shows the wordmark only, no balance. - Wallet always reads US$ regardless of locale (product ruling 2026-09-23). - zh-CN tier names follow the design: 免费版 / 标准版 / 专业版 / 旗舰版. - Drop the dead billing.codingPlan* keys and model helpers; add billing.codingPlanWeeklyAllowance and entry.creditsManage in 19 locales. * feat(web): show 管理 on personal Max per design PR #8364 The design's Max card sells 管理 (console auto-recharge settings) where every other tier sells 升级; product ruled on 2026-09-23 to follow the design for personal Max too, superseding the earlier "personal tiers are always upgradeable" ruling. The gate now asks isMaxPlanTier and 管理 wins over 升级 when both would show. * feat(web): route the plan card's three entries per product ruling 2026-09-23 ruling on the card's landings: 升级 opens the console's pricing dialog (billing=plan, unchanged), the allowance row opens the console dashboard (unchanged), the wallet row asks for the manual top-up dialog (new billing=recharge intent; B does not honor it yet and degrades to the dashboard), and 管理 on Max opens the plain dashboard instead of the auto-recharge dialog. * fix(web): align personal plan cards with v2 design and scoped loading * fix(web): center plan wordmarks inside the top-right pill * fix(web): reuse shared billing cache for plan hover cards * test(web): match personal billing preflight in avatar fixture * fix(billing): require usable quota evidence for funding recovery * test(billing): cover scoped preflight and quota event refresh end to end * feat(daemon): relay scoped coding plan usage events and health * fix(billing): refresh quota caches from scoped realtime events * test(daemon): accept capability query on mock event stream --------- Co-authored-by: lefarcen <935902669@qq.com>
190 lines
8.1 KiB
TypeScript
190 lines
8.1 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||
import type { WorkspaceBillingResponse, WorkspaceCollabContext } from '@open-design/contracts';
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { CodingPlanUsage } from '../../src/components/CodingPlanUsage';
|
||
import { I18nProvider } from '../../src/i18n';
|
||
import { workspaceContextFixture } from '../helpers/workspace-context';
|
||
|
||
const USAGE_URL = 'https://console.example.com/dashboard?workspaceId=ws';
|
||
function personal(overrides: Partial<WorkspaceCollabContext> = {}) {
|
||
return workspaceContextFixture({ workspaceId: 'ws', workspaceMemberId: 'member',
|
||
workspaceType: 'personal', planId: 'plus', ...overrides });
|
||
}
|
||
interface WindowInput {
|
||
policyId?: string;
|
||
durationSeconds: number;
|
||
usedCredits?: string;
|
||
limitCredits?: string;
|
||
remainingCredits?: string;
|
||
resetsAt?: string | null;
|
||
}
|
||
|
||
function preflight(
|
||
options: {
|
||
member?: string;
|
||
eligible?: boolean;
|
||
tier?: 'go' | 'plus' | 'pro' | 'max' | null;
|
||
windows?: WindowInput[];
|
||
omit?: boolean;
|
||
} = {},
|
||
) {
|
||
const {
|
||
member = 'member',
|
||
eligible = true,
|
||
tier = 'pro',
|
||
windows = [{ durationSeconds: 604_800 }],
|
||
omit = false,
|
||
} = options;
|
||
if (omit) {
|
||
// An older vela CLI answers billing without a preflight at all.
|
||
return {};
|
||
}
|
||
return {
|
||
preflight: {
|
||
schemaVersion: 1,
|
||
workspaceId: 'ws',
|
||
workspaceMemberId: member,
|
||
modelId: null,
|
||
generatedAt: new Date().toISOString(),
|
||
balanceUsd: '0',
|
||
modelCovered: null,
|
||
funding: 'gateway',
|
||
codingPlan: {
|
||
workspaceId: 'ws',
|
||
generatedAt: new Date().toISOString(),
|
||
eligible,
|
||
tier,
|
||
windows: windows.map((entry) => ({
|
||
policyId: entry.policyId ?? String(entry.durationSeconds),
|
||
durationSeconds: entry.durationSeconds,
|
||
usedCredits: entry.usedCredits ?? '250000',
|
||
limitCredits: entry.limitCredits ?? '1000000',
|
||
remainingCredits: entry.remainingCredits ?? '750000',
|
||
resetsAt:
|
||
entry.resetsAt === undefined
|
||
? new Date(Date.now() + 1000).toISOString()
|
||
: entry.resetsAt,
|
||
windowStart: null,
|
||
resetMode: 'activity_triggered',
|
||
})),
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
|
||
function billing(options: Parameters<typeof preflight>[0] = {}): WorkspaceBillingResponse {
|
||
return { summary: null, workspaceBalance: null, ...preflight(options) } as WorkspaceBillingResponse;
|
||
}
|
||
function panel(options: {
|
||
response?: WorkspaceBillingResponse | null;
|
||
context?: WorkspaceCollabContext;
|
||
locale?: 'en' | 'zh-CN';
|
||
usageUrl?: string | null;
|
||
onUsageClick?: () => void;
|
||
} = {}) {
|
||
return <I18nProvider initial={options.locale ?? 'zh-CN'}>
|
||
<CodingPlanUsage context={options.context ?? personal()}
|
||
billing={options.response === undefined ? billing() : options.response}
|
||
usageUrl={options.usageUrl === undefined ? USAGE_URL : options.usageUrl}
|
||
onUsageClick={options.onUsageClick}
|
||
wallet={{ balanceUsd: '99', url: `${USAGE_URL}&billing=recharge` }} />
|
||
</I18nProvider>;
|
||
}
|
||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||
|
||
describe('personal plan presentation from the shared billing snapshot', () => {
|
||
it.each([
|
||
['go', [18000, 604800], 2],
|
||
['plus', [18000, 604800, 2592000], 1],
|
||
['pro', [18000, 604800], 1],
|
||
['max', [604800], 1],
|
||
] as const)('follows the design windows for %s', (tier, durations, count) => {
|
||
render(panel({ response: billing({ tier, windows: [...durations].reverse().map(durationSeconds => ({ durationSeconds })) }) }));
|
||
const blocks = screen.getAllByTestId('coding-plan-quota-block');
|
||
expect(blocks).toHaveLength(count);
|
||
expect(screen.queryByText('30 天')).toBeNull();
|
||
if (tier === 'go') expect(within(blocks[0]!).getByText('5 小时')).toBeTruthy();
|
||
else expect(screen.queryByText('5 小时')).toBeNull();
|
||
expect(screen.getByText('7 天')).toBeTruthy();
|
||
});
|
||
it.each([
|
||
['250000', '75'], ['355000', '64'], ['1000000', '0'], ['1100000', '0'],
|
||
])('shows remaining quota for %s spent credits', (usedCredits, percent) => {
|
||
render(panel({ response: billing({ windows: [{ durationSeconds: 604800, usedCredits }] }) }));
|
||
expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe(percent);
|
||
expect(screen.getByTestId('coding-plan-quota-fill').style.width).toBe(`${percent}%`);
|
||
expect(screen.getByText(`剩余 ${percent}%`)).toBeTruthy();
|
||
expect(screen.queryByText('已用完')).toBeNull();
|
||
expect(screen.queryByText(/重置/)).toBeNull();
|
||
});
|
||
it('renders the English allowance and period', () => {
|
||
render(panel({ locale: 'en' }));
|
||
expect(screen.getByText('Design Plan')).toBeTruthy();
|
||
expect(screen.getByText('7 days')).toBeTruthy();
|
||
expect(screen.getByText('75% left')).toBeTruthy();
|
||
expect(screen.getByText('US$0.00')).toBeTruthy();
|
||
});
|
||
it('links the allowance and wallet to their distinct destinations', () => {
|
||
const click = vi.fn();
|
||
render(panel({ onUsageClick: click }));
|
||
const link = screen.getByTestId('coding-plan-quota-entry');
|
||
expect(link.getAttribute('href')).toBe(USAGE_URL);
|
||
expect(link.getAttribute('target')).toBe('_blank');
|
||
expect(link.getAttribute('rel')).toContain('noopener');
|
||
fireEvent.click(link);
|
||
expect(click).toHaveBeenCalledOnce();
|
||
expect(screen.getByTestId('entry-nav-credits-row').getAttribute('href')).toContain('billing=recharge');
|
||
});
|
||
it('keeps the allowance readable without a destination', () => {
|
||
render(panel({ usageUrl: null }));
|
||
expect(screen.getByText('剩余 75%')).toBeTruthy();
|
||
expect(screen.queryByTestId('coding-plan-quota-entry')).toBeNull();
|
||
});
|
||
it.each([
|
||
{ eligible: false, tier: null }, { eligible: true, tier: null }, { windows: [] },
|
||
{ windows: [{ durationSeconds: 604800, limitCredits: '0' }] }, { omit: true },
|
||
])('falls back to the scoped wallet when quota is unavailable: %j', options => {
|
||
render(panel({ response: billing(options) }));
|
||
expect(screen.queryByRole('progressbar')).toBeNull();
|
||
expect(screen.getByTestId('entry-nav-credits-row')).toBeTruthy();
|
||
});
|
||
it.each([{ workspaceType: 'team' as const }, { planId: 'team_pro' }])('does not render personal quota for %j', overrides => {
|
||
const { container } = render(panel({ context: personal(overrides) }));
|
||
expect(container.firstChild).toBeNull();
|
||
});
|
||
it.each([['free', 0], ['go', 2], ['plus', 1], ['pro', 1], ['max', 1]] as const)('matches the %s cold-loading skeleton', (tier, blocks) => {
|
||
render(<I18nProvider initial="zh-CN"><CodingPlanUsage context={personal({ planId: null })}
|
||
planTier={tier} billing={null} wallet={{ balanceUsd: '99', url: null }} /></I18nProvider>);
|
||
expect(screen.getByTestId('coding-plan-quota-skeleton')).toBeTruthy();
|
||
expect(screen.queryAllByTestId('coding-plan-skeleton-block')).toHaveLength(blocks);
|
||
expect(screen.getByTestId('coding-plan-wallet-skeleton')).toBeTruthy();
|
||
expect(screen.queryByText('US$99.00')).toBeNull();
|
||
});
|
||
it('renders a cached snapshot immediately without requesting on mount or remount', () => {
|
||
const fetcher = vi.fn(); vi.stubGlobal('fetch', fetcher);
|
||
const first = render(panel());
|
||
expect(screen.getByText('剩余 75%')).toBeTruthy();
|
||
first.unmount();
|
||
render(panel());
|
||
expect(screen.queryByTestId('coding-plan-quota-skeleton')).toBeNull();
|
||
expect(screen.getByText('剩余 75%')).toBeTruthy();
|
||
expect(fetcher).not.toHaveBeenCalled();
|
||
});
|
||
it('replaces quota and wallet together when the shared snapshot changes', () => {
|
||
const { rerender } = render(panel());
|
||
const next = billing({ windows: [{ durationSeconds: 604800, usedCredits: '1000000' }] });
|
||
next.preflight!.balanceUsd = '12';
|
||
rerender(panel({ response: next }));
|
||
expect(screen.getByText('剩余 0%')).toBeTruthy();
|
||
expect(screen.getByText('US$12.00')).toBeTruthy();
|
||
expect(screen.queryByTestId('coding-plan-quota-skeleton')).toBeNull();
|
||
});
|
||
it('never displays another member’s cached snapshot', () => {
|
||
render(panel({ response: billing({ member: 'other' }) }));
|
||
expect(screen.queryByRole('progressbar')).toBeNull();
|
||
expect(screen.getByText('US$99.00')).toBeTruthy();
|
||
});
|
||
});
|