- Add an `icons:arma` tool (extractArmaIcons.ts) that pulls inventory icons out of the game's PBOs and materializes the .paa files the catalog needs. - Extend the catalog import library with the matching planning and upsert support, and cover it with integration tests.
457 lines
17 KiB
TypeScript
457 lines
17 KiB
TypeScript
import { getPayload, Payload } from "payload";
|
|
import config from "@/payload.config";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
|
import { join } from "path";
|
|
import { tmpdir } from "os";
|
|
import {
|
|
ASSET_TYPES,
|
|
enrichPlanMedia,
|
|
linkWeaponCompatibility,
|
|
planRecord,
|
|
upsertPlan,
|
|
type CatalogRecord,
|
|
type MediaEnv,
|
|
} from "@/lib/arma-import/catalog";
|
|
|
|
let payload: Payload;
|
|
const RUN = `armacat-${Date.now().toString(36)}`;
|
|
|
|
let fuelId: number;
|
|
let createdFuel = false;
|
|
const assetIds: number[] = [];
|
|
const vehicleIds: number[] = [];
|
|
const mediaIds: number[] = [];
|
|
const SHOT_DIR = join(tmpdir(), `ptf-shots-${RUN}`);
|
|
// 1x1 PNG: enough for the upload path, tiny enough to inline.
|
|
const PNG_1PX = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
"base64",
|
|
);
|
|
function mediaEnv(screenshots = ""): MediaEnv {
|
|
return { paaRoot: "", screenshots, outDir: SHOT_DIR, hemtt: "" };
|
|
}
|
|
|
|
describe("Arma catalog import", () => {
|
|
beforeAll(async () => {
|
|
const payloadConfig = await config;
|
|
payload = await getPayload({ config: payloadConfig });
|
|
|
|
// Vehicles need a fuel resource for the fuel group; reuse one if a prior
|
|
// fixture left it, otherwise create it (and only then clean it up).
|
|
const existingFuel = await payload.find({
|
|
collection: "resources",
|
|
where: { name: { equals: "Fuel" } },
|
|
limit: 1,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
if (existingFuel.docs[0]) {
|
|
fuelId = existingFuel.docs[0].id;
|
|
} else {
|
|
const fuel = await payload.create({
|
|
collection: "resources",
|
|
data: {
|
|
name: "Fuel",
|
|
codeName: `res_fuel_${RUN}`,
|
|
unitOfMeasure: "liter",
|
|
approvalStatus: "in_progress",
|
|
type: "fluid",
|
|
baseValue: 1,
|
|
rarity: "common",
|
|
massPerUnit: 0,
|
|
gridWidth: 1,
|
|
gridHeight: 1,
|
|
tradeable: false,
|
|
},
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
fuelId = fuel.id;
|
|
createdFuel = true;
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
for (const id of vehicleIds) {
|
|
await payload.delete({ collection: "vehicles", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
for (const id of assetIds) {
|
|
await payload.delete({ collection: "assets", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
for (const id of mediaIds) {
|
|
await payload.delete({ collection: "media", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
rmSync(SHOT_DIR, { recursive: true, force: true });
|
|
if (createdFuel && fuelId) {
|
|
await payload.delete({ collection: "resources", id: fuelId, overrideAccess: true }).catch(() => {});
|
|
}
|
|
});
|
|
|
|
describe("planRecord classification", () => {
|
|
it("trusts type for every contract asset type", () => {
|
|
for (const t of ASSET_TYPES) {
|
|
const rec: CatalogRecord = {
|
|
classname: `PTF_Test_Thing_${t}`,
|
|
type: t,
|
|
displayName: "Thing",
|
|
picture: "",
|
|
mod: "CBA_3den",
|
|
description: "",
|
|
mass: 1,
|
|
extra: "",
|
|
};
|
|
const plan = planRecord(rec);
|
|
expect(plan).toBeDefined();
|
|
expect(plan!.kind).toBe("asset");
|
|
expect(plan!.assetType).toBe(t);
|
|
}
|
|
});
|
|
|
|
it("routes vehicle records to the vehicles collection with transportMode from extra", () => {
|
|
const air = planRecord({
|
|
classname: "B_Heli_Light_01_F",
|
|
type: "vehicle",
|
|
displayName: "Ghost Hawk",
|
|
picture: "",
|
|
mod: "Arma 3",
|
|
description: "",
|
|
mass: 3000,
|
|
extra: "air",
|
|
});
|
|
expect(air!.kind).toBe("vehicle");
|
|
expect(air!.transportMode).toBe("air");
|
|
|
|
const sea = planRecord({ classname: "C_Boat_Civil_01_F", type: "vehicle", extra: "sea" });
|
|
expect(sea!.transportMode).toBe("sea");
|
|
});
|
|
|
|
it("defaults transportMode to ground when extra is not a valid mode", () => {
|
|
const plan = planRecord({ classname: "B_MRAP_01_F", type: "vehicle", extra: "submarine" });
|
|
expect(plan!.kind).toBe("vehicle");
|
|
expect(plan!.transportMode).toBe("ground");
|
|
});
|
|
|
|
it("falls back to heuristics for unknown types (miscellaneous, not item)", () => {
|
|
const plan = planRecord({ classname: "PTF_Test_Odd_Gadget", type: "qrd_mysterious_device" });
|
|
expect(plan!.kind).toBe("asset");
|
|
expect(plan!.assetType).toBe("miscellaneous");
|
|
});
|
|
|
|
it("falls back to the legacy vehicle heuristic for unknown types", () => {
|
|
const plan = planRecord({ classname: "B_Truck_01_transport_F", type: "who_knows" });
|
|
expect(plan!.kind).toBe("vehicle");
|
|
expect(plan!.transportMode).toBe("ground");
|
|
});
|
|
|
|
it("captures magazine round counts from extra", () => {
|
|
const plan = planRecord({ classname: "30rnd_556x45_Mag", type: "magazine", extra: 30 });
|
|
expect(plan!.kind).toBe("asset");
|
|
expect(plan!.roundCapacity).toBe(30);
|
|
expect(plan!.assetType).toBe("magazine");
|
|
});
|
|
|
|
it("ignores numeric extra for non-magazine assets", () => {
|
|
const plan = planRecord({ classname: "PTF_Test_Rifle", type: "weapon", extra: 30 });
|
|
expect(plan!.assetType).toBe("weapon");
|
|
expect(plan!.roundCapacity).toBeUndefined();
|
|
});
|
|
|
|
it("supports legacy class/category/name aliases and icon-map overrides", () => {
|
|
const plan = planRecord(
|
|
{ class: "Legacy_Thing", category: "optic", name: "Legacy Optic" },
|
|
{ icons: { legacy_thing: "\\a3\\data\\ui\\icon.paa" } },
|
|
);
|
|
expect(plan!.className).toBe("Legacy_Thing");
|
|
expect(plan!.assetType).toBe("optic");
|
|
expect(plan!.displayName).toBe("Legacy Optic");
|
|
expect(plan!.pictureRef).toBe("\\a3\\data\\ui\\icon.paa");
|
|
});
|
|
|
|
it("defaults mod to Arma 3 and rejects records without a classname", () => {
|
|
const plan = planRecord({ classname: "PTF_Test_Thing", type: "weapon" });
|
|
expect(plan).toBeDefined();
|
|
expect(plan!.mod).toBe("Arma 3");
|
|
expect(planRecord({ type: "weapon" })).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("upsertPlan assets", () => {
|
|
const assetClassName = `${RUN}_asset_rifle`;
|
|
|
|
it("creates an asset with correct className/assetType and Asset Metadata.mod", async () => {
|
|
const plan = planRecord({
|
|
classname: assetClassName,
|
|
type: "weapon",
|
|
displayName: `${RUN} Rifle`,
|
|
picture: "",
|
|
mod: "PTF_Test_Mod",
|
|
description: "An import test rifle.",
|
|
mass: 2,
|
|
extra: "",
|
|
})!;
|
|
|
|
const res = await upsertPlan(payload, plan);
|
|
expect(res.action).toBe("created");
|
|
expect(res.collection).toBe("assets");
|
|
assetIds.push(res.id);
|
|
|
|
const doc = await payload.findByID({ collection: "assets", id: res.id, depth: 0, overrideAccess: true });
|
|
expect(doc.className).toBe(assetClassName);
|
|
expect(doc.assetType).toBe("weapon");
|
|
expect(doc.name).toBe(`${RUN} Rifle`);
|
|
expect(doc["Asset Metadata"]?.mod).toBe("PTF_Test_Mod");
|
|
expect(doc["Asset Metadata"]?.gameDescription).toBe("An import test rifle.");
|
|
expect(doc.approvalStatus).toBe("in_progress");
|
|
});
|
|
|
|
it("updates on re-run without duplicating and preserves approvalStatus and Virtual Data", async () => {
|
|
const assetId = assetIds[0];
|
|
// Simulate human edits before the second import run.
|
|
await payload.update({
|
|
collection: "assets",
|
|
id: assetId,
|
|
data: { approvalStatus: "approved", tradeable: true },
|
|
overrideAccess: true,
|
|
});
|
|
|
|
const res = await upsertPlan(
|
|
payload,
|
|
planRecord({
|
|
classname: assetClassName,
|
|
type: "weapon",
|
|
displayName: `${RUN} Rifle MkII`,
|
|
picture: "",
|
|
mod: "PTF_Test_Mod_2",
|
|
description: "Updated in-game description.",
|
|
mass: 2,
|
|
extra: "",
|
|
})!,
|
|
undefined, // assets never need fuel
|
|
);
|
|
expect(res.action).toBe("updated");
|
|
expect(res.collection).toBe("assets");
|
|
expect(res.id).toBe(assetId);
|
|
|
|
const count = await payload.count({
|
|
collection: "assets",
|
|
where: { className: { equals: assetClassName } },
|
|
});
|
|
expect(count.totalDocs).toBe(1);
|
|
|
|
const doc = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true });
|
|
expect(doc.name).toBe(`${RUN} Rifle MkII`);
|
|
expect(doc["Asset Metadata"]?.mod).toBe("PTF_Test_Mod_2");
|
|
expect(doc["Asset Metadata"]?.gameDescription).toBe("Updated in-game description.");
|
|
expect(doc.approvalStatus).toBe("approved"); // never reset by re-import
|
|
expect(doc.tradeable).toBe(true); // Virtual Data untouched
|
|
});
|
|
});
|
|
|
|
describe("adding images in a later import pass", () => {
|
|
const lateClassName = `${RUN}_late_image_rifle`;
|
|
|
|
function planFor(screenshot: string) {
|
|
const plan = planRecord({
|
|
classname: lateClassName,
|
|
type: "weapon",
|
|
displayName: `${RUN} Late Image Rifle`,
|
|
picture: "",
|
|
mod: "PTF_Test_Mod",
|
|
description: "",
|
|
mass: 1,
|
|
extra: "",
|
|
})!;
|
|
enrichPlanMedia(plan, mediaEnv(screenshot));
|
|
return plan;
|
|
}
|
|
|
|
it("imports the catalog with no image, then attaches one on a later run", async () => {
|
|
// Pass 1: data only (no --screenshots).
|
|
const first = await upsertPlan(payload, planFor(""));
|
|
expect(first.action).toBe("created");
|
|
assetIds.push(first.id);
|
|
let doc = await payload.findByID({ collection: "assets", id: first.id, depth: 0, overrideAccess: true });
|
|
expect(doc["Asset Metadata"]?.thumbnail).toBeFalsy();
|
|
|
|
// Pass 2: the screenshot now exists on disk.
|
|
mkdirSync(SHOT_DIR, { recursive: true });
|
|
writeFileSync(join(SHOT_DIR, `${lateClassName}.png`), PNG_1PX);
|
|
|
|
const second = await upsertPlan(payload, planFor(SHOT_DIR));
|
|
expect(second.action).toBe("updated");
|
|
expect(second.id).toBe(first.id); // upsert, not a duplicate
|
|
|
|
doc = await payload.findByID({ collection: "assets", id: first.id, depth: 0, overrideAccess: true });
|
|
const thumbnail = doc["Asset Metadata"]?.thumbnail;
|
|
expect(thumbnail).toBeTruthy();
|
|
if (typeof thumbnail === "number") mediaIds.push(thumbnail);
|
|
else if (thumbnail && typeof thumbnail === "object") mediaIds.push(thumbnail.id);
|
|
|
|
const count = await payload.count({
|
|
collection: "assets",
|
|
where: { className: { equals: lateClassName } },
|
|
});
|
|
expect(count.totalDocs).toBe(1);
|
|
});
|
|
|
|
it("keeps the image when re-run, and repairs a cleared one", async () => {
|
|
const assetId = assetIds[assetIds.length - 1];
|
|
const before = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true });
|
|
const originalId = typeof before["Asset Metadata"]?.thumbnail === "number"
|
|
? (before["Asset Metadata"]!.thumbnail as number)
|
|
: (before["Asset Metadata"]!.thumbnail as { id: number }).id;
|
|
|
|
// Re-run: the media doc already exists, so it must not be duplicated.
|
|
await upsertPlan(payload, planFor(SHOT_DIR));
|
|
const after = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true });
|
|
const afterId = typeof after["Asset Metadata"]?.thumbnail === "number"
|
|
? (after["Asset Metadata"]!.thumbnail as number)
|
|
: (after["Asset Metadata"]!.thumbnail as { id: number }).id;
|
|
expect(afterId).toBe(originalId);
|
|
|
|
// A cleared thumbnail is repaired by the next run (media already exists).
|
|
await payload.update({
|
|
collection: "assets",
|
|
id: assetId,
|
|
data: { "Asset Metadata": { thumbnail: null } },
|
|
overrideAccess: true,
|
|
});
|
|
await upsertPlan(payload, planFor(SHOT_DIR));
|
|
const repaired = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true });
|
|
expect(repaired["Asset Metadata"]?.thumbnail).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("weapon compatibility", () => {
|
|
const rifle = `${RUN}_compat_rifle`;
|
|
const mag = `${RUN}_compat_mag`;
|
|
const optic = `${RUN}_compat_optic`;
|
|
const light = `${RUN}_compat_light`;
|
|
|
|
function catalog() {
|
|
return [
|
|
{ classname: rifle, type: "weapon", displayName: "Compat Rifle", compat: {
|
|
slot: "primary",
|
|
// duplicate + different case + a ref that was never imported
|
|
magazines: [mag, mag.toUpperCase(), `${RUN}_not_in_app`],
|
|
attachments: [optic, light],
|
|
} },
|
|
{ classname: mag, type: "magazine", displayName: "Compat Mag", extra: 30 },
|
|
{ classname: optic, type: "optic", displayName: "Compat Optic" },
|
|
{ classname: light, type: "miscellaneous", displayName: "Compat Light" },
|
|
].map((r) => planRecord(r as CatalogRecord)!);
|
|
}
|
|
|
|
it("parses compat on weapons only, deduplicating classnames", () => {
|
|
const plans = catalog();
|
|
expect(plans[0].compat).toEqual({
|
|
slot: "primary",
|
|
magazines: [mag, `${RUN}_not_in_app`],
|
|
attachments: [optic, light],
|
|
});
|
|
expect(plans[1].compat).toBeUndefined();
|
|
expect(planRecord({ classname: "x", type: "magazine", compat: { magazines: ["y"] } })!.compat).toBeUndefined();
|
|
});
|
|
|
|
it("links magazines/attachments as references, merges on re-run, never duplicates", async () => {
|
|
const plans = catalog();
|
|
const ids: Record<string, number> = {};
|
|
for (const plan of plans) {
|
|
const res = await upsertPlan(payload, plan);
|
|
assetIds.push(res.id);
|
|
ids[plan.className] = res.id;
|
|
}
|
|
|
|
const first = await linkWeaponCompatibility(payload, plans);
|
|
expect(first.updated).toBe(1);
|
|
expect(first.unresolved).toBe(1); // the never-imported magazine
|
|
expect(first.skippedType).toBe(1); // rail light is "miscellaneous"
|
|
|
|
let doc = await payload.findByID({ collection: "assets", id: ids[rifle], depth: 0, overrideAccess: true });
|
|
const wc = () => (doc as unknown as { "Weapon Configuration": Record<string, unknown> })["Weapon Configuration"];
|
|
expect(wc().slotType).toBe("primary");
|
|
expect(wc().compatibleMagazines).toEqual([ids[mag]]);
|
|
expect(wc().compatibleAttachments).toEqual([ids[optic]]);
|
|
|
|
// A link added by hand survives, and re-running adds nothing twice.
|
|
const extraOptic = await upsertPlan(payload, planRecord({ classname: `${RUN}_hand_optic`, type: "optic" })!);
|
|
assetIds.push(extraOptic.id);
|
|
await payload.update({
|
|
collection: "assets",
|
|
id: ids[rifle],
|
|
data: { "Weapon Configuration": { compatibleAttachments: [ids[optic], extraOptic.id] } } as never,
|
|
overrideAccess: true,
|
|
});
|
|
const second = await linkWeaponCompatibility(payload, plans);
|
|
expect(second.updated).toBe(0);
|
|
expect(second.unchanged).toBe(1);
|
|
|
|
doc = await payload.findByID({ collection: "assets", id: ids[rifle], depth: 0, overrideAccess: true });
|
|
expect(wc().compatibleMagazines).toEqual([ids[mag]]);
|
|
expect(wc().compatibleAttachments).toEqual([ids[optic], extraOptic.id]);
|
|
|
|
// Each referenced item still exists exactly once.
|
|
for (const name of [mag, optic]) {
|
|
const count = await payload.count({ collection: "assets", where: { className: { equals: name } } });
|
|
expect(count.totalDocs).toBe(1);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("upsertPlan vehicles", () => {
|
|
const vehicleClassname = `${RUN}_heli_ghost`;
|
|
|
|
it("creates a vehicle with classname and transportMode air", async () => {
|
|
const plan = planRecord({
|
|
classname: vehicleClassname,
|
|
type: "vehicle",
|
|
displayName: `${RUN} Ghost Hawk`,
|
|
picture: "",
|
|
mod: "PTF_Test_Mod",
|
|
description: "An import test helicopter.",
|
|
mass: 3000,
|
|
extra: "air",
|
|
})!;
|
|
|
|
const res = await upsertPlan(payload, plan, fuelId);
|
|
expect(res.action).toBe("created");
|
|
expect(res.collection).toBe("vehicles");
|
|
vehicleIds.push(res.id);
|
|
|
|
const doc = await payload.findByID({ collection: "vehicles", id: res.id, depth: 0, overrideAccess: true });
|
|
expect(doc.classname).toBe(vehicleClassname);
|
|
expect(doc.name).toBe(`${RUN} Ghost Hawk`);
|
|
expect(doc.transportMode).toBe("air");
|
|
expect(doc.fuel.fuelType).toBe(fuelId);
|
|
expect(doc.fuel.fuelCapacity).toBe(100);
|
|
expect(doc.approvalStatus).toBe("in_progress");
|
|
});
|
|
|
|
it("updates an existing vehicle without duplicating", async () => {
|
|
const vehicleId = vehicleIds[0];
|
|
const res = await upsertPlan(
|
|
payload,
|
|
planRecord({
|
|
classname: vehicleClassname,
|
|
type: "vehicle",
|
|
displayName: `${RUN} Ghost Hawk II`,
|
|
extra: "air",
|
|
})!,
|
|
fuelId,
|
|
);
|
|
expect(res.action).toBe("updated");
|
|
expect(res.id).toBe(vehicleId);
|
|
|
|
const count = await payload.count({
|
|
collection: "vehicles",
|
|
where: { classname: { equals: vehicleClassname } },
|
|
});
|
|
expect(count.totalDocs).toBe(1);
|
|
|
|
const doc = await payload.findByID({ collection: "vehicles", id: vehicleId, depth: 0, overrideAccess: true });
|
|
expect(doc.name).toBe(`${RUN} Ghost Hawk II`);
|
|
expect(doc.transportMode).toBe("air");
|
|
});
|
|
});
|
|
});
|