feat(team-calc) : add character-wide team import

This commit is contained in:
2026-10-06 02:53:10 +07:00 Unverified
parent c856904c12
commit c851693854
6 changed files with 447 additions and 92 deletions
+31 -2
View File
@@ -26,10 +26,10 @@ vi.mock("@/db", () => ({
import {
artifactConditions, artifactProfiles, constellationConditions, extraSectionRows,
guides, guideSections, teamConditions, weaponConditions,
guides, guideSections, teamCompositions, teamConditions, teamMembers, weaponConditions,
catalogMaterials, guidePosterBleeding, media,
} from "@/db/schema";
import { getAdminGuideContent } from "./queries";
import { getAdminGuideContent, listAdminTeamImportsForCharacter } from "./queries";
beforeEach(() => {
mocks.reads.length = 0;
@@ -102,3 +102,32 @@ describe("admin section reads", () => {
expect(mocks.reads.filter((table) => table === media)).toHaveLength(1);
});
});
describe("team import lookup", () => {
it("hydrates matching teams and source labels with one member read", async () => {
mocks.rows.set(teamCompositions, [{
id: "team-1", title: "Example", rotationSeconds: "20", combo: "E > Q",
guideName: "Other Guide", conditionName: "Boss",
}]);
mocks.rows.set(teamMembers, [{
member: {
teamId: "team-1", characterKey: "character-1", weaponKey: "weapon-1",
artifactKey: "artifact-1", secondArtifactKey: null,
constellation: "2", refinement: "1", damage: "100",
},
characterName: "Character One",
}]);
const result = await listAdminTeamImportsForCharacter("current-guide", "character-1");
expect(result).toEqual([{
id: "team-1", title: "Example", rotationSeconds: "20", combo: "E > Q",
guideName: "Other Guide", conditionName: "Boss",
members: [{
characterKey: "character-1", characterName: "Character One", weaponKey: "weapon-1",
artifactKey: "artifact-1", secondArtifactKey: null,
constellation: "2", refinement: "1", damage: "100",
}],
}]);
expect(mocks.reads).toEqual([teamCompositions, teamMembers]);
});
});
+58
View File
@@ -200,6 +200,64 @@ export async function listAdminTeamImportGuides(excludeGuideId: string) {
}));
}
export async function listAdminTeamImportsForCharacter(
excludeGuideId: string,
characterKey: string,
) {
const matches = await getDb()
.select({
id: teamCompositions.id,
title: teamCompositions.title,
rotationSeconds: teamCompositions.rotationSeconds,
combo: teamCompositions.combo,
guideName: guides.name,
conditionName: teamConditions.name,
})
.from(teamCompositions)
.innerJoin(guides, eq(guides.id, teamCompositions.guideId))
.innerJoin(teamMembers, eq(teamMembers.teamId, teamCompositions.id))
.leftJoin(teamConditions, eq(teamConditions.id, teamCompositions.conditionId))
.where(and(
eq(teamMembers.characterKey, characterKey),
ne(guides.id, excludeGuideId),
isNull(guides.trashedAt),
))
.orderBy(asc(guides.name), asc(guides.id), asc(teamConditions.sortOrder), asc(teamCompositions.sortOrder), asc(teamCompositions.id));
if (!matches.length) return [];
const members = await getDb()
.select({ member: teamMembers, characterName: catalogCharacters.name })
.from(teamMembers)
.leftJoin(catalogCharacters, eq(catalogCharacters.key, teamMembers.characterKey))
.where(inArray(teamMembers.teamId, matches.map((team) => team.id)))
.orderBy(asc(teamMembers.position));
const membersByTeam = new Map<string, Array<{
characterKey: string;
characterName: string;
weaponKey: string | null;
artifactKey: string | null;
secondArtifactKey: string | null;
constellation: string;
refinement: string;
damage: string;
}>>();
for (const { member, characterName } of members) {
const group = membersByTeam.get(member.teamId) ?? [];
group.push({
characterKey: member.characterKey,
characterName: characterName ?? member.characterKey,
weaponKey: member.weaponKey,
artifactKey: member.artifactKey,
secondArtifactKey: member.secondArtifactKey,
constellation: member.constellation,
refinement: member.refinement,
damage: member.damage,
});
membersByTeam.set(member.teamId, group);
}
return matches.map((team) => ({ ...team, members: membersByTeam.get(team.id) ?? [] }));
}
export async function getAdminTeamImportGuide(guideId: string) {
const [guide] = await getDb()
.select({ id: guides.id, name: guides.name })
+7
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
applyTeamFields,
cloneTeamImports,
filterTeamImportsByName,
findInvalidTeamCatalogReference,
findUnsupportedTeamWeapon,
limitPresets,
@@ -160,6 +161,12 @@ describe("team bulk tools", () => {
expect(imported[0].members[0]).not.toBe(source.members[0]);
});
it("ignores existing and repeated names unless duplicate filtering is disabled", () => {
const candidates = [team("one", " Alpha "), team("two", "Beta"), team("three", "BETA")];
expect(filterTeamImportsByName([team("current", "alpha")], candidates, true).map((item) => item.clientId)).toEqual(["two"]);
expect(filterTeamImportsByName([team("current", "alpha")], candidates, false)).toEqual(candidates);
});
it("rejects malformed and unsupported team transfers", () => {
expect(() => parseTeamTransfer("not-json")).toThrow();
expect(() => parseTeamTransfer(JSON.stringify({
+15
View File
@@ -125,6 +125,21 @@ export function cloneTeamImports(
}));
}
export function filterTeamImportsByName<T extends { title: string }>(
currentTeams: readonly { title: string }[],
candidates: readonly T[],
ignoreSameName: boolean,
): T[] {
if (!ignoreSameName) return [...candidates];
const names = new Set(currentTeams.map((team) => team.title.trim().toLocaleLowerCase()));
return candidates.filter((team) => {
const name = team.title.trim().toLocaleLowerCase();
if (names.has(name)) return false;
names.add(name);
return true;
});
}
export function findInvalidTeamCatalogReference(
team: Omit<TeamToolValue, "clientId">,
characters: readonly TeamToolCatalogItem[],