refactor(guides) : consolidate duplicate guide and Stygian logic

This commit is contained in:
2026-10-07 01:41:13 +07:00 Unverified
parent 3c8f60433a
commit b35c1a4471
17 changed files with 268 additions and 171 deletions
+22 -1
View File
@@ -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");
+6
View File
@@ -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;
+44 -2
View File
@@ -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
View File
@@ -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),
})),
};
}
+94
View File
@@ -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,
});
});
});
+20
View File
@@ -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,
})),
};
}
+3
View File
@@ -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
View File
@@ -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,
});
}
});
+4 -1
View File
@@ -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({