refactor(catalog): unify commands and embed guarded catalog workspace
This commit is contained in:
1 parent
16f35568f5
commit
193b6686a9
57 files changed
+3268
-728
No files matched your search
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertParent,
|
||||
collectSubtree,
|
||||
validateSiblingOrder,
|
||||
} from "./hierarchy";
|
||||
|
||||
const pages = [
|
||||
{ id: 1, parentId: -1, orderNum: 0 },
|
||||
{ id: 2, parentId: 1, orderNum: 1 },
|
||||
{ id: 3, parentId: 1, orderNum: 2 },
|
||||
{ id: 4, parentId: 2, orderNum: 1 },
|
||||
];
|
||||
describe("catalog hierarchy commands", () => {
|
||||
it("rejects self, descendant, missing and malformed destinations", () => {
|
||||
for (const parent of [1, 2, 4, 999, 1.5, -2])
|
||||
expect(() => assertParent(pages, 1, parent)).toThrow();
|
||||
});
|
||||
it("allows detaching a page from an inherited cycle", () =>
|
||||
expect(() =>
|
||||
assertParent(
|
||||
[
|
||||
{ id: 1, parentId: 2 },
|
||||
{ id: 2, parentId: 1 },
|
||||
],
|
||||
1,
|
||||
-1,
|
||||
),
|
||||
).not.toThrow());
|
||||
it("rejects a destination with an inherited parent cycle", () =>
|
||||
expect(() =>
|
||||
assertParent(
|
||||
[
|
||||
{ id: 1, parentId: -1 },
|
||||
{ id: 2, parentId: 3 },
|
||||
{ id: 3, parentId: 2 },
|
||||
],
|
||||
1,
|
||||
2,
|
||||
),
|
||||
).toThrow());
|
||||
it("collects cyclic subtrees once without recursion", () =>
|
||||
expect(
|
||||
collectSubtree(
|
||||
[
|
||||
{ id: 1, parentId: 2 },
|
||||
{ id: 2, parentId: 1 },
|
||||
],
|
||||
1,
|
||||
),
|
||||
).toEqual([1, 2]));
|
||||
it("rejects stale or partial sibling lists before reordering", () => {
|
||||
expect(() => validateSiblingOrder(pages, 1, [2], [2, 3])).toThrow();
|
||||
expect(() => validateSiblingOrder(pages, 1, [3, 2], [3, 2])).toThrow();
|
||||
expect(() => validateSiblingOrder(pages, 1, [2, 2], [2, 3])).toThrow();
|
||||
});
|
||||
it("accepts a complete reorder matching the previous sibling order", () =>
|
||||
expect(() => validateSiblingOrder(pages, 1, [3, 2], [2, 3])).not.toThrow());
|
||||
});
|
||||
|
||||
it("orders mixed root conventions with a stable ID tie breaker", () => {
|
||||
const roots = [
|
||||
{ id: 3, parentId: 0, orderNum: 1 },
|
||||
{ id: 2, parentId: -1, orderNum: 1 },
|
||||
];
|
||||
expect(() => validateSiblingOrder(roots, -1, [3, 2], [2, 3])).not.toThrow();
|
||||
expect(() => validateSiblingOrder(roots, -1, [2, 3], [3, 2])).toThrow();
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
export interface HierarchyPage {
|
||||
id: number;
|
||||
parentId: number;
|
||||
orderNum?: number;
|
||||
}
|
||||
export class CatalogConflict extends Error {}
|
||||
export class CatalogInputError extends Error {}
|
||||
export class CatalogNotFound extends Error {}
|
||||
export function positiveId(id: number) {
|
||||
if (!Number.isSafeInteger(id) || id <= 0)
|
||||
throw new CatalogInputError("Invalid catalog ID");
|
||||
}
|
||||
export function assertParent(
|
||||
pages: readonly HierarchyPage[],
|
||||
pageId: number,
|
||||
parentId: number,
|
||||
) {
|
||||
if (!Number.isSafeInteger(parentId) || parentId < -1)
|
||||
throw new CatalogInputError("Invalid parent category");
|
||||
const map = new Map(pages.map((page) => [page.id, page]));
|
||||
const visited = new Set<number>();
|
||||
let current = parentId;
|
||||
while (current > 0) {
|
||||
if (current === pageId || visited.has(current))
|
||||
throw new CatalogInputError(
|
||||
"Cannot move page: would create a circular hierarchy",
|
||||
);
|
||||
visited.add(current);
|
||||
const parent = map.get(current);
|
||||
if (!parent) throw new CatalogInputError("Parent category does not exist");
|
||||
current = parent.parentId;
|
||||
}
|
||||
}
|
||||
export function collectSubtree(
|
||||
pages: readonly HierarchyPage[],
|
||||
pageId: number,
|
||||
): number[] {
|
||||
const children = new Map<number, number[]>();
|
||||
for (const page of pages) {
|
||||
const list = children.get(page.parentId) ?? [];
|
||||
list.push(page.id);
|
||||
children.set(page.parentId, list);
|
||||
}
|
||||
const seen = new Set<number>();
|
||||
const queue = [pageId];
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
const id = queue[index];
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
queue.push(...(children.get(id) ?? []));
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
export function validateSiblingOrder(
|
||||
pages: readonly HierarchyPage[],
|
||||
parentId: number,
|
||||
ids: readonly number[],
|
||||
expected: readonly number[],
|
||||
) {
|
||||
const current = pages
|
||||
.filter((page) =>
|
||||
parentId <= 0
|
||||
? page.parentId === -1 || page.parentId === 0
|
||||
: page.parentId === parentId,
|
||||
)
|
||||
.sort((a, b) => (a.orderNum ?? 0) - (b.orderNum ?? 0) || a.id - b.id)
|
||||
.map((page) => page.id);
|
||||
if (
|
||||
current.length !== expected.length ||
|
||||
current.some((id, index) => id !== expected[index])
|
||||
)
|
||||
throw new CatalogConflict(
|
||||
"The category order changed. Reload before trying again.",
|
||||
);
|
||||
if (
|
||||
ids.length !== current.length ||
|
||||
new Set(ids).size !== ids.length ||
|
||||
ids.some((id) => !current.includes(id))
|
||||
)
|
||||
throw new CatalogInputError(
|
||||
"A reorder must include every sibling exactly once",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
numericValue,
|
||||
offerPatchSchema,
|
||||
parseFurnitureIds,
|
||||
} from "./offer-input";
|
||||
|
||||
describe("offer compatibility", () => {
|
||||
it.each([0, "0", -1, "-1"])(
|
||||
"preserves explicit numeric value %s",
|
||||
(value) => {
|
||||
expect(numericValue(value, 99)).toBe(Number(value));
|
||||
},
|
||||
);
|
||||
it.each([null, undefined, "", "invalid", Infinity])(
|
||||
"defaults missing or invalid value %s",
|
||||
(value) => {
|
||||
expect(numericValue(value, 99)).toBe(99);
|
||||
},
|
||||
);
|
||||
it("preserves bundle multiplicity and LTD/special offer values", () => {
|
||||
expect(parseFurnitureIds("12;23;12")).toEqual([12, 23, 12]);
|
||||
const values = {
|
||||
itemIds: "12;23;12",
|
||||
amount: 0,
|
||||
offerId: -1,
|
||||
orderNumber: 0,
|
||||
limitedSells: 4,
|
||||
limitedStack: 20,
|
||||
songId: 0,
|
||||
extradata: "a;b",
|
||||
haveOffer: "0",
|
||||
clubOnly: "1",
|
||||
};
|
||||
expect(offerPatchSchema.parse(values)).toEqual(values);
|
||||
});
|
||||
it.each(["1;0", "1;2x", "1;;2", "-1", "1.5"])(
|
||||
"rejects invalid furniture references %s",
|
||||
(value) => {
|
||||
expect(() => parseFurnitureIds(value)).toThrow();
|
||||
},
|
||||
);
|
||||
it("rejects invalid page ids and nonfinite prices without changing field meaning", () => {
|
||||
expect(offerPatchSchema.safeParse({ pageId: 0 }).success).toBe(false);
|
||||
expect(offerPatchSchema.safeParse({ costCredits: Infinity }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(offerPatchSchema.parse({ offerId: 0, amount: 0 })).toEqual({
|
||||
offerId: 0,
|
||||
amount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const offerInteger = z.number().int().min(-2147483648).max(2147483647);
|
||||
export const offerIdSchema = offerInteger.positive();
|
||||
|
||||
/** Missing SQL values use the field default; explicit zero and special negatives survive. */
|
||||
export function numericValue(value: unknown, fallback: number): number {
|
||||
if (value == null || (typeof value === "string" && value.trim() === ""))
|
||||
return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
export function parseFurnitureIds(value: string): number[] {
|
||||
const parts = value.split(";");
|
||||
if (!parts.length || parts.some((part) => !/^\d+$/.test(part.trim())))
|
||||
throw Error("Invalid furniture references");
|
||||
return parts.map((part) => offerIdSchema.parse(Number(part)));
|
||||
}
|
||||
const itemIds = z
|
||||
.string()
|
||||
.max(666)
|
||||
.refine((value) => {
|
||||
try {
|
||||
parseFurnitureIds(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "Invalid furniture references");
|
||||
// Keep signed/special values on existing offer fields: bundle/LTD meaning belongs to the emulator.
|
||||
export const offerPatchSchema = z.object({
|
||||
pageId: offerIdSchema.optional(),
|
||||
itemIds: itemIds.optional(),
|
||||
catalogName: z.string().max(100).optional(),
|
||||
costCredits: offerInteger.optional(),
|
||||
costPoints: offerInteger.optional(),
|
||||
pointsType: offerInteger.optional(),
|
||||
amount: offerInteger.optional(),
|
||||
orderNumber: offerInteger.optional(),
|
||||
offerId: offerInteger.optional(),
|
||||
songId: z.number().int().min(0).max(4294967295).optional(),
|
||||
limitedSells: offerInteger.optional(),
|
||||
limitedStack: offerInteger.optional(),
|
||||
extradata: z.string().max(500).optional(),
|
||||
haveOffer: z.enum(["0", "1"]).optional(),
|
||||
clubOnly: z.enum(["0", "1"]).optional(),
|
||||
});
|
||||
const flag = z.union([
|
||||
z.literal(0),
|
||||
z.literal(1),
|
||||
z.literal("0"),
|
||||
z.literal("1"),
|
||||
]);
|
||||
export const furniturePatchSchema = z.object({
|
||||
publicName: z.string().max(56).optional(),
|
||||
itemName: z.string().max(70).optional(),
|
||||
type: z.string().max(3).optional(),
|
||||
width: offerInteger.optional(),
|
||||
length: offerInteger.optional(),
|
||||
stackHeight: z.number().finite().optional(),
|
||||
allowStack: flag.optional(),
|
||||
allowSit: flag.optional(),
|
||||
allowLay: flag.optional(),
|
||||
allowWalk: flag.optional(),
|
||||
allowGift: flag.optional(),
|
||||
allowTrade: flag.optional(),
|
||||
allowRecycle: flag.optional(),
|
||||
allowMarketplaceSell: flag.optional(),
|
||||
allowInventoryStack: flag.optional(),
|
||||
interactionType: z.string().max(500).optional(),
|
||||
interactionModesCount: offerInteger.optional(),
|
||||
vendingIds: z.string().max(255).optional(),
|
||||
customparams: z.string().max(25600).optional(),
|
||||
effectIdMale: offerInteger.optional(),
|
||||
effectIdFemale: offerInteger.optional(),
|
||||
clothingOnWalk: z.string().max(255).optional(),
|
||||
});
|
||||
export function distinctOfferIds(ids: number[]): number[] {
|
||||
for (const id of ids) offerIdSchema.parse(id);
|
||||
if (new Set(ids).size !== ids.length)
|
||||
throw Error("Duplicate catalog item ids");
|
||||
return ids;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { pagePatchSchema } from "./page-input";
|
||||
|
||||
it("preserves nullable page texts when editing unrelated fields", () => {
|
||||
const fields = {
|
||||
caption: "Changed",
|
||||
pageText1: null,
|
||||
pageText2: null,
|
||||
pageTextDetails: null,
|
||||
pageTextTeaser: null,
|
||||
pageSpecial: null,
|
||||
};
|
||||
expect(pagePatchSchema.parse(fields)).toEqual(fields);
|
||||
});
|
||||
it("preserves valid zero values and rejects invalid flags", () => {
|
||||
expect(
|
||||
pagePatchSchema.parse({ parentId: 0, orderNum: 0, iconImage: 0 }),
|
||||
).toEqual({ parentId: 0, orderNum: 0, iconImage: 0 });
|
||||
expect(() => pagePatchSchema.parse({ enabled: "true" })).toThrow();
|
||||
});
|
||||
|
||||
it("preserves caption whitespace for optimistic comparisons but rejects blank captions", () => {
|
||||
expect(pagePatchSchema.parse({ caption: " Existing " })).toEqual({
|
||||
caption: " Existing ",
|
||||
});
|
||||
expect(() => pagePatchSchema.parse({ caption: " " })).toThrow();
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const flag = z.enum(["0", "1"]);
|
||||
const integer = z.number().int().min(-2147483648).max(2147483647);
|
||||
export const pagePatchSchema = z.object({
|
||||
caption: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.refine((value) => value.trim().length > 0, "Category name is required")
|
||||
.optional(),
|
||||
captionSave: z.string().max(25).optional(),
|
||||
parentId: integer.min(-1).optional(),
|
||||
pageLayout: z.string().min(1).max(26).optional(),
|
||||
enabled: flag.optional(),
|
||||
visible: flag.optional(),
|
||||
clubOnly: flag.optional(),
|
||||
vipOnly: flag.optional(),
|
||||
minRank: integer.min(0).optional(),
|
||||
orderNum: integer.optional(),
|
||||
iconImage: integer.min(0).optional(),
|
||||
iconColor: integer.min(0).optional(),
|
||||
pageHeadline: z.string().max(1024).optional(),
|
||||
pageTeaser: z.string().max(64).optional(),
|
||||
pageSpecial: z.string().max(2048).nullable().optional(),
|
||||
pageText1: z.string().nullable().optional(),
|
||||
pageText2: z.string().nullable().optional(),
|
||||
pageTextDetails: z.string().nullable().optional(),
|
||||
pageTextTeaser: z.string().nullable().optional(),
|
||||
includes: z.string().max(128).optional(),
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCatalogSearch, validSearchQuery } from "./search";
|
||||
|
||||
describe("catalog search input", () => {
|
||||
it("allows a one digit ID but requires at least two text characters", () => {
|
||||
expect(validSearchQuery("7")).toBe(true);
|
||||
expect(validSearchQuery("a")).toBe(false);
|
||||
expect(validSearchQuery(" ab ")).toBe(true);
|
||||
});
|
||||
it("does not round oversized numeric identifiers", () => {
|
||||
expect(parseCatalogSearch("normal", "9007199254740992").numericId).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
it("escapes LIKE wildcard and escape characters", () => {
|
||||
expect(parseCatalogSearch("bc", "a_%=").pattern).toBe("a=_=%==%");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CatalogInputError } from "./hierarchy";
|
||||
export type SearchCatalogKind = "normal" | "bc";
|
||||
export interface CatalogSearchResult {
|
||||
kind: "category" | "offer";
|
||||
id: number;
|
||||
pageId: number;
|
||||
caption: string;
|
||||
path: string[];
|
||||
href: string;
|
||||
furniture?: { id: number; classname: string; spriteId: number };
|
||||
}
|
||||
export function validSearchQuery(query: string) {
|
||||
const value = query.trim();
|
||||
return query.length <= 100 && (value.length >= 2 || /^\d$/.test(value));
|
||||
}
|
||||
export function parseCatalogSearch(kind: string, query: string) {
|
||||
if (kind !== "normal" && kind !== "bc")
|
||||
throw new CatalogInputError("Invalid catalog type");
|
||||
if (!validSearchQuery(query))
|
||||
throw new CatalogInputError(
|
||||
"Search requires 2 to 100 characters or a numeric ID",
|
||||
);
|
||||
const value = query.trim();
|
||||
const parsed = /^\d+$/.test(value) ? Number(value) : NaN;
|
||||
const numericId = Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
return {
|
||||
kind,
|
||||
query: value,
|
||||
numericId,
|
||||
pattern: `${value.replace(/[=%_]/g, (char) => `=${char}`)}%`,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user