feat : big update
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { imageContentDisposition } from "@/lib/guides/export-image";
|
||||
|
||||
describe("guide image downloads", () => {
|
||||
it("provides a safe ASCII fallback and a UTF-8 filename", () => {
|
||||
expect(imageContentDisposition("ไกด์อาวุธ", "weapons")).toBe(
|
||||
"attachment; filename=\"weapons.png\"; filename*=UTF-8''%E0%B9%84%E0%B8%81%E0%B8%94%E0%B9%8C%E0%B8%AD%E0%B8%B2%E0%B8%A7%E0%B8%B8%E0%B8%98.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes punctuation in the fallback filename", () => {
|
||||
expect(imageContentDisposition("Arlecchino / R5", "weapons")).toContain(
|
||||
'filename="Arlecchino-R5.png"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export function imageContentDisposition(displayName: string, fallbackName: string): string {
|
||||
const safeDisplayName = displayName.trim() || fallbackName;
|
||||
const fallback = safeDisplayName
|
||||
.normalize("NFKD")
|
||||
.replace(/[^a-z0-9]+/giu, "-")
|
||||
.replace(/^-|-$/gu, "") || fallbackName;
|
||||
const encoded = encodeURIComponent(`${safeDisplayName}.png`).replace(
|
||||
/['()*]/gu,
|
||||
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
return `attachment; filename="${fallback}.png"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
@@ -72,6 +72,10 @@ export async function createGuide(input: unknown) {
|
||||
coverMediaId: parsed.coverMediaId,
|
||||
}).returning();
|
||||
await tx.insert(guideSections).values(DEFAULT_SECTIONS.map((section) => ({ ...section, guideId: guide.id })));
|
||||
await tx.insert(artifactGroups).values([
|
||||
{ guideId: guide.id, name: "Late Game", sortOrder: 0 },
|
||||
{ guideId: guide.id, name: "Early Game", sortOrder: 1 },
|
||||
]);
|
||||
await tx.insert(artifactProfiles).values({ guideId: guide.id });
|
||||
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, parsed.coverMediaId));
|
||||
await emitGuideEvents(tx, guide.id, guide.version);
|
||||
|
||||
+43
-1
@@ -23,7 +23,7 @@ import {
|
||||
} from "@/db/schema";
|
||||
import { publicMediaUrl } from "@/lib/media/storage";
|
||||
|
||||
function catalogCharacterWithUrls<T extends typeof catalogCharacters.$inferSelect>(item: T) {
|
||||
function catalogCharacterWithUrls(item: typeof catalogCharacters.$inferSelect) {
|
||||
return {
|
||||
...item,
|
||||
imageUrl: publicMediaUrl(item.imageKey),
|
||||
@@ -32,6 +32,10 @@ function catalogCharacterWithUrls<T extends typeof catalogCharacters.$inferSelec
|
||||
...constellation,
|
||||
iconUrl: constellation.iconKey ? publicMediaUrl(constellation.iconKey) : null,
|
||||
})),
|
||||
talents: item.talents.map((talent) => ({
|
||||
...talent,
|
||||
iconUrl: talent.iconKey ? publicMediaUrl(talent.iconKey) : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,4 +189,42 @@ export async function getPublicTeamExport(teamId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPublicWeaponExport(guideId: string) {
|
||||
const [row] = await getDb()
|
||||
.select({ guide: guides })
|
||||
.from(guides)
|
||||
.innerJoin(
|
||||
guideSections,
|
||||
and(
|
||||
eq(guideSections.guideId, guides.id),
|
||||
eq(guideSections.slug, "weapon"),
|
||||
eq(guideSections.enabled, true),
|
||||
),
|
||||
)
|
||||
.where(and(eq(guides.id, guideId), isNull(guides.trashedAt)))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
|
||||
const weapons = await getDb()
|
||||
.select({ recommendation: weaponRecommendations, weapon: catalogWeapons })
|
||||
.from(weaponRecommendations)
|
||||
.innerJoin(
|
||||
catalogWeapons,
|
||||
and(
|
||||
eq(catalogWeapons.version, weaponRecommendations.catalogVersion),
|
||||
eq(catalogWeapons.key, weaponRecommendations.weaponKey),
|
||||
),
|
||||
)
|
||||
.where(eq(weaponRecommendations.guideId, guideId))
|
||||
.orderBy(asc(weaponRecommendations.sortOrder));
|
||||
|
||||
return {
|
||||
guideName: row.guide.name,
|
||||
weapons: weapons.map(({ recommendation, weapon }) => ({
|
||||
...recommendation,
|
||||
weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export type PublicGuide = NonNullable<Awaited<ReturnType<typeof getPublicGuide>>>;
|
||||
|
||||
@@ -4,6 +4,10 @@ import { calculateTeamMetrics } from "@/lib/guides/calculations";
|
||||
import { artifactSectionSchema, createGuideSchema, teamSectionSchema, weaponSectionSchema } from "@/lib/guides/schemas";
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -14,10 +18,17 @@ describe("structured guide validation", () => {
|
||||
});
|
||||
|
||||
it("limits artifact main stats to two per slot", () => {
|
||||
const result = artifactSectionSchema.safeParse({ expectedVersion: 1, groups: [], sands: ["EM", "ER", "ATK%"], goblet: [], circlet: [], substats: [], targets: [], talents: [], note: null });
|
||||
const result = artifactSectionSchema.safeParse({ expectedVersion: 1, groups: artifactGroups, sands: ["EM", "ER", "ATK%"], goblet: [], circlet: [], substats: [], targets: [], talents: [], note: null });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires the fixed Late Game and Early Game artifact groups", () => {
|
||||
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targets: [], 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("requires four unique team members and non-zero damage", () => {
|
||||
const member = (characterKey: string) => ({ characterKey, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1, damage: 100 });
|
||||
const base = { expectedVersion: 1, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] };
|
||||
|
||||
+13
-8
@@ -38,16 +38,21 @@ export const mainStatOptions = {
|
||||
|
||||
export const substatOptions = ["CRIT Rate%/CRIT DMG%", "HP%", "ATK%", "DEF%", "EM", "ER"] as const;
|
||||
|
||||
const artifactGroupSchema = (name: "Late Game" | "Early Game") => z.strictObject({
|
||||
name: z.literal(name),
|
||||
rows: z.array(z.strictObject({
|
||||
firstArtifactKey: catalogKey,
|
||||
secondArtifactKey: catalogKey.nullable(),
|
||||
percent,
|
||||
})).max(50),
|
||||
});
|
||||
|
||||
export const artifactSectionSchema = z.strictObject({
|
||||
expectedVersion: z.number().int().positive(),
|
||||
groups: z.array(z.strictObject({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
rows: z.array(z.strictObject({
|
||||
firstArtifactKey: catalogKey,
|
||||
secondArtifactKey: catalogKey.nullable(),
|
||||
percent,
|
||||
})).max(50),
|
||||
})).max(12),
|
||||
groups: z.tuple([
|
||||
artifactGroupSchema("Late Game"),
|
||||
artifactGroupSchema("Early Game"),
|
||||
]),
|
||||
sands: z.array(z.enum(mainStatOptions.sands)).max(2),
|
||||
goblet: z.array(z.enum(mainStatOptions.goblet)).max(2),
|
||||
circlet: z.array(z.enum(mainStatOptions.circlet)).max(2),
|
||||
|
||||
Reference in New Issue
Block a user