diff --git a/frontend/src/__tests__/plans-ramp-label.test.ts b/frontend/src/__tests__/plans-ramp-label.test.ts new file mode 100644 index 000000000..071100259 --- /dev/null +++ b/frontend/src/__tests__/plans-ramp-label.test.ts @@ -0,0 +1,120 @@ +/** + * Plan list ramp label agrees with Edit (#1185). + * + * A preset name ("Weekly 25%") is shown only when type, percent AND interval + * match the preset, the same rule Edit uses to pick the ramp radio; anything + * else reads "Custom N% every D days". The generated plan name counts steps + * like the backend (floor), so 30% is 3 steps. Uses the real index.html. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { loadPlans, openCreatePlanModal, setupPlanHandlers, _resetRampHandlersForTest } from '../plans'; + +jest.mock('../api', () => ({ + getPlans: jest.fn(), + getPlan: jest.fn(), + getPlannedPurchases: jest.fn().mockResolvedValue({ purchases: [] }), + listPlanAccounts: jest.fn().mockResolvedValue([]), + listAccounts: jest.fn().mockResolvedValue([]), + listAccountsMinimal: jest.fn().mockResolvedValue([]), + getAccount: jest.fn().mockResolvedValue(null), +})); + +jest.mock('../state', () => ({ + getRecommendations: jest.fn().mockReturnValue([]), + getSelectedRecommendationIDs: jest.fn().mockReturnValue(new Set()), + getVisibleRecommendations: jest.fn().mockReturnValue([]), + setVisibleRecommendations: jest.fn(), + getCurrentProvider: jest.fn().mockReturnValue(''), + setCurrentProvider: jest.fn(), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + setCurrentAccountIDs: jest.fn(), + subscribeProvider: jest.fn().mockReturnValue(() => {}), + subscribeAccount: jest.fn().mockReturnValue(() => {}), + // Literal ids (jest.mock is hoisted): administrators + purchasers groups. + getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', groups: ['00000000-0000-5000-8000-000000000001', '00000000-0000-5000-8000-000000000007'] }), + getPlansColumnFilters: jest.fn().mockReturnValue({}), + setPlansColumnFilter: jest.fn(), + clearAllPlansColumnFilters: jest.fn(), +})); + +jest.mock('../history', () => ({ viewPlanHistory: jest.fn() })); +jest.mock('../archera', () => ({ openArcheraOfferModal: jest.fn() })); +jest.mock('../toast', () => ({ showToast: jest.fn(() => ({ dismiss: jest.fn() })) })); +jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn(() => Promise.resolve(true)) })); + +import * as api from '../api'; + +function loadRealIndexBody(): string { + const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); + return new DOMParser().parseFromString(html, 'text/html').body.innerHTML; +} + +function plan(type: string, percent: number, interval: number): Record { + return { + id: 'plan-1', name: 'Ramp Plan', enabled: true, auto_purchase: false, notification_days_before: 3, + services: { ec2: { provider: 'aws', service: 'ec2', enabled: true, term: 1, payment: 'all-upfront', coverage: 80 } }, + ramp_schedule: { type, percent_per_step: percent, step_interval_days: interval, current_step: 0, total_steps: Math.floor(100 / percent) }, + }; +} + +function listedRampLabel(): string { + const detail = Array.from(document.querySelectorAll('.plan-detail')) + .find(el => el.querySelector('.plan-detail-label')?.textContent === 'Ramp Schedule'); + return detail?.querySelector('.plan-detail-value')?.textContent ?? ''; +} + +describe('plan list ramp label (#1185)', () => { + beforeEach(() => { + document.body.innerHTML = loadRealIndexBody(); + jest.clearAllMocks(); + _resetRampHandlersForTest(); + }); + + test.each([ + ['weekly', 25, 7, 'Weekly 25%', 'weekly-25pct'], + ['monthly', 10, 30, 'Monthly 10%', 'monthly-10pct'], + ['weekly', 20, 7, 'Custom 20% every 7 days', 'custom'], + ['weekly', 25, 14, 'Custom 25% every 14 days', 'custom'], + ['custom', 25, 7, 'Custom 25% every 7 days', 'custom'], + ['custom', 50, 1, 'Custom 50% every 1 day', 'custom'], + ['immediate', 100, 0, 'Immediate', 'immediate'], + ])('stored %s %d%%/%dd lists as %p and Edit opens %s', async (type, percent, interval, label, radio) => { + const stored = plan(type, percent, interval); + (api.getPlans as jest.Mock).mockResolvedValue({ plans: [stored] }); + (api.getPlan as jest.Mock).mockResolvedValue(stored); + setupPlanHandlers(); + + await loadPlans(); + expect(listedRampLabel()).toBe(label); + + (document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement).click(); + await new Promise(resolve => setTimeout(resolve, 50)); + expect((document.querySelector('input[name="ramp-schedule"]:checked') as HTMLInputElement | null)?.value).toBe(radio); + }); +}); + +describe('generated plan name step count (#1185)', () => { + beforeEach(() => { + document.body.innerHTML = loadRealIndexBody(); + jest.clearAllMocks(); + _resetRampHandlersForTest(); + }); + + test.each([ + ['30', '3 steps'], + ['25', '4 steps'], + ['60', '1 step'], + ])('custom %s%% names the plan with %s, matching the backend', (percent, steps) => { + setupPlanHandlers(); + openCreatePlanModal(); + const custom = document.querySelector('input[value="custom"]') as HTMLInputElement; + custom.checked = true; + custom.dispatchEvent(new Event('change', { bubbles: true })); + const step = document.getElementById('ramp-step-percent') as HTMLInputElement; + step.value = percent; + step.dispatchEvent(new Event('input')); + + expect((document.getElementById('plan-name') as HTMLInputElement).value).toContain(`(${steps})`); + }); +}); diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index e9365d926..b5216b2bf 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -1057,16 +1057,19 @@ function isPlanOverdue(plan: BackendPlan): boolean { return nextDate < today; } -// Format ramp schedule from backend struct +// Format ramp schedule from backend struct. A preset name is shown only when +// type, percent AND interval match the preset, the same rule Edit uses +// (rampRadioValue), so the list never says "Weekly 20%" for a plan Edit opens +// as Custom 20% / 7 days, or hides a 14-day interval behind "Weekly 25%" (#1185). function formatBackendRampSchedule(ramp: BackendPlan['ramp_schedule']): string { if (!ramp) return 'Immediate'; - switch (ramp.type) { - case 'immediate': return 'Immediate'; - case 'weekly': return `Weekly ${ramp.percent_per_step}%`; - case 'monthly': return `Monthly ${ramp.percent_per_step}%`; - case 'custom': return `Custom ${ramp.percent_per_step}% every ${ramp.step_interval_days} days`; - default: return ramp.type || 'Unknown'; - } + if (!['immediate', 'weekly', 'monthly', 'custom'].includes(ramp.type)) return ramp.type || 'Unknown'; + const value = rampRadioValue(ramp); + if (value === 'immediate') return 'Immediate'; + const preset = PRESET_RAMPS.find(p => p.value === value); + if (preset) return preset.label; + const days = ramp.step_interval_days; + return days ? `Custom ${ramp.percent_per_step}% every ${days} day${days === 1 ? '' : 's'}` : `Custom ${ramp.percent_per_step}%`; } async function loadPlanAccountNames(planId: string, cardEl: Element): Promise { @@ -1274,9 +1277,9 @@ function openPlanForm(modal: HTMLElement, restartsFromToday = false): void { // Presets mirror config.PresetRampSchedules; any other stored ramp is shown as // custom so Edit never rewrites it to a different schedule on Save (#1156). -const PRESET_RAMPS: ReadonlyArray<{ value: string; type: string; percent: number; intervalDays: number }> = [ - { value: 'weekly-25pct', type: 'weekly', percent: 25, intervalDays: 7 }, - { value: 'monthly-10pct', type: 'monthly', percent: 10, intervalDays: 30 }, +const PRESET_RAMPS: ReadonlyArray<{ value: string; label: string; type: string; percent: number; intervalDays: number }> = [ + { value: 'weekly-25pct', label: 'Weekly 25%', type: 'weekly', percent: 25, intervalDays: 7 }, + { value: 'monthly-10pct', label: 'Monthly 10%', type: 'monthly', percent: 10, intervalDays: 30 }, ]; function rampRadioValue(ramp: { type?: string; percent_per_step?: number; step_interval_days?: number }): string { @@ -1879,11 +1882,13 @@ function generatePlanName(rampSchedule: string, customStepPercent?: number, cust return `${serviceUpper} Monthly 10% Ramp-up (10 months)`; case 'custom': if (customStepPercent && customIntervalDays) { - const totalSteps = Math.ceil(100 / customStepPercent); + // Same count as the backend (buildCustomRampSchedule) and the schedule + // preview: whole steps only, at least one. 30% is 3 steps, not 4 (#1185). + const totalSteps = Math.max(1, Math.floor(100 / customStepPercent)); const intervalLabel = customIntervalDays === 7 ? 'weekly' : customIntervalDays === 30 ? 'monthly' : `every ${customIntervalDays} days`; - return `${serviceUpper} Custom ${customStepPercent}% ${intervalLabel} (${totalSteps} steps)`; + return `${serviceUpper} Custom ${customStepPercent}% ${intervalLabel} (${totalSteps} step${totalSteps === 1 ? '' : 's'})`; } return `${serviceUpper} Custom Ramp-up Plan`; default: