feat : update
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyTeamFields, limitPresets, parseTeamPresets } from "./team-tools";
|
||||
|
||||
const team = (id: string, title = id) => ({
|
||||
clientId: id, title, rotationSeconds: "20", combo: "E > Q",
|
||||
members: [1, 2, 3, 4].map((n) => ({ characterKey: `${id}-${n}`, weaponKey: null, artifactKey: null, secondArtifactKey: null, constellation: 0, refinement: 1, damage: String(n) })),
|
||||
});
|
||||
|
||||
describe("team bulk tools", () => {
|
||||
it("copies selected fields without changing titles or mutating source", () => {
|
||||
const source = team("source", "Source");
|
||||
const target = team("target", "Keep title");
|
||||
const result = applyTeamFields([source, target], "source", new Set(["target"]), ["loadout"]);
|
||||
expect(result[1].title).toBe("Keep title");
|
||||
expect(result[1].members.map((member) => member.characterKey)).toEqual(["source-1", "source-2", "source-3", "source-4"]);
|
||||
expect(result[1].members.map((member) => member.damage)).toEqual(["1", "2", "3", "4"]);
|
||||
expect(source.members[0].characterKey).toBe("source-1");
|
||||
});
|
||||
|
||||
it("does not apply to the source team", () => {
|
||||
const source = team("source");
|
||||
expect(applyTeamFields([source], "source", new Set(["source"]), ["combo"])).toEqual([source]);
|
||||
});
|
||||
|
||||
it("deduplicates and limits presets", () => {
|
||||
const presets = Array.from({ length: 7 }, (_, index) => ({ id: String(index), name: String(index), createdAt: String(index), team: { ...team(String(index)), clientId: undefined as never } }));
|
||||
expect(limitPresets([presets[0], presets[0], ...presets.slice(1)])).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("rejects malformed preset storage", () => {
|
||||
expect(parseTeamPresets("not-json")).toEqual([]);
|
||||
expect(parseTeamPresets(JSON.stringify([{ id: 1 }]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
export interface TeamToolMember {
|
||||
characterKey: string;
|
||||
weaponKey: string | null;
|
||||
artifactKey: string | null;
|
||||
secondArtifactKey: string | null;
|
||||
constellation: number;
|
||||
refinement: number;
|
||||
damage: string;
|
||||
}
|
||||
|
||||
export interface TeamToolValue {
|
||||
clientId: string;
|
||||
title: string;
|
||||
rotationSeconds: string;
|
||||
combo: string;
|
||||
members: TeamToolMember[];
|
||||
}
|
||||
|
||||
export const teamApplyFields = ["loadout", "damage", "rotation", "combo"] as const;
|
||||
export type TeamApplyField = (typeof teamApplyFields)[number];
|
||||
|
||||
export interface TeamPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
team: Omit<TeamToolValue, "clientId">;
|
||||
}
|
||||
|
||||
function cloneMember(member: TeamToolMember): TeamToolMember {
|
||||
return { ...member };
|
||||
}
|
||||
|
||||
function cloneTeam(team: TeamToolValue): TeamToolValue {
|
||||
return { ...team, members: team.members.map(cloneMember) };
|
||||
}
|
||||
|
||||
export function applyTeamFields(
|
||||
teams: readonly TeamToolValue[],
|
||||
sourceId: string,
|
||||
targetIds: ReadonlySet<string>,
|
||||
fields: readonly TeamApplyField[],
|
||||
): TeamToolValue[] {
|
||||
const source = teams.find((team) => team.clientId === sourceId);
|
||||
if (!source) return teams.map(cloneTeam);
|
||||
const selected = new Set(fields);
|
||||
return teams.map((team) => {
|
||||
if (!targetIds.has(team.clientId) || team.clientId === sourceId) return cloneTeam(team);
|
||||
const next = cloneTeam(team);
|
||||
if (selected.has("loadout")) {
|
||||
next.members = source.members.map((member, index) => ({
|
||||
...cloneMember(member),
|
||||
damage: next.members[index]?.damage ?? "0",
|
||||
}));
|
||||
}
|
||||
if (selected.has("damage")) next.members = source.members.map(cloneMember);
|
||||
if (selected.has("rotation")) next.rotationSeconds = source.rotationSeconds;
|
||||
if (selected.has("combo")) next.combo = source.combo;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export function applyPresetFields(
|
||||
teams: readonly TeamToolValue[],
|
||||
preset: TeamPreset,
|
||||
targetIds: ReadonlySet<string>,
|
||||
fields: readonly TeamApplyField[],
|
||||
): TeamToolValue[] {
|
||||
return applyTeamFields(
|
||||
[
|
||||
...teams,
|
||||
{ clientId: preset.id, ...preset.team, members: preset.team.members.map(cloneMember) },
|
||||
],
|
||||
preset.id,
|
||||
targetIds,
|
||||
fields,
|
||||
).slice(0, teams.length);
|
||||
}
|
||||
|
||||
export function presetFingerprint(team: Omit<TeamToolValue, "clientId">): string {
|
||||
return JSON.stringify(team);
|
||||
}
|
||||
|
||||
export function limitPresets(presets: readonly TeamPreset[], limit = 5): TeamPreset[] {
|
||||
const seen = new Set<string>();
|
||||
return presets.filter((preset) => {
|
||||
const fingerprint = presetFingerprint(preset.team);
|
||||
if (seen.has(fingerprint)) return false;
|
||||
seen.add(fingerprint);
|
||||
return true;
|
||||
}).slice(0, limit);
|
||||
}
|
||||
|
||||
export function parseTeamPresets(serialized: string | null): TeamPreset[] {
|
||||
if (!serialized) return [];
|
||||
try {
|
||||
const value: unknown = JSON.parse(serialized);
|
||||
if (!Array.isArray(value)) return [];
|
||||
return limitPresets(value.filter((item): item is TeamPreset => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const candidate = item as Record<string, unknown>;
|
||||
return typeof candidate.id === "string" && typeof candidate.name === "string" &&
|
||||
typeof candidate.createdAt === "string" && Boolean(candidate.team);
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function teamPresetStorageKey(guideId: string): string {
|
||||
return `buzz-sheet:team-presets:${guideId}`;
|
||||
}
|
||||
|
||||
export function teamPresetCookieKey(guideId: string): string {
|
||||
return `buzz-sheet-team-presets-${guideId}`;
|
||||
}
|
||||
@@ -14,4 +14,12 @@ describe("guide validation messages", () => {
|
||||
"Team 2: Team ต้องมีตัวละครไม่ซ้ำกัน 4 ตัว",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the authored team name when one is available", () => {
|
||||
expect(formatGuideValidationIssues(
|
||||
"team",
|
||||
[{ path: ["teams", 1, "combo"], message: "Invalid input" }],
|
||||
{ teamNames: ["First team", "Hypercarry"] },
|
||||
)).toEqual(["Hypercarry: กรุณากรอก Combo"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ function indexAfter(path: readonly PropertyKey[], key: string): number | null {
|
||||
return position >= 0 && typeof value === "number" ? value : null;
|
||||
}
|
||||
|
||||
function issueContext(section: GuideValidationSection, path: readonly PropertyKey[]): string {
|
||||
function issueContext(section: GuideValidationSection, path: readonly PropertyKey[], teamNames?: readonly string[]): string {
|
||||
const rowIndex = indexAfter(path, "rows");
|
||||
const groupIndex = indexAfter(path, "groups");
|
||||
const teamIndex = indexAfter(path, "teams");
|
||||
@@ -50,7 +50,8 @@ function issueContext(section: GuideValidationSection, path: readonly PropertyKe
|
||||
return rowIndex === null ? `Condition ${groupIndex + 1}` : `Condition ${groupIndex + 1} · C${rowIndex + 1}`;
|
||||
}
|
||||
if (section === "team" && teamIndex !== null) {
|
||||
return memberIndex === null ? `Team ${teamIndex + 1}` : `Team ${teamIndex + 1} · ตัวละคร ${memberIndex + 1}`;
|
||||
const teamName = teamNames?.[teamIndex]?.trim() || `Team ${teamIndex + 1}`;
|
||||
return memberIndex === null ? teamName : `${teamName} · ตัวละคร ${memberIndex + 1}`;
|
||||
}
|
||||
if (section === "extra" && sectionIndex !== null) {
|
||||
return rowIndex === null ? `Extra section ${sectionIndex + 1}` : `Extra section ${sectionIndex + 1} · แถว ${rowIndex + 1}`;
|
||||
@@ -67,6 +68,10 @@ function issueMessage(issue: ValidationIssue): string {
|
||||
return "กรุณาตรวจสอบข้อมูลให้ถูกต้อง";
|
||||
}
|
||||
|
||||
export function formatGuideValidationIssues(section: GuideValidationSection, issues: readonly ValidationIssue[]): string[] {
|
||||
return issues.map((issue) => `${issueContext(section, issue.path)}: ${issueMessage(issue)}`);
|
||||
export function formatGuideValidationIssues(
|
||||
section: GuideValidationSection,
|
||||
issues: readonly ValidationIssue[],
|
||||
options: { teamNames?: readonly string[] } = {},
|
||||
): string[] {
|
||||
return issues.map((issue) => `${issueContext(section, issue.path, options.teamNames)}: ${issueMessage(issue)}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user