Merge pull request 'feat(categories): categoryTaxonomyService + useCategoryTaxonomy (#116)' (#127) from issue-116-category-taxonomy-service into main
This commit is contained in:
commit
b8fa089c5f
3 changed files with 222 additions and 0 deletions
32
src/hooks/useCategoryTaxonomy.ts
Normal file
32
src/hooks/useCategoryTaxonomy.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useMemo } from "react";
|
||||
import {
|
||||
getTaxonomyV1,
|
||||
findById,
|
||||
findByPath,
|
||||
getLeaves,
|
||||
getParentById,
|
||||
type Taxonomy,
|
||||
type TaxonomyNode,
|
||||
type TaxonomyLeaf,
|
||||
} from "../services/categoryTaxonomyService";
|
||||
|
||||
export interface UseCategoryTaxonomyResult {
|
||||
taxonomy: Taxonomy;
|
||||
findById: (id: number) => TaxonomyNode | null;
|
||||
findByPath: (path: string[]) => TaxonomyNode | null;
|
||||
getLeaves: () => TaxonomyLeaf[];
|
||||
getParentById: (id: number) => TaxonomyNode | null;
|
||||
}
|
||||
|
||||
export function useCategoryTaxonomy(): UseCategoryTaxonomyResult {
|
||||
return useMemo<UseCategoryTaxonomyResult>(
|
||||
() => ({
|
||||
taxonomy: getTaxonomyV1(),
|
||||
findById,
|
||||
findByPath,
|
||||
getLeaves,
|
||||
getParentById,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
102
src/services/categoryTaxonomyService.test.ts
Normal file
102
src/services/categoryTaxonomyService.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
getTaxonomyV1,
|
||||
findById,
|
||||
findByPath,
|
||||
getLeaves,
|
||||
getParentById,
|
||||
resetTaxonomyCache,
|
||||
} from "./categoryTaxonomyService";
|
||||
|
||||
beforeEach(() => {
|
||||
resetTaxonomyCache();
|
||||
});
|
||||
|
||||
describe("getTaxonomyV1", () => {
|
||||
it("returns version v1 with non-empty roots", () => {
|
||||
const tax = getTaxonomyV1();
|
||||
expect(tax.version).toBe("v1");
|
||||
expect(tax.roots.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("caches the taxonomy across calls (same reference)", () => {
|
||||
const a = getTaxonomyV1();
|
||||
const b = getTaxonomyV1();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findById", () => {
|
||||
it("finds a root-level node", () => {
|
||||
const node = findById(1000);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node?.name).toBe("Revenus");
|
||||
});
|
||||
|
||||
it("finds a nested leaf node", () => {
|
||||
const node = findById(1011);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node?.i18n_key).toBe("categoriesSeed.revenus.emploi.paie");
|
||||
});
|
||||
|
||||
it("returns null for an unknown id", () => {
|
||||
expect(findById(99999)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByPath", () => {
|
||||
it("returns null for an empty path", () => {
|
||||
expect(findByPath([])).toBeNull();
|
||||
});
|
||||
|
||||
it("finds a root by single-segment path", () => {
|
||||
const node = findByPath(["Revenus"]);
|
||||
expect(node?.id).toBe(1000);
|
||||
});
|
||||
|
||||
it("finds a nested leaf by multi-segment path", () => {
|
||||
const node = findByPath(["Revenus", "Emploi", "Paie régulière"]);
|
||||
expect(node?.id).toBe(1011);
|
||||
});
|
||||
|
||||
it("returns null when a segment does not match", () => {
|
||||
expect(findByPath(["Revenus", "Emploi", "NoSuchLeaf"])).toBeNull();
|
||||
expect(findByPath(["Unknown"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLeaves", () => {
|
||||
it("returns only nodes with no children", () => {
|
||||
const leaves = getLeaves();
|
||||
expect(leaves.length).toBeGreaterThan(0);
|
||||
for (const leaf of leaves) {
|
||||
expect(leaf.children.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes a known leaf by id", () => {
|
||||
const leaves = getLeaves();
|
||||
expect(leaves.some((l) => l.id === 1011)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getParentById", () => {
|
||||
it("returns the direct parent of a leaf", () => {
|
||||
const parent = getParentById(1011);
|
||||
expect(parent?.id).toBe(1010);
|
||||
expect(parent?.name).toBe("Emploi");
|
||||
});
|
||||
|
||||
it("returns the root as parent of a level-2 node", () => {
|
||||
const parent = getParentById(1010);
|
||||
expect(parent?.id).toBe(1000);
|
||||
});
|
||||
|
||||
it("returns null for a root-level node", () => {
|
||||
expect(getParentById(1000)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an unknown id", () => {
|
||||
expect(getParentById(99999)).toBeNull();
|
||||
});
|
||||
});
|
||||
88
src/services/categoryTaxonomyService.ts
Normal file
88
src/services/categoryTaxonomyService.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import taxonomyData from "../data/categoryTaxonomyV1.json";
|
||||
|
||||
export type TaxonomyCategoryType = "expense" | "income" | "transfer";
|
||||
|
||||
export interface TaxonomyNode {
|
||||
id: number;
|
||||
name: string;
|
||||
i18n_key: string;
|
||||
type: TaxonomyCategoryType;
|
||||
color: string;
|
||||
sort_order: number;
|
||||
children: TaxonomyNode[];
|
||||
}
|
||||
|
||||
export type TaxonomyLeaf = TaxonomyNode & { children: [] };
|
||||
|
||||
export type TaxonomyRoot = TaxonomyNode;
|
||||
|
||||
export interface Taxonomy {
|
||||
version: string;
|
||||
description: string;
|
||||
roots: TaxonomyRoot[];
|
||||
}
|
||||
|
||||
let cached: Taxonomy | null = null;
|
||||
|
||||
function loadTaxonomy(): Taxonomy {
|
||||
if (cached !== null) return cached;
|
||||
cached = taxonomyData as Taxonomy;
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function getTaxonomyV1(): Taxonomy {
|
||||
return loadTaxonomy();
|
||||
}
|
||||
|
||||
export function findById(id: number): TaxonomyNode | null {
|
||||
const stack: TaxonomyNode[] = [...loadTaxonomy().roots];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop() as TaxonomyNode;
|
||||
if (node.id === id) return node;
|
||||
stack.push(...node.children);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findByPath(path: string[]): TaxonomyNode | null {
|
||||
if (path.length === 0) return null;
|
||||
const { roots } = loadTaxonomy();
|
||||
let current: TaxonomyNode | undefined = roots.find((r) => r.name === path[0]);
|
||||
for (let i = 1; i < path.length && current !== undefined; i++) {
|
||||
current = current.children.find((c) => c.name === path[i]);
|
||||
}
|
||||
return current ?? null;
|
||||
}
|
||||
|
||||
export function getLeaves(): TaxonomyLeaf[] {
|
||||
const leaves: TaxonomyLeaf[] = [];
|
||||
const walk = (node: TaxonomyNode): void => {
|
||||
if (node.children.length === 0) {
|
||||
leaves.push(node as TaxonomyLeaf);
|
||||
return;
|
||||
}
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
for (const root of loadTaxonomy().roots) walk(root);
|
||||
return leaves;
|
||||
}
|
||||
|
||||
export function getParentById(id: number): TaxonomyNode | null {
|
||||
const visit = (node: TaxonomyNode): TaxonomyNode | null => {
|
||||
for (const child of node.children) {
|
||||
if (child.id === id) return node;
|
||||
const deeper = visit(child);
|
||||
if (deeper !== null) return deeper;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
for (const root of loadTaxonomy().roots) {
|
||||
const result = visit(root);
|
||||
if (result !== null) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resetTaxonomyCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
Loading…
Reference in a new issue