sendou.ink/app/features/builds/BuildRepository.server.test.ts
Kalle 8dc92140fc
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled
Optimize builds loaders (#3076)
2026-05-17 16:21:13 +03:00

382 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { db } from "~/db/sql";
import type {
BuildAbilitiesTuple,
MainWeaponId,
} from "~/modules/in-game-lists/types";
import { dbInsertUsers, dbReset } from "~/utils/Test";
import * as AdminRepository from "../admin/AdminRepository.server";
import * as BuildRepository from "./BuildRepository.server";
const OWNER_ID = 1;
// Splattershot (40) is the canonical base, Hero Shot Replica (45) is an alt skin
// that should be folded to 40 by the canonical id mapping.
const SPLATTERSHOT: MainWeaponId = 40;
const HERO_SHOT_REPLICA: MainWeaponId = 45;
const SPLATTERSHOT_NOUVEAU: MainWeaponId = 41;
// Head ["ISM", "ISM", "ISS", "ISS"]: ISM main+sub = 13, ISS sub+sub = 6
// Clothes ["ISS", "ISM", "ISS", "ISM"]: ISS main+sub = 13, ISM sub+sub = 6
// Shoes ["ISM", "ISM", "ISM", "ISM"]: ISM main+3 subs = 19
// Totals: ISM = 38, ISS = 19 (MAIN_SLOT_AP=10, SUB_SLOT_AP=3)
const ABILITIES: BuildAbilitiesTuple = [
["ISM", "ISM", "ISS", "ISS"],
["ISS", "ISM", "ISS", "ISM"],
["ISM", "ISM", "ISM", "ISM"],
];
const EXPECTED_SIGNATURE = "ISM_38,ISS_19";
const baseArgs = (
overrides: Partial<Parameters<typeof BuildRepository.create>[0]> = {},
): Parameters<typeof BuildRepository.create>[0] => ({
ownerId: OWNER_ID,
title: "Test Build",
description: null,
modes: null,
headGearSplId: null,
clothesGearSplId: null,
shoesGearSplId: null,
weaponSplIds: [SPLATTERSHOT],
abilities: ABILITIES,
private: 0,
...overrides,
});
const insertSplatoonPlayer = async (userId: number, splId: string) => {
const { id } = await db
.insertInto("SplatoonPlayer")
.values({ splId, userId })
.returning("id")
.executeTakeFirstOrThrow();
return id;
};
const insertXRankPlacement = async (
playerId: number,
weaponSplId: MainWeaponId,
rank: number,
) => {
await db
.insertInto("XRankPlacement")
.values({
playerId,
weaponSplId,
badges: "[]",
bannerSplId: 1,
mode: "SZ",
month: 1,
year: 2024,
name: "Test Player",
nameDiscriminator: "0000",
power: 2500,
rank,
region: "WEST",
title: "Test",
})
.execute();
};
const buildById = (id: number) =>
db
.selectFrom("Build")
.select(["abilitiesSignature", "private"])
.where("id", "=", id)
.executeTakeFirstOrThrow();
const buildWeaponsByBuildId = (buildId: number) =>
db
.selectFrom("BuildWeapon")
.select(["weaponSplId", "canonicalWeaponSplId", "sortValue"])
.where("buildId", "=", buildId)
.orderBy("weaponSplId", "asc")
.execute();
const buildAbilitySumsByBuildId = (buildId: number) =>
db
.selectFrom("BuildAbilitySum")
.select(["ability", "abilityPoints"])
.where("buildId", "=", buildId)
.execute();
const buildWeaponAbilitiesByBuildId = (buildId: number) =>
db
.selectFrom("BuildWeaponAbility")
.select(["canonicalWeaponSplId", "ability", "abilityPoints"])
.where("buildId", "=", buildId)
.execute();
const onlyBuildId = async () => {
const row = await db
.selectFrom("Build")
.select("id")
.executeTakeFirstOrThrow();
return row.id;
};
describe("BuildRepository.create — computeBuildData", () => {
beforeEach(async () => {
await dbInsertUsers(2);
});
afterEach(() => {
dbReset();
});
describe("abilitiesSignature & ability sums", () => {
test("writes the serialized abilitiesSignature sorted by AP desc", async () => {
await BuildRepository.create(baseArgs());
const build = await buildById(await onlyBuildId());
expect(build.abilitiesSignature).toBe(EXPECTED_SIGNATURE);
});
test("inserts one BuildAbilitySum row per distinct ability with summed AP", async () => {
await BuildRepository.create(baseArgs());
const sums = await buildAbilitySumsByBuildId(await onlyBuildId());
expect(sums).toHaveLength(2);
expect(sums).toContainEqual({ ability: "ISM", abilityPoints: 38 });
expect(sums).toContainEqual({ ability: "ISS", abilityPoints: 19 });
});
test("does not insert BuildAbilitySum rows for private builds", async () => {
await BuildRepository.create(baseArgs({ private: 1 }));
const sums = await buildAbilitySumsByBuildId(await onlyBuildId());
expect(sums).toHaveLength(0);
});
test("still writes abilitiesSignature for private builds", async () => {
await BuildRepository.create(baseArgs({ private: 1 }));
const build = await buildById(await onlyBuildId());
expect(build.abilitiesSignature).toBe(EXPECTED_SIGNATURE);
});
});
describe("BuildWeaponAbility rows", () => {
test("inserts one row per weapon × ability for public builds", async () => {
await BuildRepository.create(
baseArgs({ weaponSplIds: [SPLATTERSHOT, SPLATTERSHOT_NOUVEAU] }),
);
const rows = await buildWeaponAbilitiesByBuildId(await onlyBuildId());
expect(rows).toHaveLength(4);
expect(rows).toContainEqual({
canonicalWeaponSplId: SPLATTERSHOT,
ability: "ISM",
abilityPoints: 38,
});
expect(rows).toContainEqual({
canonicalWeaponSplId: SPLATTERSHOT_NOUVEAU,
ability: "ISS",
abilityPoints: 19,
});
});
test("folds alt skins to their canonical weapon id", async () => {
await BuildRepository.create(
baseArgs({ weaponSplIds: [HERO_SHOT_REPLICA] }),
);
const rows = await buildWeaponAbilitiesByBuildId(await onlyBuildId());
const weaponIds = new Set(rows.map((r) => r.canonicalWeaponSplId));
expect(weaponIds).toEqual(new Set([SPLATTERSHOT]));
});
test("does not insert any rows for private builds", async () => {
await BuildRepository.create(baseArgs({ private: 1 }));
const rows = await buildWeaponAbilitiesByBuildId(await onlyBuildId());
expect(rows).toHaveLength(0);
});
});
describe("BuildWeapon.canonicalWeaponSplId", () => {
test("stores the canonical id alongside the original weaponSplId", async () => {
await BuildRepository.create(
baseArgs({ weaponSplIds: [HERO_SHOT_REPLICA] }),
);
const weapons = await buildWeaponsByBuildId(await onlyBuildId());
expect(weapons).toHaveLength(1);
expect(weapons[0].weaponSplId).toBe(HERO_SHOT_REPLICA);
expect(weapons[0].canonicalWeaponSplId).toBe(SPLATTERSHOT);
});
});
describe("sortValue", () => {
test("defaults to tier 4 (sortValue = 9) when owner has no PlusTier", async () => {
await BuildRepository.create(baseArgs());
const [weapon] = await buildWeaponsByBuildId(await onlyBuildId());
expect(weapon.sortValue).toBe(9);
});
test("uses owner's PlusTier (tier 2 → sortValue = 5)", async () => {
await AdminRepository.replacePlusTiers([
{ userId: OWNER_ID, plusTier: 2 },
]);
await BuildRepository.create(baseArgs());
const [weapon] = await buildWeaponsByBuildId(await onlyBuildId());
expect(weapon.sortValue).toBe(5);
});
test("is null for private builds regardless of tier", async () => {
await AdminRepository.replacePlusTiers([
{ userId: OWNER_ID, plusTier: 1 },
]);
await BuildRepository.create(baseArgs({ private: 1 }));
const [weapon] = await buildWeaponsByBuildId(await onlyBuildId());
expect(weapon.sortValue).toBeNull();
});
test("subtracts 1 when the weapon is top500 for the owner", async () => {
const playerId = await insertSplatoonPlayer(OWNER_ID, "owner-spl-id");
await insertXRankPlacement(playerId, SPLATTERSHOT, 1);
await BuildRepository.create(
baseArgs({ weaponSplIds: [SPLATTERSHOT, SPLATTERSHOT_NOUVEAU] }),
);
const weapons = await buildWeaponsByBuildId(await onlyBuildId());
const splattershot = weapons.find((w) => w.weaponSplId === SPLATTERSHOT);
const nouveau = weapons.find(
(w) => w.weaponSplId === SPLATTERSHOT_NOUVEAU,
);
expect(splattershot?.sortValue).toBe(8);
expect(nouveau?.sortValue).toBe(9);
});
test("combines top500 with the owner's PlusTier", async () => {
await AdminRepository.replacePlusTiers([
{ userId: OWNER_ID, plusTier: 1 },
]);
const playerId = await insertSplatoonPlayer(OWNER_ID, "owner-spl-id");
await insertXRankPlacement(playerId, SPLATTERSHOT, 1);
await BuildRepository.create(baseArgs());
const [weapon] = await buildWeaponsByBuildId(await onlyBuildId());
expect(weapon.sortValue).toBe(2);
});
});
test("allByWeaponId.weapons[].isTop500 matches the sortValue formula", async () => {
const playerId = await insertSplatoonPlayer(OWNER_ID, "owner-spl-id");
await insertXRankPlacement(playerId, SPLATTERSHOT, 1);
await BuildRepository.create(
baseArgs({ weaponSplIds: [SPLATTERSHOT, SPLATTERSHOT_NOUVEAU] }),
);
const [build] = await BuildRepository.allByWeaponId(SPLATTERSHOT, {
limit: 10,
});
const splattershot = build.weapons.find(
(w) => w.weaponSplId === SPLATTERSHOT,
);
const nouveau = build.weapons.find(
(w) => w.weaponSplId === SPLATTERSHOT_NOUVEAU,
);
expect(splattershot?.isTop500).toBe(1);
expect(nouveau?.isTop500).toBe(0);
});
test("a multi-weapon build is returned by allByWeaponId for each of its weapons", async () => {
await BuildRepository.create(
baseArgs({
title: "Multi-weapon Build",
weaponSplIds: [SPLATTERSHOT, SPLATTERSHOT_NOUVEAU],
}),
);
const splattershotBuilds = await BuildRepository.allByWeaponId(
SPLATTERSHOT,
{ limit: 10 },
);
const nouveauBuilds = await BuildRepository.allByWeaponId(
SPLATTERSHOT_NOUVEAU,
{ limit: 10 },
);
expect(splattershotBuilds).toHaveLength(1);
expect(splattershotBuilds[0].title).toBe("Multi-weapon Build");
expect(nouveauBuilds).toHaveLength(1);
expect(nouveauBuilds[0].id).toBe(splattershotBuilds[0].id);
});
});
describe("BuildRepository.popularAbilitiesByWeaponId", () => {
// All SS: each gear sums to 10 (main) + 3*3 (subs) = 19, total 57.
const SS_ABILITIES: BuildAbilitiesTuple = [
["SS", "SS", "SS", "SS"],
["SS", "SS", "SS", "SS"],
["SS", "SS", "SS", "SS"],
];
beforeEach(async () => {
await dbInsertUsers(2);
});
afterEach(() => {
dbReset();
});
test("counts each user at most once across signature buckets", async () => {
// Each user has two Splattershot builds with different signatures.
// Without per-user dedup, both users would inflate both buckets and
// the total count across rows would be 4 instead of <=2.
await BuildRepository.create(baseArgs({ ownerId: 1 }));
await BuildRepository.create(
baseArgs({ ownerId: 1, abilities: SS_ABILITIES }),
);
await BuildRepository.create(baseArgs({ ownerId: 2 }));
await BuildRepository.create(
baseArgs({ ownerId: 2, abilities: SS_ABILITIES }),
);
const rows = await BuildRepository.popularAbilitiesByWeaponId(SPLATTERSHOT);
const totalCount = rows.reduce((acc, row) => acc + row.count, 0);
expect(totalCount).toBeLessThanOrEqual(2);
expect(rows.every((row) => row.count <= 2)).toBe(true);
});
test("only counts public builds", async () => {
await BuildRepository.create(baseArgs({ ownerId: 1 }));
await BuildRepository.create(baseArgs({ ownerId: 2, private: 1 }));
const rows = await BuildRepository.popularAbilitiesByWeaponId(SPLATTERSHOT);
// only one user with a public build → filtered by HAVING count > 1
expect(rows).toHaveLength(0);
});
test("folds alt skins via canonicalWeaponSplId", async () => {
await BuildRepository.create(baseArgs({ ownerId: 1 }));
await BuildRepository.create(
baseArgs({ ownerId: 2, weaponSplIds: [HERO_SHOT_REPLICA] }),
);
const rows = await BuildRepository.popularAbilitiesByWeaponId(SPLATTERSHOT);
expect(rows).toEqual([
{ abilitiesSignature: EXPECTED_SIGNATURE, count: 2 },
]);
// the alt-skin id alone should also resolve to the same canonical bucket
const altRows =
await BuildRepository.popularAbilitiesByWeaponId(HERO_SHOT_REPLICA);
expect(altRows).toEqual(rows);
});
});