refactor(guides) : consolidate duplicate guide and Stygian logic
This commit is contained in:
@@ -1,8 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { finalizePercentInput, normalizePercentInput } from "@/lib/guides/percent-input";
|
||||
import { finalizePercentInput, isValidPercent, normalizePercentInput } from "@/lib/guides/percent-input";
|
||||
|
||||
describe("percentage input", () => {
|
||||
it.each([
|
||||
["-9999", true],
|
||||
["9999", true],
|
||||
["0", true],
|
||||
["12.5", true],
|
||||
["-12.5", true],
|
||||
[" 100 ", true],
|
||||
["1e2", true],
|
||||
["", false],
|
||||
[" \t\n", false],
|
||||
["-", false],
|
||||
["invalid", false],
|
||||
["NaN", false],
|
||||
["Infinity", false],
|
||||
["-Infinity", false],
|
||||
["9999.01", false],
|
||||
["-9999.01", false],
|
||||
])("validates %j as %s", (value, valid) => {
|
||||
expect(isValidPercent(value)).toBe(valid);
|
||||
});
|
||||
|
||||
it("turns only a lone dash into zero when editing finishes", () => {
|
||||
expect(normalizePercentInput("-")).toBe("-");
|
||||
expect(finalizePercentInput("-")).toBe("0");
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
const DECIMAL = /^-?(?:\d+\.?\d*|\.\d*)?$/u;
|
||||
|
||||
export function isValidPercent(value: string): boolean {
|
||||
if (!value.trim()) return false;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= -9999 && number <= 9999;
|
||||
}
|
||||
|
||||
export function normalizePercentInput(nextValue: string, currentValue = ""): string {
|
||||
const value = nextValue.trim();
|
||||
return DECIMAL.test(value) ? value : currentValue;
|
||||
|
||||
@@ -27,9 +27,9 @@ vi.mock("@/db", () => ({
|
||||
import {
|
||||
artifactConditions, artifactProfiles, constellationConditions, extraSectionRows,
|
||||
guides, guideSections, teamCompositions, teamConditions, teamMembers, weaponConditions,
|
||||
catalogMaterials, guidePosterBleeding, media,
|
||||
catalogCharacters, catalogMaterials, guidePosterBleeding, media,
|
||||
} from "@/db/schema";
|
||||
import { getAdminGuideContent, listAdminTeamImportsForCharacter } from "./queries";
|
||||
import { getActiveCatalog, getAdminGuideContent, listAdminTeamImportsForCharacter } from "./queries";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.reads.length = 0;
|
||||
@@ -103,6 +103,48 @@ describe("admin section reads", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("character material metadata", () => {
|
||||
it.each(["catalog", "overview"] as const)(
|
||||
"preserves ascension and leveling metadata in the %s read",
|
||||
async (read) => {
|
||||
const character = {
|
||||
key: "test", imageKey: "character", constellations: [], talents: [],
|
||||
ascension: [{ type: "ascension", items: [{ key: "known", amount: 2 }, { key: "missing/key", amount: 3 }] }],
|
||||
leveling: [{ type: "leveling", items: [{ key: "missing/key", amount: 5 }, { key: "known", amount: 7 }] }],
|
||||
};
|
||||
const original = structuredClone(character);
|
||||
mocks.rows.set(catalogCharacters, [character]);
|
||||
mocks.rows.set(guides, [{
|
||||
guide: { id: "guide", slug: "test", posterArtMediaId: null },
|
||||
cover: { objectKey: "cover", status: "ready" },
|
||||
character,
|
||||
}]);
|
||||
mocks.rows.set(catalogMaterials, [{
|
||||
key: "known", name: "Known material", description: "Description",
|
||||
rarity: 4, type: "upgrade", imageKey: "material.webp",
|
||||
}]);
|
||||
|
||||
const result = read === "catalog"
|
||||
? (await getActiveCatalog())?.characters[0]
|
||||
: (await getAdminGuideContent("test", "overview"))?.character;
|
||||
const known = {
|
||||
key: "known", name: "Known material", description: "Description",
|
||||
rarity: 4, type: "upgrade", imageUrl: "/media/material.webp",
|
||||
lunarisUrl: "https://lunaris.moe/material/known", available: true,
|
||||
};
|
||||
const missing = {
|
||||
key: "missing/key", name: "missing/key", description: null,
|
||||
rarity: null, type: null, imageUrl: null,
|
||||
lunarisUrl: "https://lunaris.moe/material/missing%2Fkey", available: false,
|
||||
};
|
||||
expect(result?.ascension).toEqual([{ type: "ascension", items: [{ ...known, amount: 2 }, { ...missing, amount: 3 }] }]);
|
||||
expect(result?.leveling).toEqual([{ type: "leveling", items: [{ ...missing, amount: 5 }, { ...known, amount: 7 }] }]);
|
||||
expect(mocks.reads.filter((table) => table === catalogMaterials)).toHaveLength(1);
|
||||
expect(character).toEqual(original);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("team import lookup", () => {
|
||||
it("hydrates matching teams and source labels with one member read", async () => {
|
||||
mocks.rows.set(teamCompositions, [{
|
||||
|
||||
+18
-26
@@ -58,6 +58,22 @@ function catalogCharacterWithUrls(
|
||||
item: typeof catalogCharacters.$inferSelect,
|
||||
materialByKey = new Map<string, typeof catalogMaterials.$inferSelect>(),
|
||||
) {
|
||||
const materialWithMetadata = (
|
||||
entry: typeof item.ascension[number]["items"][number],
|
||||
) => {
|
||||
const material = materialByKey.get(entry.key);
|
||||
return {
|
||||
...entry,
|
||||
name: material?.name ?? entry.key,
|
||||
description: material?.description ?? null,
|
||||
rarity: material?.rarity ?? null,
|
||||
type: material?.type ?? null,
|
||||
imageUrl: material ? publicMediaUrl(material.imageKey) : null,
|
||||
lunarisUrl: `https://lunaris.moe/material/${encodeURIComponent(entry.key)}`,
|
||||
available: Boolean(material),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
...item,
|
||||
imageUrl: publicMediaUrl(item.imageKey),
|
||||
@@ -74,35 +90,11 @@ function catalogCharacterWithUrls(
|
||||
})),
|
||||
ascension: item.ascension.map((group) => ({
|
||||
...group,
|
||||
items: group.items.map((entry) => {
|
||||
const material = materialByKey.get(entry.key);
|
||||
return {
|
||||
...entry,
|
||||
name: material?.name ?? entry.key,
|
||||
description: material?.description ?? null,
|
||||
rarity: material?.rarity ?? null,
|
||||
type: material?.type ?? null,
|
||||
imageUrl: material ? publicMediaUrl(material.imageKey) : null,
|
||||
lunarisUrl: `https://lunaris.moe/material/${encodeURIComponent(entry.key)}`,
|
||||
available: Boolean(material),
|
||||
};
|
||||
}),
|
||||
items: group.items.map(materialWithMetadata),
|
||||
})),
|
||||
leveling: item.leveling.map((group) => ({
|
||||
...group,
|
||||
items: group.items.map((entry) => {
|
||||
const material = materialByKey.get(entry.key);
|
||||
return {
|
||||
...entry,
|
||||
name: material?.name ?? entry.key,
|
||||
description: material?.description ?? null,
|
||||
rarity: material?.rarity ?? null,
|
||||
type: material?.type ?? null,
|
||||
imageUrl: material ? publicMediaUrl(material.imageKey) : null,
|
||||
lunarisUrl: `https://lunaris.moe/material/${encodeURIComponent(entry.key)}`,
|
||||
available: Boolean(material),
|
||||
};
|
||||
}),
|
||||
items: group.items.map(materialWithMetadata),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { teamSectionSchema } from "@/lib/guides/schemas";
|
||||
import { toTeamDraft } from "@/lib/guides/team-draft";
|
||||
import { cloneTeamImports } from "@/lib/guides/team-tools";
|
||||
|
||||
const storedTeam = () => ({
|
||||
id: "stored-team",
|
||||
title: "Example team",
|
||||
rotationSeconds: "21.5",
|
||||
combo: "E > Q",
|
||||
guideName: "Source guide",
|
||||
conditionName: "Boss",
|
||||
members: [1, 2, 3, 4].map((index) => ({
|
||||
characterKey: `character-${index}`,
|
||||
weaponKey: `weapon-${index}`,
|
||||
artifactKey: `artifact-${index}`,
|
||||
secondArtifactKey: index === 1 ? "artifact-second" : null,
|
||||
constellation: index === 1 ? "0-6" : "2",
|
||||
refinement: index === 1 ? "1-5" : "4",
|
||||
damage: "123456.78",
|
||||
characterName: `Character ${index}`,
|
||||
character: { name: `Character ${index}`, imageUrl: "/character.webp" },
|
||||
})),
|
||||
});
|
||||
|
||||
describe("team drafts", () => {
|
||||
it("selects editable fields and excludes persisted IDs and hydrated metadata", () => {
|
||||
const source = storedTeam();
|
||||
const draft = toTeamDraft(source);
|
||||
|
||||
expect(draft).toEqual({
|
||||
title: "Example team",
|
||||
rotationSeconds: "21.5",
|
||||
combo: "E > Q",
|
||||
members: [1, 2, 3, 4].map((index) => ({
|
||||
characterKey: `character-${index}`,
|
||||
weaponKey: `weapon-${index}`,
|
||||
artifactKey: `artifact-${index}`,
|
||||
secondArtifactKey: index === 1 ? "artifact-second" : null,
|
||||
constellation: index === 1 ? "0-6" : "2",
|
||||
refinement: index === 1 ? "1-5" : "4",
|
||||
damage: "123456.78",
|
||||
})),
|
||||
});
|
||||
expect({ id: source.id, ...draft }).toHaveProperty("id", "stored-team");
|
||||
});
|
||||
|
||||
it("creates independent draft members and preserves nullable equipment", () => {
|
||||
const source = storedTeam();
|
||||
const member = { ...source.members[0], weaponKey: null, artifactKey: null };
|
||||
const input = { ...source, members: [member] };
|
||||
const draft = toTeamDraft(input);
|
||||
const otherDraft = toTeamDraft(input);
|
||||
|
||||
expect(draft.members[0]).toMatchObject({ weaponKey: null, artifactKey: null });
|
||||
expect(draft.members).not.toBe(input.members);
|
||||
expect(draft.members[0]).not.toBe(member);
|
||||
draft.members[0].damage = "0";
|
||||
expect(member.damage).toBe("123456.78");
|
||||
expect(otherDraft.members[0].damage).toBe("123456.78");
|
||||
});
|
||||
|
||||
it("imports teams with fresh IDs and produces a valid editor save payload", () => {
|
||||
const source = storedTeam();
|
||||
const draft = toTeamDraft(source);
|
||||
const [imported] = cloneTeamImports([draft], () => "new-team");
|
||||
expect(imported.clientId).toBe("new-team");
|
||||
expect(imported.members[0]).not.toBe(draft.members[0]);
|
||||
|
||||
const result = teamSectionSchema.parse({
|
||||
expectedVersion: 7,
|
||||
groups: [{
|
||||
name: "Boss",
|
||||
note: null,
|
||||
iconMediaId: null,
|
||||
teams: [{
|
||||
title: imported.title,
|
||||
rotationSeconds: Number(imported.rotationSeconds),
|
||||
combo: imported.combo,
|
||||
members: imported.members.map((member) => ({
|
||||
...member,
|
||||
damage: Number(member.damage),
|
||||
})),
|
||||
}],
|
||||
}],
|
||||
});
|
||||
expect(result.expectedVersion).toBe(7);
|
||||
expect(result.groups[0].teams[0].rotationSeconds).toBe(21.5);
|
||||
expect(result.groups[0].teams[0].members[0]).toMatchObject({
|
||||
constellation: "0-6", refinement: "1-5", damage: 123456.78,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TeamToolValue } from "@/lib/guides/team-tools";
|
||||
|
||||
export function toTeamDraft(
|
||||
team: Omit<TeamToolValue, "clientId">,
|
||||
): Omit<TeamToolValue, "clientId"> {
|
||||
return {
|
||||
title: team.title,
|
||||
rotationSeconds: team.rotationSeconds,
|
||||
combo: team.combo,
|
||||
members: team.members.map((member) => ({
|
||||
characterKey: member.characterKey,
|
||||
weaponKey: member.weaponKey,
|
||||
artifactKey: member.artifactKey,
|
||||
secondArtifactKey: member.secondArtifactKey,
|
||||
constellation: member.constellation,
|
||||
refinement: member.refinement,
|
||||
damage: member.damage,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const stygianLevels = [105, 110] as const;
|
||||
export const stygianBossPositions = [0, 1, 2] as const;
|
||||
export const stygianBossPositionLabels = ["บน", "กลาง", "ล่าง"] as const;
|
||||
+14
-19
@@ -80,25 +80,23 @@ export async function syncStygianArchive() {
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(stygianSchedules).values({
|
||||
scheduleId,
|
||||
const scheduleValues = {
|
||||
challengeName: parsed.data.challengeName,
|
||||
scheduleStartTime: lunarisDate(parsed.data.scheduleStartTime),
|
||||
scheduleEndTime: lunarisDate(parsed.data.scheduleEndTime),
|
||||
};
|
||||
await tx.insert(stygianSchedules).values({
|
||||
scheduleId,
|
||||
...scheduleValues,
|
||||
}).onConflictDoUpdate({
|
||||
target: stygianSchedules.scheduleId,
|
||||
set: {
|
||||
challengeName: parsed.data.challengeName,
|
||||
scheduleStartTime: lunarisDate(parsed.data.scheduleStartTime),
|
||||
scheduleEndTime: lunarisDate(parsed.data.scheduleEndTime),
|
||||
...scheduleValues,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
for (const boss of bosses) {
|
||||
await tx.insert(stygianBosses).values({
|
||||
scheduleId,
|
||||
level: boss.level,
|
||||
position: boss.position,
|
||||
const bossValues = {
|
||||
configId: boss.configId,
|
||||
name: boss.name,
|
||||
hp: String(boss.hp),
|
||||
@@ -107,18 +105,15 @@ export async function syncStygianArchive() {
|
||||
recommendedMechanics: boss.recommendedMechanics,
|
||||
recommendedMechanicTypes: boss.recommendedMechanicTypes,
|
||||
imageKey: imageKeyByName.get(boss.imageName)!,
|
||||
};
|
||||
await tx.insert(stygianBosses).values({
|
||||
scheduleId,
|
||||
level: boss.level,
|
||||
position: boss.position,
|
||||
...bossValues,
|
||||
}).onConflictDoUpdate({
|
||||
target: [stygianBosses.scheduleId, stygianBosses.level, stygianBosses.position],
|
||||
set: {
|
||||
configId: boss.configId,
|
||||
name: boss.name,
|
||||
hp: String(boss.hp),
|
||||
description: boss.description,
|
||||
mechanics: boss.mechanics,
|
||||
recommendedMechanics: boss.recommendedMechanics,
|
||||
recommendedMechanicTypes: boss.recommendedMechanicTypes,
|
||||
imageKey: imageKeyByName.get(boss.imageName)!,
|
||||
},
|
||||
set: bossValues,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const stygianLevels = [105, 110] as const;
|
||||
import { stygianLevels } from "@/lib/stygian/constants";
|
||||
|
||||
export { stygianLevels } from "@/lib/stygian/constants";
|
||||
|
||||
export const stygianElements = ["Anemo", "Geo", "Electro", "Dendro", "Hydro", "Pyro", "Cryo"] as const;
|
||||
|
||||
const levelConfigSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user