feat : big update
CI / Verify (push) Successful in 51s
CI / Build immutable images and deploy (push) Successful in 1m36s

This commit is contained in:
2026-08-30 10:52:37 +00:00 Unverified
parent 0a643dd60e
commit 273baa55d8
22 changed files with 4025 additions and 87 deletions
+17
View File
@@ -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"',
);
});
});
+12
View File
@@ -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}`;
}
+4
View File
@@ -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
View File
@@ -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>>>;
+12 -1
View File
@@ -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
View File
@@ -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),