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:
Matt Fiddaman
2026-04-26 00:35:32 +01:00
parent 4926cd5d76
commit 46ba63f370
4 changed files with 1482 additions and 114 deletions

View File

@@ -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);
});
});
});

View 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);
});
});

View File

@@ -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);
});
});

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [matt-fidd]
---
Increase test coverage for budget templates