Adds unit + integration + regression tests and a QA checklist for the v2→v1 seed migration feature. - Fixtures: src/__fixtures__/profiles.ts (makeV2Profile, makeV1Profile, makeV2ProfileWithCustom) with realistic categories, keywords, suppliers, transactions, budgets. - Unit: categoryMappingService (100 cases covering every DEFAULT_MAPPINGS entry, 4-pass priority, splits, preserved/custom detection), categoryBackupService (23 cases — Tauri mocks: success, write error, integrity check, PIN-encrypted profile), categoryMigrationService (16 cases — BEGIN/COMMIT/ROLLBACK flow, backup-missing abort, journaling, custom parent creation). - Integration: full plan→backup→migrate→verify flow; rollback via SREF import; backup failure → no DB write; migration SQL failure → ROLLBACK + intact state. - Regression: parameterised v2/v1 fixtures covering auto-categorisation, budget aggregation, splits preservation. - Docs: docs/qa-refonte-seed-categories-ipc.md — manual checklist for UX, system errors, encrypted profile, custom preservation, 90-day banner, restore flow. 331 vitest tests pass (up from 193 baseline). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
607 lines
20 KiB
TypeScript
607 lines
20 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
||
import {
|
||
computeMigrationPlan,
|
||
normalizeForMatch,
|
||
__resetMappingServiceCachesForTests,
|
||
type ProfileData,
|
||
type V2CategoryInput,
|
||
type V2KeywordInput,
|
||
type V2TransactionInput,
|
||
type V2SupplierInput,
|
||
} from "./categoryMappingService";
|
||
import { resetTaxonomyCache } from "./categoryTaxonomyService";
|
||
|
||
beforeEach(() => {
|
||
resetTaxonomyCache();
|
||
__resetMappingServiceCachesForTests();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fixture helpers — we build just enough of ProfileData per test to stay
|
||
// readable; everything defaults to empty arrays.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function makeProfile(partial: Partial<ProfileData>): ProfileData {
|
||
return {
|
||
v2Categories: partial.v2Categories ?? [],
|
||
keywords: partial.keywords ?? [],
|
||
transactions: partial.transactions ?? [],
|
||
suppliers: partial.suppliers,
|
||
};
|
||
}
|
||
|
||
function cat(id: number, name: string, parent_id: number | null = null): V2CategoryInput {
|
||
return { id, name, parent_id };
|
||
}
|
||
|
||
function kw(category_id: number, keyword: string): V2KeywordInput {
|
||
return { category_id, keyword };
|
||
}
|
||
|
||
function tx(id: number, description: string, category_id: number | null, supplier_id?: number): V2TransactionInput {
|
||
return { id, description, category_id, supplier_id: supplier_id ?? null };
|
||
}
|
||
|
||
function sup(id: number, name: string): V2SupplierInput {
|
||
return { id, name };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// normalizeForMatch
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("normalizeForMatch", () => {
|
||
it("lowercases, strips accents, and collapses spaces", () => {
|
||
expect(normalizeForMatch(" Épicerie Régulière ")).toBe("epicerie reguliere");
|
||
});
|
||
it("handles already-normalized text", () => {
|
||
expect(normalizeForMatch("stm")).toBe("stm");
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Custom categories → preserved bucket
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("computeMigrationPlan — preserved (custom)", () => {
|
||
it("moves a non-seeded v2 category into the preserved bucket", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(9001, "Ma catégorie perso", 2)],
|
||
})
|
||
);
|
||
expect(plan.rows).toHaveLength(0);
|
||
expect(plan.preserved).toHaveLength(1);
|
||
expect(plan.preserved[0]).toMatchObject({
|
||
v2CategoryId: 9001,
|
||
v2CategoryName: "Ma catégorie perso",
|
||
v1TargetId: null,
|
||
confidence: "none",
|
||
reason: "preserved",
|
||
});
|
||
});
|
||
|
||
it("ignores structural v2 parents (1–6)", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(1, "Revenus"), cat(2, "Dépenses récurrentes")],
|
||
})
|
||
);
|
||
expect(plan.rows).toHaveLength(0);
|
||
expect(plan.preserved).toHaveLength(0);
|
||
expect(plan.unresolved).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Pass 1 — keyword match
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("computeMigrationPlan — Pass 1 (keyword)", () => {
|
||
it("maps Transport en commun → 1521 (Autobus & métro) via STM keyword", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(28, "Transport en commun", 2)],
|
||
keywords: [kw(28, "STM")],
|
||
})
|
||
);
|
||
expect(plan.rows).toHaveLength(1);
|
||
expect(plan.rows[0]).toMatchObject({
|
||
v2CategoryId: 28,
|
||
v1TargetId: 1521,
|
||
v1TargetName: "Autobus & métro",
|
||
confidence: "high",
|
||
reason: "keyword",
|
||
});
|
||
});
|
||
|
||
it("maps Voiture → 1512 (Essence) via SHELL keyword", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(40, "Voiture", 3)],
|
||
keywords: [kw(40, "SHELL")],
|
||
})
|
||
);
|
||
expect(plan.rows[0]).toMatchObject({
|
||
v1TargetId: 1512,
|
||
confidence: "high",
|
||
reason: "keyword",
|
||
});
|
||
});
|
||
|
||
it("picks the first matching KEYWORD_TO_V1 rule when multiple apply", () => {
|
||
// SAAQ (1514) wins over any later rule because the list order of the
|
||
// user's keywords drives it.
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(40, "Voiture", 3)],
|
||
keywords: [kw(40, "SAAQ"), kw(40, "SHELL")],
|
||
})
|
||
);
|
||
expect(plan.rows[0].v1TargetId).toBe(1514);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Pass 2 — supplier propagation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("computeMigrationPlan — Pass 2 (supplier)", () => {
|
||
it("propagates via a transaction description when no v2 keyword matches", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(28, "Transport en commun", 2)],
|
||
// No keyword rows for cat 28.
|
||
transactions: [tx(1, "PAIEMENT STM CARTE OPUS", 28)],
|
||
})
|
||
);
|
||
expect(plan.rows[0]).toMatchObject({
|
||
v1TargetId: 1521,
|
||
confidence: "medium",
|
||
reason: "supplier",
|
||
});
|
||
});
|
||
|
||
it("propagates via a supplier name when the description has no hit", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(47, "Voyage", 3)],
|
||
transactions: [tx(1, "CARTE 1234", 47, 42)],
|
||
suppliers: [sup(42, "Hilton Montreal")],
|
||
})
|
||
);
|
||
expect(plan.rows[0]).toMatchObject({
|
||
v1TargetId: 1533,
|
||
confidence: "medium",
|
||
reason: "supplier",
|
||
});
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Pass 3 — default fallback
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("computeMigrationPlan — Pass 3 (default)", () => {
|
||
it("maps Loyer (20) → 1211 with high confidence (direct)", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(20, "Loyer", 2)],
|
||
})
|
||
);
|
||
expect(plan.rows[0]).toMatchObject({
|
||
v1TargetId: 1211,
|
||
v1TargetName: "Loyer",
|
||
confidence: "high",
|
||
reason: "default",
|
||
});
|
||
});
|
||
|
||
it("maps Restaurant (24) → 1121 with medium confidence", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(24, "Restaurant", 2)],
|
||
})
|
||
);
|
||
expect(plan.rows[0]).toMatchObject({
|
||
v1TargetId: 1121,
|
||
confidence: "medium",
|
||
reason: "default",
|
||
});
|
||
});
|
||
|
||
it("exposes splits for Transport en commun (28) when no keyword/supplier resolves it", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(28, "Transport en commun", 2)],
|
||
})
|
||
);
|
||
expect(plan.rows[0].splits).toEqual([
|
||
{ v1TargetId: 1521, v1TargetName: "Autobus & métro" },
|
||
{ v1TargetId: 1522, v1TargetName: "Train de banlieue" },
|
||
]);
|
||
expect(plan.rows[0].confidence).toBe("medium");
|
||
expect(plan.rows[0].reason).toBe("default");
|
||
// Primary target is the "reste → X par défaut" (1521 per rationale).
|
||
expect(plan.rows[0].v1TargetId).toBe(1521);
|
||
});
|
||
|
||
it("exposes 4-way splits for Voiture (40)", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(40, "Voiture", 3)],
|
||
})
|
||
);
|
||
expect(plan.rows[0].splits?.map((s) => s.v1TargetId)).toEqual([1512, 1513, 1514, 1515]);
|
||
expect(plan.rows[0].v1TargetId).toBe(1513); // entretien par défaut
|
||
expect(plan.rows[0].confidence).toBe("low");
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Pass 4 — review
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("computeMigrationPlan — Pass 4 (review)", () => {
|
||
it("flags Projets (73) for review (no direct v1 equivalent)", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(73, "Projets", 6)],
|
||
})
|
||
);
|
||
expect(plan.rows[0]).toMatchObject({
|
||
v1TargetId: null,
|
||
v1TargetName: null,
|
||
confidence: "none",
|
||
reason: "review",
|
||
});
|
||
expect(plan.unresolved).toHaveLength(1);
|
||
});
|
||
|
||
it("escapes Pass 4 for Projets when a CLAUDE.AI keyword is present", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(73, "Projets", 6)],
|
||
keywords: [kw(73, "CLAUDE.AI")],
|
||
})
|
||
);
|
||
expect(plan.rows[0]).toMatchObject({
|
||
v1TargetId: 1734, // Abonnements professionnels
|
||
confidence: "high",
|
||
reason: "keyword",
|
||
});
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stats & aggregation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("computeMigrationPlan — stats", () => {
|
||
it("reports per-confidence counts matching the rows", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [
|
||
cat(20, "Loyer", 2), // high (default direct)
|
||
cat(24, "Restaurant", 2), // medium (default)
|
||
cat(40, "Voiture", 3), // low (split, low confidence)
|
||
cat(73, "Projets", 6), // none
|
||
],
|
||
})
|
||
);
|
||
expect(plan.stats).toEqual({
|
||
total: 4,
|
||
high: 1,
|
||
medium: 1,
|
||
low: 1,
|
||
none: 1,
|
||
});
|
||
expect(plan.unresolved).toHaveLength(1);
|
||
expect(plan.unresolved[0].v2CategoryId).toBe(73);
|
||
});
|
||
|
||
it("returns empty structures for an empty profile", () => {
|
||
const plan = computeMigrationPlan(makeProfile({}));
|
||
expect(plan.rows).toEqual([]);
|
||
expect(plan.preserved).toEqual([]);
|
||
expect(plan.unresolved).toEqual([]);
|
||
expect(plan.stats).toEqual({ total: 0, high: 0, medium: 0, low: 0, none: 0 });
|
||
});
|
||
|
||
it("handles a mixed profile with seeded + custom categories in one call", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [
|
||
cat(1, "Revenus"), // structural → skipped
|
||
cat(22, "Épicerie", 2), // high default
|
||
cat(9002, "Dépenses projet X", 3), // custom → preserved
|
||
],
|
||
})
|
||
);
|
||
expect(plan.rows).toHaveLength(1);
|
||
expect(plan.rows[0].v2CategoryId).toBe(22);
|
||
expect(plan.preserved).toHaveLength(1);
|
||
expect(plan.preserved[0].v2CategoryId).toBe(9002);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Pass priority — keyword beats default
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("computeMigrationPlan — pass priority", () => {
|
||
it("Pass 1 (keyword) wins over Pass 3 (default) on split categories", () => {
|
||
// Transport en commun (28) default is 1521; with a GARE CENTRALE keyword
|
||
// Pass 1 should push to 1522 (Train) instead.
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(28, "Transport en commun", 2)],
|
||
keywords: [kw(28, "GARE CENTRALE")],
|
||
})
|
||
);
|
||
expect(plan.rows[0]).toMatchObject({
|
||
v1TargetId: 1522,
|
||
confidence: "high",
|
||
reason: "keyword",
|
||
});
|
||
// Splits are NOT exposed when Pass 1 resolves the row — only Pass 3
|
||
// attaches them.
|
||
expect(plan.rows[0].splits).toBeUndefined();
|
||
});
|
||
|
||
it("Pass 2 (supplier) wins over Pass 3 (default)", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(40, "Voiture", 3)],
|
||
// No v2 keyword row on cat 40 — description drives it.
|
||
transactions: [tx(1, "PETRO-CANADA #1234 MTL", 40)],
|
||
})
|
||
);
|
||
expect(plan.rows[0]).toMatchObject({
|
||
v1TargetId: 1512, // Essence
|
||
confidence: "medium",
|
||
reason: "supplier",
|
||
});
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DEFAULT_MAPPINGS exhaustive coverage — one expectation per seeded v2 id
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("computeMigrationPlan — DEFAULT_MAPPINGS exhaustive", () => {
|
||
// Each entry: [v2Id, v2Name, expectedV1TargetId|null, expectedBadge, isSplit]
|
||
const CASES: Array<[number, string, number | null, "high" | "medium" | "low" | "none", boolean]> = [
|
||
// Revenus
|
||
[10, "Paie", 1011, "high", false],
|
||
[11, "Autres revenus", 1090, "high", false],
|
||
// Dépenses récurrentes
|
||
[20, "Loyer", 1211, "high", false],
|
||
[21, "Électricité", 1221, "high", false],
|
||
[22, "Épicerie", 1111, "high", false],
|
||
[23, "Dons", 1931, "high", false],
|
||
[24, "Restaurant", 1121, "medium", false],
|
||
[25, "Frais bancaires", 1911, "high", false],
|
||
[26, "Jeux Films & Livres", 1710, "low", true],
|
||
[27, "Abonnements Musique", 1714, "high", false],
|
||
[28, "Transport en commun", 1521, "medium", true],
|
||
[29, "Internet & Télécom", 1231, "medium", true],
|
||
[30, "Animaux", 1751, "medium", false],
|
||
[31, "Assurances", 1250, "low", true],
|
||
[32, "Pharmacie", 1611, "high", false],
|
||
[33, "Taxes municipales", 1213, "high", false],
|
||
// Dépenses ponctuelles
|
||
[40, "Voiture", 1513, "low", true],
|
||
[41, "Amazon", 1946, "medium", false],
|
||
[42, "Électroniques", 1312, "low", false],
|
||
[43, "Alcool", 1810, "high", false],
|
||
[44, "Cadeaux", 1940, "high", false],
|
||
[45, "Vêtements", 1410, "medium", false],
|
||
[46, "CPA", 1932, "high", false],
|
||
[47, "Voyage", 1533, "medium", true],
|
||
[48, "Sports & Plein air", 1722, "medium", true],
|
||
[49, "Spectacles & sorties", 1711, "high", false],
|
||
// Maison
|
||
[50, "Hypothèque", 1212, "high", false],
|
||
[51, "Achats maison", 1243, "medium", false],
|
||
[52, "Entretien maison", 1241, "high", false],
|
||
[53, "Électroménagers & Meubles", 1311, "medium", true],
|
||
[54, "Outils", 1243, "high", false],
|
||
// Placements
|
||
[60, "Placements", 1964, "medium", false],
|
||
[61, "Transferts internes", 1980, "high", false],
|
||
// Autres
|
||
[70, "Impôts", 1922, "medium", false],
|
||
[71, "Paiement CC", 1971, "high", false],
|
||
[72, "Retrait cash", 1945, "high", false],
|
||
[73, "Projets", null, "none", false],
|
||
// Assurances children (already split in some profiles)
|
||
[310, "Assurance-auto", 1516, "high", false],
|
||
[311, "Assurance-habitation", 1250, "high", false],
|
||
[312, "Assurance-vie", 1630, "high", false],
|
||
];
|
||
|
||
it.each(CASES)(
|
||
"v2 id %i (%s) → v1 target %s with %s badge",
|
||
(v2Id, v2Name, expectedV1, expectedBadge, isSplit) => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({ v2Categories: [cat(v2Id, v2Name, 2)] })
|
||
);
|
||
expect(plan.rows).toHaveLength(1);
|
||
const row = plan.rows[0];
|
||
expect(row.v2CategoryId).toBe(v2Id);
|
||
expect(row.v1TargetId).toBe(expectedV1);
|
||
expect(row.confidence).toBe(expectedBadge);
|
||
if (isSplit) {
|
||
expect(row.splits).toBeDefined();
|
||
expect(row.splits!.length).toBeGreaterThan(1);
|
||
}
|
||
}
|
||
);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Keyword → V1 rules exhaustive coverage
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("computeMigrationPlan — KEYWORD_TO_V1 rules", () => {
|
||
// Each entry: [keyword, expected v1 id] — one test per rule so regressions
|
||
// on the mapping table fail individually with a clear signal.
|
||
const KW_RULES: Array<[string, number]> = [
|
||
// Jeux/Films/Livres
|
||
["STEAMGAMES", 1712],
|
||
["PLAYSTATION", 1712],
|
||
["NINTENDO", 1712],
|
||
["PRIMEVIDEO", 1713],
|
||
["RENAUD-BRAY", 1741],
|
||
["CINEMA DU PARC", 1711],
|
||
["LEGO", 1715],
|
||
// Transport
|
||
["STM", 1521],
|
||
["GARE MONT-SAINT", 1522],
|
||
["GARE SAINT-HUBERT", 1522],
|
||
["GARE CENTRALE", 1522],
|
||
["REM", 1522],
|
||
// Voiture
|
||
["SHELL", 1512],
|
||
["ESSO", 1512],
|
||
["ULTRAMAR", 1512],
|
||
["PETRO-CANADA", 1512],
|
||
["CREVIER", 1512],
|
||
["SAAQ", 1514],
|
||
// Assurances
|
||
["BELAIR", 1250],
|
||
["PRYSM", 1250],
|
||
["INS/ASS", 1630],
|
||
// Voyage
|
||
["NORWEGIAN CRUISE", 1533],
|
||
["AEROPORTS DE MONTREAL", 1531],
|
||
["HILTON", 1533],
|
||
// Sports
|
||
["SEPAQ", 1723],
|
||
["BLOC SHOP", 1723],
|
||
["MOUNTAIN EQUIPMENT", 1722],
|
||
["DECATHLON", 1722],
|
||
["LA CORDEE", 1722],
|
||
["PHYSIOACTIF", 1615],
|
||
// Meubles
|
||
["TANGUAY", 1311],
|
||
["BOUCLAIR", 1311],
|
||
// Projets
|
||
["CLAUDE.AI", 1734],
|
||
["NAME-CHEAP", 1734],
|
||
];
|
||
|
||
it.each(KW_RULES)(
|
||
"keyword %s resolves to v1 leaf %i with high confidence",
|
||
(keyword, expectedV1) => {
|
||
// Attach the keyword to any seeded v2 cat id — the keyword rules win
|
||
// regardless of the host v2 cat (that's by design of Pass 1).
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(73, "Projets", 6)],
|
||
keywords: [kw(73, keyword)],
|
||
})
|
||
);
|
||
expect(plan.rows[0].v1TargetId).toBe(expectedV1);
|
||
expect(plan.rows[0].confidence).toBe("high");
|
||
expect(plan.rows[0].reason).toBe("keyword");
|
||
}
|
||
);
|
||
|
||
it("propagates keyword match even when the raw v2 keyword has mixed case and spaces", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(28, "Transport en commun", 2)],
|
||
keywords: [kw(28, " Gare Centrale ")],
|
||
})
|
||
);
|
||
expect(plan.rows[0].v1TargetId).toBe(1522);
|
||
});
|
||
|
||
it("matches a keyword that literally names a v1 leaf (Loyer → 1211)", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(20, "Loyer", 2)],
|
||
keywords: [kw(20, "loyer")],
|
||
})
|
||
);
|
||
// With a matching leaf-name keyword, Pass 1 resolves with high confidence
|
||
// (instead of falling through to Pass 3's high-confidence default; both
|
||
// resolve to 1211, but the reason must be "keyword").
|
||
expect(plan.rows[0].v1TargetId).toBe(1211);
|
||
expect(plan.rows[0].reason).toBe("keyword");
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Pass 2 — deeper coverage
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("computeMigrationPlan — Pass 2 deeper cases", () => {
|
||
it("falls through to Pass 3 when neither keyword nor supplier matches anything", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(40, "Voiture", 3)],
|
||
transactions: [tx(1, "SOMETHING UNRELATED", 40)],
|
||
})
|
||
);
|
||
// Default split applies: primary = 1513 (entretien).
|
||
expect(plan.rows[0]).toMatchObject({
|
||
v1TargetId: 1513,
|
||
confidence: "low",
|
||
reason: "default",
|
||
});
|
||
expect(plan.rows[0].splits?.map((s) => s.v1TargetId)).toEqual([1512, 1513, 1514, 1515]);
|
||
});
|
||
|
||
it("ignores suppliers that are in the map but not attached to any tx", () => {
|
||
// Pass 2 iterates over transactions; a loose supplier list is irrelevant.
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [cat(40, "Voiture", 3)],
|
||
transactions: [tx(1, "MISC", 40)],
|
||
suppliers: [{ id: 777, name: "Shell" }],
|
||
})
|
||
);
|
||
// Without a tx.supplier_id pointing at 777, the Shell rule never fires.
|
||
expect(plan.rows[0].reason).toBe("default");
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Preserved — multiple custom categories
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("computeMigrationPlan — preserved (multiple)", () => {
|
||
it("collects all 3 custom categories under preserved[] with reason='preserved'", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [
|
||
cat(9001, "Projet maison", 3),
|
||
cat(9002, "Activités enfants", 3),
|
||
cat(9003, "Hobby moto", 3),
|
||
],
|
||
})
|
||
);
|
||
expect(plan.preserved).toHaveLength(3);
|
||
for (const row of plan.preserved) {
|
||
expect(row.reason).toBe("preserved");
|
||
expect(row.v1TargetId).toBeNull();
|
||
expect(row.confidence).toBe("none");
|
||
}
|
||
});
|
||
|
||
it("keeps seeded + custom in different buckets when both are present", () => {
|
||
const plan = computeMigrationPlan(
|
||
makeProfile({
|
||
v2Categories: [
|
||
cat(22, "Épicerie", 2),
|
||
cat(9999, "Projet perso", 3),
|
||
],
|
||
})
|
||
);
|
||
expect(plan.rows.map((r) => r.v2CategoryId)).toEqual([22]);
|
||
expect(plan.preserved.map((r) => r.v2CategoryId)).toEqual([9999]);
|
||
});
|
||
});
|