feat : server side
CI / Verify (push) Successful in 1m33s
CI / Build immutable images and deploy (push) Successful in 2m16s

This commit is contained in:
2026-09-23 21:45:46 +07:00 Unverified
parent bd84d49108
commit e0b25500bc
10 changed files with 1126 additions and 611 deletions
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import type { PublicGuide } from "@/lib/guides/queries";
import { filterPublicTeamGroups, resolvePublicGuideFilters } from "./public-filters";
function team(
id: string,
title: string,
character: string,
alias: string,
damage: number,
rotationSeconds: number,
) {
return {
id,
title,
rotationSeconds,
members: [{
damage,
character: { name: character, aliases: [alias] },
weapon: null,
artifact: null,
secondArtifact: null,
}],
};
}
const guide = {
weaponGroups: [{ rows: [{}] }, { rows: [] }],
constellationGroups: [{ rows: [] }, { rows: [{}] }],
artifactGroups: [{ name: "Sets one" }, { name: "Sets two" }],
artifactProfile: {
recommendedConditions: [{ name: "Stats one" }, { name: "Stats two" }],
targetConditions: [
{ name: "Empty", rows: [] },
{ name: "Targets", rows: [{ stat: "ATK", value: "100" }] },
],
},
teamGroups: [
{
id: "first",
name: "First condition",
note: "First note",
teams: [team("first-team", "First team", "Lyney", "The Knave", 100, 10)],
},
{
id: "second",
name: "Second condition",
note: "Second note",
teams: [
team("lower", "Lower DPS", "Mualani", "Shark Surfer", 100, 20),
team("higher", "Higher DPS", "Mualani", "Shark Surfer", 200, 10),
],
},
],
} as unknown as PublicGuide;
describe("public guide filters", () => {
it("resolves one-based condition and artifact selections with safe defaults", () => {
const filters = resolvePublicGuideFilters(guide, "artifact", {
recommended: ["2", "1"],
sets: "99",
targets: "1",
field: "unknown",
sort: "unknown",
});
expect(filters.recommendedIndex).toBe(1);
expect(filters.setsIndex).toBe(0);
expect(filters.targetsIndex).toBe(0);
expect(filters.field).toBe("all");
expect(filters.sort).toBeNull();
expect(filters.recommendedLabels).toEqual(["Stats one", "Stats two"]);
expect(filters.targetsLabels).toEqual(["Targets"]);
});
it("filters the selected team condition on the server, including fuzzy aliases and DPS order", () => {
const filters = resolvePublicGuideFilters(guide, "team", {
condition: "2",
q: "mulani",
field: "character",
sort: "best",
});
const groups = filterPublicTeamGroups(guide, filters);
expect(groups[0].teams).toEqual([]);
expect(groups[0].note).toBeNull();
expect(groups[1].teams.map(({ id }) => id)).toEqual(["higher", "lower"]);
expect(groups[1].note).toBe("Second note");
expect(groups[1].teams[0].members[0].character).not.toHaveProperty("aliases");
});
it("defaults invalid team condition indexes to the first condition", () => {
const filters = resolvePublicGuideFilters(guide, "team", { condition: "0" });
expect(filters.conditionIndex).toBe(0);
});
});
+143
View File
@@ -0,0 +1,143 @@
import type { PublicGuide } from "@/lib/guides/queries";
import {
searchTeams,
sortTeamsByDps,
type DpsOrder,
type TeamSearchTarget,
} from "@/lib/guides/team-search";
export type GuideSearchParams = Record<string, string | string[] | undefined>;
export interface PublicGuideFilters {
conditionIndex: number;
recommendedIndex: number;
setsIndex: number;
targetsIndex: number;
query: string;
field: TeamSearchTarget;
sort: DpsOrder;
recommendedLabels: string[];
setsLabels: string[];
targetsLabels: string[];
}
type GuideTeam = PublicGuide["teams"][number];
type GuideMember = GuideTeam["members"][number];
type GuideItem = NonNullable<GuideMember["weapon"]>;
export type PublicTeamCardData = Pick<GuideTeam, "id" | "title" | "rotationSeconds" | "combo"> & {
members: Array<Pick<GuideMember, "position" | "damage" | "constellation" | "refinement"> & {
character: Pick<GuideMember["character"], "key" | "name" | "imageUrl" | "element" | "rarity"> & { aliases?: string[] };
weapon: (Pick<GuideItem, "key" | "name" | "imageUrl" | "rarity"> & { aliases?: string[] }) | null;
artifact: (Pick<GuideItem, "key" | "name" | "imageUrl" | "rarity"> & { aliases?: string[] }) | null;
secondArtifact: (Pick<GuideItem, "key" | "name" | "imageUrl" | "rarity"> & { aliases?: string[] }) | null;
}>;
};
export type PublicTeamConditionGroup = Pick<PublicGuide["teamGroups"][number], "id" | "name" | "note"> & {
teams: PublicTeamCardData[];
};
export function firstSearchValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
export function resolveOneBasedIndex(value: string | undefined, length: number): number {
if (length <= 0 || !value || !/^\d+$/u.test(value)) return 0;
const index = Number(value) - 1;
return Number.isSafeInteger(index) && index >= 0 && index < length ? index : 0;
}
export function resolvePublicGuideFilters(
guide: PublicGuide,
section: string,
params: GuideSearchParams,
): PublicGuideFilters {
const profile = guide.artifactProfile;
const recommended = profile?.recommendedConditions ?? [];
const sets = guide.artifactGroups;
const targets = profile?.targetConditions.filter((condition) => condition.rows.length) ?? [];
const fieldValue = firstSearchValue(params.field);
const sortValue = firstSearchValue(params.sort);
return {
conditionIndex: resolveOneBasedIndex(
firstSearchValue(params.condition),
section === "weapon"
? guide.weaponGroups.filter((group) => group.rows.length > 0).length
: section === "constellations"
? guide.constellationGroups.filter((group) => group.rows.length > 0).length
: guide.teamGroups.length,
),
recommendedIndex: resolveOneBasedIndex(firstSearchValue(params.recommended), recommended.length),
setsIndex: resolveOneBasedIndex(firstSearchValue(params.sets), sets.length),
targetsIndex: resolveOneBasedIndex(firstSearchValue(params.targets), targets.length),
query: firstSearchValue(params.q)?.trim() ?? "",
field: fieldValue === "team" || fieldValue === "character" || fieldValue === "weapon" || fieldValue === "artifact"
? fieldValue
: "all",
sort: sortValue === "best" || sortValue === "worst" ? sortValue : null,
recommendedLabels: recommended.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`),
setsLabels: sets.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`),
targetsLabels: targets.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`),
};
}
export function filterPublicTeamGroups(
guide: PublicGuide,
filters: PublicGuideFilters,
): PublicTeamConditionGroup[] {
const activeGroup = guide.teamGroups[filters.conditionIndex] ?? guide.teamGroups[0];
if (!activeGroup) return [];
const matches = searchTeams(activeGroup.teams, (team) => ({
team: [team.title],
character: team.members.flatMap((member) => [member.character.name, ...member.character.aliases]),
weapon: team.members.flatMap((member) => member.weapon ? [member.weapon.name, ...member.weapon.aliases] : []),
artifact: team.members.flatMap((member) => [member.artifact, member.secondArtifact].flatMap((artifact) => artifact ? [artifact.name, ...artifact.aliases] : [])),
}), filters.query, filters.field);
const teams = sortTeamsByDps(matches.items, filters.sort).map((team) => ({
id: team.id,
title: team.title,
rotationSeconds: team.rotationSeconds,
combo: team.combo,
members: team.members.map((member) => ({
position: member.position,
damage: member.damage,
constellation: member.constellation,
refinement: member.refinement,
character: {
key: member.character.key,
name: member.character.name,
imageUrl: member.character.imageUrl,
element: member.character.element,
rarity: member.character.rarity,
},
weapon: member.weapon ? {
key: member.weapon.key,
name: member.weapon.name,
imageUrl: member.weapon.imageUrl,
rarity: member.weapon.rarity,
} : null,
artifact: member.artifact ? {
key: member.artifact.key,
name: member.artifact.name,
imageUrl: member.artifact.imageUrl,
rarity: member.artifact.rarity,
} : null,
secondArtifact: member.secondArtifact ? {
key: member.secondArtifact.key,
name: member.secondArtifact.name,
imageUrl: member.secondArtifact.imageUrl,
rarity: member.secondArtifact.rarity,
} : null,
})),
}));
return guide.teamGroups.map((group, index) => ({
id: group.id,
name: group.name,
note: index === filters.conditionIndex ? group.note : null,
teams: index === filters.conditionIndex ? teams : [],
}));
}