diff --git a/src/hooks/useCategories.test.ts b/src/hooks/useCategories.test.ts index 5e8adf4..6b4bfea 100644 --- a/src/hooks/useCategories.test.ts +++ b/src/hooks/useCategories.test.ts @@ -181,7 +181,8 @@ describe("useCategories — « Vérifier la hiérarchie » (#393)", () => { let api = await mount(); await api.checkHierarchy(); api = harness.render(useCategories); - expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(1); + // The mount's read, then the check's. + expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2); await api.reattachOrphan(CYCLE, 1); @@ -192,7 +193,7 @@ describe("useCategories — « Vérifier la hiérarchie » (#393)", () => { expect(line).toContain("3 categories"); for (const row of ROWS) expect(line).not.toContain(row.name); expect(findOrphanCategories).toHaveBeenCalledTimes(2); - expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2); + expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(3); const { state } = harness.render(useCategories); expect(state.hierarchyCheck).toEqual({ orphans: [], error: null }); expect(state.isSaving).toBe(false); @@ -209,7 +210,8 @@ describe("useCategories — « Vérifier la hiérarchie » (#393)", () => { await api.reattachOrphan(CYCLE, null); expect(reattachCategory).toHaveBeenCalledWith(3, null); expect(logInfo).toHaveBeenCalledTimes(1); - expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2); + // The mount's read, the check's, and the reload after the committed reattachment. + expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(3); const { state } = harness.render(useCategories); expect(state.hierarchyCheck?.error).toEqual({ message: "database is locked" }); expect(state.isSaving).toBe(false); @@ -232,6 +234,50 @@ describe("useCategories — « Vérifier la hiérarchie » (#393)", () => { expect(logInfo).not.toHaveBeenCalled(); }); + it("a refusal, then a reattachment that goes through: the window's message goes", async () => { + vi.mocked(findOrphanCategories).mockResolvedValueOnce([CYCLE]).mockResolvedValueOnce([]); + vi.mocked(reattachCategory) + .mockRejectedValueOnce(new CategoryHierarchyError("parent_has_transactions")) + .mockResolvedValueOnce(3); + let api = await mount(); + await api.checkHierarchy(); + api = harness.render(useCategories); + await api.reattachOrphan(CYCLE, 1); + api = harness.render(useCategories); + expect(api.state.hierarchyCheck?.error).toEqual({ key: "categories.hierarchy.errors.parent_has_transactions" }); + await api.reattachOrphan(CYCLE, null); + expect(harness.render(useCategories).state.hierarchyCheck).toEqual({ orphans: [], error: null }); + }); + + it("after a drag-and-drop, reads the categories again: the window gets the members of a loop back", async () => { + // 1 with 10 under it, 2, and 3 <-> 5: no root reaches 3 nor 5. + const row = (id: number, parent_id: number | null) => ({ ...ROWS[1], id, name: `c${id}`, parent_id, sort_order: id }); + vi.mocked(getAllCategoriesWithCounts).mockResolvedValue([ + row(1, null), + row(10, 1), + row(2, null), + row(3, 5), + row(5, 3), + ] as never); + vi.mocked(findOrphanCategories).mockResolvedValue([CYCLE]); + let api = await mount(); + const ids = () => harness.render(useCategories).state.categories.map((c) => c.id).sort((a, b) => a - b); + expect(ids()).toEqual([1, 2, 3, 5, 10]); + + await api.moveCategory(2, null, 0); + // The drag-and-drop keeps what the tree shows: the loop is gone from the list. + expect(ids()).toEqual([1, 2, 10]); + + api = harness.render(useCategories); + harness.dispatch.mockClear(); + await api.checkHierarchy(); + expect(ids()).toEqual([1, 2, 3, 5, 10]); + expect(harness.render(useCategories).state.hierarchyCheck).toEqual({ orphans: [CYCLE], error: null }); + // Read without the page's loading state: the tree and an open form stay on screen. + const types = harness.dispatch.mock.calls.map(([action]) => (action as { type: string }).type); + expect(types).not.toContain("SET_LOADING"); + }); + it("closes the window; a check that ends after it closed changes nothing", async () => { let resolve: (orphans: OrphanCategory[]) => void = () => undefined; vi.mocked(findOrphanCategories).mockReturnValue(new Promise((r) => (resolve = r))); diff --git a/src/hooks/useCategories.ts b/src/hooks/useCategories.ts index 711269e..0826f3c 100644 --- a/src/hooks/useCategories.ts +++ b/src/hooks/useCategories.ts @@ -403,10 +403,23 @@ export function useCategories() { [state.tree, loadCategories] ); - /** Opens the « Vérifier la hiérarchie » window and runs the diagnostic (#393). */ + /** + * Opens the « Vérifier la hiérarchie » window and runs the diagnostic (#393). + * The window names the orphans and offers their parents from + * `state.categories`, which a drag-and-drop leaves with what the tree shows + * only (`SET_TREE`), without the categories no root reaches: the categories + * are read again first. Without the page's loading state, so a form open + * behind the window stays as it is; a load started meanwhile wins. + */ const checkHierarchy = useCallback(async () => { dispatch({ type: "OPEN_HIERARCHY_CHECK" }); try { + const fetchId = fetchIdRef.current; + const rows = await getAllCategoriesWithCounts(); + if (fetchId === fetchIdRef.current) { + const flat = rows.map((r) => ({ ...r, children: [] as CategoryTreeNode[] })); + dispatch({ type: "SET_CATEGORIES", payload: { flat, tree: buildTree(flat) } }); + } const orphans = await findOrphanCategories(); dispatch({ type: "SET_ORPHANS", payload: orphans }); } catch (e) {