306 lines
16 KiB
TypeScript
306 lines
16 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { calculateTeamMetrics } from "@/lib/guides/calculations";
|
|
import { createExtraSheet } from "@/lib/guides/extra-sheet";
|
|
import {
|
|
artifactSectionSchema,
|
|
constellationSectionSchema,
|
|
createExtraSectionSchema,
|
|
createGuideSchema,
|
|
extraSectionSchema,
|
|
reorderExtraSectionsSchema,
|
|
saveExtraSectionSchema,
|
|
teamSectionSchema,
|
|
updateGuideSchema,
|
|
weaponSectionSchema,
|
|
} from "@/lib/guides/schemas";
|
|
import {
|
|
recommendedStatElement,
|
|
recommendedStatIconName,
|
|
targetStatIconName,
|
|
} from "@/lib/guides/stats";
|
|
|
|
describe("structured guide validation", () => {
|
|
const artifactGroups = [
|
|
{ name: "Late Game", rows: [] },
|
|
{ name: "Early Game", rows: [] },
|
|
] as const;
|
|
it("allows creating a guide without an overview", () => {
|
|
expect(createGuideSchema.safeParse({ characterKey: "1", coverMediaId: "550e8400-e29b-41d4-a716-446655440000", overview: "" }).success).toBe(true);
|
|
});
|
|
|
|
it("normalizes and orders multiple optional YouTube videos", () => {
|
|
const base = {
|
|
expectedVersion: 1,
|
|
coverMediaId: "550e8400-e29b-41d4-a716-446655440000",
|
|
overview: "Overview",
|
|
};
|
|
expect(updateGuideSchema.parse({ ...base, youtubeUrls: ["https://youtu.be/dQw4w9WgXcQ?si=share", "https://youtube.com/shorts/aqz-KE-bpKQ"] }).youtubeUrls).toEqual([
|
|
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
"https://www.youtube.com/watch?v=aqz-KE-bpKQ",
|
|
]);
|
|
expect(updateGuideSchema.parse({ ...base, youtubeUrls: [] }).youtubeUrls).toEqual([]);
|
|
expect(updateGuideSchema.safeParse({ ...base, youtubeUrls: ["https://example.com/video"] }).success).toBe(false);
|
|
});
|
|
|
|
it("requires at least one weapon percentage", () => {
|
|
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: null, personalPercent: null, note: null }] }] }).success).toBe(false);
|
|
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null, note: "Best free-to-play option" }] }] }).success).toBe(true);
|
|
});
|
|
|
|
it("supports an all-refinements weapon range", () => {
|
|
const result = weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: "1-5", overallPercent: 100, personalPercent: null, note: null }] }] });
|
|
expect(result.success).toBe(true);
|
|
if (result.success) expect(result.data.groups[0].rows[0].refinement).toBe("1-5");
|
|
});
|
|
|
|
it("preserves negative weapon percentages", () => {
|
|
const result = weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: -12.5, personalPercent: null, note: null }] }] });
|
|
expect(result.success).toBe(true);
|
|
if (result.success) expect(result.data.groups[0].rows[0].overallPercent).toBe(-12.5);
|
|
});
|
|
|
|
it("limits an optional note on each weapon", () => {
|
|
const row = { weaponKey: "1", refinement: 1, overallPercent: 100, personalPercent: null };
|
|
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ ...row, note: null }] }] }).success).toBe(true);
|
|
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ ...row, note: "x".repeat(2_001) }] }] }).success).toBe(false);
|
|
});
|
|
|
|
it("normalizes optional weapon condition names", () => {
|
|
const row = { weaponKey: "1", refinement: 1, overallPercent: 100, personalPercent: null, note: null };
|
|
const named = weaponSectionSchema.parse({ expectedVersion: 1, note: null, groups: [{ name: " Main DPS ", note: null, rows: [row] }] });
|
|
const unnamed = weaponSectionSchema.parse({ expectedVersion: 1, note: null, groups: [{ name: "", note: null, rows: [row] }] });
|
|
|
|
expect(named.groups[0].name).toBe("Main DPS");
|
|
expect(unnamed.groups[0].name).toBeNull();
|
|
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ name: "x".repeat(121), note: null, rows: [row] }] }).success).toBe(false);
|
|
});
|
|
|
|
it("limits weapon and constellation header notes", () => {
|
|
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", groups: [] }).success).toBe(true);
|
|
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), groups: [] }).success).toBe(false);
|
|
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", groups: [] }).success).toBe(true);
|
|
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), groups: [] }).success).toBe(false);
|
|
});
|
|
|
|
it("limits artifact main stats to three per slot", () => {
|
|
const base = { expectedVersion: 1, groups: artifactGroups, goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null };
|
|
expect(artifactSectionSchema.safeParse({ ...base, sands: ["EM", "ER", "ATK%"] }).success).toBe(true);
|
|
expect(artifactSectionSchema.safeParse({ ...base, sands: ["EM", "ER", "ATK%", "HP%"] }).success).toBe(false);
|
|
});
|
|
|
|
it("accepts flat artifact substats with the same icons as their percentage variants", () => {
|
|
const result = artifactSectionSchema.safeParse({ expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: ["ATK", "HP", "DEF"], targetGroups: [], talents: [], note: null });
|
|
expect(result.success).toBe(true);
|
|
expect(targetStatIconName("ATK")).toBe(recommendedStatIconName("ATK%"));
|
|
expect(targetStatIconName("HP")).toBe(recommendedStatIconName("HP%"));
|
|
expect(targetStatIconName("DEF")).toBe(recommendedStatIconName("DEF%"));
|
|
});
|
|
|
|
it("maps elemental damage recommendations to Lunaris element icons", () => {
|
|
expect(recommendedStatElement("Pyro DMG%")).toBe("Pyro");
|
|
expect(recommendedStatElement("Hydro DMG%")).toBe("Hydro");
|
|
expect(recommendedStatElement("Physical DMG%")).toBeNull();
|
|
});
|
|
|
|
it("limits an optional note on each artifact recommendation", () => {
|
|
const row = { firstArtifactKey: "1", secondArtifactKey: null, percent: 100 };
|
|
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null };
|
|
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: "Late Game", rows: [{ ...row, note: null }] }, artifactGroups[1]] }).success).toBe(true);
|
|
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: "Late Game", rows: [{ ...row, note: "x".repeat(2_001) }] }, artifactGroups[1]] }).success).toBe(false);
|
|
});
|
|
|
|
it("accepts fixed target stats with text values", () => {
|
|
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], talents: [], note: null };
|
|
expect(artifactSectionSchema.safeParse({
|
|
...base,
|
|
targetGroups: [{ note: "ER build", rows: [
|
|
{ stat: "ER", value: "100%" },
|
|
{ stat: "CRIT RATE", value: "5%" },
|
|
{ stat: "CRIT DAMAGE", value: "-%" },
|
|
] }],
|
|
}).success).toBe(true);
|
|
expect(artifactSectionSchema.safeParse({ ...base, targetGroups: [{ note: null, rows: [{ stat: "Energy Recharge", value: "100%" }] }] }).success).toBe(false);
|
|
});
|
|
|
|
it("requires the fixed Late Game and Early Game artifact groups", () => {
|
|
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null };
|
|
expect(artifactSectionSchema.safeParse({ ...base, groups: artifactGroups }).success).toBe(true);
|
|
expect(artifactSectionSchema.safeParse({ ...base, groups: [...artifactGroups].reverse() }).success).toBe(false);
|
|
expect(artifactSectionSchema.safeParse({ ...base, groups: [...artifactGroups, { name: "Other", rows: [] }] }).success).toBe(false);
|
|
});
|
|
|
|
it("accepts only talent target levels from 1 through 10", () => {
|
|
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], note: null };
|
|
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "NA", priority: 1, level: "10" }] }).success).toBe(true);
|
|
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "NA", priority: 1, level: "11" }] }).success).toBe(false);
|
|
});
|
|
|
|
it("accepts talent comparison relations while keeping legacy talents valid", () => {
|
|
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], note: null };
|
|
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1, relationToNext: "gte" }] }).success).toBe(true);
|
|
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1, relationToNext: "eq" }] }).success).toBe(true);
|
|
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1 }] }).success).toBe(true);
|
|
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1, relationToNext: "equal" }] }).success).toBe(false);
|
|
});
|
|
|
|
it("persists whether a constellation percentage is marked as great", () => {
|
|
const row = { level: 1, detail: "Test constellation", cumulativePercent: 120, isGreat: true };
|
|
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: "Signature weapon", rows: [row] }] }).success).toBe(true);
|
|
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ ...row, isGreat: "yes" }] }] }).success).toBe(false);
|
|
});
|
|
|
|
it("allows the same constellation level across conditions but not twice within one condition", () => {
|
|
const row = { level: 2, detail: "Test constellation", cumulativePercent: 130, isGreat: false };
|
|
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: "A", rows: [row] }, { note: "B", rows: [row] }] }).success).toBe(true);
|
|
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [row, row] }] }).success).toBe(false);
|
|
});
|
|
|
|
it("requires four unique team members and non-zero damage", () => {
|
|
const member = (characterKey: string) => ({ characterKey, weaponKey: null, artifactKey: null, secondArtifactKey: null, constellation: 0, refinement: 1, damage: 100 });
|
|
const teams = [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }];
|
|
const base = { expectedVersion: 1, groups: [{ note: null, teams }] };
|
|
expect(teamSectionSchema.safeParse(base).success).toBe(true);
|
|
expect(teamSectionSchema.safeParse({ ...base, groups: [{ note: null, teams: [{ ...teams[0], members: ["1", "1", "3", "4"].map(member) }] }] }).success).toBe(false);
|
|
});
|
|
|
|
it("accepts a second artifact set for a team member", () => {
|
|
const member = (characterKey: string) => ({ characterKey, weaponKey: null, artifactKey: "artifact-1", secondArtifactKey: characterKey === "1" ? "artifact-2" : null, constellation: 0, refinement: 1, damage: 100 });
|
|
const input = { expectedVersion: 1, groups: [{ note: null, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] }] };
|
|
expect(teamSectionSchema.safeParse(input).success).toBe(true);
|
|
});
|
|
|
|
it("validates Extra card backgrounds and word highlights", () => {
|
|
const row = {
|
|
mediaId: null,
|
|
heading: "Extra card",
|
|
body: "First line\nSecond line",
|
|
percent: null,
|
|
};
|
|
const input = (style: { backgroundColor: string | null; highlights: Array<{ text: string; color: string }> }) => ({
|
|
expectedVersion: 1,
|
|
sections: [{ title: "Extra", rows: [{ ...row, ...style }] }],
|
|
});
|
|
|
|
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [] })).success).toBe(true);
|
|
expect(extraSectionSchema.safeParse(input({ backgroundColor: "#24232C", highlights: [{ text: "MASSIVE", color: "#FF0000" }] })).success).toBe(true);
|
|
expect(extraSectionSchema.safeParse({ expectedVersion: 1, sections: [{ title: "Extra", rows: [row] }] }).success).toBe(true);
|
|
expect(extraSectionSchema.safeParse(input({ backgroundColor: "red", highlights: [] })).success).toBe(false);
|
|
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [{ text: "MASSIVE", color: "red" }] })).success).toBe(false);
|
|
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [{ text: "MASSIVE", color: "#FF0000" }, { text: "MASSIVE", color: "#00FF00" }] })).success).toBe(false);
|
|
});
|
|
|
|
it("sanitizes Extra rich text and allows formatting markup", () => {
|
|
const result = extraSectionSchema.safeParse({
|
|
expectedVersion: 1,
|
|
sections: [{
|
|
title: "Extra",
|
|
rows: [{
|
|
mediaId: null,
|
|
heading: "Formatted card",
|
|
body: '<p><strong>Safe</strong><script>bad()</script></p>',
|
|
percent: null,
|
|
backgroundColor: null,
|
|
highlights: [],
|
|
}],
|
|
}],
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.sections[0].rows[0].body).toBe("<p><strong>Safe</strong></p>");
|
|
}
|
|
});
|
|
|
|
it("keeps ordered mixed catalog items in Extra rows and rejects duplicates", () => {
|
|
const row = {
|
|
mediaId: null,
|
|
heading: "Build options",
|
|
body: "",
|
|
percent: null,
|
|
backgroundColor: null,
|
|
highlights: [],
|
|
catalogItems: [
|
|
{ kind: "character", key: "10000089" },
|
|
{ kind: "weapon", key: "WEAPON_CLAYMORE" },
|
|
{ kind: "artifact", key: "ARTIFACT_SET" },
|
|
],
|
|
} as const;
|
|
const result = extraSectionSchema.safeParse({
|
|
expectedVersion: 1,
|
|
sections: [{ title: "Extra", rows: [row] }],
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) expect(result.data.sections[0].rows[0].catalogItems).toEqual(row.catalogItems);
|
|
expect(extraSectionSchema.safeParse({
|
|
expectedVersion: 1,
|
|
sections: [{ title: "Extra", rows: [{ ...row, catalogItems: [row.catalogItems[0], row.catalogItems[0]] }] }],
|
|
}).success).toBe(false);
|
|
});
|
|
|
|
it("validates an optional Extra card sheet", () => {
|
|
let id = 0;
|
|
const sheet = createExtraSheet((prefix) => `${prefix}-${++id}`);
|
|
sheet.rows[0].cells[sheet.columns[0].id] = "=1+1";
|
|
const row = {
|
|
mediaId: null,
|
|
heading: "Stats",
|
|
body: "",
|
|
percent: null,
|
|
backgroundColor: null,
|
|
highlights: [],
|
|
catalogItems: [],
|
|
sheet,
|
|
};
|
|
const result = extraSectionSchema.safeParse({
|
|
expectedVersion: 1,
|
|
sections: [{ title: "Extra", rows: [row] }],
|
|
});
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.sections[0].rows[0].sheet?.rows[0].cells[sheet.columns[0].id]).toBe("<p>=1+1</p>");
|
|
}
|
|
|
|
const invalidSheet = structuredClone(sheet);
|
|
invalidSheet.rows[0].cells.missing = "orphan";
|
|
expect(extraSectionSchema.safeParse({
|
|
expectedVersion: 1,
|
|
sections: [{ title: "Extra", rows: [{ ...row, sheet: invalidSheet }] }],
|
|
}).success).toBe(false);
|
|
});
|
|
|
|
it("validates targeted Extra create, save, and reorder inputs", () => {
|
|
const section = {
|
|
title: "Build notes",
|
|
rows: [{
|
|
mediaId: null,
|
|
heading: "Rotation",
|
|
body: "",
|
|
percent: null,
|
|
backgroundColor: null,
|
|
highlights: [],
|
|
catalogItems: [],
|
|
sheet: null,
|
|
}],
|
|
};
|
|
const firstId = "550e8400-e29b-41d4-a716-446655440000";
|
|
const secondId = "d9428888-122b-11e1-b85c-61cd3cbb3210";
|
|
|
|
expect(createExtraSectionSchema.parse({ title: " Build notes " }).title).toBe("Build notes");
|
|
expect(saveExtraSectionSchema.safeParse({ expectedVersion: 2, section }).success).toBe(true);
|
|
expect(reorderExtraSectionsSchema.safeParse({ expectedVersion: 2, sectionIds: [firstId, secondId] }).success).toBe(true);
|
|
expect(reorderExtraSectionsSchema.safeParse({ expectedVersion: 2, sectionIds: [firstId, firstId] }).success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Team Calc", () => {
|
|
it("calculates exact shares and total DPS", () => {
|
|
expect(calculateTeamMetrics([50, 25, 15, 10], 20)).toEqual({ totalDamage: 100, totalDps: 5, shares: [50, 25, 15, 10] });
|
|
});
|
|
|
|
it("rejects an empty damage rotation", () => {
|
|
expect(() => calculateTeamMetrics([0, 0, 0, 0], 20)).toThrow();
|
|
});
|
|
});
|