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, recommendedStatOptionConflicts, targetStatIconName, } from "@/lib/guides/stats"; describe("structured guide validation", () => { const artifactTiers = [ { name: "Late Game", rows: [] }, { name: "Early Game", rows: [] }, ] as const; const artifactGroups = [{ name: null, note: null, tiers: artifactTiers }] 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("accepts optional condition icons and rejects invalid media IDs", () => { const iconMediaId = "550e8400-e29b-41d4-a716-446655440000"; const weapon = { expectedVersion: 1, note: null, groups: [{ name: "DPS", note: null, iconMediaId, rows: [] }] }; expect(weaponSectionSchema.parse(weapon).groups[0].iconMediaId).toBe(iconMediaId); expect(weaponSectionSchema.parse({ ...weapon, groups: [{ name: null, note: null, rows: [] }] }).groups[0].iconMediaId).toBeNull(); expect(weaponSectionSchema.safeParse({ ...weapon, groups: [{ ...weapon.groups[0], iconMediaId: "bad" }] }).success).toBe(false); expect(constellationSectionSchema.parse({ expectedVersion: 1, note: null, groups: [{ name: "C1", note: null, iconMediaId, rows: [] }] }).groups[0].iconMediaId).toBe(iconMediaId); expect(teamSectionSchema.parse({ expectedVersion: 1, groups: [{ name: "Team", note: null, iconMediaId, teams: [] }] }).groups[0].iconMediaId).toBe(iconMediaId); }); 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("allows every artifact main stat option to be selected", () => { const base = { expectedVersion: 1, groups: artifactGroups, goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null }; expect(artifactSectionSchema.safeParse({ ...base, sands: ["EM", "ER", "ATK%", "HP%", "DEF%"] }).success).toBe(true); }); it("validates recommended stat conditions independently and preserves their order", () => { const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null }; const first = { name: " DPS ", note: null, sands: ["ATK%"], goblet: ["Pyro DMG%"], circlet: ["CRIT Rate%/CRIT DMG%"], substats: ["ATK", "ER"] }; const second = { name: "", note: "Support", sands: ["ER"], goblet: ["HP%"], circlet: ["Healing Bonus%"], substats: ["HP", "ER"] }; const result = artifactSectionSchema.parse({ ...base, recommendedGroups: [first, second] }); expect(result.recommendedGroups?.map((group) => group.name)).toEqual(["DPS", null]); expect(result.recommendedGroups?.[1].sands).toEqual(["ER"]); expect(artifactSectionSchema.safeParse({ ...base, recommendedGroups: [{ ...first, sands: ["EM", "ER", "ATK%", "HP%", "DEF%"] }] }).success).toBe(true); expect(artifactSectionSchema.safeParse({ ...base, recommendedGroups: [{ ...first, goblet: ["invalid"] }] }).success).toBe(false); expect(artifactSectionSchema.safeParse({ ...base, recommendedGroups: [] }).success).toBe(false); expect(artifactSectionSchema.safeParse({ ...base, recommendedGroups: Array(21).fill(first) }).success).toBe(false); expect(artifactSectionSchema.safeParse({ ...base, recommendedGroups: [{ ...first, note: "x".repeat(2_001) }] }).success).toBe(false); }); it("uses distinct icons for flat and percentage HP, ATK, and DEF stats", () => { 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(recommendedStatIconName("ATK%")).toBe("atk-percent"); expect(recommendedStatIconName("HP%")).toBe("hp-percent"); expect(recommendedStatIconName("DEF%")).toBe("def-percent"); expect(targetStatIconName("ATK")).toBe("atk"); expect(targetStatIconName("HP")).toBe("hp"); expect(targetStatIconName("DEF")).toBe("def"); }); it("supports separate CRIT choices and keeps them mutually exclusive with the combined choice", () => { const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], targetGroups: [], talents: [], note: null }; expect(artifactSectionSchema.safeParse({ ...base, circlet: ["CRIT Rate%", "CRIT DMG%"], substats: ["CRIT Rate%", "CRIT DMG%"] }).success).toBe(true); expect(recommendedStatIconName("CRIT Rate%")).toBe("critrate"); expect(recommendedStatIconName("CRIT DMG%")).toBe("critdmg"); expect(recommendedStatOptionConflicts("CRIT Rate%/CRIT DMG%", ["CRIT Rate%"])).toBe(true); expect(recommendedStatOptionConflicts("CRIT Rate%", ["CRIT Rate%/CRIT DMG%"])).toBe(true); expect(recommendedStatOptionConflicts("CRIT DMG%", ["CRIT Rate%"])).toBe(false); }); 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: null, note: null, tiers: [{ name: "Late Game", rows: [{ ...row, note: null }] }, artifactTiers[1]] }] }).success).toBe(true); expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: null, note: null, tiers: [{ name: "Late Game", rows: [{ ...row, note: "x".repeat(2_001) }] }, artifactTiers[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: [{ name: null, note: null, tiers: [...artifactTiers].reverse() }] }).success).toBe(false); expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: null, note: null, tiers: [...artifactTiers, { name: "Other", rows: [] }] }] }).success).toBe(false); }); it("supports named artifact tier-list conditions", () => { const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null }; const result = artifactSectionSchema.parse({ ...base, groups: [ { name: " On field ", note: "Best damage", tiers: artifactTiers }, { name: "", note: null, tiers: artifactTiers }, ] }); expect(result.groups.map((group) => group.name)).toEqual(["On field", null]); expect(artifactSectionSchema.safeParse({ ...base, groups: [] }).success).toBe(false); expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: "x".repeat(121), note: null, tiers: artifactTiers }] }).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: "weapon-1", artifactKey: "artifact-1", 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); expect(teamSectionSchema.safeParse({ ...base, groups: [{ note: null, teams: [{ ...teams[0], members: [{ ...member("1"), weaponKey: null }, ...["2", "3", "4"].map(member)] }] }] }).success).toBe(false); expect(teamSectionSchema.safeParse({ ...base, groups: [{ note: null, teams: [{ ...teams[0], members: [{ ...member("1"), artifactKey: null }, ...["2", "3", "4"].map(member)] }] }] }).success).toBe(false); }); it("accepts more than 30 teams in one condition", () => { const members = ["1", "2", "3", "4"].map((characterKey) => ({ characterKey, weaponKey: "weapon-1", artifactKey: "artifact-1", secondArtifactKey: null, constellation: 0, refinement: 1, damage: 100, })); const teams = Array.from({ length: 31 }, (_, index) => ({ title: `Team ${index + 1}`, rotationSeconds: 20, combo: "E > Q", members, })); expect(teamSectionSchema.safeParse({ expectedVersion: 1, groups: [{ note: null, teams }] }).success).toBe(true); }); it("accepts all-level ranges for team constellations and refinements", () => { const member = (characterKey: string) => ({ characterKey, weaponKey: "weapon-1", artifactKey: "artifact-1", secondArtifactKey: null, constellation: "0-6", refinement: "1-5", damage: 100 }); const input = { expectedVersion: 1, groups: [{ note: null, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] }] }; const result = teamSectionSchema.safeParse(input); expect(result.success).toBe(true); if (result.success) expect(result.data.groups[0].teams[0].members[0]).toMatchObject({ constellation: "0-6", refinement: "1-5" }); }); it("accepts a second artifact set for a team member", () => { const member = (characterKey: string) => ({ characterKey, weaponKey: "weapon-1", 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: '
Safe
', percent: null, backgroundColor: null, highlights: [], }], }], }); expect(result.success).toBe(true); if (result.success) { expect(result.data.sections[0].rows[0].body).toBe("Safe
"); } }); 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("=1+1
"); } 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(); }); });