Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions frontend/src/__tests__/plans-ramp-label.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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})`);
});
});
31 changes: 18 additions & 13 deletions frontend/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
Loading