Files
Epicnabbo-Catalogus-Updated…/src/features/catalog/domain/organize.test.ts
T
openhands 01a85ebcd0 feat(studio): add organize-imports tool with suggestion review and approval
Adds a Studio 'Organize imports' dialog that groups recently imported
furniture and furniture already sitting in the auto-created import
pages into suggested catalog categories.  Each group is presented with
its suggested name, icon, and layout which can be overridden before
approval; approved groups are turned into real catalog pages in a
single atomic export run.  Offers already inside the import subtree are
moved to the new pages; brand-new furniture gets a fresh offer.

- groupSuggestedCategories: generic pure helper reusing the same
  per-item label heuristic that drives suggestCategoryName; items with
  no label land in a 'Other Furni' remainder bucket
- GET /api/admin/import/organize: returns items from the imported
  furniture tree (catalog_items JOIN items_base via page id set from
  the imported-furniture root) union audit-logged but not-yet-placed
  recent imports; marks alreadyPlaced vs new
- organizeImportFurni server action: validates import-page membership
  before moving any offer, creates pages + inserts/moves items in one
  withCatalogExport snapshot, logs activity
- OrganizeImportsDialog: full-featured Studio dialog with price panel
  (applies to new offers only), parent select, per-group approval,
  editable name/icon/layout with suggestion reset chips, source tags
- import-pages.ts server helper: locates the imported-furniture tree
- Studio nav: 'Organize imports' button with FolderTree icon,
  gated on CATALOG_EDIT, wired next to the catalog manager
- en + nl translations for organizeImports.* keys with ICU plurals
- groupSuggestedCategories unit tests (deterministic grouping,
  remainder handling, size+alpha ordering, label consistency)
2026-09-11 19:38:18 +02:00

293 lines
8.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import {
buildSortOrders,
groupSuggestedCategories,
type SortStrategy,
type SugFurni,
type SugFurniEntry,
sortCatalogItems,
suggestCatalogIcon,
suggestCatalogLayout,
suggestCatalogLayoutFromBaseItems,
suggestCategoryName,
} from "./organize";
function offer(overrides: Partial<CatalogItemData> = {}): CatalogItemData {
return {
id: 1,
catalogName: "",
itemIds: "1",
costCredits: 0,
costPoints: 0,
pointsType: 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 0,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
baseName: "",
baseItemName: "",
spriteId: 0,
baseItem: null,
...overrides,
};
}
const sample = [
offer({ id: 3, catalogName: "Zebra", costCredits: 10, limitedStack: 0 }),
offer({ id: 1, catalogName: "Apple", costCredits: 5, limitedStack: 0 }),
offer({ id: 2, catalogName: "Mango", costCredits: 5, limitedStack: 50 }),
];
describe("sortCatalogItems", () => {
it("sorts by name ascending", () => {
const sorted = sortCatalogItems(sample, "name-asc");
expect(sorted.map((i) => i.id)).toEqual([1, 2, 3]);
});
it("sorts by name descending", () => {
const sorted = sortCatalogItems(sample, "name-desc");
expect(sorted.map((i) => i.id)).toEqual([3, 2, 1]);
});
it("sorts by price then id", () => {
const sorted = sortCatalogItems(sample, "price-asc");
expect(sorted.map((i) => i.id)).toEqual([1, 2, 3]);
});
it("sorts expensive first", () => {
const sorted = sortCatalogItems(sample, "price-desc");
expect(sorted.map((i) => i.id)).toEqual([3, 1, 2]);
});
it("keeps limited editions first", () => {
const sorted = sortCatalogItems(sample, "limited-first");
expect(sorted.map((i) => i.id)).toEqual([2, 1, 3]);
});
it("keeps offers first", () => {
const input = [
offer({ id: 1, catalogName: "a", haveOffer: "0" }),
offer({ id: 2, catalogName: "b", haveOffer: "1" }),
];
const sorted = sortCatalogItems(input, "offer-first");
expect(sorted.map((i) => i.id)).toEqual([2, 1]);
});
it("sorts amount descending", () => {
const input = [
offer({ id: 1, catalogName: "a", amount: 1 }),
offer({ id: 2, catalogName: "b", amount: 10 }),
];
const sorted = sortCatalogItems(input, "amount-desc");
expect(sorted.map((i) => i.id)).toEqual([2, 1]);
});
it("is stable for unknown strategies", () => {
const sorted = sortCatalogItems(sample, "name-asc" as SortStrategy);
const re = sortCatalogItems([...sorted].reverse(), "name-asc");
expect(re.map((i) => i.id)).toEqual([1, 2, 3]);
});
});
describe("buildSortOrders", () => {
it("assigns sequential order numbers", () => {
const orders = buildSortOrders([sample[2], sample[0]]);
expect(orders).toEqual([
{ id: 2, orderNumber: 0 },
{ id: 3, orderNumber: 1 },
]);
});
});
describe("suggestCatalogLayout", () => {
it("returns null for empty lists", () => {
expect(suggestCatalogLayout([])).toBeNull();
});
it("suggests soundmachine when songs present", () => {
const input = [
offer({ songId: 55, baseItem: { interactionType: "default" } as never }),
];
expect(suggestCatalogLayout(input)?.layout).toBe("soundmachine");
});
it("suggests pets when base item type is 'p'", () => {
const input = [offer({ baseItem: { type: "p" } as never })];
expect(suggestCatalogLayout(input)?.layout).toBe("pets");
});
it("suggests trophies for trophy interactions", () => {
const input = [offer({ baseItem: { interactionType: "trophy" } as never })];
expect(suggestCatalogLayout(input)?.layout).toBe("trophies");
});
it("suggests guilds for guild interactions", () => {
const input = [offer({ baseItem: { interactionType: "guild" } as never })];
expect(suggestCatalogLayout(input)?.layout).toBe("guilds");
});
it("returns null when no strong signal", () => {
const input = [
offer({ baseItem: { interactionType: "default" } as never }),
];
expect(suggestCatalogLayout(input)).toBeNull();
});
});
describe("suggestCatalogLayoutFromBaseItems", () => {
it("returns null for empty lists", () => {
expect(suggestCatalogLayoutFromBaseItems([])).toBeNull();
});
it("suggests soundmachine from interaction type", () => {
expect(
suggestCatalogLayoutFromBaseItems([{ interactionType: "soundmachine" }])
?.layout,
).toBe("soundmachine");
});
it("suggests pets from base type", () => {
expect(
suggestCatalogLayoutFromBaseItems([
{ type: "p", interactionType: "default" },
])?.layout,
).toBe("pets");
});
it("suggests trophies for trophy interactions", () => {
expect(
suggestCatalogLayoutFromBaseItems([{ interactionType: "trophy" }])
?.layout,
).toBe("trophies");
});
it("returns null when no strong signal", () => {
expect(
suggestCatalogLayoutFromBaseItems([{ interactionType: "default" }]),
).toBeNull();
});
});
function fum(itemName?: string, interactionType?: string): SugFurni {
return { itemName, interactionType };
}
describe("suggestCategoryName", () => {
it("returns null for empty lists", () => {
expect(suggestCategoryName([])).toBeNull();
});
it("derives a pluralized label from the dominant classname token", () => {
const input = [fum("chair_wooden"), fum("chair_oak"), fum("chair_polyfon")];
expect(suggestCategoryName(input)).toBe("Chairs");
});
it("keeps singular-only tokens singular", () => {
expect(suggestCategoryName([fum("wallpaper_leaf")])).toBe("Wallpaper");
expect(suggestCategoryName([fum("floor_plain")])).toBe("Floor");
});
it("prefers interaction labels over classname tokens", () => {
const input = [
fum("trophy_cup", "trophy"),
fum("trophy_crown", "trophy"),
fum("chair_oak", "default"),
];
expect(suggestCategoryName(input)).toBe("Trophies");
});
it("uses the last-resort mapping for known interactions", () => {
expect(
suggestCategoryName([fum("soundmachine_flat", "soundmachine")]),
).toBe("Sounds");
});
it("returns null when nothing can be derived", () => {
expect(suggestCategoryName([fum("")])).toBeNull();
});
});
describe("suggestCatalogIcon", () => {
it("returns 0 for unknown categories", () => {
expect(suggestCatalogIcon([fum("")])).toBe(0);
});
it("maps detected categories to a catalog icon id", () => {
expect(suggestCatalogIcon([fum("chair_oak")])).toBeGreaterThan(0);
expect(suggestCatalogIcon([fum("trophy_cup", "trophy")])).toBe(7);
});
it("is deterministic", () => {
const a = suggestCatalogIcon([fum("chair_oak"), fum("chair_oak")]);
const b = suggestCatalogIcon([fum("chair_oak"), fum("chair_oak")]);
expect(a).toBe(b);
});
});
describe("groupSuggestedCategories", () => {
function entry(
id: number,
itemName?: string,
interactionType?: string,
): SugFurniEntry {
return { id, itemName, interactionType };
}
it("returns an empty list for no input", () => {
expect(groupSuggestedCategories([])).toEqual([]);
});
it("groups furniture by shared label, largest group first", () => {
const groups = groupSuggestedCategories([
entry(1, "chair_wooden"),
entry(2, "chair_oak"),
entry(3, "sofa_polyfon"),
entry(4, "chair_polyfon"),
]);
expect(groups.map((g) => g.name)).toEqual(["Chairs", "Sofas"]);
expect(groups[0]?.items.map((i) => i.id)).toEqual([1, 2, 4]);
expect(groups[1]?.items.map((i) => i.id)).toEqual([3]);
});
it("sorts by size then alphabetically on ties", () => {
const groups = groupSuggestedCategories([
entry(1, "sofa_polyfon"),
entry(2, "chair_oak"),
]);
expect(groups.map((g) => g.name)).toEqual(["Chairs", "Sofas"]);
});
it("falls back deterministically and drops nothing", () => {
const groups = groupSuggestedCategories([
entry(1, "sofa_polyfon"),
entry(2, ""),
entry(3, ""),
]);
expect(groups.map((g) => g.name)).toEqual(["Other Furni", "Sofas"]);
const leftover = groups.find((g) => g.name === "Other Furni");
expect(leftover?.items.map((i) => i.id)).toEqual([2, 3]);
expect(leftover).not.toBeNull();
});
it("suggests a plausible icon and layout per group", () => {
const groups = groupSuggestedCategories([entry(1, "chair_oak")]);
expect(groups[0]).toBeDefined();
expect(groups[0]?.icon).toBeGreaterThan(0);
});
it("uses the same label as the batch name suggestion", () => {
const group = groupSuggestedCategories([
entry(1, "trophy_cup", "trophy"),
entry(2, "trophy_crown", "trophy"),
entry(3, "chair_oak"),
])[0];
expect(group?.name).toBe("Trophies");
});
});