mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-06 15:12:35 -05:00
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) <noreply@anthropic.com> * note --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ReturnType<typeof statements.getActiveSchedules>>);
|
||||
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<ReturnType<typeof statements.getActiveSchedules>>);
|
||||
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<ReturnType<typeof statements.getActiveSchedules>>,
|
||||
);
|
||||
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<ReturnType<typeof statements.getActiveSchedules>>,
|
||||
);
|
||||
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<ReturnType<typeof statements.getActiveSchedules>>,
|
||||
);
|
||||
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<ReturnType<typeof statements.getActiveSchedules>>,
|
||||
);
|
||||
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<ReturnType<typeof statements.getActiveSchedules>>,
|
||||
);
|
||||
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<ReturnType<typeof statements.getActiveSchedules>>,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
373
packages/loot-core/src/server/budget/goal-template.test.ts
Normal file
373
packages/loot-core/src/server/budget/goal-template.test.ts
Normal file
@@ -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<void>) => fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./statements', () => ({
|
||||
getActiveSchedules: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./template-notes', () => ({
|
||||
checkTemplateNotes: vi.fn(),
|
||||
storeNoteTemplates: vi.fn(),
|
||||
}));
|
||||
|
||||
function setupSheetMock(values: Record<string, number | boolean | null>) {
|
||||
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<string, Template[]>,
|
||||
) {
|
||||
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<ReturnType<typeof statements.getActiveSchedules>>,
|
||||
);
|
||||
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<ReturnType<typeof statements.getActiveSchedules>>,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { spec: RuleSpec; completed?: number }>,
|
||||
) {
|
||||
const names = Object.keys(specsByName);
|
||||
const sidByName: Record<string, number> = 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);
|
||||
});
|
||||
});
|
||||
|
||||
6
upcoming-release-notes/7620.md
Normal file
6
upcoming-release-notes/7620.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Increase test coverage for budget templates
|
||||
Reference in New Issue
Block a user