From 46ba63f3702f9ef56dcc1d15bbb4d770657805bf Mon Sep 17 00:00:00 2001 From: Matt Fiddaman Date: Sun, 26 Apr 2026 00:35:32 +0100 Subject: [PATCH] increase test coverage for budget templates (#7620) * [AI] cover existing template engine logic with regression tests Adds tests for goal template behavior that predates this PR so the suite can be cherry-picked onto master to confirm no regressions. No production code changes. Covers: - init() validation: schedule names, by/schedule priority match, past by-target with and without annual/repeat, percentage source not found, special source aliases, duplicate limit/spend/goal directives, weekly limit missing start date, invalid limit period, unrecognized periodic period - runRemainder cap clamping and hideDecimal fraction removal - Income-category branch in runTemplatesForPriority - getLimitExcess against an aggregate weekly cap - Past by-target rolling forward via the annual period - runSchedule full=true (no sinking accumulation), percent and fixed adjustments, completed-schedule filtering, past-date error for non-repeating schedules, monthly/weekly/daily sinking contribution branches when interval exceeds the pay-month-of cap, surplus absorption when last-month balance exceeds the target, and tracking-budget mode forcing all schedules pay-month-of - applyMultipleCategoryTemplates orchestration: per-category writes, cross-category priority clamping when funds run out, error notification path - applyTemplate force=false skipping already-budgeted categories Co-Authored-By: Claude Opus 4.7 (1M context) * note --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../budget/category-template-context.test.ts | 604 ++++++++++++++++- .../src/server/budget/goal-template.test.ts | 373 +++++++++++ .../server/budget/schedule-template.test.ts | 613 ++++++++++++++---- upcoming-release-notes/7620.md | 6 + 4 files changed, 1482 insertions(+), 114 deletions(-) create mode 100644 packages/loot-core/src/server/budget/goal-template.test.ts create mode 100644 upcoming-release-notes/7620.md diff --git a/packages/loot-core/src/server/budget/category-template-context.test.ts b/packages/loot-core/src/server/budget/category-template-context.test.ts index 962c3da922..057d846aea 100644 --- a/packages/loot-core/src/server/budget/category-template-context.test.ts +++ b/packages/loot-core/src/server/budget/category-template-context.test.ts @@ -8,6 +8,7 @@ import type { Template } from '#types/models/templates'; import * as actions from './actions'; import { CategoryTemplateContext } from './category-template-context'; +import * as statements from './statements'; // Mock getSheetValue and getCategories vi.mock('./actions', () => ({ @@ -24,6 +25,10 @@ vi.mock('#server/aql', () => ({ aqlQuery: vi.fn(), })); +vi.mock('./statements', () => ({ + getActiveSchedules: vi.fn(), +})); + // Helper function to mock preferences (hideFraction and defaultCurrencyCode) function mockPreferences( hideFraction: boolean = false, @@ -56,8 +61,17 @@ class TestCategoryTemplateContext extends CategoryTemplateContext { fromLastMonth: number, budgeted: number, currencyCode: string = 'USD', + hideDecimal: boolean = false, ) { - super(templates, category, month, fromLastMonth, budgeted, currencyCode); + super( + templates, + category, + month, + fromLastMonth, + budgeted, + currencyCode, + hideDecimal, + ); } } @@ -1655,4 +1669,592 @@ describe('CategoryTemplateContext', () => { expect(valuesUSD.budgeted).toBe(10000); }); }); + + describe('validation (init checks)', () => { + const category: CategoryEntity = { + id: 'val-cat', + name: 'Validation Category', + group: 'g', + is_income: false, + }; + + beforeEach(() => { + mockPreferences(false, 'USD'); + vi.mocked(actions.getSheetValue).mockResolvedValue(0); + vi.mocked(actions.getSheetBoolean).mockResolvedValue(false); + vi.mocked(actions.isTrackingBudget).mockReturnValue(false); + }); + + it('throws when a schedule template references a non-existent schedule', async () => { + vi.mocked(statements.getActiveSchedules).mockResolvedValue([ + { name: 'Rent', id: 's1' }, + ] as Awaited>); + const templates: Template[] = [ + { + type: 'schedule', + name: 'Internet', + directive: 'template', + priority: 1, + }, + ]; + await expect( + CategoryTemplateContext.init(templates, category, '2024-01', 0), + ).rejects.toThrow(/Schedule Internet does not exist/); + }); + + it('throws when schedule and by templates have mismatched priorities', async () => { + vi.mocked(statements.getActiveSchedules).mockResolvedValue([ + { name: 'Rent', id: 's1' }, + ] as Awaited>); + const templates: Template[] = [ + { + type: 'schedule', + name: 'Rent', + directive: 'template', + priority: 1, + }, + { + type: 'by', + amount: 1200, + month: '2024-12', + annual: false, + directive: 'template', + priority: 2, + }, + ]; + await expect( + CategoryTemplateContext.init(templates, category, '2024-01', 0), + ).rejects.toThrow( + /Schedule and By templates must be the same priority level/, + ); + }); + + it('throws when a non-recurring `by` target month is in the past', async () => { + vi.mocked(statements.getActiveSchedules).mockResolvedValue( + [] as Awaited>, + ); + const templates: Template[] = [ + { + type: 'by', + amount: 1200, + month: '2023-12', + annual: false, + directive: 'template', + priority: 1, + }, + ]; + await expect( + CategoryTemplateContext.init(templates, category, '2024-06', 0), + ).rejects.toThrow( + /Target month has passed, remove or update the target month/, + ); + }); + + it('accepts a past `by` target when annual or repeat is set (engine rolls it forward)', async () => { + vi.mocked(statements.getActiveSchedules).mockResolvedValue( + [] as Awaited>, + ); + const templates: Template[] = [ + { + type: 'by', + amount: 1200, + month: '2023-12', + annual: true, + directive: 'template', + priority: 1, + }, + ]; + await expect( + CategoryTemplateContext.init(templates, category, '2024-06', 0), + ).resolves.toBeDefined(); + }); + + it('throws when a percentage template references an unknown income category', async () => { + vi.mocked(statements.getActiveSchedules).mockResolvedValue( + [] as Awaited>, + ); + vi.mocked(db.getCategories).mockResolvedValue([ + { id: 'inc-1', name: 'Salary', is_income: true } as CategoryEntity, + ]); + const templates: Template[] = [ + { + type: 'percentage', + percent: 10, + previous: false, + category: 'Bonus', + directive: 'template', + priority: 1, + }, + ]; + await expect( + CategoryTemplateContext.init(templates, category, '2024-01', 0), + ).rejects.toThrow(/is not found in available income categories/i); + }); + + it('rolls a past `by` target forward by its annual period', async () => { + // Past target with annual:true is rolled forward by 12 months until + // the target is in the future, then budgeted normally. + vi.mocked(statements.getActiveSchedules).mockResolvedValue( + [] as Awaited>, + ); + const templates: Template[] = [ + { + type: 'by', + amount: 1200, + month: '2023-12', + annual: true, + directive: 'template', + priority: 1, + }, + ]; + const ctx = await CategoryTemplateContext.init( + templates, + category, + '2024-06', + 0, + ); + const budgeted = await ctx.runTemplatesForPriority( + 1, + 1_000_000, + 1_000_000, + ); + expect(budgeted).toBeGreaterThan(0); + }); + + it('accepts the special `all income` and `available funds` source aliases', async () => { + vi.mocked(statements.getActiveSchedules).mockResolvedValue( + [] as Awaited>, + ); + vi.mocked(db.getCategories).mockResolvedValue([] as CategoryEntity[]); + const templates: Template[] = [ + { + type: 'percentage', + percent: 10, + previous: false, + category: 'all income', + directive: 'template', + priority: 1, + }, + { + type: 'percentage', + percent: 5, + previous: false, + category: 'available funds', + directive: 'template', + priority: 1, + }, + ]; + await expect( + CategoryTemplateContext.init(templates, category, '2024-01', 0), + ).resolves.toBeDefined(); + }); + + it('throws when more than one limit template is defined', () => { + const templates: Template[] = [ + { + type: 'limit', + amount: 100, + hold: false, + period: 'monthly', + directive: 'template', + priority: null, + }, + { + type: 'limit', + amount: 200, + hold: false, + period: 'monthly', + directive: 'template', + priority: null, + }, + ]; + expect( + () => + new TestCategoryTemplateContext(templates, category, '2024-01', 0, 0), + ).toThrow(/Only one .up to. allowed per category/); + }); + + it('throws when a weekly limit has no start date', () => { + const templates: Template[] = [ + { + type: 'limit', + amount: 50, + hold: false, + period: 'weekly', + directive: 'template', + priority: null, + }, + ]; + expect( + () => + new TestCategoryTemplateContext(templates, category, '2024-01', 0, 0), + ).toThrow(/Weekly limit requires a start date/); + }); + + it('throws when a limit period is not daily/weekly/monthly', () => { + const templates: Template[] = [ + { + type: 'limit', + amount: 50, + hold: false, + // @ts-expect-error deliberately invalid period value + period: 'fortnightly', + directive: 'template', + priority: null, + }, + ]; + expect( + () => + new TestCategoryTemplateContext(templates, category, '2024-01', 0, 0), + ).toThrow(/Invalid limit period/); + }); + + it('throws when more than one spend template is defined', () => { + const templates: Template[] = [ + { + type: 'spend', + amount: 100, + from: '2024-01', + month: '2024-12', + directive: 'template', + priority: 1, + }, + { + type: 'spend', + amount: 200, + from: '2024-01', + month: '2024-12', + directive: 'template', + priority: 1, + }, + ]; + expect( + () => + new TestCategoryTemplateContext(templates, category, '2024-01', 0, 0), + ).toThrow(/Only one spend template is allowed per category/); + }); + + it('throws when more than one #goal directive is defined', () => { + const templates: Template[] = [ + { type: 'goal', amount: 1000, directive: 'goal', priority: null }, + { type: 'goal', amount: 2000, directive: 'goal', priority: null }, + ]; + expect( + () => + new TestCategoryTemplateContext(templates, category, '2024-01', 0, 0), + ).toThrow(/Only one #goal is allowed per category/); + }); + + it('throws when a periodic template uses an unknown period unit', async () => { + vi.mocked(statements.getActiveSchedules).mockResolvedValue( + [] as Awaited>, + ); + const templates: Template[] = [ + { + type: 'periodic', + amount: 100, + // @ts-expect-error deliberately invalid period unit + period: { period: 'fortnight', amount: 1 }, + starting: '2024-01-01', + directive: 'template', + priority: 1, + }, + ]; + const ctx = await CategoryTemplateContext.init( + templates, + category, + '2024-01', + 0, + ); + await expect( + ctx.runTemplatesForPriority(1, 1_000_000, 1_000_000), + ).rejects.toThrow(/Unrecognized periodic period/); + }); + }); + + describe('further engine coverage', () => { + const category: CategoryEntity = { + id: 'engine-cat', + name: 'Engine Category', + group: 'g', + is_income: false, + }; + const incomeCategory: CategoryEntity = { + id: 'income-cat', + name: 'Income', + group: 'g', + is_income: true, + }; + + it('clamps remainder allocation when the per-category cap is reached', () => { + // Cap $100, carryover $30 → remainder can only contribute up to $70 + // to fill the gap, even though perWeight × weight would give $80. + const templates: Template[] = [ + { + type: 'limit', + amount: 100, + hold: false, + period: 'monthly', + directive: 'template', + priority: null, + }, + { + type: 'remainder', + weight: 1, + directive: 'template', + priority: null, + }, + ]; + const instance = new TestCategoryTemplateContext( + templates, + category, + '2024-01', + 3000, + 0, + ); + expect(instance.runRemainder(10000, 8000)).toBe(7000); + }); + + it('drops sub-dollar amounts from remainder when hideDecimal is set', () => { + const templates: Template[] = [ + { + type: 'remainder', + weight: 1, + directive: 'template', + priority: null, + }, + ]; + const instance = new TestCategoryTemplateContext( + templates, + category, + '2024-01', + 0, + 0, + 'USD', + true, + ); + const result = instance.runRemainder(20000, 12345); + expect(result).toBe(12300); + }); + + it('negates the budget for income categories at non-zero priority', async () => { + // Income categories produce funds rather than consume them. + const templates: Template[] = [ + { + type: 'periodic', + amount: 100, + period: { period: 'month', amount: 1 }, + starting: '2024-01-01', + directive: 'template', + priority: 1, + }, + ]; + const instance = new TestCategoryTemplateContext( + templates, + incomeCategory, + '2024-01', + 0, + 0, + ); + const result = await instance.runTemplatesForPriority( + 1, + 1_000_000, + 1_000_000, + ); + expect(result).toBe(-10000); + }); + + it('reports limit excess when carried-over balance exceeds a weekly cap', () => { + // Aggregate cap = 5 week-starts in Jan 2024 × $50 = $250. Carryover + // $300 → excess $50. + const templates: Template[] = [ + { + type: 'limit', + amount: 50, + hold: false, + period: 'weekly', + start: '2024-01-01', + directive: 'template', + priority: null, + }, + ]; + const instance = new TestCategoryTemplateContext( + templates, + category, + '2024-01', + 30000, + 0, + ); + expect(instance.getLimitExcess()).toBe(5000); + }); + + it('runAll iterates priorities in order and budgets the high-priority template first', async () => { + const templates: Template[] = [ + { + type: 'periodic', + amount: 100, + period: { period: 'month', amount: 1 }, + starting: '2024-01-01', + directive: 'template', + priority: 1, + }, + { + type: 'periodic', + amount: 50, + period: { period: 'month', amount: 1 }, + starting: '2024-01-01', + directive: 'template', + priority: 2, + }, + ]; + const instance = new TestCategoryTemplateContext( + templates, + category, + '2024-01', + 0, + 0, + ); + const total = await instance.runAll(1_000_000); + expect(total).toBe(15000); + const values = instance.getValues(); + expect(values.budgeted).toBe(15000); + }); + + it('partial-month coverage when a weekly limit starts mid-month', async () => { + // Week-starts in January from 2024-01-15: 15, 22, 29 → 3 × $50. + const templates: Template[] = [ + { + type: 'simple', + monthly: 200, + limit: { + amount: 50, + hold: false, + period: 'weekly', + start: '2024-01-15', + }, + directive: 'template', + priority: 1, + }, + ]; + const instance = new TestCategoryTemplateContext( + templates, + category, + '2024-01', + 0, + 0, + ); + const result = await instance.runTemplatesForPriority( + 1, + 1_000_000, + 1_000_000, + ); + expect(result).toBe(15000); + }); + + it('does not double-count weeks for a weekly limit starting before the month', async () => { + // Limit starts in Dec; only the 5 week-starts inside Jan (1, 8, 15, + // 22, 29) count. + const templates: Template[] = [ + { + type: 'simple', + monthly: 1000, + limit: { + amount: 100, + hold: false, + period: 'weekly', + start: '2023-12-25', + }, + directive: 'template', + priority: 1, + }, + ]; + const instance = new TestCategoryTemplateContext( + templates, + category, + '2024-01', + 0, + 0, + ); + const result = await instance.runTemplatesForPriority( + 1, + 1_000_000, + 1_000_000, + ); + expect(result).toBe(50000); + }); + + it('returns zero excess when hold is set on a weekly limit, even with carryover above the cap', () => { + // hold=true keeps the surplus in the category rather than releasing + // it back to To Budget. + const templates: Template[] = [ + { + type: 'limit', + amount: 50, + hold: true, + period: 'weekly', + start: '2024-01-01', + directive: 'template', + priority: null, + }, + ]; + const instance = new TestCategoryTemplateContext( + templates, + category, + '2024-01', + 30000, + 0, + ); + expect(instance.getLimitExcess()).toBe(0); + }); + + it('uses the actual day count for a daily limit in February (28 days)', async () => { + const templates: Template[] = [ + { + type: 'simple', + monthly: 10000, + limit: { amount: 10, hold: false, period: 'daily' }, + directive: 'template', + priority: 1, + }, + ]; + const instance = new TestCategoryTemplateContext( + templates, + category, + '2023-02', + 0, + 0, + ); + const result = await instance.runTemplatesForPriority( + 1, + 1_000_000, + 1_000_000, + ); + expect(result).toBe(28000); + }); + + it('uses the actual day count for a daily limit in February of a leap year (29 days)', async () => { + const templates: Template[] = [ + { + type: 'simple', + monthly: 10000, + limit: { amount: 10, hold: false, period: 'daily' }, + directive: 'template', + priority: 1, + }, + ]; + const instance = new TestCategoryTemplateContext( + templates, + category, + '2024-02', + 0, + 0, + ); + const result = await instance.runTemplatesForPriority( + 1, + 1_000_000, + 1_000_000, + ); + expect(result).toBe(29000); + }); + }); }); diff --git a/packages/loot-core/src/server/budget/goal-template.test.ts b/packages/loot-core/src/server/budget/goal-template.test.ts new file mode 100644 index 0000000000..056b08ff6c --- /dev/null +++ b/packages/loot-core/src/server/budget/goal-template.test.ts @@ -0,0 +1,373 @@ +import { vi } from 'vitest'; + +import * as aql from '#server/aql'; +import * as db from '#server/db'; +import type { CategoryEntity } from '#types/models'; +import type { Template } from '#types/models/templates'; + +import * as actions from './actions'; +import { applyMultipleCategoryTemplates, applyTemplate } from './goal-template'; +import * as statements from './statements'; + +vi.mock('./actions', () => ({ + getSheetValue: vi.fn(), + getSheetBoolean: vi.fn(), + isTrackingBudget: vi.fn(), + setBudget: vi.fn(), + setGoal: vi.fn(), +})); + +vi.mock('#server/db', () => ({ + getCategories: vi.fn(), + first: vi.fn(), +})); + +vi.mock('#server/aql', () => ({ + aqlQuery: vi.fn(), +})); + +vi.mock('#server/sync', () => ({ + batchMessages: (fn: () => Promise) => fn(), +})); + +vi.mock('./statements', () => ({ + getActiveSchedules: vi.fn(), +})); + +vi.mock('./template-notes', () => ({ + checkTemplateNotes: vi.fn(), + storeNoteTemplates: vi.fn(), +})); + +function setupSheetMock(values: Record) { + vi.mocked(actions.getSheetValue).mockImplementation( + async (_sheet: string, key: string) => values[key] ?? 0, + ); + vi.mocked(actions.getSheetBoolean).mockResolvedValue(false); + vi.mocked(actions.isTrackingBudget).mockReturnValue(false); +} +function setupAqlForWideScope( + savedTemplatesByCategory: Array<{ + category: CategoryEntity; + templates: Template[]; + }>, + categoriesInGroup: CategoryEntity[], +) { + vi.mocked(aql.aqlQuery).mockImplementation(async (query: unknown) => { + const queryStr = JSON.stringify(query); + if (queryStr.includes('hideFraction')) { + return { data: [{ value: 'false' }], dependencies: [] }; + } + if (queryStr.includes('defaultCurrencyCode')) { + return { data: [{ value: 'USD' }], dependencies: [] }; + } + if (queryStr.includes('goal_def')) { + return { + data: savedTemplatesByCategory.map(({ category, templates }) => ({ + ...category, + goal_def: JSON.stringify(templates), + })), + dependencies: [], + }; + } + if (queryStr.includes('category_groups')) { + return { + data: [{ id: 'g1', hidden: false, categories: categoriesInGroup }], + dependencies: [], + }; + } + return { data: [], dependencies: [] }; + }); +} +describe('applyMultipleCategoryTemplates', () => { + const cat1: CategoryEntity = { + id: 'cat-1', + name: 'Groceries', + group: 'g1', + is_income: false, + }; + const cat2: CategoryEntity = { + id: 'cat-2', + name: 'Rent', + group: 'g1', + is_income: false, + }; + + function setupAqlMultiCategory( + cats: CategoryEntity[], + templatesById: Record, + ) { + vi.mocked(aql.aqlQuery).mockImplementation(async (query: unknown) => { + const queryStr = JSON.stringify(query); + if (queryStr.includes('hideFraction')) { + return { data: [{ value: 'false' }], dependencies: [] }; + } + if (queryStr.includes('defaultCurrencyCode')) { + return { data: [{ value: 'USD' }], dependencies: [] }; + } + if (queryStr.includes('goal_def')) { + return { + data: cats + .filter(c => templatesById[c.id]) + .map(c => ({ + ...c, + goal_def: JSON.stringify(templatesById[c.id]), + })), + dependencies: [], + }; + } + if (queryStr.includes('categories')) { + return { data: cats, dependencies: [] }; + } + return { data: [], dependencies: [] }; + }); + } + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(statements.getActiveSchedules).mockResolvedValue( + [] as Awaited>, + ); + vi.mocked(db.getCategories).mockResolvedValue([] as CategoryEntity[]); + }); + + it('writes per-category budgets and returns a success notification', async () => { + setupSheetMock({ 'to-budget': 100000 }); + setupAqlMultiCategory([cat1, cat2], { + [cat1.id]: [ + { + type: 'periodic', + amount: 100, + period: { period: 'month', amount: 1 }, + starting: '2024-01-01', + directive: 'template', + priority: 1, + }, + ], + [cat2.id]: [ + { + type: 'periodic', + amount: 200, + period: { period: 'month', amount: 1 }, + starting: '2024-01-01', + directive: 'template', + priority: 1, + }, + ], + }); + + const result = await applyMultipleCategoryTemplates({ + month: '2024-01', + categoryIds: [cat1.id, cat2.id], + }); + + expect(result.message).toMatch(/Successfully applied/); + expect(actions.setBudget).toHaveBeenCalledTimes(2); + const budgetCalls = vi + .mocked(actions.setBudget) + .mock.calls.map(call => call[0]); + const cat1Budget = budgetCalls.find(c => c.category === cat1.id); + const cat2Budget = budgetCalls.find(c => c.category === cat2.id); + expect(cat1Budget?.amount).toBe(10000); + expect(cat2Budget?.amount).toBe(20000); + }); + + it('clamps lower-priority categories when funds run out', async () => { + // Only $150 available; cat1 (p1) wants $100, cat2 (p2) wants $100. + // p1 fully funded, p2 gets the remaining $50. + setupSheetMock({ 'to-budget': 15000 }); + setupAqlMultiCategory([cat1, cat2], { + [cat1.id]: [ + { + type: 'periodic', + amount: 100, + period: { period: 'month', amount: 1 }, + starting: '2024-01-01', + directive: 'template', + priority: 1, + }, + ], + [cat2.id]: [ + { + type: 'periodic', + amount: 100, + period: { period: 'month', amount: 1 }, + starting: '2024-01-01', + directive: 'template', + priority: 2, + }, + ], + }); + + await applyMultipleCategoryTemplates({ + month: '2024-01', + categoryIds: [cat1.id, cat2.id], + }); + + const budgetCalls = vi + .mocked(actions.setBudget) + .mock.calls.map(call => call[0]); + const cat1Budget = budgetCalls.find(c => c.category === cat1.id); + const cat2Budget = budgetCalls.find(c => c.category === cat2.id); + expect(cat1Budget?.amount).toBe(10000); + expect(cat2Budget?.amount).toBe(5000); + expect((cat1Budget?.amount ?? 0) + (cat2Budget?.amount ?? 0)).toBe(15000); + }); + + it('returns an error notification when a template fails validation', async () => { + setupSheetMock({ 'to-budget': 100000 }); + setupAqlMultiCategory([cat1], { + [cat1.id]: [ + { + type: 'by', + amount: 1200, + month: '2023-12', + annual: false, + directive: 'template', + priority: 1, + }, + ], + }); + + const result = await applyMultipleCategoryTemplates({ + month: '2024-06', + categoryIds: [cat1.id], + }); + + expect(result.message).toMatch(/There were errors/); + expect(result.pre).toMatch(/Target month has passed/); + expect(actions.setBudget).not.toHaveBeenCalled(); + }); + + it('resets goal_def for orphan categories whose templates were removed', async () => { + // Category had a stored goal but no current template — the goal must + // be cleared so the sidebar marker disappears. + setupSheetMock({ + 'to-budget': 0, + 'budget-cat-1': 5000, + 'goal-cat-1': 12345, + }); + setupAqlMultiCategory([cat1], {}); + + const result = await applyMultipleCategoryTemplates({ + month: '2024-01', + categoryIds: [cat1.id], + }); + + expect(result.message).toBe('Everything is up to date'); + const goalCalls = vi.mocked(actions.setGoal).mock.calls.map(c => c[0]); + expect(goalCalls).toContainEqual( + expect.objectContaining({ category: cat1.id, goal: null }), + ); + }); + + it('distributes remainder funds across categories by weight', async () => { + // Weights 3 and 1; $200 split 75% / 25%. + setupSheetMock({ 'to-budget': 20000 }); + setupAqlMultiCategory([cat1, cat2], { + [cat1.id]: [ + { + type: 'remainder', + weight: 3, + directive: 'template', + priority: null, + }, + ], + [cat2.id]: [ + { + type: 'remainder', + weight: 1, + directive: 'template', + priority: null, + }, + ], + }); + + await applyMultipleCategoryTemplates({ + month: '2024-01', + categoryIds: [cat1.id, cat2.id], + }); + + const budgetCalls = vi + .mocked(actions.setBudget) + .mock.calls.map(call => call[0]); + const cat1Budget = budgetCalls.find(c => c.category === cat1.id); + const cat2Budget = budgetCalls.find(c => c.category === cat2.id); + expect(cat1Budget?.amount).toBe(15000); + expect(cat2Budget?.amount).toBe(5000); + expect((cat1Budget?.amount ?? 0) + (cat2Budget?.amount ?? 0)).toBe(20000); + }); +}); + +describe('applyTemplate (force=false)', () => { + const cat1: CategoryEntity = { + id: 'cat-1', + name: 'Groceries', + group: 'g1', + is_income: false, + }; + const cat2: CategoryEntity = { + id: 'cat-2', + name: 'Rent', + group: 'g1', + is_income: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(statements.getActiveSchedules).mockResolvedValue( + [] as Awaited>, + ); + vi.mocked(db.getCategories).mockResolvedValue([] as CategoryEntity[]); + }); + + it('skips categories that already have a non-zero budget', async () => { + // cat1 starts unbudgeted → gets templated. cat2 already has $50 + // budgeted → must be left alone, since force=false is the + // "fill in the blanks" semantic. + setupSheetMock({ + 'to-budget': 100000, + 'budget-cat-1': 0, + 'budget-cat-2': 5000, + }); + setupAqlForWideScope( + [ + { + category: cat1, + templates: [ + { + type: 'periodic', + amount: 100, + period: { period: 'month', amount: 1 }, + starting: '2024-01-01', + directive: 'template', + priority: 1, + }, + ], + }, + { + category: cat2, + templates: [ + { + type: 'periodic', + amount: 200, + period: { period: 'month', amount: 1 }, + starting: '2024-01-01', + directive: 'template', + priority: 1, + }, + ], + }, + ], + [cat1, cat2], + ); + + await applyTemplate({ month: '2024-01' }); + + const budgetCalls = vi + .mocked(actions.setBudget) + .mock.calls.map(call => call[0]); + expect(budgetCalls.map(c => c.category)).toEqual([cat1.id]); + expect(budgetCalls[0].amount).toBe(10000); + }); +}); diff --git a/packages/loot-core/src/server/budget/schedule-template.test.ts b/packages/loot-core/src/server/budget/schedule-template.test.ts index d1284584b2..e236694881 100644 --- a/packages/loot-core/src/server/budget/schedule-template.test.ts +++ b/packages/loot-core/src/server/budget/schedule-template.test.ts @@ -17,6 +17,88 @@ vi.mock('#server/schedules/app', async () => { }; }); +const defaultCurrency: Currency = { + code: '', + symbol: '', + name: '', + decimalPlaces: 2, + numberFormat: 'comma-dot', + symbolFirst: false, +}; + +const defaultCategory = { id: '1', name: 'Test Category' } as CategoryEntity; + +type RuleSpec = { + id?: string; + start: string; + amount: number; + frequency: 'monthly' | 'yearly' | 'weekly' | 'daily'; + interval?: number; +}; + +function makeRule({ + id = 'r', + start, + amount, + frequency, + interval = 1, +}: RuleSpec): Rule { + return new Rule({ + id, + stage: 'pre', + conditionsOp: 'and', + conditions: [ + { + op: 'is', + field: 'date', + value: { + start, + interval, + frequency, + patterns: [], + skipWeekend: false, + weekendSolveMode: 'before', + endMode: 'never', + endOccurrences: 1, + endDate: '2099-01-01', + }, + type: 'date', + }, + { op: 'is', field: 'amount', value: amount, type: 'number' }, + ], + actions: [], + }); +} + +function mockSingleSchedule(spec: RuleSpec, completed: number = 0) { + vi.mocked(db.first).mockResolvedValue({ id: 1, completed }); + vi.mocked(getRuleForSchedule).mockResolvedValue(makeRule(spec)); + vi.mocked(isTrackingBudget).mockReturnValue(false); +} + +function mockSchedulesByName( + specsByName: Record, +) { + const names = Object.keys(specsByName); + const sidByName: Record = Object.fromEntries( + names.map((name, i) => [name, i + 1]), + ); + vi.mocked(db.first).mockImplementation( + async (_q: string, params?: unknown[]) => { + const name = (params as string[] | undefined)?.[0] ?? ''; + return { + id: sidByName[name], + completed: specsByName[name]?.completed ?? 0, + }; + }, + ); + vi.mocked(getRuleForSchedule).mockImplementation(async (sid: number) => { + const name = names.find(n => sidByName[n] === sid) ?? names[0]; + return makeRule(specsByName[name].spec); + }); + vi.mocked(isTrackingBudget).mockReturnValue(false); +} + describe('runSchedule', () => { beforeEach(() => { vi.clearAllMocks(); @@ -24,7 +106,6 @@ describe('runSchedule', () => { }); it('should return correct budget when recurring schedule set', async () => { - // Given const template_lines = [ { type: 'schedule', @@ -33,78 +114,30 @@ describe('runSchedule', () => { directive: 'template', } as const, ]; - const current_month = '2024-08-01'; - const balance = 0; - const remainder = 0; - const last_month_balance = 0; - const to_budget = 0; - const errors: string[] = []; - const category = { id: '1', name: 'Test Category' } as CategoryEntity; - const currency: Currency = { - code: '', - symbol: '', - name: '', - decimalPlaces: 2, - numberFormat: 'comma-dot', - symbolFirst: false, - }; + mockSingleSchedule({ + start: '2024-08-01', + amount: -10000, + frequency: 'monthly', + }); - vi.mocked(db.first).mockResolvedValue({ id: 1, completed: 0 }); - vi.mocked(getRuleForSchedule).mockResolvedValue( - new Rule({ - id: 'test', - stage: 'pre', - conditionsOp: 'and', - conditions: [ - { - op: 'is', - field: 'date', - value: { - start: '2024-08-01', - interval: 1, - frequency: 'monthly', - patterns: [], - skipWeekend: false, - weekendSolveMode: 'before', - endMode: 'never', - endOccurrences: 1, - endDate: '2024-08-04', - }, - type: 'date', - }, - { - op: 'is', - field: 'amount', - value: -10000, - type: 'number', - }, - ], - actions: [], - }), - ); - vi.mocked(isTrackingBudget).mockReturnValue(false); - - // When const result = await runSchedule( template_lines, - current_month, - balance, - remainder, - last_month_balance, - to_budget, - errors, - category, - currency, + '2024-08-01', + 0, + 0, + 0, + 0, + [], + defaultCategory, + defaultCurrency, ); - // Then expect(result.to_budget).toBe(10000); expect(result.errors).toHaveLength(0); expect(result.remainder).toBe(0); }); it('should return correct budget when yearly recurring schedule set and balance is greater than target', async () => { - // Given const template_lines = [ { type: 'schedule', @@ -113,73 +146,427 @@ describe('runSchedule', () => { priority: 0, } as const, ]; - const current_month = '2024-09-01'; - const balance = 12000; - const remainder = 0; - const last_month_balance = 12000; - const to_budget = 0; - const errors: string[] = []; - const category = { id: '1', name: 'Test Category' } as CategoryEntity; - const currency: Currency = { - code: '', - symbol: '', - name: '', - decimalPlaces: 2, - numberFormat: 'comma-dot', - symbolFirst: false, - }; + mockSingleSchedule({ + start: '2024-08-01', + amount: -12000, + frequency: 'yearly', + }); + const result = await runSchedule( + template_lines, + '2024-09-01', + 12000, + 0, + 12000, + 0, + [], + defaultCategory, + defaultCurrency, + ); + + expect(result.to_budget).toBe(1000); + expect(result.errors).toHaveLength(0); + expect(result.remainder).toBe(0); + }); + + it('budgets nothing in advance for a yearly schedule with `full: true`', async () => { + const template_lines = [ + { + type: 'schedule', + name: 'Insurance', + full: true, + directive: 'template', + priority: 0, + } as const, + ]; + mockSingleSchedule({ + start: '2024-12-15', + amount: -60000, + frequency: 'yearly', + }); + + const result = await runSchedule( + template_lines, + '2024-01-01', + 0, + 0, + 0, + 0, + [], + defaultCategory, + defaultCurrency, + ); + expect(result.to_budget).toBe(0); + }); + + it('applies a percent adjustment to the schedule amount', async () => { + const template_lines = [ + { + type: 'schedule', + name: 'Bill', + adjustment: 10, + adjustmentType: 'percent', + directive: 'template', + priority: 0, + } as const, + ]; + mockSingleSchedule({ + start: '2024-08-15', + amount: -10000, + frequency: 'monthly', + }); + + const result = await runSchedule( + template_lines, + '2024-08-01', + 0, + 0, + 0, + 0, + [], + defaultCategory, + defaultCurrency, + ); + expect(result.to_budget).toBe(11000); // $100 × 1.10 + }); + + it('applies a fixed adjustment to the schedule amount', async () => { + const template_lines = [ + { + type: 'schedule', + name: 'Bill', + adjustment: 5, + adjustmentType: 'fixed', + directive: 'template', + priority: 0, + } as const, + ]; + mockSingleSchedule({ + start: '2024-08-15', + amount: -10000, + frequency: 'monthly', + }); + + const result = await runSchedule( + template_lines, + '2024-08-01', + 0, + 0, + 0, + 0, + [], + defaultCategory, + defaultCurrency, + ); + expect(result.to_budget).toBe(10500); // $100 + $5 + }); + + it('skips completed schedules from the budget total', async () => { + const template_lines = [ + { + type: 'schedule', + name: 'Done', + directive: 'template', + priority: 0, + } as const, + ]; + mockSingleSchedule( + { start: '2024-08-15', amount: -10000, frequency: 'monthly' }, + 1, + ); + + const result = await runSchedule( + template_lines, + '2024-08-01', + 0, + 0, + 0, + 0, + [], + defaultCategory, + defaultCurrency, + ); + expect(result.to_budget).toBe(0); + }); + + it('budgets all daily occurrences within the month for a daily schedule', async () => { + const template_lines = [ + { + type: 'schedule', + name: 'Daily Bill', + directive: 'template', + priority: 0, + } as const, + ]; + mockSingleSchedule({ + start: '2024-01-01', + amount: -100, + frequency: 'daily', + }); + + const result = await runSchedule( + template_lines, + '2024-01-01', + 0, + 0, + 0, + 0, + [], + defaultCategory, + defaultCurrency, + ); + expect(result.to_budget).toBe(3100); // 31 days × $1 + }); + + it('sorts sinking schedules by next due date so existing balance covers the earliest first', async () => { + // Templates given in reverse-date order to verify the engine sorts. + // Sorted (May first): ($1200-$200)/5 + $600/11 = $254.55 → 25455 + // Unsorted (Nov first): ($600-$200)/11 + $1200/5 = $276.36 — the + // assertion below only matches if the sort runs. + const template_lines = [ + { + type: 'schedule', + name: 'November bill', + directive: 'template', + priority: 0, + } as const, + { + type: 'schedule', + name: 'May bill', + directive: 'template', + priority: 0, + } as const, + ]; + mockSchedulesByName({ + 'November bill': { + spec: { start: '2024-11-15', amount: -60000, frequency: 'yearly' }, + }, + 'May bill': { + spec: { start: '2024-05-15', amount: -120000, frequency: 'yearly' }, + }, + }); + + const result = await runSchedule( + template_lines, + '2024-01-01', + 0, + 0, + 20000, + 0, + [], + defaultCategory, + defaultCurrency, + ); + + expect(result.errors).toHaveLength(0); + expect(result.to_budget).toBe(25455); + }); + + it('records a Past error for a non-repeating schedule whose date has already passed', async () => { + // Non-repeating (no frequency) and dated before current_month → engine + // marks it as past rather than rolling forward. + const template_lines = [ + { + type: 'schedule', + name: 'Past', + directive: 'template', + priority: 0, + } as const, + ]; vi.mocked(db.first).mockResolvedValue({ id: 1, completed: 0 }); vi.mocked(getRuleForSchedule).mockResolvedValue( new Rule({ - id: 'test', + id: 'r', stage: 'pre', conditionsOp: 'and', conditions: [ - { - op: 'is', - field: 'date', - value: { - start: '2024-08-01', - interval: 1, - frequency: 'yearly', - patterns: [], - skipWeekend: false, - weekendSolveMode: 'before', - endMode: 'never', - endOccurrences: 1, - endDate: '2024-08-04', - }, - type: 'date', - }, - { - op: 'is', - field: 'amount', - value: -12000, - type: 'number', - }, + { op: 'is', field: 'date', value: '2023-06-01', type: 'date' }, + { op: 'is', field: 'amount', value: -10000, type: 'number' }, ], actions: [], }), ); vi.mocked(isTrackingBudget).mockReturnValue(false); - // When const result = await runSchedule( template_lines, - current_month, - balance, - remainder, - last_month_balance, - to_budget, - errors, - category, - currency, + '2024-01-01', + 0, + 0, + 0, + 0, + [], + defaultCategory, + defaultCurrency, ); + expect(result.errors).toContainEqual( + expect.stringMatching(/Schedule Past is in the Past/), + ); + expect(result.to_budget).toBe(0); + }); - // Then - expect(result.to_budget).toBe(1000); - expect(result.errors).toHaveLength(0); - expect(result.remainder).toBe(0); + it('contributes target/interval per month for a fully-funded bi-monthly schedule', async () => { + // Every-2-months from 2024-03-15: interval 2 keeps it out of the + // pay-month-of fast path. With balance == target the engine takes + // the base-contribution branch: target / interval = $200 / 2 = $100. + const template_lines = [ + { + type: 'schedule', + name: 'BiMonthly', + directive: 'template', + priority: 0, + } as const, + ]; + mockSingleSchedule({ + start: '2024-03-15', + amount: -20000, + frequency: 'monthly', + interval: 2, + }); + + const result = await runSchedule( + template_lines, + '2024-01-01', + 20000, + 0, + 0, + 0, + [], + defaultCategory, + defaultCurrency, + ); + expect(result.to_budget).toBe(10000); + }); + + it('contributes target / months-spanned for a fully-funded six-week schedule', async () => { + // Every 6 weeks from 2024-02-12: outside the weekly pay-month-of + // cap (≤4), so it sinks. With balance == target the base path runs: + // prev = subWeeks(2024-02-12, 6) = 2024-01-01, span = 1 month → + // contribution = $60 / 1 = $60. + const template_lines = [ + { + type: 'schedule', + name: 'EverySixWeeks', + directive: 'template', + priority: 0, + } as const, + ]; + mockSingleSchedule({ + start: '2024-02-12', + amount: -6000, + frequency: 'weekly', + interval: 6, + }); + + const result = await runSchedule( + template_lines, + '2024-01-01', + 6000, + 0, + 0, + 0, + [], + defaultCategory, + defaultCurrency, + ); + expect(result.to_budget).toBe(6000); + }); + + it('contributes target / months-spanned for a fully-funded sixty-day schedule', async () => { + // Every 60 days from 2024-03-01: outside the daily pay-month-of + // cap (≤31), so it sinks. With balance == target the base path + // runs: prev = subDays(2024-03-01, 60) = 2024-01-01, span = 2 + // months → contribution = $60 / 2 = $30. + const template_lines = [ + { + type: 'schedule', + name: 'EverySixtyDays', + directive: 'template', + priority: 0, + } as const, + ]; + mockSingleSchedule({ + start: '2024-03-01', + amount: -6000, + frequency: 'daily', + interval: 60, + }); + + const result = await runSchedule( + template_lines, + '2024-01-01', + 6000, + 0, + 0, + 0, + [], + defaultCategory, + defaultCurrency, + ); + expect(result.to_budget).toBe(3000); + }); + + it('absorbs surplus when last-month balance exceeds a sinking schedule target', async () => { + // Last-month balance ($150) > yearly target ($120). The sink rolls + // the surplus forward and contributes nothing this month. + const template_lines = [ + { + type: 'schedule', + name: 'Overfunded', + directive: 'template', + priority: 0, + } as const, + ]; + mockSingleSchedule({ + start: '2024-12-15', + amount: -12000, + frequency: 'yearly', + }); + + const result = await runSchedule( + template_lines, + '2024-01-01', + 0, + 0, + 15000, + 0, + [], + defaultCategory, + defaultCurrency, + ); + expect(result.to_budget).toBe(0); + }); + + it('forces sinking schedules into pay-month-of mode when tracking-budget is on', async () => { + // In tracking mode every schedule is treated as pay-month-of. A + // far-future yearly schedule that would normally contribute ~$100/mo + // sinking instead contributes 0 this month, since pay-month-of only + // counts schedules whose num_months is 0. + const template_lines = [ + { + type: 'schedule', + name: 'YearlyFar', + directive: 'template', + priority: 0, + } as const, + ]; + vi.mocked(db.first).mockResolvedValue({ id: 1, completed: 0 }); + vi.mocked(getRuleForSchedule).mockResolvedValue( + makeRule({ start: '2024-12-15', amount: -12000, frequency: 'yearly' }), + ); + vi.mocked(isTrackingBudget).mockReturnValue(true); + + const result = await runSchedule( + template_lines, + '2024-01-01', + 0, + 0, + 0, + 0, + [], + defaultCategory, + defaultCurrency, + ); + expect(result.to_budget).toBe(0); }); }); diff --git a/upcoming-release-notes/7620.md b/upcoming-release-notes/7620.md new file mode 100644 index 0000000000..2e76258b5e --- /dev/null +++ b/upcoming-release-notes/7620.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [matt-fidd] +--- + +Increase test coverage for budget templates