feat(catalog): add auto-category wizard with live preview and smart suggestions
- Add AutoCategoryDialog: pick furni (or empty page), suggest caption/icon/layout, live preview - Add createAutoCategory server action (page + offers in one export) with CatalogKind - Extend furni search API with interactionType - Share ShopTile and refactor inline-editor/items-shop-preview to use it - Add suggestion heuristics (suggestCategoryName/dIcon/layout) with tests - Add autoCategory translations (en/nl)
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
import {
|
||||
buildSortOrders,
|
||||
type SortStrategy,
|
||||
type SugFurni,
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user