updateCategory checks a new parent before writing: the category itself, one of its descendants or a category caught in a cycle (cycle), a fourth level counting its sub-tree (too_deep), a parent holding transactions of its own (parent_has_transactions). The refusal is a CategoryHierarchyError that the Categories page shows by the translation of its code. The reads and the writes, the new parent's is_inputable included, run in one transaction through its handle; an unchanged parent is not checked, so a category stored before these rules can still be renamed. getCategoryDepth keeps the ids it has seen and ends on a cycle. A "Check hierarchy" button, between "All keywords" and "Re-initialize", opens a window that lists the orphan categories (caught in a parent loop, parent deleted, parent gone), one per group, with a new parent for each: the root by default, or a parent of the category form's list. reattachCategory applies the same rules; after it, the list and the tree reload and the Journal gets one line with the reason and the number of categories put back, no name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
8.3 KiB
TypeScript
211 lines
8.3 KiB
TypeScript
// CategoriesPage and its getting-started card (#432). No jsdom:
|
|
// react-dom/server renders in vitest's node environment. The categories hook
|
|
// is stubbed (the page is loading, its header shows), and the path is a fake
|
|
// `useOnboarding()` built from the real checklist. What is pinned is the
|
|
// page's wiring: where the card sits, what it says, and which control carries
|
|
// the one highlight ("All keywords", never "Reset").
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
|
|
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
|
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
|
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
|
|
|
const harness = vi.hoisted(() => ({
|
|
onboarding: null as unknown,
|
|
/** Overrides of the categories state, for the tests of #393. */
|
|
state: {} as Record<string, unknown>,
|
|
}));
|
|
|
|
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
|
vi.mock("../hooks/useCategories", async (importOriginal) => ({
|
|
// The real error text helper: the page must translate a refusal's key.
|
|
categoriesErrorText: (await importOriginal<typeof import("../hooks/useCategories")>()).categoriesErrorText,
|
|
useCategories: () => ({
|
|
state: {
|
|
categories: [],
|
|
tree: [],
|
|
keywords: [],
|
|
selectedCategoryId: null,
|
|
editingCategory: null,
|
|
isCreating: false,
|
|
isLoading: true,
|
|
isSaving: false,
|
|
error: null,
|
|
hierarchyCheck: null,
|
|
...harness.state,
|
|
},
|
|
}),
|
|
}));
|
|
|
|
import CategoriesPage, { CATEGORIES_ALL_KEYWORDS_TARGET } from "./CategoriesPage";
|
|
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
|
|
import { computeChecklist } from "../utils/onboardingChecklist";
|
|
import { nextStep } from "../utils/onboardingPath";
|
|
import type { ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
|
|
|
/** Folder, source and import done: the next step is the categories step. */
|
|
const IMPORTED: OnboardingCounters = {
|
|
sources: 1,
|
|
imports: 1,
|
|
manualCategorized: 0,
|
|
budgetEntries: 0,
|
|
transactions: 41,
|
|
};
|
|
|
|
/** The path latched on /categories (card mode), as the provider shares it. */
|
|
function path(acknowledged: ChecklistItemKey[], overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
|
const checklist = computeChecklist(IMPORTED, {
|
|
importFolderSet: true,
|
|
budgetAllowed: true,
|
|
visitStepsInitialized: true,
|
|
acknowledged,
|
|
});
|
|
return {
|
|
status: "ready",
|
|
checklist,
|
|
counters: IMPORTED,
|
|
hidden: false,
|
|
budgetReady: true,
|
|
visitStepsInitialized: true,
|
|
nextStep: nextStep(checklist),
|
|
mode: "card",
|
|
cardPage: "/categories",
|
|
hide: vi.fn(async () => undefined),
|
|
show: vi.fn(async () => undefined),
|
|
skip: vi.fn(async () => undefined),
|
|
resume: vi.fn(async () => undefined),
|
|
acknowledge: vi.fn(async () => undefined),
|
|
refresh: vi.fn(async () => undefined),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const render = () =>
|
|
renderToStaticMarkup(
|
|
<MemoryRouter initialEntries={["/categories"]}>
|
|
<CategoriesPage />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
const CARD = 'aria-label="t(onboarding.banner.label)"';
|
|
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
|
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
|
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
|
|
|
|
beforeEach(() => {
|
|
harness.onboarding = path([]);
|
|
harness.state = {};
|
|
});
|
|
|
|
describe("CategoriesPage — the step card (#432)", () => {
|
|
it("renders the card first, before the page's heading, fifth of eight", () => {
|
|
const html = render();
|
|
expect(count(html, CARD)).toBe(1);
|
|
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
|
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=8])");
|
|
expect(html).toContain("t(onboarding.checklist.items.categories)");
|
|
});
|
|
|
|
it("explains keywords, 'All keywords' named by its own label", () => {
|
|
expect(sentence(render())).toBe(
|
|
"t(onboarding.card.categories[allKeywords=t(categories.allKeywords)])",
|
|
);
|
|
});
|
|
|
|
it("offers 'Got it' and 'Skip this step'", () => {
|
|
const html = render();
|
|
expect(html).toContain("t(onboarding.card.acknowledge)");
|
|
expect(html).toContain("t(onboarding.card.skip)");
|
|
});
|
|
|
|
it("'All keywords' is the one target, never 'Reset'", () => {
|
|
const html = render();
|
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
|
expect(html).toContain(`data-onboarding-target="${CATEGORIES_ALL_KEYWORDS_TARGET}" ${HIGHLIGHT}`);
|
|
const ring = html.indexOf(HIGHLIGHT);
|
|
const allKeywords = html.indexOf("t(categories.allKeywords)</button>");
|
|
const reset = html.indexOf("t(categories.reinitialize)</button>");
|
|
// The ring sits on the button whose label closes right after it, before "Reset".
|
|
expect(ring).toBeLessThan(allKeywords);
|
|
expect(html.lastIndexOf("<button", allKeywords)).toBeLessThan(ring);
|
|
expect(ring).toBeLessThan(reset);
|
|
});
|
|
|
|
it("the step acknowledged: 'Done.', the next step is the manual category, no ring", () => {
|
|
harness.onboarding = path(["categories"]);
|
|
const html = render();
|
|
expect(count(html, CARD)).toBe(1);
|
|
expect(html).toContain("t(onboarding.card.done)");
|
|
expect(html).toContain("t(onboarding.checklist.items.manualCategory)");
|
|
expect(html).not.toContain("t(onboarding.card.acknowledge)");
|
|
expect(html).not.toContain(HIGHLIGHT);
|
|
});
|
|
|
|
it("without the card on screen, no card and no ring", () => {
|
|
for (const overrides of [{ mode: "banner" as const }, { cardPage: null }, { cardPage: "/import" }]) {
|
|
harness.onboarding = path([], overrides);
|
|
const html = render();
|
|
expect(html, JSON.stringify(overrides)).not.toContain(CARD);
|
|
expect(html, JSON.stringify(overrides)).not.toContain(HIGHLIGHT);
|
|
}
|
|
});
|
|
|
|
it("opening the page acknowledges nothing", () => {
|
|
const value = harness.onboarding as OnboardingValue;
|
|
render();
|
|
expect(value.acknowledge).not.toHaveBeenCalled();
|
|
expect(value.skip).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("CategoriesPage — « Vérifier la hiérarchie » (#393)", () => {
|
|
/** The opening tag of the header button whose label closes right after it. */
|
|
const buttonTag = (html: string, label: string) => {
|
|
const end = html.indexOf(`${label}</button>`);
|
|
expect(end).toBeGreaterThan(-1);
|
|
const start = html.lastIndexOf("<button", end);
|
|
return html.slice(start, html.indexOf(">", start) + 1);
|
|
};
|
|
|
|
it("sits between « All keywords » and « Reset », styled as « Reset »", () => {
|
|
const html = render();
|
|
const allKeywords = html.indexOf("t(categories.allKeywords)</button>");
|
|
const check = html.indexOf("t(categories.hierarchy.check)</button>");
|
|
const reset = html.indexOf("t(categories.reinitialize)</button>");
|
|
expect(allKeywords).toBeLessThan(check);
|
|
expect(check).toBeLessThan(reset);
|
|
const classOf = (tag: string) => tag.match(/class="([^"]*)"/)?.[1];
|
|
expect(classOf(buttonTag(html, "t(categories.hierarchy.check)"))).toBe(
|
|
classOf(buttonTag(html, "t(categories.reinitialize)")),
|
|
);
|
|
});
|
|
|
|
it("is disabled while a save runs, as « Reset »", () => {
|
|
harness.state = { isSaving: true };
|
|
expect(buttonTag(render(), "t(categories.hierarchy.check)")).toContain('disabled=""');
|
|
});
|
|
|
|
it("opens the window only while the check is open", () => {
|
|
expect(render()).not.toContain('role="dialog"');
|
|
harness.state = { hierarchyCheck: { orphans: [], error: null } };
|
|
const html = render();
|
|
expect(html).toContain('role="dialog"');
|
|
expect(html).toContain("t(categories.hierarchy.none)");
|
|
});
|
|
|
|
it("translates a refusal in the window by its key", () => {
|
|
harness.state = {
|
|
hierarchyCheck: { orphans: [], error: { key: "categories.hierarchy.errors.too_deep" } },
|
|
};
|
|
expect(render()).toContain("t(categories.hierarchy.errors.too_deep)");
|
|
});
|
|
|
|
it("translates a refused save of the form by its key, and shows any other failure as is", () => {
|
|
harness.state = { error: { key: "categories.hierarchy.errors.parent_has_transactions" } };
|
|
expect(render()).toContain(">t(categories.hierarchy.errors.parent_has_transactions)</div>");
|
|
harness.state = { error: { message: "database is locked" } };
|
|
expect(render()).toContain(">database is locked</div>");
|
|
});
|
|
});
|