diff --git a/.env.example b/.env.example index f16b38698..3cce938f2 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,6 @@ SKALOP_TOKEN=secret VITE_SITE_DOMAIN=http://localhost:5173 VITE_SKALOP_WS_URL=ws://localhost:5900 + +// trunc, full or none (default: none) +SQL_LOG=trunc diff --git a/.env.test b/.env.test new file mode 100644 index 000000000..1f705b00d --- /dev/null +++ b/.env.test @@ -0,0 +1,8 @@ +DB_PATH=db-test-active.sqlite3 + +SQL_LOG=none + +BASE_URL=https://example.com + +SKALOP_SYSTEM_MESSAGE_URL=http://skalop.test +SKALOP_TOKEN=test diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c3c26c005..8a57ed350 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,25 +11,17 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - - name: Setup Node.js - uses: actions/setup-node@v2 + - uses: oven-sh/setup-bun@v2 with: - node-version-file: ".nvmrc" - - - name: Cache dependencies - uses: actions/cache@v2 - with: - path: ~/.npm - key: npm-${{ hashFiles('package-lock.json') }} - restore-keys: npm- + bun-version: latest - name: Install dependencies - run: npm ci --ignore-scripts - + run: bun install --frozen-lockfile - name: Formatter/Linter - run: npm run biome:check + run: bun run biome:check - name: Typecheck - run: npm run typecheck + run: bun run typecheck + - name: Unit tests + run: bun run test:unit:all - name: Check translations jsons - run: npm run check-translation-jsons:no-write + run: bun run check-translation-jsons:no-write diff --git a/.gitignore b/.gitignore index a5094bab3..387cd86d8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,4 @@ dump /playwright-report/ /playwright/.cache/ -newrelic_agent.log - .vscode diff --git a/README.md b/README.md index 1cf910e4a..5e2ab2ed0 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,10 @@ Competitive Splatoon Platform - React - Remix - Sqlite3 +- Bun - CSS (plain) - E2E tests via Playwright -- Unit/integration tests via uvu +- Unit/integration tests via bun:test ## Screenshots @@ -61,11 +62,12 @@ Prerequisites: [nvm](https://github.com/nvm-sh/nvm) There is a sequence of commands you need to run: 1. `nvm use` to switch to the correct Node version. If you don't have the correct Node.js version yet it will prompt you to install it via the `nvm install` command. If you have problems with nvm you can also install the latest LTS version of Node.js from [their website](https://nodejs.org/en/). -2. `npm i` to install the dependencies. -3. Make a copy of `.env.example` that's called `.env`. Filling additional values is not necessary unless you want to use real Discord authentication or develop the Lohi bot. -4. `npm run migrate up` to set up the database tables. -5. `npm run dev` to run the project in development mode. -6. Navigate to `http://localhost:5173/admin`. There press the seed button to fill the DB with test data. You can also impersonate any user (Sendou#0043 = admin). +2. Install latest version of [Bun](https://bun.sh/docs/installation) +3. `bun install` to install the dependencies. +4. Make a copy of `.env.example` that's called `.env`. Filling additional values is not necessary unless you want to use real Discord authentication or develop the Lohi bot. +5. `bun migrate up` to set up the database tables. +6. `bun run dev` to run the project in development mode. +7. Navigate to `http://localhost:5173/admin`. There press the seed button to fill the DB with test data. You can also impersonate any user (Sendou#0043 = admin). ## Contributing @@ -114,6 +116,12 @@ Any questions please ask Sendou! 7. Send the file to Sendou (or open a pull request if you know how) 8. Optional: also send an image as .png if you want to show a link preview. The preferred dimensions are 1200 × 630. +## SQL Logging + +By default SQL is logged in truncated format. You can adjust this by changing the `SQL_LOG` env var. Possible values are "trunc", "full" and "none". + +Note it only logs queries made via Kysely. + ## API If you want to use the API then please leave an issue explaining your use case. By default, I want to allow open use of the data on the site. It's just not recommended to use the same APIs the web pages use as they are not stable at all and can change at any time without warning. @@ -160,25 +168,25 @@ Some common files: ### Update friend code ```bash -npx tsx scripts/update-fc.ts 79237403620945920 1234-1234-1234 +bun scripts/update-fc.ts 79237403620945920 1234-1234-1234 ``` ### Add new badge to the database ```bash -npx tsx scripts/add-badge.ts fire_green "Octofin Eliteboard" +bun scripts/add-badge.ts fire_green "Octofin Eliteboard" ``` ### Rename display name of a badge ```bash -npx tsx scripts/rename-badge.ts 10 "New 4v4 Sundaes" +bun scripts/rename-badge.ts 10 "New 4v4 Sundaes" ``` ### Add many badge owners ```bash -npx tsx scripts/add-badge-winners.ts 10 "750705955909664791,79237403620945920" +bun scripts/add-badge-winners.ts 10 "750705955909664791,79237403620945920" ``` ### Converting gifs (badges) to thumbnail (.png) @@ -220,19 +228,19 @@ Note: This is only useful if you have access to a production running on Render.c 4. Update `CURRENT_PATCH` constants 5. Update `PATCHES` constant with the late patch + remove the oldest 6. Update the stage list in `stage-ids.ts` and `create-misc-json.ts`. Add images from Lean's repository and avify them. -7. `npx tsx scripts/create-misc-json.ts` -8. `npx tsx scripts/create-gear-json.ts` -9. `npx tsx scripts/create-analyzer-json.ts` +7. `bun scripts/create-misc-json.ts` +8. `bun scripts/create-gear-json.ts` +9. `bun scripts/create-analyzer-json.ts` 8a. Double check that no hard-coded special damages changed -10. `npx tsx scripts/create-object-dmg-json.ts` +10. `bun scripts/create-object-dmg-json.ts` 11. Fill new weapon IDs by category to `weapon-ids.ts` (easy to take from the diff of English weapons.json) 12. Get gear IDs for each slot from /output folder and update `gear-ids.ts`. 13. Replace `object-dmg.json` with the `object-dmg.json` in /output folder 14. Replace `weapon-params.ts` with the `params.json` in /output folder 15. Delete all images inside `main-weapons`, `main-weapons-outlined`, `main-weapons-outlined-2` and `gear` folders. 16. Replace with images from Lean's repository. -17. Run the `npx tsx scripts/replace-img-names.ts` command -18. Run the `npx tsx scripts/replace-weapon-names.ts` command +17. Run the `bun scripts/replace-img-names.ts` command +18. Run the `bun scripts/replace-weapon-names.ts` command 19. Run the .avif generating command in each image folder. 20. Update manually any languages that use English `gear.json` and `weapons.json` files @@ -241,7 +249,7 @@ Note: This is only useful if you have access to a production running on Render.c If you change any files and the CI pipeline errors out on certain formatting/linting steps (Biome) run this command in the repo's root directory: ```sh -npm run cf +bun cf ``` Before committing, if for some reason you see an abnormally high amount of files changed, simply run `git add --renormalize .` and it will fix the error. diff --git a/app/components/FormErrors.tsx b/app/components/FormErrors.tsx index f4661b4cf..adc453b2e 100644 --- a/app/components/FormErrors.tsx +++ b/app/components/FormErrors.tsx @@ -1,11 +1,11 @@ import { useActionData } from "@remix-run/react"; -import type { CustomTypeOptions } from "react-i18next"; import { useTranslation } from "react-i18next"; +import type { Namespace } from "~/modules/i18n/resources.server"; export function FormErrors({ namespace, }: { - namespace: keyof CustomTypeOptions["resources"]; + namespace: Namespace; }) { const { t } = useTranslation(["common", namespace]); const actionData = useActionData<{ errors?: string[] }>(); diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 41acb4c55..9078e690e 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -725,7 +725,7 @@ function calendarEvents() { description: faker.lorem.paragraph(), discordInviteCode: faker.lorem.word(), bracketUrl: faker.internet.url(), - authorId: id === 1 ? NZAP_TEST_ID : userIds.pop(), + authorId: id === 1 ? NZAP_TEST_ID : userIds.pop() ?? null, tags: Math.random() > 0.2 ? shuffledTags @@ -1835,7 +1835,7 @@ function arts() { return faker.image.url(); } - return urls.pop(); + return urls.pop() ?? null; }; const addedArt = addArtStm.get({ @@ -1859,7 +1859,7 @@ function arts() { ) { addArtUserMetadataStm.run({ artId: addedArt.id, - userId: i === 0 ? NZAP_TEST_ID : allUsers.pop(), + userId: i === 0 ? NZAP_TEST_ID : allUsers.pop() ?? null, }); } } @@ -2026,7 +2026,6 @@ async function playedMatches() { invariant(groupAlpha !== 0 && groupBravo !== 0, "groups not created"); - // @ts-expect-error creating without memento on purpose const match = createMatch({ alphaGroupId: groupAlpha, bravoGroupId: groupBravo, diff --git a/app/db/sql.ts b/app/db/sql.ts index 235b720ea..915317e0c 100644 --- a/app/db/sql.ts +++ b/app/db/sql.ts @@ -1,32 +1,87 @@ -import Database from "better-sqlite3"; -import { Kysely, ParseJSONResultsPlugin, SqliteDialect } from "kysely"; +import { Database } from "bun:sqlite"; +import { styleText } from "node:util"; +import { Kysely, type LogEvent, ParseJSONResultsPlugin } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite"; +import { format } from "sql-formatter"; import invariant from "~/utils/invariant"; +import { roundToNDecimalPlaces } from "~/utils/number"; import type { DB } from "./tables"; -const migratedEmptyDb = new Database("db-test.sqlite3").serialize(); - -invariant(process.env.DB_PATH, "DB_PATH env variable must be set"); -const isInMemoryDB = process.env.DB_PATH === ":memory:"; - -export const sql = new Database( - isInMemoryDB ? migratedEmptyDb : process.env.DB_PATH, +const LOG_LEVEL = (["trunc", "full", "none"] as const).find( + (val) => val === process.env.SQL_LOG, ); -sql.pragma("journal_mode = WAL"); -sql.pragma("foreign_keys = ON"); -sql.pragma("busy_timeout = 5000"); +invariant(process.env.DB_PATH, "DB_PATH env variable must be set"); + +export const sql = new Database(process.env.DB_PATH, { + strict: true, +}); + +sql.exec("PRAGMA journal_mode = WAL;"); +sql.exec("PRAGMA foreign_keys = ON;"); +sql.exec("PRAGMA busy_timeout = 5000;"); export const db = new Kysely({ - dialect: new SqliteDialect({ + dialect: new BunSqliteDialect({ database: sql, }), - // uncomment if you want examine the queries - // log: process.env.NODE_ENV === "development" ? ["query"] : undefined, - // log(event): void { - // if (event.level === "query") { - // console.log(event.query.sql); - // console.log(event.query.parameters); - // } - // }, + log: LOG_LEVEL === "trunc" || LOG_LEVEL === "full" ? logQuery : undefined, plugins: [new ParseJSONResultsPlugin()], }); + +function logQuery(event: LogEvent) { + const isSelectQuery = Boolean((event.query.query as any).from?.froms); + + if (event.level === "query" && isSelectQuery) { + const from = () => + (event.query.query as any).from.froms.map( + (f: any) => f.table.identifier.name, + ); + // biome-ignore lint/suspicious/noConsoleLog: dev only + console.log(styleText("blue", `-- SQLITE QUERY to "${from()}" --`)); + // biome-ignore lint/suspicious/noConsoleLog: dev only + console.log( + styleText( + millisToColor(event.queryDurationMillis), + `${roundToNDecimalPlaces(event.queryDurationMillis, 1)}ms`, + ), + ); + // biome-ignore lint/suspicious/noConsoleLog: dev only + console.log(formatSql(event.query.sql, event.query.parameters)); + } +} + +function millisToColor(millis: number) { + if (millis < 1) { + return "bgGreen"; + } + if (millis < 5) { + return "green"; + } + if (millis < 50) { + return "yellow"; + } + return "red"; +} + +function formatSql(sql: string, params: readonly unknown[]) { + const formatted = format(sql); + + const lines = formatted.split("\n"); + + if (LOG_LEVEL === "full" || lines.length <= 11) { + return addParams(formatted, params); + } + + const linesNotShown = lines.length - 10; + + return `${lines.slice(0, 10).join("\n")}\n... (${linesNotShown} more lines) ...\n`; +} + +function addParams(sql: string, params: readonly unknown[]) { + const coloredParams = params.map((param) => + styleText("yellow", JSON.stringify(param)), + ); + + return sql.replace(/\?/g, () => coloredParams.shift() || ""); +} diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 8f8d284d5..72a108cf1 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,167 +1,79 @@ import { PassThrough } from "node:stream"; - import { - type ActionFunctionArgs, type EntryContext, - type LoaderFunctionArgs, createReadableStreamFromReadable, } from "@remix-run/node"; import { RemixServer } from "@remix-run/react"; +import { createInstance } from "i18next"; import { isbot } from "isbot"; import cron from "node-cron"; import { renderToPipeableStream } from "react-dom/server"; -import { I18nextProvider } from "react-i18next"; -import { getUser } from "./features/auth/core/user.server"; -import { i18Instance } from "./modules/i18n/loader.server"; +import { I18nextProvider, initReactI18next } from "react-i18next"; +import { config } from "~/modules/i18n/config"; // your i18n configuration file +import i18next from "~/modules/i18n/i18next.server"; +import { resources } from "./modules/i18n/resources.server"; import { updatePatreonData } from "./modules/patreon"; -import { noticeError, setTransactionName } from "./utils/newrelic.server"; const ABORT_DELAY = 5000; -const handleRequest = ( +export default async function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext, -) => { - const userAgent = request.headers.get("user-agent"); - - const lastMatch = - remixContext.staticHandlerContext.matches[ - remixContext.staticHandlerContext.matches.length - 1 - ]; - - if (lastMatch) setTransactionName(`ssr/${lastMatch.route.id}`); - - return userAgent && isbot(userAgent) - ? handleBotRequest( - request, - responseStatusCode, - responseHeaders, - remixContext, - ) - : handleBrowserRequest( - request, - responseStatusCode, - responseHeaders, - remixContext, - ); -}; -export default handleRequest; - -export function handleDataRequest( - response: Response, - { request }: LoaderFunctionArgs | ActionFunctionArgs, ) { - const name = new URL(request.url).searchParams.get("_data"); - if (name) setTransactionName(name); + const callbackName = isbot(request.headers.get("user-agent")) + ? "onAllReady" + : "onShellReady"; - return response; -} + const instance = createInstance(); + const lng = await i18next.getLocale(request); + const ns = i18next.getRouteNamespaces(remixContext); -const handleBotRequest = ( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, -) => - new Promise((resolve, reject) => { + await instance + .use(initReactI18next) // Tell our instance to use react-i18next + .init({ + ...config, // spread the configuration + lng, // The locale we detected above + ns, // The namespaces the routes about to render wants to use + resources, + }); + + return new Promise((resolve, reject) => { let didError = false; - void i18Instance(request, remixContext).then((i18n) => { - const { pipe, abort } = renderToPipeableStream( - - - , - { - onAllReady: () => { - const body = new PassThrough(); + const { pipe, abort } = renderToPipeableStream( + + + , + { + [callbackName]: () => { + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + responseHeaders.set("Content-Type", "text/html"); - responseHeaders.set("Content-Type", "text/html"); + resolve( + new Response(stream, { + headers: responseHeaders, + status: didError ? 500 : responseStatusCode, + }), + ); - resolve( - new Response(createReadableStreamFromReadable(body), { - headers: responseHeaders, - status: didError ? 500 : responseStatusCode, - }), - ); - - pipe(body); - }, - onShellError: (error: unknown) => { - reject(error); - }, - onError: (error: unknown) => { - didError = true; - - console.error(error); - }, + pipe(body); }, - ); - - setTimeout(abort, ABORT_DELAY); - }); - }); - -const handleBrowserRequest = ( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, -) => - new Promise((resolve, reject) => { - let didError = false; - - void i18Instance(request, remixContext).then((i18n) => { - const { pipe, abort } = renderToPipeableStream( - - - , - { - onShellReady: () => { - const body = new PassThrough(); - - responseHeaders.set("Content-Type", "text/html"); - - resolve( - new Response(createReadableStreamFromReadable(body), { - headers: responseHeaders, - status: didError ? 500 : responseStatusCode, - }), - ); - - pipe(body); - }, - onShellError: (error: unknown) => { - reject(error); - }, - onError: (error: unknown) => { - didError = true; - - console.error(error); - }, + onShellError(error: unknown) { + reject(error); }, - ); + onError(error: unknown) { + didError = true; - setTimeout(abort, ABORT_DELAY); - }); + console.error(error); + }, + }, + ); + + setTimeout(abort, ABORT_DELAY); }); - -export async function handleError( - error: unknown, - { request }: LoaderFunctionArgs | ActionFunctionArgs, -) { - const user = await getUser(request); - if (!request.signal.aborted) { - if (error instanceof Error) { - noticeError(error, { - "enduser.id": user?.id, - // TODO: FetchError: Invalid response body while trying to fetch http://localhost:5800/admin?_data=features%2Fadmin%2Froutes%2Fadmin: This stream has already been locked for exclusive reading by another reader - // formData: JSON.stringify(formDataToObject(await request.formData())), - }); - } - console.error(error); - } } // example from https://github.com/BenMcH/remix-rss/blob/main/app/entry.server.tsx diff --git a/app/features/admin/routes/admin.test.ts b/app/features/admin/routes/admin.test.ts index 93f726d5d..14dbffb2c 100644 --- a/app/features/admin/routes/admin.test.ts +++ b/app/features/admin/routes/admin.test.ts @@ -1,16 +1,12 @@ -import MockDate from "mockdate"; -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import { afterEach, describe, expect, setSystemTime, test } from "bun:test"; import { db } from "~/db/sql"; import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server"; -import * as Test from "~/utils/Test"; +import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import type { adminActionSchema } from "../actions/admin.server"; import { action } from "./admin"; -const PlusVoting = suite("Plus voting"); - -const adminAction = Test.wrappedAction({ action }); +const adminAction = wrappedAction({ action }); const voteArgs = ({ score, @@ -57,75 +53,74 @@ const createLeaderboard = (userIds: number[]) => ) .execute(); -PlusVoting.after.each(() => { - MockDate.reset(); - Test.database.reset(); -}); +describe("Plus voting", () => { + afterEach(() => { + setSystemTime(); + dbReset(); + }); -PlusVoting("gives correct amount of plus tiers", async () => { - MockDate.set(new Date("2023-12-12T00:00:00.000Z")); + test("gives correct amount of plus tiers", async () => { + setSystemTime(new Date("2023-12-12T00:00:00.000Z")); - await Test.database.insertUsers(10); - await PlusVotingRepository.upsertMany( - Array.from({ length: 10 }).map((_, i) => { - const id = i + 1; + await dbInsertUsers(10); + await PlusVotingRepository.upsertMany( + Array.from({ length: 10 }).map((_, i) => { + const id = i + 1; - return voteArgs({ - score: id <= 5 ? -1 : 1, - votedId: id, - }); - }), - ); + return voteArgs({ + score: id <= 5 ? -1 : 1, + votedId: id, + }); + }), + ); - await adminAction({ _action: "REFRESH" }, { user: "admin" }); + await adminAction({ _action: "REFRESH" }, { user: "admin" }); - assert.equal(await countPlusTierMembers(), 5); -}); + expect(await countPlusTierMembers()).toBe(5); + }); -PlusVoting("60% is the criteria to pass voting", async () => { - MockDate.set(new Date("2023-12-12T00:00:00.000Z")); + test("60% is the criteria to pass voting", async () => { + setSystemTime(new Date("2023-12-12T00:00:00.000Z")); - await Test.database.insertUsers(10); + await dbInsertUsers(10); - // 50% - await PlusVotingRepository.upsertMany( - Array.from({ length: 10 }).map((_, i) => { - return voteArgs({ - authorId: i + 1, - score: i < 5 ? -1 : 1, - votedId: 1, - }); - }), - ); - // 60% - await PlusVotingRepository.upsertMany( - Array.from({ length: 10 }).map((_, i) => { - return voteArgs({ - authorId: i + 1, - score: i < 4 ? -1 : 1, - votedId: 2, - }); - }), - ); + // 50% + await PlusVotingRepository.upsertMany( + Array.from({ length: 10 }).map((_, i) => { + return voteArgs({ + authorId: i + 1, + score: i < 5 ? -1 : 1, + votedId: 1, + }); + }), + ); + // 60% + await PlusVotingRepository.upsertMany( + Array.from({ length: 10 }).map((_, i) => { + return voteArgs({ + authorId: i + 1, + score: i < 4 ? -1 : 1, + votedId: 2, + }); + }), + ); - await adminAction({ _action: "REFRESH" }, { user: "admin" }); + await adminAction({ _action: "REFRESH" }, { user: "admin" }); - const rows = await db - .selectFrom("PlusTier") - .select(["PlusTier.tier", "PlusTier.userId"]) - .where("PlusTier.tier", "=", 1) - .execute(); + const rows = await db + .selectFrom("PlusTier") + .select(["PlusTier.tier", "PlusTier.userId"]) + .where("PlusTier.tier", "=", 1) + .execute(); - assert.equal(rows.length, 1); - assert.equal(rows[0].userId, 2); -}); + expect(rows.length).toBe(1); + expect(rows[0].userId).toBe(2); + }); -PlusVoting( - "combines leaderboard and voting results (after season over)", - async () => { - MockDate.set(new Date("2023-11-29T00:00:00.000Z")); + test("combines leaderboard and voting results (after season over)", async () => { + setSystemTime(new Date("2023-11-29T00:00:00.000Z")); - await Test.database.insertUsers(2); + await dbInsertUsers(2); await PlusVotingRepository.upsertMany([ voteArgs({ score: 1, @@ -136,16 +131,13 @@ PlusVoting( await adminAction({ _action: "REFRESH" }, { user: "admin" }); - assert.equal(await countPlusTierMembers(), 2); - }, -); + expect(await countPlusTierMembers()).toBe(2); + }); -PlusVoting( - "skips users from leaderboard with the skip flag for the season", - async () => { - MockDate.set(new Date("2023-11-29T00:00:00.000Z")); + test("skips users from leaderboard with the skip flag for the season", async () => { + setSystemTime(new Date("2023-11-29T00:00:00.000Z")); - await Test.database.insertUsers(11); + await dbInsertUsers(11); await createLeaderboard(Array.from({ length: 11 }).map((_, i) => i + 1)); await db @@ -156,66 +148,63 @@ PlusVoting( await adminAction({ _action: "REFRESH" }, { user: "admin" }); - assert.equal(await countPlusTierMembers(1), 10); - assert.equal(await countPlusTierMembers(2), 0); - }, -); + expect(await countPlusTierMembers(1)).toBe(10); + expect(await countPlusTierMembers(2)).toBe(0); + }); -PlusVoting("plus server skip flag ignored if for past season", async () => { - MockDate.set(new Date("2023-11-29T00:00:00.000Z")); + test("plus server skip flag ignored if for past season", async () => { + setSystemTime(new Date("2023-11-29T00:00:00.000Z")); - await Test.database.insertUsers(11); - await createLeaderboard(Array.from({ length: 11 }).map((_, i) => i + 1)); + await dbInsertUsers(11); + await createLeaderboard(Array.from({ length: 11 }).map((_, i) => i + 1)); - await db - .updateTable("User") - .set({ plusSkippedForSeasonNth: 0 }) - .where("User.id", "=", 1) - .execute(); + await db + .updateTable("User") + .set({ plusSkippedForSeasonNth: 0 }) + .where("User.id", "=", 1) + .execute(); - await adminAction({ _action: "REFRESH" }, { user: "admin" }); + await adminAction({ _action: "REFRESH" }, { user: "admin" }); - assert.equal(await countPlusTierMembers(1), 10); - assert.equal(await countPlusTierMembers(2), 1); -}); + expect(await countPlusTierMembers(1)).toBe(10); + expect(await countPlusTierMembers(2)).toBe(1); + }); -PlusVoting("ignores leaderboard while season is ongoing", async () => { - MockDate.set(new Date("2024-02-15T00:00:00.000Z")); + test("ignores leaderboard while season is ongoing", async () => { + setSystemTime(new Date("2024-02-15T00:00:00.000Z")); - await Test.database.insertUsers(2); - await PlusVotingRepository.upsertMany([ - voteArgs({ - score: 1, - votedId: 1, - }), - ]); - await createLeaderboard([2]); + await dbInsertUsers(2); + await PlusVotingRepository.upsertMany([ + voteArgs({ + score: 1, + votedId: 1, + }), + ]); + await createLeaderboard([2]); - await adminAction({ _action: "REFRESH" }, { user: "admin" }); + await adminAction({ _action: "REFRESH" }, { user: "admin" }); - assert.equal(await countPlusTierMembers(), 1); - assert.equal(await countPlusTierMembers(2), 0); -}); + expect(await countPlusTierMembers()).toBe(1); + expect(await countPlusTierMembers(2)).toBe(0); + }); -PlusVoting("leaderboard gives members to all tiers", async () => { - MockDate.set(new Date("2023-11-20T00:00:00.000Z")); + test("leaderboard gives members to all tiers", async () => { + setSystemTime(new Date("2023-11-20T00:00:00.000Z")); - await Test.database.insertUsers(60); - await createLeaderboard(Array.from({ length: 60 }, (_, i) => i + 1)); + await dbInsertUsers(60); + await createLeaderboard(Array.from({ length: 60 }, (_, i) => i + 1)); - await adminAction({ _action: "REFRESH" }, { user: "admin" }); + await adminAction({ _action: "REFRESH" }, { user: "admin" }); - assert.ok((await countPlusTierMembers()) > 0); - assert.ok((await countPlusTierMembers(2)) > 0); - assert.ok((await countPlusTierMembers(3)) > 0); -}); + expect(await countPlusTierMembers()).toBeGreaterThan(0); + expect(await countPlusTierMembers(2)).toBeGreaterThan(0); + expect(await countPlusTierMembers(3)).toBeGreaterThan(0); + }); -PlusVoting( - "gives membership if failed voting and is on the leaderboard", - async () => { - MockDate.set(new Date("2023-11-29T00:00:00.000Z")); + test("gives membership if failed voting and is on the leaderboard", async () => { + setSystemTime(new Date("2023-11-29T00:00:00.000Z")); - await Test.database.insertUsers(1); + await dbInsertUsers(1); await PlusVotingRepository.upsertMany([ voteArgs({ score: -1, @@ -226,35 +215,33 @@ PlusVoting( await adminAction({ _action: "REFRESH" }, { user: "admin" }); - assert.equal(await countPlusTierMembers(1), 1); - }, -); + expect(await countPlusTierMembers(1)).toBe(1); + }); -PlusVoting("members who fails voting drops one tier", async () => { - MockDate.set(new Date("2024-02-15T00:00:00.000Z")); + test("members who fails voting drops one tier", async () => { + setSystemTime(new Date("2024-02-15T00:00:00.000Z")); - await Test.database.insertUsers(1); - await PlusVotingRepository.upsertMany([ - voteArgs({ - score: 1, - votedId: 1, - month: 11, - year: 2023, - }), - ]); + await dbInsertUsers(1); + await PlusVotingRepository.upsertMany([ + voteArgs({ + score: 1, + votedId: 1, + month: 11, + year: 2023, + }), + ]); - await PlusVotingRepository.upsertMany([ - voteArgs({ - score: -1, - votedId: 1, - month: 2, - year: 2024, - }), - ]); + await PlusVotingRepository.upsertMany([ + voteArgs({ + score: -1, + votedId: 1, + month: 2, + year: 2024, + }), + ]); - await adminAction({ _action: "REFRESH" }, { user: "admin" }); + await adminAction({ _action: "REFRESH" }, { user: "admin" }); - assert.equal(await countPlusTierMembers(2), 1); + expect(await countPlusTierMembers(2)).toBe(1); + }); }); - -PlusVoting.run(); diff --git a/app/features/art/queries/addNewArt.server.ts b/app/features/art/queries/addNewArt.server.ts index 4555284ce..7e3107feb 100644 --- a/app/features/art/queries/addNewArt.server.ts +++ b/app/features/art/queries/addNewArt.server.ts @@ -99,8 +99,16 @@ type AddNewArtArgs = Pick & }; export const addNewArt = sql.transaction((args: AddNewArtArgs) => { - const img = addImgStm.get(args) as UserSubmittedImage; - const art = addArtStm.get({ ...args, imgId: img.id }) as Art; + const img = addImgStm.get({ + authorId: args.authorId, + url: args.url, + validatedAt: args.validatedAt, + }) as UserSubmittedImage; + const art = addArtStm.get({ + authorId: args.authorId, + description: args.description, + imgId: img.id, + }) as Art; for (const userId of args.linkedUsers) { addArtUserMetadataStm.run({ artId: art.id, userId }); diff --git a/app/features/art/queries/countArtByUserId.server.ts b/app/features/art/queries/countArtByUserId.server.ts deleted file mode 100644 index dbab28df4..000000000 --- a/app/features/art/queries/countArtByUserId.server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { sql } from "~/db/sql"; - -const stm = sql.prepare(/* sql */ ` - select - count(distinct "Art"."id") as "count" - from - "Art" - left join "ArtUserMetadata" on "ArtUserMetadata"."artId" = "Art"."id" - inner join "UserSubmittedImage" on "UserSubmittedImage"."id" = "Art"."imgId" - where "Art"."authorId" = @userId - or "ArtUserMetadata"."userId" = @userId -`); - -export function countArtByUserId(userId: number) { - return stm.pluck().get({ userId }) as number; -} diff --git a/app/features/auth/core/DiscordStrategy.server.ts b/app/features/auth/core/DiscordStrategy.server.ts index ba61d05d2..01662510d 100644 --- a/app/features/auth/core/DiscordStrategy.server.ts +++ b/app/features/auth/core/DiscordStrategy.server.ts @@ -66,7 +66,7 @@ export class DiscordStrategy extends OAuth2Strategy< discordUserDetailsSchema.parse(discordResponses); const isAlreadyRegistered = Boolean( - await UserRepository.findByIdentifier(user.id), + await UserRepository.identifierToUserId(user.id), ); if (!isAlreadyRegistered && !user.verified) { diff --git a/app/features/badges/queries/syncXPBadges.server.ts b/app/features/badges/queries/syncXPBadges.server.ts index ae7a96bcf..680ef38c2 100644 --- a/app/features/badges/queries/syncXPBadges.server.ts +++ b/app/features/badges/queries/syncXPBadges.server.ts @@ -37,9 +37,8 @@ const addXPBadgeStm = sql.prepare(/* sql */ ` export const syncXPBadges = sql.transaction(() => { for (const value of SPLATOON_3_XP_BADGE_VALUES) { - const badgeId = badgeCodeToIdStm - .pluck() - .get({ code: String(value) }) as number; + const badgeId = (badgeCodeToIdStm.get({ code: String(value) }) as any) + .id as number; invariant(badgeId, `Badge ${value} not found`); diff --git a/app/features/badges/routes/badges.tsx b/app/features/badges/routes/badges.tsx index 110c38986..461c3d08e 100644 --- a/app/features/badges/routes/badges.tsx +++ b/app/features/badges/routes/badges.tsx @@ -36,7 +36,7 @@ export const loader = async () => { }; export default function BadgesPageLayout() { - const { t } = useTranslation("badges"); + const { t } = useTranslation(["badges"]); const data = useLoaderData(); const user = useUser(); const [inputValue, setInputValue] = React.useState(""); diff --git a/app/features/build-analyzer/core/abilityChunksCalc.test.ts b/app/features/build-analyzer/core/abilityChunksCalc.test.ts index 16a8f7724..d16c65233 100644 --- a/app/features/build-analyzer/core/abilityChunksCalc.test.ts +++ b/app/features/build-analyzer/core/abilityChunksCalc.test.ts @@ -1,5 +1,4 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import { describe, expect, test } from "bun:test"; import type { AbilityWithUnknown, BuildAbilitiesTupleWithUnknown, @@ -24,147 +23,146 @@ function validateAbilityChunksArray( expectedOutput, )}\nActual Output: ${JSON.stringify(abilityChunksArray)}`; - assert.ok(isFoundInAbilityChunksArray, errorString); + expect(isFoundInAbilityChunksArray, errorString).toBeTruthy(); } } -const GetAbilityChunksMapAsArray = suite("getAbilityChunksMapAsArray()"); +describe("getAbilityChunksMapAsArray()", () => { + test("Empty build results in an empty array", () => { + const emptyBuild = [ + ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ] as unknown as BuildAbilitiesTupleWithUnknown; -GetAbilityChunksMapAsArray("Empty build results in an empty array", () => { - const emptyBuild = [ - ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], - ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], - ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], - ] as unknown as BuildAbilitiesTupleWithUnknown; + const abilityChunksArray = getAbilityChunksMapAsArray(emptyBuild); + expect( + abilityChunksArray, + "Ability chunks array is not empty.", + ).toBeEmpty(); + }); - const abilityChunksArray = getAbilityChunksMapAsArray(emptyBuild); - assert.equal(abilityChunksArray, [], "Ability chunks array is not empty."); + describe("getAbilityChunksMapAsArray()", () => { + test("Empty build results in an empty array", () => { + const emptyBuild = [ + ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ] as unknown as BuildAbilitiesTupleWithUnknown; + + const abilityChunksArray = getAbilityChunksMapAsArray(emptyBuild); + expect( + abilityChunksArray, + "Ability chunks array is not empty.", + ).toBeEmpty(); + }); + + test("Ability Doubler ability does not count towards Ability Chunks", () => { + const buildWithOnlyAbilityDoubler = [ + ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ["AD", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ] as unknown as BuildAbilitiesTupleWithUnknown; + + const abilityChunksArray = getAbilityChunksMapAsArray( + buildWithOnlyAbilityDoubler, + ); + expect(abilityChunksArray).toEqual([]); + }); + + test("Main Ability stackable ability chunk calculation is correct", () => { + const build = [ + ["ISS", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ["ISM", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ] as unknown as BuildAbilitiesTupleWithUnknown; + + const expectedOutput: any = [ + ["ISM", 45], + ["ISS", 45], + ]; + + const abilityChunksArray = getAbilityChunksMapAsArray(build); + validateAbilityChunksArray(abilityChunksArray, expectedOutput); + }); + + test("Ninja Squid ability chunk calculation is correct (for a primary slot-only ability)", () => { + const build = [ + ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ["NS", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ] as unknown as BuildAbilitiesTupleWithUnknown; + + const expectedOutput: any = [ + ["IRU", 15], + ["RSU", 15], + ["SSU", 15], + ]; + + const abilityChunksArray = getAbilityChunksMapAsArray(build); + validateAbilityChunksArray(abilityChunksArray, expectedOutput); + }); + + test("Ability chunk calculation is correct for a real build. Each gear has 1, 2 or 3 ability chunks of same type", () => { + const slayerBuild = [ + ["LDE", "SSU", "SSU", "SSU"], + ["NS", "QR", "QR", "ISM"], + ["SJ", "SSU", "RES", "QSJ"], + ] as unknown as BuildAbilitiesTupleWithUnknown; + + const expectedOutput: any = [ + ["SSU", 85], + ["IRU", 30], + ["QR", 30], + ["ISM", 25], + ["QSJ", 25], + ["IA", 15], + ["ISS", 15], + ["RSU", 15], + ["SRU", 15], + ["RES", 10], + ]; + + const abilityChunksArray = getAbilityChunksMapAsArray(slayerBuild); + validateAbilityChunksArray(abilityChunksArray, expectedOutput); + }); + + test("Ability chunk calculation is correct for a real build (Splatling)", () => { + const splatlingBuild = [ + ["RSU", "QSJ", "SSU", "RSU"], + ["RSU", "ISM", "ISM", "RSU"], + ["OS", "SSU", "SSU", "RES"], + ] as unknown as BuildAbilitiesTupleWithUnknown; + + const expectedOutput: any = [ + ["RSU", 110], + ["SSU", 40], + ["ISM", 30], + ["BRU", 15], + ["IRU", 15], + ["SPU", 15], + ["QSJ", 10], + ["RES", 10], + ]; + + const abilityChunksArray = getAbilityChunksMapAsArray(splatlingBuild); + validateAbilityChunksArray(abilityChunksArray, expectedOutput); + }); + + test("Sub abilities chunk calculation with Ability Doubler in Clothing slot is correct", () => { + const build = [ + ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ["AD", "SSU", "SSU", "ISM"], + ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], + ] as unknown as BuildAbilitiesTupleWithUnknown; + + const expectedOutput: any = [ + ["SSU", 9], + ["ISM", 3], + ]; + + const abilityChunksArray = getAbilityChunksMapAsArray(build); + validateAbilityChunksArray(abilityChunksArray, expectedOutput); + }); + }); }); - -GetAbilityChunksMapAsArray( - "Ability Doubler ability does not count towards Ability Chunks", - () => { - const buildWithOnlyAbilityDoubler = [ - ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], - ["AD", "UNKNOWN", "UNKNOWN", "UNKNOWN"], - ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], - ] as unknown as BuildAbilitiesTupleWithUnknown; - - const abilityChunksArray = getAbilityChunksMapAsArray( - buildWithOnlyAbilityDoubler, - ); - assert.equal(abilityChunksArray, [], "Ability chunks array is not empty."); - }, -); - -GetAbilityChunksMapAsArray( - "Main Ability stackable ability chunk calculation is correct", - () => { - const build = [ - ["ISS", "UNKNOWN", "UNKNOWN", "UNKNOWN"], - ["ISM", "UNKNOWN", "UNKNOWN", "UNKNOWN"], - ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], - ] as unknown as BuildAbilitiesTupleWithUnknown; - - const expectedOutput = [ - ["ISM", 45], - ["ISS", 45], - ]; - - const abilityChunksArray = getAbilityChunksMapAsArray(build); - validateAbilityChunksArray(abilityChunksArray, expectedOutput); - }, -); - -GetAbilityChunksMapAsArray( - "Ninja Squid ability chunk calculation is correct (for a primary slot-only ability)", - () => { - const build = [ - ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], - ["NS", "UNKNOWN", "UNKNOWN", "UNKNOWN"], - ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], - ] as unknown as BuildAbilitiesTupleWithUnknown; - - const expectedOutput = [ - ["RSU", 15], - ["IRU", 15], - ["SSU", 15], - ]; - - const abilityChunksArray = getAbilityChunksMapAsArray(build); - validateAbilityChunksArray(abilityChunksArray, expectedOutput); - }, -); - -GetAbilityChunksMapAsArray( - "Ability chunk calculation is correct for a real build. Each gear has 1, 2 or 3 ability chunks of same type", - () => { - const slayerBuild = [ - ["LDE", "SSU", "SSU", "SSU"], - ["NS", "QR", "QR", "ISM"], - ["SJ", "SSU", "RES", "QSJ"], - ] as unknown as BuildAbilitiesTupleWithUnknown; - - const expectedOutput = [ - ["SSU", 85], - ["IRU", 30], - ["QR", 30], - ["ISM", 25], - ["QSJ", 25], - ["IA", 15], - ["ISS", 15], - ["RSU", 15], - ["SRU", 15], - ["RES", 10], - ]; - - const abilityChunksArray = getAbilityChunksMapAsArray(slayerBuild); - validateAbilityChunksArray(abilityChunksArray, expectedOutput); - }, -); - -GetAbilityChunksMapAsArray( - "Ability chunk calculation is correct for a real build (Splatling)", - () => { - const splatlingBuild = [ - ["RSU", "QSJ", "SSU", "RSU"], - ["RSU", "ISM", "ISM", "RSU"], - ["OS", "SSU", "SSU", "RES"], - ] as unknown as BuildAbilitiesTupleWithUnknown; - - const expectedOutput = [ - ["RSU", 110], - ["SSU", 40], - ["ISM", 30], - ["BRU", 15], - ["IRU", 15], - ["SPU", 15], - ["QSJ", 10], - ["RES", 10], - ]; - - const abilityChunksArray = getAbilityChunksMapAsArray(splatlingBuild); - validateAbilityChunksArray(abilityChunksArray, expectedOutput); - }, -); - -GetAbilityChunksMapAsArray( - "Sub abilities chunk calculation with Ability Doubler in Clothing slot is correct", - () => { - const build = [ - ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], - ["AD", "SSU", "SSU", "ISM"], - ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"], - ] as unknown as BuildAbilitiesTupleWithUnknown; - - const expectedOutput = [ - ["SSU", 9], - ["ISM", 3], - ]; - - const abilityChunksArray = getAbilityChunksMapAsArray(build); - validateAbilityChunksArray(abilityChunksArray, expectedOutput); - }, -); - -GetAbilityChunksMapAsArray.run(); diff --git a/app/features/build-analyzer/core/specialEffects.test.ts b/app/features/build-analyzer/core/specialEffects.test.ts index eb7b4ba47..b06bea8eb 100644 --- a/app/features/build-analyzer/core/specialEffects.test.ts +++ b/app/features/build-analyzer/core/specialEffects.test.ts @@ -1,95 +1,86 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import { describe, expect, test } from "bun:test"; import { applySpecialEffects } from "./specialEffects"; -const ApplySpecialEffects = suite("applySpecialEffects()"); +describe("applySpecialEffects()", () => { + test("Adds an effect to empty build", () => { + const aps = applySpecialEffects({ + effects: ["CB"], + abilityPoints: new Map(), + ldeIntensity: 0, + }); -ApplySpecialEffects("Adds an effect to empty build", () => { - const aps = applySpecialEffects({ - effects: ["CB"], - abilityPoints: new Map(), - ldeIntensity: 0, + expect(aps.size).toBe(6); + expect(aps.get("ISM")).toBe(10); }); - assert.equal(aps.size, 6); - assert.equal(aps.get("ISM"), 10); -}); - -ApplySpecialEffects( - "Adds an effect to build while keeping existing abilities untouched", - () => { + test("Adds an effect to build while keeping existing abilities untouched", () => { const aps = applySpecialEffects({ effects: ["CB"], abilityPoints: new Map([["SPU", 10]]), ldeIntensity: 0, }); - assert.equal(aps.size, 7); - assert.equal(aps.get("SPU"), 10); - }, -); - -ApplySpecialEffects("Does not boost ability beyond 57", () => { - const aps = applySpecialEffects({ - effects: ["CB"], - abilityPoints: new Map([["ISM", 57]]), - ldeIntensity: 0, + expect(aps.size).toBe(7); + expect(aps.get("SPU")).toBe(10); }); - assert.equal(aps.get("ISM"), 57); -}); + test("Does not boost ability beyond 57", () => { + const aps = applySpecialEffects({ + effects: ["CB"], + abilityPoints: new Map([["ISM", 57]]), + ldeIntensity: 0, + }); -ApplySpecialEffects("Tacticooler doesn't boost swim speed beyond 29", () => { - const aps = applySpecialEffects({ - effects: ["TACTICOOLER"], - abilityPoints: new Map([["SSU", 28]]), - ldeIntensity: 0, + expect(aps.get("ISM")).toBe(57); }); - assert.equal(aps.get("SSU"), 29); -}); + test("Tacticooler doesn't boost swim speed beyond 29", () => { + const aps = applySpecialEffects({ + effects: ["TACTICOOLER"], + abilityPoints: new Map([["SSU", 28]]), + ldeIntensity: 0, + }); -ApplySpecialEffects( - "Tacticooler limit swim speed at 29 if more in build", - () => { + expect(aps.get("SSU")).toBe(29); + }); + + test("Tacticooler limit swim speed at 29 if more in build", () => { const aps = applySpecialEffects({ effects: ["TACTICOOLER"], abilityPoints: new Map([["SSU", 30]]), ldeIntensity: 0, }); - assert.equal(aps.get("SSU"), 30); - }, -); - -ApplySpecialEffects("Applies many effects", () => { - const aps = applySpecialEffects({ - effects: ["DR", "CB"], - abilityPoints: new Map([["SSU", 1]]), - ldeIntensity: 0, + expect(aps.get("SSU")).toBe(30); }); - assert.equal(aps.get("SSU"), 41); -}); + test("Applies many effects", () => { + const aps = applySpecialEffects({ + effects: ["DR", "CB"], + abilityPoints: new Map([["SSU", 1]]), + ldeIntensity: 0, + }); -ApplySpecialEffects("Applies LDE", () => { - const aps = applySpecialEffects({ - effects: ["LDE"], - abilityPoints: new Map([["ISM", 1]]), - ldeIntensity: 1, + expect(aps.get("SSU")).toBe(41); }); - assert.equal(aps.get("ISM"), 1); -}); + test("Applies LDE", () => { + const aps = applySpecialEffects({ + effects: ["LDE"], + abilityPoints: new Map([["ISM", 1]]), + ldeIntensity: 1, + }); -ApplySpecialEffects("Applies LDE (intensity != aps given)", () => { - const aps = applySpecialEffects({ - effects: ["LDE"], - abilityPoints: new Map([["ISM", 1]]), - ldeIntensity: 15, + expect(aps.get("ISM")).toBe(1); }); - assert.equal(aps.get("ISM"), 13); -}); + test("Applies LDE (intensity != aps given)", () => { + const aps = applySpecialEffects({ + effects: ["LDE"], + abilityPoints: new Map([["ISM", 1]]), + ldeIntensity: 15, + }); -ApplySpecialEffects.run(); + expect(aps.get("ISM")).toBe(13); + }); +}); diff --git a/app/features/build-analyzer/core/stats.test.ts b/app/features/build-analyzer/core/stats.test.ts index 3160f1e67..829241227 100644 --- a/app/features/build-analyzer/core/stats.test.ts +++ b/app/features/build-analyzer/core/stats.test.ts @@ -1,86 +1,79 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import { describe, expect, test } from "bun:test"; import { type MainWeaponId, mainWeaponIds } from "~/modules/in-game-lists"; import { damageTypeToWeaponType } from "../analyzer-constants"; import { buildStats } from "./stats"; -const AnalyzeBuild = suite("Analyze build"); +describe("Analyze build", () => { + test("Every main weapon has damage", () => { + const weaponsWithoutDamage: MainWeaponId[] = []; -AnalyzeBuild("Every main weapon has damage", () => { - const weaponsWithoutDamage: MainWeaponId[] = []; + for (const weaponSplId of mainWeaponIds) { + const analyzed = buildStats({ + weaponSplId, + hasTacticooler: false, + }); - for (const weaponSplId of mainWeaponIds) { + const hasDamage = + analyzed.stats.damages.filter( + (dmg) => damageTypeToWeaponType[dmg.type] === "MAIN", + ).length > 0; + + if (!hasDamage) { + weaponsWithoutDamage.push(weaponSplId); + } + } + + expect( + weaponsWithoutDamage.length, + `Weapons without damage set: ${weaponsWithoutDamage.join(", ")}`, + ).toBe(0); + }); + + test("Ninja Squid decreases swim speed", () => { const analyzed = buildStats({ - weaponSplId, + weaponSplId: 0, hasTacticooler: false, }); - const hasDamage = - analyzed.stats.damages.filter( - (dmg) => damageTypeToWeaponType[dmg.type] === "MAIN", - ).length > 0; + const analyzedWithNS = buildStats({ + weaponSplId: 0, + mainOnlyAbilities: ["NS"], + hasTacticooler: false, + }); - if (!hasDamage) { - weaponsWithoutDamage.push(weaponSplId); - } - } - - assert.ok( - weaponsWithoutDamage.length === 0, - `Weapons without damage set: ${weaponsWithoutDamage.join(", ")}`, - ); -}); - -AnalyzeBuild("Ninja Squid decreases swim speed", () => { - const analyzed = buildStats({ - weaponSplId: 0, - hasTacticooler: false, + expect(analyzed.stats.swimSpeed.value).toBeGreaterThan( + analyzedWithNS.stats.swimSpeed.value, + ); }); - const analyzedWithNS = buildStats({ - weaponSplId: 0, - mainOnlyAbilities: ["NS"], - hasTacticooler: false, - }); + test("Tacticooler / RP calculated correctly", () => { + const fullQR = buildStats({ + weaponSplId: 0, + abilityPoints: new Map([["QR", 57]]), + hasTacticooler: false, + }); - assert.ok( - analyzed.stats.swimSpeed.value > analyzedWithNS.stats.swimSpeed.value, - ); -}); + const tacticooler = buildStats({ + weaponSplId: 0, + abilityPoints: new Map([["QR", 57]]), + hasTacticooler: true, + }); -AnalyzeBuild("Tacticooler / RP calculated correctly", () => { - const fullQR = buildStats({ - weaponSplId: 0, - abilityPoints: new Map([["QR", 57]]), - hasTacticooler: false, - }); - - const tacticooler = buildStats({ - weaponSplId: 0, - abilityPoints: new Map([["QR", 57]]), - hasTacticooler: true, - }); - - assert.ok( - fullQR.stats.quickRespawnTime.value === + expect( + fullQR.stats.quickRespawnTime.value, + "Base QR should be same whether 57AP of QR or Tacticooler", + ).toBe(tacticooler.stats.quickRespawnTime.value); + expect( + fullQR.stats.quickRespawnTimeSplattedByRP.value, + "Tacticooler splatted by RP should respawn faster than 57AP of QR", + ).toBeGreaterThan(tacticooler.stats.quickRespawnTimeSplattedByRP.value); + expect( tacticooler.stats.quickRespawnTime.value, - "Base QR should be same whether 57AP of QR or Tacticooler", - ); - assert.ok( - fullQR.stats.quickRespawnTimeSplattedByRP.value > - tacticooler.stats.quickRespawnTimeSplattedByRP.value, - "Tacticooler splatted by RP should respawn faster than 57AP of QR", - ); - assert.ok( - tacticooler.stats.quickRespawnTime.value < - tacticooler.stats.quickRespawnTimeSplattedByRP.value, - "Tacticooler should respawn faster than Tacticooler splatted by RP", - ); -}); + "Tacticooler should respawn faster than Tacticooler splatted by RP", + ).toBeLessThan(tacticooler.stats.quickRespawnTimeSplattedByRP.value); + }); -AnalyzeBuild( - "Accounts for Jr. big ink tank with sub weapon ink consumption %", - () => { + test("Accounts for Jr. big ink tank with sub weapon ink consumption %", () => { const analyzedDualieSquelchers = buildStats({ weaponSplId: 5030, hasTacticooler: false, @@ -91,37 +84,34 @@ AnalyzeBuild( hasTacticooler: false, }); - assert.ok( - analyzedDualieSquelchers.stats.subWeaponInkConsumptionPercentage.value > - analyzedJr.stats.subWeaponInkConsumptionPercentage.value, - ); - }, -); + expect( + analyzedDualieSquelchers.stats.subWeaponInkConsumptionPercentage.value, + ).toBeGreaterThan(analyzedJr.stats.subWeaponInkConsumptionPercentage.value); + }); -const subPowerApToQuickSuperJumpAp = new Map([ - [0, 0], - [3, 4], - [6, 9], - [13, 18], - [28, 36], - [57, 57], -]); + const subPowerApToQuickSuperJumpAp = new Map([ + [0, 0], + [3, 4], + [6, 9], + [13, 18], + [28, 36], + [57, 57], + ]); -AnalyzeBuild("Sub Power Up Beakon AP boost matches Lean", () => { - for (const [subPowerAp, quickSuperJumpAp] of subPowerApToQuickSuperJumpAp) { - const analyzed = buildStats({ - weaponSplId: 1011, - abilityPoints: new Map([["BRU" as const, subPowerAp]]), - hasTacticooler: false, - }); + test("Sub Power Up Beakon AP boost matches Lean", () => { + for (const [subPowerAp, quickSuperJumpAp] of subPowerApToQuickSuperJumpAp) { + const analyzed = buildStats({ + weaponSplId: 1011, + abilityPoints: new Map([["BRU" as const, subPowerAp]]), + hasTacticooler: false, + }); - assert.ok( - analyzed.stats.subQsjBoost?.value === quickSuperJumpAp, - `Wrong AP boost for ${subPowerAp}AP of Sub Power Up: ${ - analyzed.stats.subQsjBoost!.value - } (expected ${quickSuperJumpAp}))`, - ); - } + expect( + analyzed.stats.subQsjBoost?.value, + `Wrong AP boost for ${subPowerAp}AP of Sub Power Up: ${ + analyzed.stats.subQsjBoost!.value + } (expected ${quickSuperJumpAp}))`, + ).toBe(quickSuperJumpAp); + } + }); }); - -AnalyzeBuild.run(); diff --git a/app/features/build-analyzer/core/utils.test.ts b/app/features/build-analyzer/core/utils.test.ts index 47fdc5057..dd49c69fa 100644 --- a/app/features/build-analyzer/core/utils.test.ts +++ b/app/features/build-analyzer/core/utils.test.ts @@ -1,47 +1,44 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import { describe, expect, test } from "bun:test"; import type { AbilityWithUnknown } from "~/modules/in-game-lists/types"; import { buildToAbilityPoints } from "./utils"; -const BuildToAbilityPoints = suite("buildToAbilityPoints()"); +describe("buildToAbilityPoints", () => { + const EMPTY_ROW: [ + AbilityWithUnknown, + AbilityWithUnknown, + AbilityWithUnknown, + AbilityWithUnknown, + ] = ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"]; -const EMPTY_ROW: [ - AbilityWithUnknown, - AbilityWithUnknown, - AbilityWithUnknown, - AbilityWithUnknown, -] = ["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"]; + test("calculates ability points correctly", () => { + const aps = buildToAbilityPoints([ + ["SS", "SS", "RSU", "RSU"], + EMPTY_ROW, + EMPTY_ROW, + ]); -BuildToAbilityPoints("Calculates ability points", () => { - const aps = buildToAbilityPoints([ - ["SS", "SS", "RSU", "RSU"], - EMPTY_ROW, - EMPTY_ROW, - ]); + expect(aps.get("SS")).toBe(13); + expect(aps.get("RSU")).toBe(6); + expect(aps.get("UNKNOWN")).toBe(38); + }); - assert.equal(aps.get("SS"), 13); - assert.equal(aps.get("RSU"), 6); - assert.equal(aps.get("UNKNOWN"), 38); + test("handles ability doubler correctly", () => { + const aps = buildToAbilityPoints([ + EMPTY_ROW, + ["AD", "SS", "UNKNOWN", "UNKNOWN"], + EMPTY_ROW, + ]); + + expect(aps.get("SS")).toBe(6); + }); + + test("does not calculate AP for main only abilities", () => { + const aps = buildToAbilityPoints([ + ["LDE", "SS", "RSU", "RSU"], + EMPTY_ROW, + EMPTY_ROW, + ]); + + expect(aps.has("LDE")).toBeFalsy(); + }); }); - -BuildToAbilityPoints("Handles ability doubler", () => { - const aps = buildToAbilityPoints([ - EMPTY_ROW, - ["AD", "SS", "UNKNOWN", "UNKNOWN"], - EMPTY_ROW, - ]); - - assert.equal(aps.get("SS"), 6); -}); - -BuildToAbilityPoints("Does not calculate AP for main only abilities", () => { - const aps = buildToAbilityPoints([ - ["LDE", "SS", "RSU", "RSU"], - EMPTY_ROW, - EMPTY_ROW, - ]); - - assert.not.ok(aps.has("LDE")); -}); - -BuildToAbilityPoints.run(); diff --git a/app/features/build-stats/build-stats-utils.test.ts b/app/features/build-stats/build-stats-utils.test.ts index cd670ddc4..5fe87359f 100644 --- a/app/features/build-stats/build-stats-utils.test.ts +++ b/app/features/build-stats/build-stats-utils.test.ts @@ -1,13 +1,9 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import { describe, expect, test } from "bun:test"; import { abilityPointCountsToAverages, popularBuilds, } from "./build-stats-utils"; -const AbilityPointCountsToAverages = suite("abilityPointCountsToAverages()"); -const PopularBuilds = suite("popularBuilds()"); - const commonAbilities = [ { ability: "QR" as const, @@ -36,115 +32,113 @@ const allAbilities = [ { ability: "BRU" as const, abilityPointsSum: 57 }, ]; -AbilityPointCountsToAverages("calculates build count", () => { - const { weaponBuildsCount } = abilityPointCountsToAverages({ - allAbilities, - weaponAbilities: commonAbilities, +describe("abilityPointCountsToAverages", () => { + test("calculates build count", () => { + const { weaponBuildsCount } = abilityPointCountsToAverages({ + allAbilities, + weaponAbilities: commonAbilities, + }); + + expect(weaponBuildsCount).toBe(2); }); - assert.is(weaponBuildsCount, 2); -}); + test("calculates average ap (main only)", () => { + const { mainOnlyAbilities } = abilityPointCountsToAverages({ + allAbilities, + weaponAbilities: commonAbilities, + }); -AbilityPointCountsToAverages("calculates average ap (main only)", () => { - const { mainOnlyAbilities } = abilityPointCountsToAverages({ - allAbilities, - weaponAbilities: commonAbilities, + expect( + mainOnlyAbilities.find((a) => a.name === "T")?.percentage.weapon, + ).toBe(50); }); - assert.is( - mainOnlyAbilities.find((a) => a.name === "T")?.percentage.weapon, - 50, - ); -}); + test("calculates average ap (stackable)", () => { + const { stackableAbilities } = abilityPointCountsToAverages({ + allAbilities, + weaponAbilities: commonAbilities, + }); -AbilityPointCountsToAverages("calculates average ap (stackable)", () => { - const { stackableAbilities } = abilityPointCountsToAverages({ - allAbilities, - weaponAbilities: commonAbilities, + expect( + stackableAbilities.find((a) => a.name === "SS")?.apAverage.weapon, + ).toBe(13.5); }); - assert.is( - stackableAbilities.find((a) => a.name === "SS")?.apAverage.weapon, - 13.5, - ); + test("calculates average ap for all builds", () => { + const { mainOnlyAbilities } = abilityPointCountsToAverages({ + allAbilities, + weaponAbilities: commonAbilities, + }); + + expect(mainOnlyAbilities.find((a) => a.name === "T")?.percentage.all).toBe( + 33.33, + ); + }); }); -AbilityPointCountsToAverages("calculates average ap for all builds", () => { - const { mainOnlyAbilities } = abilityPointCountsToAverages({ - allAbilities, - weaponAbilities: commonAbilities, +describe("popularBuilds", () => { + test("calculates popular build", () => { + const builds = popularBuilds([ + ...new Array(10).fill(null).map(() => ({ + abilities: [{ ability: "QR" as const, abilityPoints: 57 }], + })), + { + abilities: [{ ability: "BRU" as const, abilityPoints: 57 }], + }, + ]); + + expect(builds.length).toBe(1); + expect(builds[0].count).toBe(10); + expect(builds[0].abilities[0].ability).toBe("QR"); }); - assert.is( - mainOnlyAbilities.find((a) => a.name === "T")?.percentage.all, - 33.33, - ); + test("calculates second most popular build (sorted by count)", () => { + const builds = popularBuilds([ + ...new Array(10).fill(null).map(() => ({ + abilities: [{ ability: "QR" as const, abilityPoints: 57 }], + })), + ...new Array(3).fill(null).map(() => ({ + abilities: [{ ability: "SS" as const, abilityPoints: 57 }], + })), + ...new Array(5).fill(null).map(() => ({ + abilities: [{ ability: "SSU" as const, abilityPoints: 57 }], + })), + ]); + + expect(builds.length).toBe(3); + expect(builds[1].abilities[0].ability).toBe("SSU"); + }); + + test("sums up abilities", () => { + const builds = popularBuilds([ + { abilities: [{ ability: "QR" as const, abilityPoints: 57 }] }, + { + abilities: [ + { ability: "QR" as const, abilityPoints: 10 }, + { ability: "QR" as const, abilityPoints: 47 }, + ], + }, + ]); + + expect(builds.length).toBe(1); + }); + + test("sorts abilities", () => { + const builds = popularBuilds([ + { + abilities: [ + { ability: "QR" as const, abilityPoints: 10 }, + { ability: "SS" as const, abilityPoints: 47 }, + ], + }, + { + abilities: [ + { ability: "QR" as const, abilityPoints: 10 }, + { ability: "SS" as const, abilityPoints: 47 }, + ], + }, + ]); + + expect(builds[0].abilities[1].ability).toBe("QR"); + }); }); - -PopularBuilds("calculates popular build", () => { - const builds = popularBuilds([ - ...new Array(10).fill(null).map(() => ({ - abilities: [{ ability: "QR" as const, abilityPoints: 57 }], - })), - { - abilities: [{ ability: "BRU" as const, abilityPoints: 57 }], - }, - ]); - - assert.is(builds.length, 1); - assert.is(builds[0].count, 10); - assert.is(builds[0].abilities[0].ability, "QR"); -}); - -PopularBuilds("calculates second most popular build (sorted by count)", () => { - const builds = popularBuilds([ - ...new Array(10).fill(null).map(() => ({ - abilities: [{ ability: "QR" as const, abilityPoints: 57 }], - })), - ...new Array(3).fill(null).map(() => ({ - abilities: [{ ability: "SS" as const, abilityPoints: 57 }], - })), - ...new Array(5).fill(null).map(() => ({ - abilities: [{ ability: "SSU" as const, abilityPoints: 57 }], - })), - ]); - - assert.is(builds.length, 3); - assert.is(builds[1].abilities[0].ability, "SSU"); -}); - -PopularBuilds("sums up abilities", () => { - const builds = popularBuilds([ - { abilities: [{ ability: "QR" as const, abilityPoints: 57 }] }, - { - abilities: [ - { ability: "QR" as const, abilityPoints: 10 }, - { ability: "QR" as const, abilityPoints: 47 }, - ], - }, - ]); - - assert.is(builds.length, 1); -}); - -PopularBuilds("sorts abilities", () => { - const builds = popularBuilds([ - { - abilities: [ - { ability: "QR" as const, abilityPoints: 10 }, - { ability: "SS" as const, abilityPoints: 47 }, - ], - }, - { - abilities: [ - { ability: "QR" as const, abilityPoints: 10 }, - { ability: "SS" as const, abilityPoints: 47 }, - ], - }, - ]); - - assert.is(builds[0].abilities[1].ability, "QR"); -}); - -AbilityPointCountsToAverages.run(); -PopularBuilds.run(); diff --git a/app/features/build-stats/queries/averageAbilityPoints.server.ts b/app/features/build-stats/queries/averageAbilityPoints.server.ts index f518ef2eb..a80a4bf81 100644 --- a/app/features/build-stats/queries/averageAbilityPoints.server.ts +++ b/app/features/build-stats/queries/averageAbilityPoints.server.ts @@ -25,5 +25,7 @@ export interface AverageAbilityPointsResult { export function averageAbilityPoints(weaponSplId?: MainWeaponId | null) { const stm = typeof weaponSplId === "number" ? findByWeaponIdStm : findAllStm; - return stm.all({ weaponSplId }) as Array; + return stm.all({ + weaponSplId: weaponSplId ?? null, + }) as Array; } diff --git a/app/features/builds/core/filter.test.ts b/app/features/builds/core/filter.test.ts index 229b02eea..8d58b2253 100644 --- a/app/features/builds/core/filter.test.ts +++ b/app/features/builds/core/filter.test.ts @@ -1,5 +1,4 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import { describe, expect, test } from "bun:test"; import type { Tables } from "~/db/tables"; import type { Ability, @@ -9,8 +8,6 @@ import type { import { dateToDatabaseTimestamp } from "~/utils/dates"; import { filterBuilds } from "./filter.server"; -const FilterBuilds = suite("Filter builds"); - const createBuild = ({ headAbilities, modes, @@ -36,246 +33,249 @@ const createBuild = ({ }; }; -FilterBuilds("returns correct build back based on abilities (AT_LEAST)", () => { - const filtered = filterBuilds({ - builds: [ - createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), - createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), - ], - count: 2, - filters: [ - { - type: "ability", - ability: "ISM", - value: 10, - comparison: "AT_LEAST", - }, - ], +describe("Filter builds", () => { + test("returns correct build back based on abilities (AT_LEAST)", () => { + const filtered = filterBuilds({ + builds: [ + createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), + createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), + ], + count: 2, + filters: [ + { + type: "ability", + ability: "ISM", + value: 10, + comparison: "AT_LEAST", + }, + ], + }); + + expect(filtered.length).toBe(1); + expect(filtered[0].abilities[0]).toEqual(["ISM", "ISM", "ISM", "ISM"]); }); - assert.equal(filtered.length, 1); - assert.equal(filtered[0].abilities[0], ["ISM", "ISM", "ISM", "ISM"]); -}); + test("returns correct build back based on abilities (AT_MOST)", () => { + const filtered = filterBuilds({ + builds: [ + createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), + createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), + ], + count: 2, + filters: [ + { + type: "ability", + ability: "ISM", + value: 6, + comparison: "AT_MOST", + }, + ], + }); -FilterBuilds("returns correct build back based on abilities (AT_MOST)", () => { - const filtered = filterBuilds({ - builds: [ - createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), - createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), - ], - count: 2, - filters: [ - { - type: "ability", - ability: "ISM", - value: 6, - comparison: "AT_MOST", - }, - ], + expect(filtered.length).toBe(1); + expect(filtered[0].abilities[0]).toEqual(["ISS", "ISS", "ISM", "ISM"]); }); - assert.equal(filtered.length, 1); - assert.equal(filtered[0].abilities[0], ["ISS", "ISS", "ISM", "ISM"]); -}); + test("filters based on main ability (true)", () => { + const filtered = filterBuilds({ + builds: [ + createBuild({ headAbilities: ["T", "ISM", "ISM", "ISM"] }), + createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), + ], + count: 2, + filters: [ + { + type: "ability", + ability: "T", + value: true, + }, + ], + }); -FilterBuilds("filters based on main ability (true)", () => { - const filtered = filterBuilds({ - builds: [ - createBuild({ headAbilities: ["T", "ISM", "ISM", "ISM"] }), - createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), - ], - count: 2, - filters: [ - { - type: "ability", - ability: "T", - value: true, - }, - ], + expect(filtered.length).toBe(1); + expect(filtered[0].abilities[0]).toEqual(["T", "ISM", "ISM", "ISM"]); }); - assert.equal(filtered.length, 1); - assert.equal(filtered[0].abilities[0], ["T", "ISM", "ISM", "ISM"]); -}); + test("filters based on main ability (false)", () => { + const filtered = filterBuilds({ + builds: [ + createBuild({ headAbilities: ["T", "ISM", "ISM", "ISM"] }), + createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), + ], + count: 2, + filters: [ + { + type: "ability", + ability: "T", + value: false, + }, + ], + }); -FilterBuilds("filters based on main ability (false)", () => { - const filtered = filterBuilds({ - builds: [ - createBuild({ headAbilities: ["T", "ISM", "ISM", "ISM"] }), - createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), - ], - count: 2, - filters: [ - { - type: "ability", - ability: "T", - value: false, - }, - ], + expect(filtered.length).toBe(1); + expect(filtered[0].abilities[0]).toEqual(["ISS", "ISS", "ISM", "ISM"]); }); - assert.equal(filtered.length, 1); - assert.equal(filtered[0].abilities[0], ["ISS", "ISS", "ISM", "ISM"]); -}); + test("filters based on mode", () => { + const filtered = filterBuilds({ + builds: [ + createBuild({ + headAbilities: ["ISS", "ISM", "ISM", "ISM"], + modes: ["SZ"], + }), + createBuild({ + headAbilities: ["ISM", "ISM", "ISM", "ISM"], + modes: null, + }), + createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"], modes: [] }), + ], + count: 3, + filters: [ + { + type: "mode", + mode: "SZ", + }, + ], + }); -FilterBuilds("filters based on mode", () => { - const filtered = filterBuilds({ - builds: [ - createBuild({ - headAbilities: ["ISS", "ISM", "ISM", "ISM"], - modes: ["SZ"], - }), - createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"], modes: null }), - createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"], modes: [] }), - ], - count: 3, - filters: [ - { - type: "mode", - mode: "SZ", - }, - ], + expect(filtered.length).toBe(1); + expect(filtered[0].abilities[0]).toEqual(["ISS", "ISM", "ISM", "ISM"]); }); - assert.equal(filtered.length, 1); - assert.equal(filtered[0].abilities[0], ["ISS", "ISM", "ISM", "ISM"]); -}); + test("filters based on many modes", () => { + const filtered = filterBuilds({ + builds: [ + createBuild({ + headAbilities: ["ISS", "ISM", "ISM", "ISM"], + modes: ["SZ", "TC"], + }), + createBuild({ + headAbilities: ["ISM", "ISM", "ISM", "ISM"], + modes: ["SZ"], + }), + createBuild({ + headAbilities: ["ISM", "ISM", "ISM", "ISM"], + modes: ["TC"], + }), + ], + count: 3, + filters: [ + { + type: "mode", + mode: "SZ", + }, + { + type: "mode", + mode: "TC", + }, + ], + }); -FilterBuilds("filters based on many modes", () => { - const filtered = filterBuilds({ - builds: [ - createBuild({ - headAbilities: ["ISS", "ISM", "ISM", "ISM"], - modes: ["SZ", "TC"], - }), - createBuild({ - headAbilities: ["ISM", "ISM", "ISM", "ISM"], - modes: ["SZ"], - }), - createBuild({ - headAbilities: ["ISM", "ISM", "ISM", "ISM"], - modes: ["TC"], - }), - ], - count: 3, - filters: [ - { - type: "mode", - mode: "SZ", - }, - { - type: "mode", - mode: "TC", - }, - ], + expect(filtered.length).toBe(1); + expect(filtered[0].abilities[0]).toEqual(["ISS", "ISM", "ISM", "ISM"]); }); - assert.equal(filtered.length, 1); - assert.equal(filtered[0].abilities[0], ["ISS", "ISM", "ISM", "ISM"]); -}); + test("filters based on date", () => { + const filtered = filterBuilds({ + builds: [ + createBuild({ + headAbilities: ["ISS", "ISM", "ISM", "ISM"], + updatedAt: dateToDatabaseTimestamp(new Date(2023, 0, 1)), + }), + createBuild({ + headAbilities: ["ISM", "ISM", "ISM", "ISM"], + updatedAt: dateToDatabaseTimestamp(new Date(2021, 0, 1)), + }), + ], + count: 2, + filters: [ + { + type: "date", + date: "2022-01-01", + }, + ], + }); -FilterBuilds("filters based on date", () => { - const filtered = filterBuilds({ - builds: [ - createBuild({ - headAbilities: ["ISS", "ISM", "ISM", "ISM"], - updatedAt: dateToDatabaseTimestamp(new Date(2023, 0, 1)), - }), - createBuild({ - headAbilities: ["ISM", "ISM", "ISM", "ISM"], - updatedAt: dateToDatabaseTimestamp(new Date(2021, 0, 1)), - }), - ], - count: 2, - filters: [ - { - type: "date", - date: "2022-01-01", - }, - ], + expect(filtered.length).toBe(1); + expect(filtered[0].abilities[0]).toEqual(["ISS", "ISM", "ISM", "ISM"]); }); - assert.equal(filtered.length, 1); - assert.equal(filtered[0].abilities[0], ["ISS", "ISM", "ISM", "ISM"]); -}); + test("combines filters of same type", () => { + const filtered = filterBuilds({ + builds: [ + createBuild({ headAbilities: ["T", "ISM", "ISM", "ISM"] }), + createBuild({ headAbilities: ["T", "RES", "RES", "RES"] }), + createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), + ], + count: 2, + filters: [ + { + type: "ability", + ability: "T", + value: true, + }, + { + type: "ability", + ability: "ISM", + value: 9, + comparison: "AT_LEAST", + }, + ], + }); -FilterBuilds("combines filters of same type", () => { - const filtered = filterBuilds({ - builds: [ - createBuild({ headAbilities: ["T", "ISM", "ISM", "ISM"] }), - createBuild({ headAbilities: ["T", "RES", "RES", "RES"] }), - createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), - ], - count: 2, - filters: [ - { - type: "ability", - ability: "T", - value: true, - }, - { - type: "ability", - ability: "ISM", - value: 9, - comparison: "AT_LEAST", - }, - ], + expect(filtered.length).toBe(1); + expect(filtered[0].abilities[0]).toEqual(["T", "ISM", "ISM", "ISM"]); }); - assert.equal(filtered.length, 1); - assert.equal(filtered[0].abilities[0], ["T", "ISM", "ISM", "ISM"]); -}); + test("combines filters of different type", () => { + const filtered = filterBuilds({ + builds: [ + // has both + createBuild({ + headAbilities: ["ISS", "ISM", "ISM", "ISM"], + updatedAt: dateToDatabaseTimestamp(new Date(2023, 0, 1)), + }), + // has abilities + createBuild({ + headAbilities: ["ISM", "ISM", "ISM", "ISM"], + updatedAt: dateToDatabaseTimestamp(new Date(2021, 0, 1)), + }), + // has date + createBuild({ + headAbilities: ["ISS", "ISS", "ISM", "ISM"], + updatedAt: dateToDatabaseTimestamp(new Date(2023, 0, 1)), + }), + ], + count: 2, + filters: [ + { + type: "date", + date: "2022-01-01", + }, + { + type: "ability", + ability: "ISM", + value: 9, + comparison: "AT_LEAST", + }, + ], + }); -FilterBuilds("combines filters of different type", () => { - const filtered = filterBuilds({ - builds: [ - // has both - createBuild({ - headAbilities: ["ISS", "ISM", "ISM", "ISM"], - updatedAt: dateToDatabaseTimestamp(new Date(2023, 0, 1)), - }), - // has abilities - createBuild({ - headAbilities: ["ISM", "ISM", "ISM", "ISM"], - updatedAt: dateToDatabaseTimestamp(new Date(2021, 0, 1)), - }), - // has date - createBuild({ - headAbilities: ["ISS", "ISS", "ISM", "ISM"], - updatedAt: dateToDatabaseTimestamp(new Date(2023, 0, 1)), - }), - ], - count: 2, - filters: [ - { - type: "date", - date: "2022-01-01", - }, - { - type: "ability", - ability: "ISM", - value: 9, - comparison: "AT_LEAST", - }, - ], + expect(filtered.length).toBe(1); + expect(filtered[0].abilities[0]).toEqual(["ISS", "ISM", "ISM", "ISM"]); }); - assert.equal(filtered.length, 1); - assert.equal(filtered[0].abilities[0], ["ISS", "ISM", "ISM", "ISM"]); -}); + test("count limits returned builds", () => { + const filtered = filterBuilds({ + builds: [ + createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), + createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), + createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), + ], + count: 2, + filters: [], + }); -FilterBuilds("count limits returned builds", () => { - const filtered = filterBuilds({ - builds: [ - createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), - createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), - createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), - ], - count: 2, - filters: [], + expect(filtered.length).toBe(2); }); - - assert.equal(filtered.length, 2); }); - -FilterBuilds.run(); diff --git a/app/features/img-upload/queries/addNewImage.ts b/app/features/img-upload/queries/addNewImage.ts index 7d7748985..20cd83310 100644 --- a/app/features/img-upload/queries/addNewImage.ts +++ b/app/features/img-upload/queries/addNewImage.ts @@ -51,11 +51,14 @@ export const addNewImage = sql.transaction( }) as UserSubmittedImage; if (type === "team-pfp") { - updateTeamAvatarStm.run({ avatarImgId: img.id, teamId }); + updateTeamAvatarStm.run({ avatarImgId: img.id, teamId: teamId ?? null }); } else if (type === "team-banner") { - updateTeamBannerStm.run({ bannerImgId: img.id, teamId }); + updateTeamBannerStm.run({ bannerImgId: img.id, teamId: teamId ?? null }); } else if (type === "org-pfp") { - updateOrganizationAvatarStm.run({ avatarImgId: img.id, organizationId }); + updateOrganizationAvatarStm.run({ + avatarImgId: img.id, + organizationId: organizationId ?? null, + }); } return img; diff --git a/app/features/leaderboards/routes/leaderboards.tsx b/app/features/leaderboards/routes/leaderboards.tsx index 5399411ca..7b4f4f5cd 100644 --- a/app/features/leaderboards/routes/leaderboards.tsx +++ b/app/features/leaderboards/routes/leaderboards.tsx @@ -461,7 +461,7 @@ function TeamTable({ entries: NonNullable["teamLeaderboard"]>; showQualificationDividers?: boolean; }) { - const { t } = useTranslation("common"); + const { t } = useTranslation(["common"]); const data = useLoaderData(); const isCurrentSeason = data.season === currentSeason(new Date())?.nth; const showQualificationDividers = diff --git a/app/features/lfg/actions/lfg.new.server.ts b/app/features/lfg/actions/lfg.new.server.ts index d3f4ef952..2504c44a7 100644 --- a/app/features/lfg/actions/lfg.new.server.ts +++ b/app/features/lfg/actions/lfg.new.server.ts @@ -17,7 +17,8 @@ export const action = async ({ request }: ActionFunctionArgs) => { }); const identifier = String(user.id); - const { team } = (await UserRepository.findByIdentifier(identifier)) ?? {}; + const { team } = + (await UserRepository.findProfileByIdentifier(identifier)) ?? {}; const shouldIncludeTeam = TEAM_POST_TYPES.includes(data.type); diff --git a/app/features/lfg/components/LFGAddFilterButton.tsx b/app/features/lfg/components/LFGAddFilterButton.tsx index d66f061d9..0a4c5d5e2 100644 --- a/app/features/lfg/components/LFGAddFilterButton.tsx +++ b/app/features/lfg/components/LFGAddFilterButton.tsx @@ -45,7 +45,7 @@ export function LFGAddFilterButton({ ({ id: tag, - text: t(`lfg:filters.${tag}`), + text: t(`lfg:filters.${tag as LFGFilter["_tag"]}`), disabled: filters.some((filter) => filter._tag === tag), onClick: () => addFilter(defaultFilter), }))} diff --git a/app/features/lfg/components/LFGPost.tsx b/app/features/lfg/components/LFGPost.tsx index 945e3e0e3..a32b2dfd2 100644 --- a/app/features/lfg/components/LFGPost.tsx +++ b/app/features/lfg/components/LFGPost.tsx @@ -450,7 +450,7 @@ function PostDeleteButton({ id, type }: { id: number; type: Post["type"] }) { return ( { const user = await requireUser(request); - const userProfileData = await UserRepository.findByIdentifier( + const userProfileData = await UserRepository.findProfileByIdentifier( String(user.id), ); const userQSettingsData = await QSettingsRepository.settingsByUserId(user.id); diff --git a/app/features/lfg/routes/lfg.tsx b/app/features/lfg/routes/lfg.tsx index 4eadab7f8..127bbb337 100644 --- a/app/features/lfg/routes/lfg.tsx +++ b/app/features/lfg/routes/lfg.tsx @@ -66,7 +66,7 @@ function encodeURLQuery(filters: LFGFilter[]): string { } export default function LFGPage() { - const { t } = useTranslation(["common, lfg"]); + const { t } = useTranslation(["common", "lfg"]); const user = useUser(); const data = useLoaderData(); const [filterFromSearch, setTilterFromSearch] = useSearchParamStateEncoder({ diff --git a/app/features/map-list-generator/core/map-pool-serializer/serializer.test.ts b/app/features/map-list-generator/core/map-pool-serializer/serializer.test.ts index e5efbe30f..e89277c64 100644 --- a/app/features/map-list-generator/core/map-pool-serializer/serializer.test.ts +++ b/app/features/map-list-generator/core/map-pool-serializer/serializer.test.ts @@ -1,91 +1,92 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import { describe, expect, test } from "bun:test"; import { mapPoolToSerializedString, serializedStringToMapPool, } from "./serializer"; import type { MapPoolObject } from "./types"; -const Serializer = suite("Map pool serializer"); - const testSerializedPool = "tw:1998000;sz:1d0a000;tc:164c000;rm:15e0000;cb:1ce0000"; -Serializer("Unserializes and then serializes to same result", () => { - const mapPool = serializedStringToMapPool(testSerializedPool); +describe("Map pool serializer", () => { + test("Unserializes and then serializes to same result", () => { + const mapPool = serializedStringToMapPool(testSerializedPool); - assert.equal(mapPoolToSerializedString(mapPool), testSerializedPool); + expect(mapPoolToSerializedString(mapPool)).toEqual(testSerializedPool); + }); + + test("Ignores invalid mode key", () => { + const testSerializedPoolWithInvalidMode = `${testSerializedPool};ab:1ce0`; + const mapPool = serializedStringToMapPool( + testSerializedPoolWithInvalidMode, + ); + + expect(mapPoolToSerializedString(mapPool)).toEqual(testSerializedPool); + }); + + test("Matching serialization with IPLMapGen2", () => { + const testMapPool: MapPoolObject = { + // Gorge, Spillway, Mincemeat, Mahi-Mahi, Inkblot + TW: [0, 3, 4, 7, 8], + // Gorge, Eeltail, Spillway, Inkblot, MakoMart + SZ: [0, 1, 3, 8, 10], + // Eeltail, Hagglefish, Bridge, Inbklot, Sturgeon + TC: [1, 2, 5, 8, 9], + // Eeltail, Spillway, Mincemeat, Bridge, Museum + RM: [1, 3, 4, 5, 6], + // Gorge, Eeltail, Mincemeat, Bridge, Museum + CB: [0, 1, 4, 5, 6], + }; + + expect(mapPoolToSerializedString(testMapPool)).toEqual(testSerializedPool); + }); + + test("Omits key if mode has no maps", () => { + const testPoolWithoutTw: MapPoolObject = { + CB: [1, 2], + RM: [1, 8], + TC: [8, 4], + SZ: [10], + TW: [], + }; + + const serialized = mapPoolToSerializedString(testPoolWithoutTw); + + expect( + serialized.includes("sz") && !serialized.includes("tw"), + ).toBeTruthy(); + }); + + test("Returns empty string if no maps", () => { + const testPoolWithoutTw: MapPoolObject = { + CB: [], + RM: [], + TC: [], + SZ: [], + TW: [], + }; + + const serialized = mapPoolToSerializedString(testPoolWithoutTw); + + expect(serialized).toEqual(""); + }); + + test("Value of two modes is the same with same maps", () => { + const testPoolWithDuplicateMaps: MapPoolObject = { + CB: [1, 2], + RM: [1, 2], + TC: [], + SZ: [], + TW: [], + }; + + const serialized = mapPoolToSerializedString(testPoolWithDuplicateMaps); + + const [modeOne, modeTwo] = serialized.split(";"); + if (!modeOne || !modeTwo) { + throw new Error("Map pool is missing modes"); + } + + expect(modeOne.split(":")[1]).toEqual(modeTwo.split(":")[1]); + }); }); - -Serializer("Ignores invalid mode key", () => { - const testSerializedPoolWithInvalidMode = `${testSerializedPool};ab:1ce0`; - const mapPool = serializedStringToMapPool(testSerializedPoolWithInvalidMode); - - assert.equal(mapPoolToSerializedString(mapPool), testSerializedPool); -}); - -Serializer("Matching serialization with IPLMapGen2", () => { - const testMapPool: MapPoolObject = { - // Gorge, Spillway, Mincemeat, Mahi-Mahi, Inkblot - TW: [0, 3, 4, 7, 8], - // Gorge, Eeltail, Spillway, Inkblot, MakoMart - SZ: [0, 1, 3, 8, 10], - // Eeltail, Hagglefish, Bridge, Inbklot, Sturgeon - TC: [1, 2, 5, 8, 9], - // Eeltail, Spillway, Mincemeat, Bridge, Museum - RM: [1, 3, 4, 5, 6], - // Gorge, Eeltail, Mincemeat, Bridge, Museum - CB: [0, 1, 4, 5, 6], - }; - - assert.equal(mapPoolToSerializedString(testMapPool), testSerializedPool); -}); - -Serializer("Omits key if mode has no maps", () => { - const testPoolWithoutTw: MapPoolObject = { - CB: [1, 2], - RM: [1, 8], - TC: [8, 4], - SZ: [10], - TW: [], - }; - - const serialized = mapPoolToSerializedString(testPoolWithoutTw); - - assert.ok(serialized.includes("sz") && !serialized.includes("tw")); -}); - -Serializer("Returns empty string if no maps", () => { - const testPoolWithoutTw: MapPoolObject = { - CB: [], - RM: [], - TC: [], - SZ: [], - TW: [], - }; - - const serialized = mapPoolToSerializedString(testPoolWithoutTw); - - assert.equal(serialized, ""); -}); - -Serializer("Value of two modes is the same with same maps", () => { - const testPoolWithDuplicateMaps: MapPoolObject = { - CB: [1, 2], - RM: [1, 2], - TC: [], - SZ: [], - TW: [], - }; - - const serialized = mapPoolToSerializedString(testPoolWithDuplicateMaps); - - const [modeOne, modeTwo] = serialized.split(";"); - if (!modeOne || !modeTwo) { - throw new Error("Map pool is missing modes"); - } - - assert.equal(modeOne.split(":")[1], modeTwo.split(":")[1]); -}); - -Serializer.run(); diff --git a/app/features/mmr/mmr-utils.ts b/app/features/mmr/mmr-utils.ts index d100dd66f..86b4b4796 100644 --- a/app/features/mmr/mmr-utils.ts +++ b/app/features/mmr/mmr-utils.ts @@ -1,5 +1,5 @@ +import type { Rating, Team } from "node_modules/openskill/dist/types"; import { rate as openskillRate, ordinal, rating } from "openskill"; -import type { Rating, Team } from "openskill/dist/types"; import invariant from "~/utils/invariant"; import type { TierName } from "./mmr-constants"; import { TIERS } from "./mmr-constants"; diff --git a/app/features/object-damage-calculator/core/objectDamage.test.ts b/app/features/object-damage-calculator/core/objectDamage.test.ts index 929e53312..910794ba7 100644 --- a/app/features/object-damage-calculator/core/objectDamage.test.ts +++ b/app/features/object-damage-calculator/core/objectDamage.test.ts @@ -1,5 +1,4 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import { describe, expect, test } from "bun:test"; import { buildStats } from "~/features/build-analyzer"; import type { AbilityPoints, @@ -54,128 +53,126 @@ function calculate({ }); } -const CalculateDamage = suite("calculateDamage()"); +describe("calculateDamage()", () => { + // the function throws if weapon resolves to more than one set of damage rates + // so this test goes through all of them to make sure they all work + test("Every weapon can calculate damage", () => { + for (const mainWeaponId of mainWeaponIds) { + const analyzed = buildStats({ + weaponSplId: mainWeaponId, + hasTacticooler: false, + }); -// the function throws if weapon resolves to more than one set of damage rates -// so this test goes through all of them to make sure they all work -CalculateDamage("Every weapon can calculate damage", () => { - for (const mainWeaponId of mainWeaponIds) { - const analyzed = buildStats({ - weaponSplId: mainWeaponId, - hasTacticooler: false, - }); - - for (const damage of analyzed.stats.damages) { - calculate({ mainWeaponId, damageType: damage.type }); + for (const damage of analyzed.stats.damages) { + calculate({ mainWeaponId, damageType: damage.type }); + } } - } - const analyzed = buildStats({ - weaponSplId: 0, - hasTacticooler: false, - }); - - for (const damage of analyzed.stats.subWeaponDefenseDamages) { - calculate({ - subWeaponId: damage.subWeaponId, - damageType: damage.type, - preAnalyzed: analyzed, - }); - } - - for (const specialWeaponId of specialWeaponIds) { - const analyzedWithSpecialWeapon = buildStats({ - weaponSplId: exampleMainWeaponIdWithSpecialWeaponId(specialWeaponId), + const analyzed = buildStats({ + weaponSplId: 0, hasTacticooler: false, }); - for (const damage of analyzedWithSpecialWeapon.stats.specialWeaponDamages) { + for (const damage of analyzed.stats.subWeaponDefenseDamages) { calculate({ - specialWeaponId, + subWeaponId: damage.subWeaponId, damageType: damage.type, - preAnalyzed: analyzedWithSpecialWeapon, + preAnalyzed: analyzed, }); } - } -}); -CalculateDamage("BRU increases Splash Wall hitpoints", () => { - const withoutBRU = calculate({}); - const withBRU = calculate({ - abilityPoints: new Map([["BRU", 10]]), + for (const specialWeaponId of specialWeaponIds) { + const analyzedWithSpecialWeapon = buildStats({ + weaponSplId: exampleMainWeaponIdWithSpecialWeaponId(specialWeaponId), + hasTacticooler: false, + }); + + for (const damage of analyzedWithSpecialWeapon.stats + .specialWeaponDamages) { + calculate({ + specialWeaponId, + damageType: damage.type, + preAnalyzed: analyzedWithSpecialWeapon, + }); + } + } }); - const hpWithoutBRU = withoutBRU.find( - (d) => d.receiver === "Wsb_Shield", - )?.hitPoints; - const hpWithBRU = withBRU.find((d) => d.receiver === "Wsb_Shield")?.hitPoints; + test("BRU increases Splash Wall hitpoints", () => { + const withoutBRU = calculate({}); + const withBRU = calculate({ + abilityPoints: new Map([["BRU", 10]]), + }); - assert.ok(typeof hpWithoutBRU === "number"); - assert.ok(typeof hpWithBRU === "number"); - assert.ok(hpWithoutBRU < hpWithBRU); -}); + const hpWithoutBRU = withoutBRU.find( + (d) => d.receiver === "Wsb_Shield", + )?.hitPoints; + const hpWithBRU = withBRU.find((d) => d.receiver === "Wsb_Shield") + ?.hitPoints!; -CalculateDamage("SPU increases Big Bubbler hitpoints", () => { - const withoutSPU = calculate({}); - const withSPU = calculate({ - abilityPoints: new Map([["SPU", 10]]), + expect(typeof hpWithoutBRU).toBe("number"); + expect(typeof hpWithBRU).toBe("number"); + expect(hpWithoutBRU).toBeLessThan(hpWithBRU); }); - const hpWithoutSPU = withoutSPU.find( - (d) => d.receiver === "GreatBarrier_Barrier", - )?.hitPoints; - const hpWithSPU = withSPU.find( - (d) => d.receiver === "GreatBarrier_Barrier", - )?.hitPoints; + test("SPU increases Big Bubbler hitpoints", () => { + const withoutSPU = calculate({}); + const withSPU = calculate({ + abilityPoints: new Map([["SPU", 10]]), + }); - assert.ok(typeof hpWithoutSPU === "number"); - assert.ok(typeof hpWithSPU === "number"); - assert.ok(hpWithoutSPU < hpWithSPU); -}); + const hpWithoutSPU = withoutSPU.find( + (d) => d.receiver === "GreatBarrier_Barrier", + )?.hitPoints; + const hpWithSPU = withSPU.find((d) => d.receiver === "GreatBarrier_Barrier") + ?.hitPoints!; -const shotsToPopRM: Array< - [ - weaponId: MainWeaponId, - damageType: DamageType, - shotsToPop: number, - shotsToPopOS: number, - ] -> = [ - // Splattershot - [40, "NORMAL_MAX", 28, 26], - // Range Blaster - [220, "DIRECT", 5, 4], - // .96 Gal - [80, "NORMAL_MAX", 17, 15], - // Luna Blaster - [200, "DIRECT", 4, 4], - // Splat Charger - [2010, "FULL_CHARGE", 4, 3], - // E-liter 4K - [2030, "TAP_SHOT", 13, 12], - // Hydra Splatling - [4020, "NORMAL_MAX", 32, 29], - // Sloshing Machine - [3020, "DIRECT_MAX", 6, 5], - // Splat Dualies - [5010, "NORMAL_MAX", 34, 31], - // Tenta Brella - [6010, "NORMAL_MAX", 4, 4], - // // Tri-Stringer - [7010, "NORMAL_MAX", 3, 3], - // REEF-LUX - [7020, "NORMAL_MIN", 8, 7], - // Splatana Wiper - [8010, "SPLATANA_HORIZONTAL", 11, 10], - // Splatana Wiper - [8010, "SPLATANA_HORIZONTAL_DIRECT", 9, 8], - // Splatana Stamper - [8000, "SPLATANA_VERTICAL_DIRECT", 3, 3], -]; + expect(typeof hpWithoutSPU).toBe("number"); + expect(typeof hpWithSPU).toBe("number"); + expect(hpWithoutSPU).toBeLessThan(hpWithSPU); + }); -CalculateDamage( - "Calculates matching HTD Rainmaker shield to in-game tests", - () => { + const shotsToPopRM: Array< + [ + weaponId: MainWeaponId, + damageType: DamageType, + shotsToPop: number, + shotsToPopOS: number, + ] + > = [ + // Splattershot + [40, "NORMAL_MAX", 28, 26], + // Range Blaster + [220, "DIRECT", 5, 4], + // .96 Gal + [80, "NORMAL_MAX", 17, 15], + // Luna Blaster + [200, "DIRECT", 4, 4], + // Splat Charger + [2010, "FULL_CHARGE", 4, 3], + // E-liter 4K + [2030, "TAP_SHOT", 13, 12], + // Hydra Splatling + [4020, "NORMAL_MAX", 32, 29], + // Sloshing Machine + [3020, "DIRECT_MAX", 6, 5], + // Splat Dualies + [5010, "NORMAL_MAX", 34, 31], + // Tenta Brella + [6010, "NORMAL_MAX", 4, 4], + // // Tri-Stringer + [7010, "NORMAL_MAX", 3, 3], + // REEF-LUX + [7020, "NORMAL_MIN", 8, 7], + // Splatana Wiper + [8010, "SPLATANA_HORIZONTAL", 11, 10], + // Splatana Wiper + [8010, "SPLATANA_HORIZONTAL_DIRECT", 9, 8], + // Splatana Stamper + [8000, "SPLATANA_VERTICAL_DIRECT", 3, 3], + ]; + + test("Calculates matching HTD Rainmaker shield to in-game tests", () => { for (const [ mainWeaponId, damageType, @@ -188,24 +185,19 @@ CalculateDamage( (d) => d.receiver === "Gachihoko_Barrier", )!; - assert.equal( + expect( damageVsRM.damages.find((d) => !d.objectShredder)!.hitsToDestroy, - shotsToPop, `Shots to pop wrong for weapon id: ${mainWeaponId}`, - ); - assert.equal( + ).toBe(shotsToPop); + expect( damageVsRM.damages.find((d) => d.objectShredder)!.hitsToDestroy, - shotsToPopOS, `Shots to pop wrong with OS for weapon id: ${mainWeaponId}`, - ); + ).toBe(shotsToPopOS); } - }, -); + }); -const HYDRA_SPLATLING_ID = 4020; -CalculateDamage( - "Hits to destroy Minimum < Maximum < Maximum (Fully charged)", - () => { + const HYDRA_SPLATLING_ID = 4020; + test("Hits to destroy Minimum < Maximum < Maximum (Fully charged)", () => { const min = calculate({ mainWeaponId: HYDRA_SPLATLING_ID, damageType: "NORMAL_MIN", @@ -219,13 +211,11 @@ CalculateDamage( damageType: "NORMAL_MAX_FULL_CHARGE", })[0]?.damages[0]?.hitsToDestroy; - assert.ok(typeof min === "number"); - assert.ok(typeof max === "number"); - assert.ok(typeof maxFullyCharged === "number"); + expect(typeof min).toBe("number"); + expect(typeof max).toBe("number"); + expect(typeof maxFullyCharged).toBe("number"); - assert.ok(min > max); - assert.ok(max > maxFullyCharged); - }, -); - -CalculateDamage.run(); + expect(min).toBeGreaterThan(max); + expect(max).toBeGreaterThan(maxFullyCharged); + }); +}); diff --git a/app/features/sendouq/core/groups.server.test.ts b/app/features/sendouq/core/groups.server.test.ts index 836b4a4cf..cc4bfb7d8 100644 --- a/app/features/sendouq/core/groups.server.test.ts +++ b/app/features/sendouq/core/groups.server.test.ts @@ -1,5 +1,4 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import { describe, expect, test } from "bun:test"; import { tierDifferenceToRangeOrExact } from "./groups.server"; const paramsToExpected = new Map< @@ -64,29 +63,24 @@ const paramsToExpected = new Map< { isPlus: false, name: "DIAMOND" }, ); -const TierDifferenceToRangeOrExact = suite("tierDifferenceToRangeOrExact()"); - -for (const [input, expected] of paramsToExpected) { - TierDifferenceToRangeOrExact( - `works for ${JSON.stringify(input)} -> ${JSON.stringify(expected)}`, - () => { +describe("tierDifferenceToRangeOrExact()", () => { + for (const [input, expected] of paramsToExpected) { + test(`works for ${JSON.stringify(input)} -> ${JSON.stringify(expected)}`, () => { const result = tierDifferenceToRangeOrExact({ ourTier: input[0], theirTier: input[1], hasLeviathan: true, }).tier; - assert.equal(result, expected); - }, - ); -} + expect(result).toEqual(expected); + }); + } -TierDifferenceToRangeOrExact("works before leviathan", () => { - const result = tierDifferenceToRangeOrExact({ - ourTier: { isPlus: true, name: "DIAMOND" }, - theirTier: { isPlus: false, name: "DIAMOND" }, - hasLeviathan: false, - }).tier; - assert.equal(result, { isPlus: false, name: "DIAMOND" }); + test("works before leviathan", () => { + const result = tierDifferenceToRangeOrExact({ + ourTier: { isPlus: true, name: "DIAMOND" }, + theirTier: { isPlus: false, name: "DIAMOND" }, + hasLeviathan: false, + }).tier; + expect(result).toEqual({ isPlus: false, name: "DIAMOND" }); + }); }); - -TierDifferenceToRangeOrExact.run(); diff --git a/app/features/sendouq/core/match.server.test.ts b/app/features/sendouq/core/match.server.test.ts index 172730ca0..80a056c82 100644 --- a/app/features/sendouq/core/match.server.test.ts +++ b/app/features/sendouq/core/match.server.test.ts @@ -1,5 +1,4 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import { describe, expect, test } from "bun:test"; import type { UserMapModePreferences } from "~/db/tables"; import type { StageId } from "~/modules/in-game-lists"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; @@ -8,18 +7,16 @@ import * as Test from "~/utils/Test"; import { nullFilledArray } from "~/utils/arrays"; import { mapLottery, mapModePreferencesToModeList } from "./match.server"; -const MapModePreferencesToModeList = suite("mapModePreferencesToModeList()"); -const MapPoolFromPreferences = suite("mapPoolFromPreferences()"); +describe("mapModePreferencesToModeList()", () => { + test("returns default list if no preferences", () => { + const modeList = mapModePreferencesToModeList([], []); -MapModePreferencesToModeList("returns default list if no preferences", () => { - const modeList = mapModePreferencesToModeList([], []); + expect( + Test.arrayContainsSameItems(["SZ", "TC", "RM", "CB"], modeList), + ).toBeTrue(); + }); - assert.ok(Test.arrayContainsSameItems(["SZ", "TC", "RM", "CB"], modeList)); -}); - -MapModePreferencesToModeList( - "returns default list if equally disliking everything", - () => { + test("returns default list if equally disliking everything", () => { const dislikingEverything = [ { mode: "TW", preference: "AVOID" } as const, { mode: "SZ", preference: "AVOID" } as const, @@ -43,25 +40,23 @@ MapModePreferencesToModeList( ], ); - assert.ok(Test.arrayContainsSameItems(["SZ", "TC", "RM", "CB"], modeList)); - }, -); + expect( + Test.arrayContainsSameItems(["SZ", "TC", "RM", "CB"], modeList), + ).toBe(true); + }); -MapModePreferencesToModeList( - "if positive about nothing, choose the most liked (-TW)", - () => { + test("if positive about nothing, choose the most liked (-TW)", () => { const modeList = mapModePreferencesToModeList( [[{ mode: "SZ", preference: "AVOID" }]], [], ); - assert.ok(Test.arrayContainsSameItems(["TC", "RM", "CB"], modeList)); - }, -); + expect(Test.arrayContainsSameItems(["TC", "RM", "CB"], modeList)).toBe( + true, + ); + }); -MapModePreferencesToModeList( - "only turf war possible to get if least bad option", - () => { + test("only turf war possible to get if least bad option", () => { const modeList = mapModePreferencesToModeList( [ [ @@ -76,110 +71,98 @@ MapModePreferencesToModeList( [], ); - assert.ok(Test.arrayContainsSameItems(["TW"], modeList)); - }, -); + expect(Test.arrayContainsSameItems(["TW"], modeList)).toBe(true); + }); -MapModePreferencesToModeList("team votes for their preference", () => { - const modeList = mapModePreferencesToModeList( - [ + test("team votes for their preference", () => { + const modeList = mapModePreferencesToModeList( [ - { mode: "SZ", preference: "PREFER" }, - { mode: "TC", preference: "PREFER" }, + [ + { mode: "SZ", preference: "PREFER" }, + { mode: "TC", preference: "PREFER" }, + ], + [{ mode: "TC", preference: "PREFER" }], + [{ mode: "TC", preference: "AVOID" }], + [{ mode: "TC", preference: "PREFER" }], ], - [{ mode: "TC", preference: "PREFER" }], - [{ mode: "TC", preference: "AVOID" }], - [{ mode: "TC", preference: "PREFER" }], - ], - [ - [{ mode: "TC", preference: "PREFER" }], - [{ mode: "TC", preference: "PREFER" }], - [{ mode: "TC", preference: "AVOID" }], - [{ mode: "TC", preference: "AVOID" }], - ], - ); + [ + [{ mode: "TC", preference: "PREFER" }], + [{ mode: "TC", preference: "PREFER" }], + [{ mode: "TC", preference: "AVOID" }], + [{ mode: "TC", preference: "AVOID" }], + ], + ); - assert.ok(Test.arrayContainsSameItems(["SZ", "TC"], modeList)); -}); + expect(Test.arrayContainsSameItems(["SZ", "TC"], modeList)).toBe(true); + }); -MapModePreferencesToModeList( - "favorite ranked mode sorted first in the array", - () => { - assert.equal( + test("favorite ranked mode sorted first in the array", () => { + expect( mapModePreferencesToModeList( [[{ mode: "TC", preference: "PREFER" }]], [], )[0], - "TC", - ); - }, -); + ).toBe("TC"); + }); -MapModePreferencesToModeList( - "includes turf war if more prefer than want to avoid", - () => { + test("includes turf war if more prefer than want to avoid", () => { const modeList = mapModePreferencesToModeList( [[{ mode: "TW", preference: "PREFER" }]], [[{ mode: "SZ", preference: "PREFER" }]], ); - assert.ok(Test.arrayContainsSameItems(["TW", "SZ"], modeList)); - }, -); + expect(Test.arrayContainsSameItems(["TW", "SZ"], modeList)).toBe(true); + }); -MapModePreferencesToModeList("doesn't include turf war if mixed", () => { - const modeList = mapModePreferencesToModeList( - [[{ mode: "TW", preference: "PREFER" }]], - [[{ mode: "TW", preference: "AVOID" }]], - ); + test("doesn't include turf war if mixed", () => { + const modeList = mapModePreferencesToModeList( + [[{ mode: "TW", preference: "PREFER" }]], + [[{ mode: "TW", preference: "AVOID" }]], + ); - assert.ok(Test.arrayContainsSameItems(["SZ", "TC", "RM", "CB"], modeList)); + expect( + Test.arrayContainsSameItems(["SZ", "TC", "RM", "CB"], modeList), + ).toBe(true); + }); }); const MODES_COUNT = 4; const STAGES_PER_MODE = 7; -MapPoolFromPreferences("returns maps even if no preferences", () => { - const mapPool = mapLottery([], rankedModesShort); +describe("mapLottery()", () => { + test("returns maps even if no preferences", () => { + const mapPool = mapLottery([], rankedModesShort); - assert.equal(mapPool.stageModePairs.length, STAGES_PER_MODE * MODES_COUNT); -}); + expect(mapPool.stageModePairs.length).toBe(STAGES_PER_MODE * MODES_COUNT); + }); -MapPoolFromPreferences("returns some maps from the map pools", () => { - const memberOnePool: UserMapModePreferences["pool"] = rankedModesShort.map( - (mode) => ({ - mode, - stages: nullFilledArray(7).map((_, i) => (i + 1) as StageId), - }), - ); - const memberTwoPool: UserMapModePreferences["pool"] = rankedModesShort.map( - (mode) => ({ - mode, - stages: nullFilledArray(7).map((_, i) => (i + 10) as StageId), - }), - ); + test("returns some maps from the map pools", () => { + const memberOnePool: UserMapModePreferences["pool"] = rankedModesShort.map( + (mode) => ({ + mode, + stages: nullFilledArray(7).map((_, i) => (i + 1) as StageId), + }), + ); + const memberTwoPool: UserMapModePreferences["pool"] = rankedModesShort.map( + (mode) => ({ + mode, + stages: nullFilledArray(7).map((_, i) => (i + 10) as StageId), + }), + ); - const pool = mapLottery( - [ - { modes: [], pool: memberOnePool }, - { modes: [], pool: memberTwoPool }, - ], - rankedModesShort, - ); + const pool = mapLottery( + [ + { modes: [], pool: memberOnePool }, + { modes: [], pool: memberTwoPool }, + ], + rankedModesShort, + ); - assert.ok( - pool.stageModePairs.some((p) => p.stageId <= 7), - "No map from memberOnePool", - ); - assert.ok( - pool.stageModePairs.some((p) => p.stageId > 10), - "No map from memberTwoPool", - ); -}); + expect(pool.stageModePairs.some((p) => p.stageId <= 7)).toBe(true); + expect(pool.stageModePairs.some((p) => p.stageId > 10)).toBe(true); + }); -MapPoolFromPreferences( - "includes modes that were given and nothing else", - () => { + test("includes modes that were given and nothing else", () => { const memberOnePool: UserMapModePreferences["pool"] = rankedModesShort.map( (mode) => ({ mode, @@ -189,31 +172,28 @@ MapPoolFromPreferences( const pool = mapLottery([{ modes: [], pool: memberOnePool }], ["SZ", "TC"]); - assert.ok( + expect( pool.stageModePairs.every((p) => p.mode === "SZ" || p.mode === "TC"), + ).toBe(true); + }); + + test("excludes map preferences if mode is avoided", () => { + const memberOnePool: UserMapModePreferences["pool"] = [ + { + mode: "SZ", + stages: nullFilledArray(7).map((_, i) => (i + 1) as StageId), + }, + ]; + + const pool = mapLottery( + [{ modes: [{ preference: "AVOID", mode: "SZ" }], pool: memberOnePool }], + ["SZ"], ); - }, -); -MapPoolFromPreferences("excludes map preferences if mode is avoided", () => { - const memberOnePool: UserMapModePreferences["pool"] = [ - { - mode: "SZ", - stages: nullFilledArray(7).map((_, i) => (i + 1) as StageId), - }, - ]; - - const pool = mapLottery( - [{ modes: [{ preference: "AVOID", mode: "SZ" }], pool: memberOnePool }], - ["SZ"], - ); - - assert.ok( - pool.stageModePairs.every((p) => - SENDOUQ_DEFAULT_MAPS.SZ.some((stageId) => stageId === p.stageId), - ), - ); + expect( + pool.stageModePairs.every((p) => + SENDOUQ_DEFAULT_MAPS.SZ.some((stageId) => stageId === p.stageId), + ), + ).toBe(true); + }); }); - -MapModePreferencesToModeList.run(); -MapPoolFromPreferences.run(); diff --git a/app/features/sendouq/core/reported-weapons.server.test.ts b/app/features/sendouq/core/reported-weapons.server.test.ts index d2c26890b..26301b4db 100644 --- a/app/features/sendouq/core/reported-weapons.server.test.ts +++ b/app/features/sendouq/core/reported-weapons.server.test.ts @@ -1,110 +1,107 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import { describe, expect, test } from "bun:test"; import type { MainWeaponId } from "~/modules/in-game-lists"; import { mergeReportedWeapons } from "./reported-weapons.server"; -const MergeReportedWeapons = suite("mergeReportedWeapons()"); +describe("mergeReportedWeapons()", () => { + const newWeapons = [ + { + groupMatchMapId: 1, + mapIndex: 0, + userId: 1, + weaponSplId: 0 as MainWeaponId, + }, + ]; -const newWeapons: Parameters[0]["newWeapons"] = [ - { - groupMatchMapId: 1, - mapIndex: 0, - userId: 1, - weaponSplId: 0 as MainWeaponId, - }, -]; + test("handles no old weapons", () => { + const result = mergeReportedWeapons({ newWeapons, oldWeapons: [] }); -MergeReportedWeapons("handles no old weapons", () => { - const result = mergeReportedWeapons({ newWeapons, oldWeapons: [] }); - - assert.equal(result, newWeapons); -}); - -MergeReportedWeapons("replaces a weapon", () => { - const result = mergeReportedWeapons({ - newWeapons, - oldWeapons: [ - { - groupMatchMapId: 1, - mapIndex: 0, - userId: 1, - weaponSplId: 1 as MainWeaponId, - }, - ], + expect(result).toEqual(newWeapons); }); - assert.equal(result, newWeapons); -}); + test("replaces a weapon", () => { + const result = mergeReportedWeapons({ + newWeapons, + oldWeapons: [ + { + groupMatchMapId: 1, + mapIndex: 0, + userId: 1, + weaponSplId: 1 as MainWeaponId, + }, + ], + }); -MergeReportedWeapons("merges two completely separate lists", () => { - const result = mergeReportedWeapons({ - newWeapons, - oldWeapons: [ + expect(result).toEqual(newWeapons); + }); + + test("merges two completely separate lists", () => { + const result = mergeReportedWeapons({ + newWeapons, + oldWeapons: [ + { + groupMatchMapId: 1, + mapIndex: 0, + userId: 2, + weaponSplId: 0 as MainWeaponId, + }, + ], + }); + + expect(result).toEqual([ { groupMatchMapId: 1, mapIndex: 0, userId: 2, weaponSplId: 0 as MainWeaponId, }, - ], + ...newWeapons, + ]); }); - assert.equal(result, [ - { - groupMatchMapId: 1, - mapIndex: 0, - userId: 2, - weaponSplId: 0 as MainWeaponId, - }, - ...newWeapons, - ]); -}); + test("handles merging partially same list", () => { + const result = mergeReportedWeapons({ + newWeapons, + oldWeapons: [ + { + groupMatchMapId: 1, + mapIndex: 0, + userId: 1, + weaponSplId: 1 as MainWeaponId, + }, + { + groupMatchMapId: 1, + mapIndex: 0, + userId: 2, + weaponSplId: 0 as MainWeaponId, + }, + ], + }); -MergeReportedWeapons("handles merging partially same list", () => { - const result = mergeReportedWeapons({ - newWeapons, - oldWeapons: [ - { - groupMatchMapId: 1, - mapIndex: 0, - userId: 1, - weaponSplId: 1 as MainWeaponId, - }, + expect(result).toEqual([ + ...newWeapons, { groupMatchMapId: 1, mapIndex: 0, userId: 2, weaponSplId: 0 as MainWeaponId, }, - ], + ]); }); - assert.equal(result, [ - ...newWeapons, - { - groupMatchMapId: 1, - mapIndex: 0, - userId: 2, - weaponSplId: 0 as MainWeaponId, - }, - ]); -}); + test("slices unplayed maps", () => { + const result = mergeReportedWeapons({ + newWeapons, + oldWeapons: [ + { + groupMatchMapId: 1, + mapIndex: 1, + userId: 1, + weaponSplId: 0 as MainWeaponId, + }, + ], + newReportedMapsCount: 1, + }); -MergeReportedWeapons("slices unplayed maps", () => { - const result = mergeReportedWeapons({ - newWeapons, - oldWeapons: [ - { - groupMatchMapId: 1, - mapIndex: 1, - userId: 1, - weaponSplId: 0 as MainWeaponId, - }, - ], - newReportedMapsCount: 1, + expect(result).toEqual(newWeapons); }); - - assert.equal(result, newWeapons); }); - -MergeReportedWeapons.run(); diff --git a/app/features/sendouq/core/skills.server.ts b/app/features/sendouq/core/skills.server.ts index 70946b170..4b0c32f18 100644 --- a/app/features/sendouq/core/skills.server.ts +++ b/app/features/sendouq/core/skills.server.ts @@ -1,5 +1,5 @@ +import type { Rating } from "node_modules/openskill/dist/types"; import { ordinal } from "openskill"; -import type { Rating } from "openskill/dist/types"; import type { Group, GroupMatch, diff --git a/app/features/sendouq/queries/addPlayerResults.server.ts b/app/features/sendouq/queries/addPlayerResults.server.ts index 2d7df52bb..dbde4fdbf 100644 --- a/app/features/sendouq/queries/addPlayerResults.server.ts +++ b/app/features/sendouq/queries/addPlayerResults.server.ts @@ -31,6 +31,15 @@ const addPlayerResultDeltaStm = sql.prepare(/* sql */ ` export function addPlayerResults(results: Array) { for (const result of results) { - addPlayerResultDeltaStm.run(result); + addPlayerResultDeltaStm.run({ + ownerUserId: result.ownerUserId, + otherUserId: result.otherUserId, + mapWins: result.mapWins, + mapLosses: result.mapLosses, + setWins: result.setWins, + setLosses: result.setLosses, + type: result.type, + season: result.season, + }); } } diff --git a/app/features/sendouq/queries/chatCodeByGroupId.server.ts b/app/features/sendouq/queries/chatCodeByGroupId.server.ts index f93309e6c..1882e503c 100644 --- a/app/features/sendouq/queries/chatCodeByGroupId.server.ts +++ b/app/features/sendouq/queries/chatCodeByGroupId.server.ts @@ -8,5 +8,5 @@ const stm = sql.prepare(/* sql */ ` `); export function chatCodeByGroupId(id: number) { - return stm.pluck().get({ id }) as string | undefined; + return (stm.get({ id }) as any)?.chatCode as string | undefined; } diff --git a/app/features/sendouq/queries/groupSize.server.ts b/app/features/sendouq/queries/groupSize.server.ts index fbeb4f858..1eb9f455c 100644 --- a/app/features/sendouq/queries/groupSize.server.ts +++ b/app/features/sendouq/queries/groupSize.server.ts @@ -10,5 +10,5 @@ const stm = sql.prepare(/* sql */ ` `); export function groupSize(groupId: number) { - return stm.pluck().get({ groupId }) as number; + return (stm.get({ groupId }) as any).count as number; } diff --git a/app/features/sendouq/routes/q.looking.test.ts b/app/features/sendouq/routes/q.looking.test.ts index 319cc14c0..047c1320d 100644 --- a/app/features/sendouq/routes/q.looking.test.ts +++ b/app/features/sendouq/routes/q.looking.test.ts @@ -1,22 +1,20 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import type { SerializeFrom } from "@remix-run/server-runtime"; -import { suite } from "uvu"; -import * as assert from "uvu/assert"; import { db } from "~/db/sql"; import type { UserMapModePreferences } from "~/db/tables"; +import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps"; import { stageIds } from "~/modules/in-game-lists"; -import * as Test from "~/utils/Test"; +import { + dbInsertUsers, + dbReset, + wrappedAction, + wrappedLoader, +} from "~/utils/Test"; import invariant from "~/utils/invariant"; import type { lookingSchema, matchSchema } from "../q-schemas.server"; import { loader, action as rawLookingAction } from "./q.looking"; import { action as rawMatchAction } from "./q.match.$id"; -const SendouQMatchCreation = suite("SendouQ match creation"); -const PrivateUserNoteSorting = suite("Private user note sorting"); - -const lookingAction = Test.wrappedAction({ - action: rawLookingAction, -}); - const createGroup = async (userIds: number[]) => { const group = await db .insertInto("Group") @@ -47,7 +45,7 @@ const SZ_ONLY_PREFERENCE: UserMapModePreferences["modes"] = [ ]; const prepareGroups = async () => { - await Test.database.insertUsers(8); + await dbInsertUsers(8); await createGroup([1, 2, 3, 4]); await createGroup([5, 6, 7, 8]); await db @@ -81,6 +79,10 @@ const insertMapModePreferences = ( .execute(); }; +const lookingAction = wrappedAction({ + action: rawLookingAction, +}); + const createMatch = () => lookingAction( { @@ -97,65 +99,68 @@ const findMatch = () => .where("id", "=", 1) .executeTakeFirstOrThrow(); -SendouQMatchCreation.before.each(async () => { - await prepareGroups(); -}); - -SendouQMatchCreation.after.each(() => { - Test.database.reset(); -}); - -SendouQMatchCreation("adds pools to memento", async () => { - await createMatch(); - - const match = await findMatch(); - const pools = match.memento?.pools; - - invariant(pools, "pools missing"); - - assert.equal(pools.length, 2); - assert.ok(pools.some((p) => p.pool[0].stages.includes(1))); - assert.ok(pools.some((p) => p.pool[0].stages.includes(19))); -}); - -SendouQMatchCreation("doesn't add pool where mode is avoided", async () => { - await insertMapModePreferences(1, { - modes: [ - { mode: "SZ", preference: "AVOID" }, - { mode: "TC", preference: "PREFER" }, - ], - pool: [ - { mode: "SZ", stages: [...stageIds].slice(0, 7) }, - { mode: "TC", stages: [...stageIds].slice(0, 7) }, - ], +describe("SendouQ match creation", () => { + beforeEach(async () => { + await prepareGroups(); }); - await createMatch(); + afterEach(() => { + dbReset(); + }); - const match = await findMatch(); - const pools = match.memento?.pools; + test("adds pools to memento", async () => { + await createMatch(); - invariant(pools, "pools missing"); + const match = await findMatch(); + const pools = match.memento?.pools; - assert.equal(pools.length, 2); - assert.ok( - pools.find((p) => p.userId === 1)!.pool.every((p) => p.mode !== "SZ"), - ); -}); + invariant(pools, "pools missing"); -SendouQMatchCreation("adds mode preferences to memento", async () => { - await createMatch(); + expect(pools.length).toBe(2); + expect(pools.some((p) => p.pool[0].stages.includes(1))).toBe(true); + expect(pools.some((p) => p.pool[0].stages.includes(19))).toBe(true); + }); - const match = await findMatch(); + test("doesn't add pool where mode is avoided", async () => { + await insertMapModePreferences(1, { + modes: [ + { mode: "SZ", preference: "AVOID" }, + { mode: "TC", preference: "PREFER" }, + ], + pool: [ + { + mode: "TC", + stages: [...stageIds] + .filter((stageId) => !BANNED_MAPS.TC.includes(stageId)) + .slice(0, 7), + }, + ], + }); - const modePreferences = match.memento?.modePreferences; + await createMatch(); - assert.equal(modePreferences?.SZ?.length, 2); -}); + const match = await findMatch(); + const pools = match.memento?.pools; -SendouQMatchCreation( - "adds mode preferences to memento including neutral", - async () => { + invariant(pools, "pools missing"); + + expect(pools.length).toBe(2); + expect( + pools.find((p) => p.userId === 1)!.pool.every((p) => p.mode !== "SZ"), + ).toBe(true); + }); + + test("adds mode preferences to memento", async () => { + await createMatch(); + + const match = await findMatch(); + + const modePreferences = match.memento?.modePreferences; + + expect(modePreferences?.SZ?.length).toBe(2); + }); + + test("adds mode preferences to memento including neutral", async () => { await insertMapModePreferences(2, { modes: [{ mode: "TC", preference: "PREFER" }], pool: [], @@ -167,78 +172,76 @@ SendouQMatchCreation( const modePreferences = match.memento?.modePreferences; - assert.equal(modePreferences?.SZ?.length, 3); - assert.ok(modePreferences?.SZ?.some((p) => !p.preference)); - }, -); - -PrivateUserNoteSorting.before.each(async () => { - await Test.database.insertUsers(8); - - await createGroup([1]); - await createGroup([2]); - await createGroup([3]); - await createGroup([4]); - await createGroup([5]); - await createGroup([6, 7]); - await createGroup([8]); - - await db - .insertInto("GroupMatch") - .values({ alphaGroupId: 2, bravoGroupId: 3 }) - .execute(); + expect(modePreferences?.SZ?.length).toBe(3); + expect(modePreferences?.SZ?.some((p) => !p.preference)).toBe(true); + }); }); -PrivateUserNoteSorting.after.each(() => { - Test.database.reset(); -}); +describe("Private user note sorting", () => { + beforeEach(async () => { + await dbInsertUsers(8); -const lookingLoader = Test.wrappedLoader>({ - loader, -}); -const matchAction = Test.wrappedAction({ - action: rawMatchAction, - params: { id: "1" }, -}); + await createGroup([1]); + await createGroup([2]); + await createGroup([3]); + await createGroup([4]); + await createGroup([5]); + await createGroup([6, 7]); + await createGroup([8]); -PrivateUserNoteSorting("users with positive note sorted first", async () => { - await matchAction( - { - _action: "ADD_PRIVATE_USER_NOTE", - targetId: 5, - sentiment: "POSITIVE", - comment: "test", - }, - { user: "admin" }, - ); + await db + .insertInto("GroupMatch") + .values({ alphaGroupId: 2, bravoGroupId: 3 }) + .execute(); + }); - const data = await lookingLoader({ user: "admin" }); + afterEach(() => { + dbReset(); + }); - assert.equal(data.groups.neutral[0].members![0].id, 5); -}); + const lookingLoader = wrappedLoader>({ + loader, + }); + const matchAction = wrappedAction({ + action: rawMatchAction, + params: { id: "1" }, + }); -PrivateUserNoteSorting("users with negative note sorted last", async () => { - await matchAction( - { - _action: "ADD_PRIVATE_USER_NOTE", - targetId: 5, - sentiment: "NEGATIVE", - comment: "test", - }, - { user: "admin" }, - ); + test("users with positive note sorted first", async () => { + await matchAction( + { + _action: "ADD_PRIVATE_USER_NOTE", + targetId: 5, + sentiment: "POSITIVE", + comment: "test", + }, + { user: "admin" }, + ); - const data = await lookingLoader({ user: "admin" }); + const data = await lookingLoader({ user: "admin" }); - assert.equal( - data.groups.neutral[data.groups.neutral.length - 1].members![0].id, - 5, - ); -}); + expect(data.groups.neutral[0].members![0].id).toBe(5); + }); -PrivateUserNoteSorting( - "group with both negative and positive sentiment sorted last", - async () => { + test("users with negative note sorted last", async () => { + await matchAction( + { + _action: "ADD_PRIVATE_USER_NOTE", + targetId: 5, + sentiment: "NEGATIVE", + comment: "test", + }, + { user: "admin" }, + ); + + const data = await lookingLoader({ user: "admin" }); + + expect( + data.groups.neutral[data.groups.neutral.length - 1].members![0].id, + ).toBe(5); + }); + + test("group with both negative and positive sentiment sorted last", async () => { await matchAction( { _action: "ADD_PRIVATE_USER_NOTE", @@ -260,13 +263,10 @@ PrivateUserNoteSorting( const data = await lookingLoader({ user: "admin" }); - assert.ok( + expect( data.groups.neutral[data.groups.neutral.length - 1].members?.some( (m) => m.id === 6, ), - ); - }, -); - -SendouQMatchCreation.run(); -PrivateUserNoteSorting.run(); + ).toBe(true); + }); +}); diff --git a/app/features/team/routes/t.$customUrl.join.tsx b/app/features/team/routes/t.$customUrl.join.tsx index 217f61919..f7fa0a79b 100644 --- a/app/features/team/routes/t.$customUrl.join.tsx +++ b/app/features/team/routes/t.$customUrl.join.tsx @@ -38,7 +38,7 @@ export const action: ActionFunction = async ({ request, params }) => { team, user, isInTeam: Boolean( - (await UserRepository.findByIdentifier(String(user.id)))?.team, + (await UserRepository.findProfileByIdentifier(String(user.id)))?.team, ), }) === "VALID", "Invite code is invalid", @@ -68,7 +68,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { team, user, isInTeam: Boolean( - (await UserRepository.findByIdentifier(String(user.id)))?.team, + (await UserRepository.findProfileByIdentifier(String(user.id)))?.team, ), }); diff --git a/app/features/top-search/index.ts b/app/features/top-search/index.ts deleted file mode 100644 index 9d4f4b372..000000000 --- a/app/features/top-search/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { userTopPlacements } from "./queries/userTopPlacements.server"; diff --git a/app/features/top-search/queries/userTopPlacements.server.ts b/app/features/top-search/queries/userTopPlacements.server.ts deleted file mode 100644 index 2292a5de6..000000000 --- a/app/features/top-search/queries/userTopPlacements.server.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { sql } from "~/db/sql"; -import type { XRankPlacement } from "~/db/types"; -import type { ModeShort } from "~/modules/in-game-lists"; - -const smt = sql.prepare(/* sql */ ` - select - "power", - "rank", - "mode", - "playerId" - from "XRankPlacement" - left join "SplatoonPlayer" on "SplatoonPlayer"."id" = "XRankPlacement"."playerId" - left join "User" on "User"."id" = "SplatoonPlayer"."userId" - where - "User"."id" = @userId -`); - -type Row = Pick; -export const userTopPlacements = (userId: number) => { - const rows = smt.all({ userId }) as Row[]; - - const playerId = rows[0]?.playerId; - - return { topPlacements: resolveTopPlacements(rows), playerId }; -}; - -type TopPlacements = Partial< - Record> ->; - -function resolveTopPlacements(placements: Row[]) { - const result: TopPlacements = {}; - - for (const { mode, power, rank } of placements) { - let current = result[mode]; - - if (!current) { - result[mode] = { power, rank }; - continue; - } - - if (current.rank > rank) { - const newResult = { ...current, rank }; - result[mode] = newResult; - current = newResult; - } - - if (current.power < power) { - result[mode] = { ...current, power }; - } - } - - return result; -} diff --git a/app/features/tournament-bracket/components/OrganizerMatchMapListDialog.tsx b/app/features/tournament-bracket/components/OrganizerMatchMapListDialog.tsx index a7c1e31eb..3a5478dc7 100644 --- a/app/features/tournament-bracket/components/OrganizerMatchMapListDialog.tsx +++ b/app/features/tournament-bracket/components/OrganizerMatchMapListDialog.tsx @@ -1,4 +1,5 @@ import type { SerializeFrom } from "@remix-run/node"; +import type { TFunction } from "i18next"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Button } from "~/components/Button"; @@ -66,7 +67,11 @@ export function OrganizerMatchMapListDialog({ {number}) {t(`game-misc:MODE_LONG_${map.mode}`)} on{" "} {t(`game-misc:STAGE_${map.stageId}`)}{" "} - {pickInfoText({ t, teams: [teamOne, teamTwo], map })} + {pickInfoText({ + t: t as unknown as TFunction<["tournament"]>, + teams: [teamOne, teamTwo], + map, + })} ); diff --git a/app/features/tournament-bracket/components/StartedMatch.tsx b/app/features/tournament-bracket/components/StartedMatch.tsx index 3bb7b5e60..c01f747c0 100644 --- a/app/features/tournament-bracket/components/StartedMatch.tsx +++ b/app/features/tournament-bracket/components/StartedMatch.tsx @@ -1,6 +1,7 @@ import type { SerializeFrom } from "@remix-run/node"; import { Form, useLoaderData } from "@remix-run/react"; import clsx from "clsx"; +import type { TFunction } from "i18next"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Image } from "~/components/Image"; @@ -378,7 +379,13 @@ function FancyStageBanner({ {t(`game-misc:STAGE_${stage.stageId}`)} -

{pickInfoText({ t, teams, map: stage })}

+

+ {pickInfoText({ + t: t as unknown as TFunction<["tournament"]>, + teams, + map: stage, + })} +

{children} diff --git a/app/features/tournament-bracket/core/Tournament.test.ts b/app/features/tournament-bracket/core/Tournament.test.ts index 9e33de80c..1ae5f02d1 100644 --- a/app/features/tournament-bracket/core/Tournament.test.ts +++ b/app/features/tournament-bracket/core/Tournament.test.ts @@ -1,5 +1,4 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import { describe, expect, test } from "bun:test"; import type { Match } from "~/modules/brackets-model"; import { Tournament } from "./Tournament"; import { @@ -9,152 +8,151 @@ import { PADDLING_POOL_257, } from "./tests/mocks"; -const FollowUp = suite("Follow-up bracket progression"); +describe("Follow-up bracket progression", () => { + const tournamentPP257 = new Tournament(PADDLING_POOL_257()); + const tournamentPP255 = new Tournament(PADDLING_POOL_255()); + const tournamentITZ32 = new Tournament(IN_THE_ZONE_32()); -const tournamentPP257 = new Tournament(PADDLING_POOL_257()); -const tournamentPP255 = new Tournament(PADDLING_POOL_255()); -const tournamentITZ32 = new Tournament(IN_THE_ZONE_32()); + test("correct amount of teams in the top cut", () => { + expect(tournamentPP257.brackets[1].seeding?.length).toBe(18); + }); -FollowUp("correct amount of teams in the top cut", () => { - assert.equal(tournamentPP257.brackets[1].seeding?.length, 18); -}); - -FollowUp("includes correct teams in the top cut", () => { - for (const tournamentTeamId of [892, 882, 881]) { - assert.ok( - tournamentPP257.brackets[1].seeding?.some( - (team) => team === tournamentTeamId, - ), - ); - } -}); - -FollowUp("underground bracket includes a checked in team", () => { - assert.ok(tournamentPP257.brackets[2].seeding?.some((team) => team === 902)); -}); - -FollowUp("underground bracket doesn't include a non checked in team", () => { - assert.ok(tournamentPP257.brackets[2].seeding?.some((team) => team === 902)); -}); - -FollowUp("underground bracket includes checked in teams (DE->SE)", () => { - assert.equal(tournamentITZ32.brackets[1].seeding?.length, 4); -}); - -const AMOUNT_OF_WORSE_VS_BEST = 5; -const AMOUNT_OF_BEST_VS_BEST = 1; -const AMOUNT_OF_WORSE_VS_WORSE = 2; - -FollowUp("correct seed distribution in the top cut", () => { - const rrPlacements = tournamentPP257.brackets[0].standings; - - let ACTUAL_AMOUNT_OF_WORSE_VS_BEST = 0; - let ACTUAL_AMOUNT_OF_BEST_VS_BEST = 0; - let ACTUAL_AMOUNT_OF_WORSE_VS_WORSE = 0; - for (const match of tournamentPP257.brackets[1].data.match) { - const opponent1 = rrPlacements.find( - (placement) => placement.team.id === match.opponent1?.id, - ); - const opponent2 = rrPlacements.find( - (placement) => placement.team.id === match.opponent2?.id, - ); - - if (!opponent1 || !opponent2) { - continue; + test("includes correct teams in the top cut", () => { + for (const tournamentTeamId of [892, 882, 881]) { + expect( + tournamentPP257.brackets[1].seeding?.some( + (team) => team === tournamentTeamId, + ), + ).toBe(true); } + }); - const placementDiff = opponent1.placement - opponent2.placement; - if (placementDiff === 0 && opponent1.placement === 1) { - ACTUAL_AMOUNT_OF_BEST_VS_BEST++; - } else if (placementDiff === 0 && opponent1.placement === 10) { - ACTUAL_AMOUNT_OF_WORSE_VS_WORSE++; - } else { - ACTUAL_AMOUNT_OF_WORSE_VS_BEST++; - } - } + test("underground bracket includes a checked in team", () => { + expect( + tournamentPP257.brackets[2].seeding?.some((team) => team === 902), + ).toBe(true); + }); - assert.equal( - ACTUAL_AMOUNT_OF_WORSE_VS_BEST, - AMOUNT_OF_WORSE_VS_BEST, - "Amount of worse vs best is incorrect", - ); - assert.equal( - ACTUAL_AMOUNT_OF_WORSE_VS_WORSE, - AMOUNT_OF_WORSE_VS_WORSE, - "Amount of worse vs worse is incorrect", - ); - assert.equal( - ACTUAL_AMOUNT_OF_BEST_VS_BEST, - AMOUNT_OF_BEST_VS_BEST, - "Amount of best vs best is incorrect", - ); -}); + test("underground bracket doesn't include a non checked in team", () => { + expect( + tournamentPP257.brackets[2].seeding?.some((team) => team === 902), + ).toBe(true); + }); -const validateNoRematches = (rrMatches: Match[], topCutMatches: Match[]) => { - for (const topCutMatch of topCutMatches) { - if (!topCutMatch.opponent1?.id || !topCutMatch.opponent2?.id) { - continue; - } + test("underground bracket includes checked in teams (DE->SE)", () => { + expect(tournamentITZ32.brackets[1].seeding?.length).toBe(4); + }); - for (const rrMatch of rrMatches) { - if ( - rrMatch.opponent1?.id === topCutMatch.opponent1.id && - rrMatch.opponent2?.id === topCutMatch.opponent2.id - ) { - throw new Error( - `Rematch detected: ${rrMatch.opponent1.id} vs ${rrMatch.opponent2.id}`, - ); + const AMOUNT_OF_WORSE_VS_BEST = 5; + const AMOUNT_OF_BEST_VS_BEST = 1; + const AMOUNT_OF_WORSE_VS_WORSE = 2; + + test("correct seed distribution in the top cut", () => { + const rrPlacements = tournamentPP257.brackets[0].standings; + + let ACTUAL_AMOUNT_OF_WORSE_VS_BEST = 0; + let ACTUAL_AMOUNT_OF_BEST_VS_BEST = 0; + let ACTUAL_AMOUNT_OF_WORSE_VS_WORSE = 0; + for (const match of tournamentPP257.brackets[1].data.match) { + const opponent1 = rrPlacements.find( + (placement) => placement.team.id === match.opponent1?.id, + ); + const opponent2 = rrPlacements.find( + (placement) => placement.team.id === match.opponent2?.id, + ); + + if (!opponent1 || !opponent2) { + continue; } - if ( - rrMatch.opponent1?.id === topCutMatch.opponent2.id && - rrMatch.opponent2?.id === topCutMatch.opponent1.id - ) { - throw new Error( - `Rematch detected: ${rrMatch.opponent1.id} vs ${rrMatch.opponent2.id}`, - ); + + const placementDiff = opponent1.placement - opponent2.placement; + if (placementDiff === 0 && opponent1.placement === 1) { + ACTUAL_AMOUNT_OF_BEST_VS_BEST++; + } else if (placementDiff === 0 && opponent1.placement === 10) { + ACTUAL_AMOUNT_OF_WORSE_VS_WORSE++; + } else { + ACTUAL_AMOUNT_OF_WORSE_VS_BEST++; } } - } -}; -FollowUp("avoids rematches in RR -> SE (PP 257)", () => { - const rrMatches = tournamentPP257.brackets[0].data.match; - const topCutMatches = tournamentPP257.brackets[1].data.match; + expect( + ACTUAL_AMOUNT_OF_WORSE_VS_BEST, + "Amount of worse vs best is incorrect", + ).toBe(AMOUNT_OF_WORSE_VS_BEST); + expect( + ACTUAL_AMOUNT_OF_WORSE_VS_WORSE, + "Amount of worse vs worse is incorrect", + ).toBe(AMOUNT_OF_WORSE_VS_WORSE); + expect( + ACTUAL_AMOUNT_OF_BEST_VS_BEST, + "Amount of best vs best is incorrect", + ).toBe(AMOUNT_OF_BEST_VS_BEST); + }); - validateNoRematches(rrMatches, topCutMatches); -}); + const validateNoRematches = (rrMatches: Match[], topCutMatches: Match[]) => { + for (const topCutMatch of topCutMatches) { + if (!topCutMatch.opponent1?.id || !topCutMatch.opponent2?.id) { + continue; + } -FollowUp("avoids rematches in RR -> SE (PP 255)", () => { - const rrMatches = tournamentPP255.brackets[0].data.match; - const topCutMatches = tournamentPP255.brackets[1].data.match; + for (const rrMatch of rrMatches) { + if ( + rrMatch.opponent1?.id === topCutMatch.opponent1.id && + rrMatch.opponent2?.id === topCutMatch.opponent2.id + ) { + throw new Error( + `Rematch detected: ${rrMatch.opponent1.id} vs ${rrMatch.opponent2.id}`, + ); + } + if ( + rrMatch.opponent1?.id === topCutMatch.opponent2.id && + rrMatch.opponent2?.id === topCutMatch.opponent1.id + ) { + throw new Error( + `Rematch detected: ${rrMatch.opponent1.id} vs ${rrMatch.opponent2.id}`, + ); + } + } + } + }; - validateNoRematches(rrMatches, topCutMatches); -}); + test("avoids rematches in RR -> SE (PP 257)", () => { + const rrMatches = tournamentPP257.brackets[0].data.match; + const topCutMatches = tournamentPP257.brackets[1].data.match; -FollowUp("avoids rematches in RR -> SE (PP 255) - only minimum swap", () => { - const oldTopCutMatches = PADDLING_POOL_255_TOP_CUT_INITIAL_MATCHES(); - const newTopCutMatches = tournamentPP255.brackets[1].data.match; + validateNoRematches(rrMatches, topCutMatches); + }); - let different = 0; + test("avoids rematches in RR -> SE (PP 255)", () => { + const rrMatches = tournamentPP255.brackets[0].data.match; + const topCutMatches = tournamentPP255.brackets[1].data.match; - for (const match of oldTopCutMatches) { - if (!match.opponent1?.id || !match.opponent2?.id) { - continue; + validateNoRematches(rrMatches, topCutMatches); + }); + + test("avoids rematches in RR -> SE (PP 255) - only minimum swap", () => { + const oldTopCutMatches = PADDLING_POOL_255_TOP_CUT_INITIAL_MATCHES(); + const newTopCutMatches = tournamentPP255.brackets[1].data.match; + + let different = 0; + + for (const match of oldTopCutMatches) { + if (!match.opponent1?.id || !match.opponent2?.id) { + continue; + } + + const newMatch = newTopCutMatches.find( + (m) => + m.opponent1?.id === match.opponent1.id && + m.opponent2?.id === match.opponent2.id, + ); + + if (!newMatch) { + different++; + } } - const newMatch = newTopCutMatches.find( - (m) => - m.opponent1?.id === match.opponent1.id && - m.opponent2?.id === match.opponent2.id, - ); - - if (!newMatch) { - different++; - } - } - - // 1 team should get swapped meaning two matches are now different - assert.equal(different, 2, "Amount of different matches is incorrect"); + // 1 team should get swapped meaning two matches are now different + expect(different, "Amount of different matches is incorrect").toBe(2); + }); }); - -FollowUp.run(); diff --git a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts index a6d96fb10..ffacd504c 100644 --- a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts +++ b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts @@ -492,7 +492,7 @@ export class Match { update() { match_updateStm.run({ - id: this.id, + id: this.id ?? null, roundId: this.roundId, stageId: this.stageId, groupId: this.groupId, diff --git a/app/features/tournament-bracket/core/summarizer.server.ts b/app/features/tournament-bracket/core/summarizer.server.ts index 6a6fb4b8c..f7c9c82fb 100644 --- a/app/features/tournament-bracket/core/summarizer.server.ts +++ b/app/features/tournament-bracket/core/summarizer.server.ts @@ -1,5 +1,5 @@ import shuffle from "just-shuffle"; -import type { Rating } from "openskill/dist/types"; +import type { Rating } from "node_modules/openskill/dist/types"; import type { MapResult, PlayerResult, diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index 370665db6..997e288ce 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -1,364 +1,338 @@ +import { describe, expect, test } from "bun:test"; import { ordinal, rating } from "openskill"; -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import invariant from "~/utils/invariant"; import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server"; import type { TournamentDataTeam } from "./Tournament.server"; import { tournamentSummary } from "./summarizer.server"; -const TournamentSummary = suite("tournamentSummary()"); - -const createTeam = (teamId: number, userIds: number[]): TournamentDataTeam => ({ - checkIns: [], - createdAt: 0, - id: teamId, - inviteCode: null, - mapPool: [], - members: userIds.map((userId) => ({ - country: null, - customUrl: null, - discordAvatar: null, - discordId: "123", - username: "test", - inGameName: "test", - twitch: null, - isOwner: 0, - plusTier: null, +describe("tournamentSummary()", () => { + const createTeam = ( + teamId: number, + userIds: number[], + ): TournamentDataTeam => ({ + checkIns: [], createdAt: 0, - userId, - })), - name: `Team ${teamId}`, - prefersNotToHost: 0, - droppedOut: 0, - noScreen: 0, - team: null, - seed: 1, - activeRosterUserIds: [], - pickupAvatarUrl: null, -}); - -function summarize({ results }: { results?: AllMatchResult[] } = {}) { - return tournamentSummary({ - finalStandings: [ - { - placement: 1, - team: createTeam(1, [1, 2, 3, 4]), - }, - { - placement: 2, - team: createTeam(2, [5, 6, 7, 8]), - }, - { - placement: 3, - team: createTeam(3, [9, 10, 11, 12]), - }, - { - placement: 4, - team: createTeam(4, [13, 14, 15, 16]), - }, - ], - results: results ?? [ - { - maps: [ - { - mode: "SZ", - stageId: 1, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], - winnerTeamId: 1, - }, - { - mode: "TC", - stageId: 2, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], - winnerTeamId: 1, - }, - ], - opponentOne: { - id: 1, - result: "win", - score: 2, - }, - opponentTwo: { - id: 2, - result: "loss", - score: 0, - }, - }, - ], - teams: [ - { - id: 1, - members: [ - { userId: 1 }, - { userId: 2 }, - { userId: 3 }, - { userId: 4 }, - { userId: 20 }, - ], - }, - { - id: 2, - members: [{ userId: 5 }, { userId: 6 }, { userId: 7 }, { userId: 8 }], - }, - { - id: 3, - members: [ - { userId: 9 }, - { userId: 10 }, - { userId: 11 }, - { userId: 12 }, - ], - }, - { - id: 4, - members: [ - { userId: 13 }, - { userId: 14 }, - { userId: 15 }, - { userId: 16 }, - ], - }, - ], - queryCurrentTeamRating: () => rating(), - queryCurrentUserRating: () => rating(), - queryTeamPlayerRatingAverage: () => rating(), + id: teamId, + inviteCode: null, + mapPool: [], + members: userIds.map((userId) => ({ + country: null, + customUrl: null, + discordAvatar: null, + discordId: "123", + username: "test", + inGameName: "test", + twitch: null, + isOwner: 0, + plusTier: null, + createdAt: 0, + userId, + })), + name: `Team ${teamId}`, + prefersNotToHost: 0, + droppedOut: 0, + noScreen: 0, + team: null, + seed: 1, + activeRosterUserIds: [], + pickupAvatarUrl: null, }); -} -TournamentSummary("calculates final standings", () => { - const summary = summarize(); + function summarize({ results }: { results?: AllMatchResult[] } = {}) { + return tournamentSummary({ + finalStandings: [ + { + placement: 1, + team: createTeam(1, [1, 2, 3, 4]), + }, + { + placement: 2, + team: createTeam(2, [5, 6, 7, 8]), + }, + { + placement: 3, + team: createTeam(3, [9, 10, 11, 12]), + }, + { + placement: 4, + team: createTeam(4, [13, 14, 15, 16]), + }, + ], + results: results ?? [ + { + maps: [ + { + mode: "SZ", + stageId: 1, + userIds: [1, 2, 3, 4, 5, 6, 7, 8], + winnerTeamId: 1, + }, + { + mode: "TC", + stageId: 2, + userIds: [1, 2, 3, 4, 5, 6, 7, 8], + winnerTeamId: 1, + }, + ], + opponentOne: { + id: 1, + result: "win", + score: 2, + }, + opponentTwo: { + id: 2, + result: "loss", + score: 0, + }, + }, + ], + teams: [ + { + id: 1, + members: [ + { userId: 1 }, + { userId: 2 }, + { userId: 3 }, + { userId: 4 }, + { userId: 20 }, + ], + }, + { + id: 2, + members: [{ userId: 5 }, { userId: 6 }, { userId: 7 }, { userId: 8 }], + }, + { + id: 3, + members: [ + { userId: 9 }, + { userId: 10 }, + { userId: 11 }, + { userId: 12 }, + ], + }, + { + id: 4, + members: [ + { userId: 13 }, + { userId: 14 }, + { userId: 15 }, + { userId: 16 }, + ], + }, + ], + queryCurrentTeamRating: () => rating(), + queryCurrentUserRating: () => rating(), + queryTeamPlayerRatingAverage: () => rating(), + }); + } - // each player of each team should have one result - assert.equal(summary.tournamentResults.length, 4 * 4); -}); - -TournamentSummary( - "winners skill should go up, losers skill should go down", - () => { + test("calculates final standings", () => { const summary = summarize(); + expect(summary.tournamentResults.length).toBe(4 * 4); + }); + test("winners skill should go up, losers skill should go down", () => { + const summary = summarize(); const winnerSkill = summary.skills.find((s) => s.userId === 1); const loserSkill = summary.skills.find((s) => s.userId === 5); - assert.ok(winnerSkill); - assert.ok(loserSkill); - - assert.ok(ordinal(winnerSkill) > ordinal(loserSkill)); - }, -); - -const resultsWith20: AllMatchResult[] = [ - { - maps: [ - { - mode: "SZ", - stageId: 1, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], - winnerTeamId: 1, - }, - { - mode: "TC", - stageId: 2, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], - winnerTeamId: 1, - }, - ], - opponentOne: { - id: 1, - result: "win", - score: 2, - }, - opponentTwo: { - id: 2, - result: "loss", - score: 0, - }, - }, - { - maps: [ - { - mode: "SZ", - stageId: 1, - userIds: [1, 20, 3, 4, 5, 6, 7, 8], - winnerTeamId: 1, - }, - { - mode: "TC", - stageId: 2, - userIds: [1, 20, 3, 4, 5, 6, 7, 8], - winnerTeamId: 1, - }, - ], - opponentOne: { - id: 1, - result: "win", - score: 2, - }, - opponentTwo: { - id: 2, - result: "loss", - score: 0, - }, - }, -]; - -TournamentSummary("winning more than once makes the skill go up more", () => { - const summary = summarize({ - results: resultsWith20, + invariant(winnerSkill, "winnerSkill should be defined"); + invariant(loserSkill, "loserSkill should be defined"); + expect(ordinal(winnerSkill)).toBeGreaterThan(ordinal(loserSkill)); }); - const twoTimeWinnerSkill = summary.skills.find((s) => s.userId === 1); - const oneTimeWinnerSkill = summary.skills.find((s) => s.userId === 2); - - assert.ok(twoTimeWinnerSkill); - assert.ok(oneTimeWinnerSkill); - - assert.ok(ordinal(twoTimeWinnerSkill) > ordinal(oneTimeWinnerSkill)); -}); - -TournamentSummary("calculates team skills (many rosters for same team)", () => { - const summary = summarize({ - results: resultsWith20, - }); - - const teamOneRosterOne = summary.skills.find( - (s) => s.identifier === "1-2-3-4", - ); - const teamOneRosterTwo = summary.skills.find( - (s) => s.identifier === "1-3-4-20", - ); - - assert.ok(teamOneRosterOne); - assert.ok(teamOneRosterTwo); -}); - -const resultsWithSubbedRoster: AllMatchResult[] = [ - { - maps: [ - { - mode: "SZ", - stageId: 1, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], - winnerTeamId: 1, + const resultsWith20: AllMatchResult[] = [ + { + maps: [ + { + mode: "SZ", + stageId: 1, + userIds: [1, 2, 3, 4, 5, 6, 7, 8], + winnerTeamId: 1, + }, + { + mode: "TC", + stageId: 2, + userIds: [1, 2, 3, 4, 5, 6, 7, 8], + winnerTeamId: 1, + }, + ], + opponentOne: { + id: 1, + result: "win", + score: 2, }, - { - mode: "TC", - stageId: 2, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], - winnerTeamId: 2, + opponentTwo: { + id: 2, + result: "loss", + score: 0, }, - { - mode: "TC", - stageId: 2, - userIds: [1, 20, 3, 4, 5, 6, 7, 8], - winnerTeamId: 1, - }, - ], - opponentOne: { - id: 1, - result: "win", - score: 2, }, - opponentTwo: { - id: 2, - result: "loss", - score: 1, + { + maps: [ + { + mode: "SZ", + stageId: 1, + userIds: [1, 20, 3, 4, 5, 6, 7, 8], + winnerTeamId: 1, + }, + { + mode: "TC", + stageId: 2, + userIds: [1, 20, 3, 4, 5, 6, 7, 8], + winnerTeamId: 1, + }, + ], + opponentOne: { + id: 1, + result: "win", + score: 2, + }, + opponentTwo: { + id: 2, + result: "loss", + score: 0, + }, }, - }, -]; + ]; -TournamentSummary( - "In the case of sub calculates skill based on the most common roster", - () => { + test("winning more than once makes the skill go up more", () => { const summary = summarize({ - results: resultsWithSubbedRoster, + results: resultsWith20, }); + const twoTimeWinnerSkill = summary.skills.find((s) => s.userId === 1); + const oneTimeWinnerSkill = summary.skills.find((s) => s.userId === 2); + invariant(twoTimeWinnerSkill, "twoTimeWinnerSkill should be defined"); + invariant(oneTimeWinnerSkill, "oneTimeWinnerSkill should be defined"); + expect(ordinal(twoTimeWinnerSkill)).toBeGreaterThan( + ordinal(oneTimeWinnerSkill), + ); + }); + + test("calculates team skills (many rosters for same team)", () => { + const summary = summarize({ + results: resultsWith20, + }); const teamOneRosterOne = summary.skills.find( (s) => s.identifier === "1-2-3-4", ); const teamOneRosterTwo = summary.skills.find( (s) => s.identifier === "1-3-4-20", ); + expect(teamOneRosterOne).toBeTruthy(); + expect(teamOneRosterTwo).toBeTruthy(); + }); - assert.ok(teamOneRosterOne); - assert.not.ok(teamOneRosterTwo); - }, -); + const resultsWithSubbedRoster: AllMatchResult[] = [ + { + maps: [ + { + mode: "SZ", + stageId: 1, + userIds: [1, 2, 3, 4, 5, 6, 7, 8], + winnerTeamId: 1, + }, + { + mode: "TC", + stageId: 2, + userIds: [1, 2, 3, 4, 5, 6, 7, 8], + winnerTeamId: 2, + }, + { + mode: "TC", + stageId: 2, + userIds: [1, 20, 3, 4, 5, 6, 7, 8], + winnerTeamId: 1, + }, + ], + opponentOne: { + id: 1, + result: "win", + score: 2, + }, + opponentTwo: { + id: 2, + result: "loss", + score: 1, + }, + }, + ]; -TournamentSummary( - "In the case of sub calculates player results based on the most common roster", - () => { + test("In the case of sub calculates skill based on the most common roster", () => { const summary = summarize({ results: resultsWithSubbedRoster, }); + const teamOneRosterOne = summary.skills.find( + (s) => s.identifier === "1-2-3-4", + ); + const teamOneRosterTwo = summary.skills.find( + (s) => s.identifier === "1-3-4-20", + ); + expect(teamOneRosterOne).toBeTruthy(); + expect(teamOneRosterTwo).toBeFalsy(); + }); - assert.not.ok( + test("In the case of sub calculates player results based on the most common roster", () => { + const summary = summarize({ + results: resultsWithSubbedRoster, + }); + expect( summary.playerResultDeltas.find( (p) => p.ownerUserId === 5 && p.otherUserId === 20 && (p.setWins > 0 || p.setLosses > 0), ), - "player 5 should not have a result against player 20 (sub for only one game)", + ).toBeFalsy(); + }); + + test("calculates results of mates", () => { + const summary = summarize(); + const result = summary.playerResultDeltas.find( + (r) => r.ownerUserId === 1 && r.otherUserId === 2, ); - }, -); -TournamentSummary("calculates results of mates", () => { - const summary = summarize(); + invariant(result, "result should be defined"); + expect(result.setWins).toBe(1); + expect(result.setLosses).toBe(0); + expect(result.mapWins).toBe(2); + expect(result.mapLosses).toBe(0); + expect(result.type).toBe("MATE"); + }); - const result = summary.playerResultDeltas.find( - (r) => r.ownerUserId === 1 && r.otherUserId === 2, - ); + test("calculates results of opponents", () => { + const summary = summarize(); + const result = summary.playerResultDeltas.find( + (r) => r.ownerUserId === 1 && r.otherUserId === 5, + ); - assert.ok(result); + invariant(result, "result should be defined"); + expect(result.setWins).toBe(1); + expect(result.setLosses).toBe(0); + expect(result.mapWins).toBe(2); + expect(result.mapLosses).toBe(0); + expect(result.type).toBe("ENEMY"); + }); - assert.equal(result.setWins, 1); - assert.equal(result.setLosses, 0); - assert.equal(result.mapWins, 2); - assert.equal(result.mapLosses, 0); - assert.equal(result.type, "MATE"); + test("calculates results of opponents (losing side)", () => { + const summary = summarize(); + const result = summary.playerResultDeltas.find( + (r) => r.ownerUserId === 5 && r.otherUserId === 1, + ); + + invariant(result, "result should be defined"); + expect(result.setWins).toBe(0); + expect(result.setLosses).toBe(1); + expect(result.mapWins).toBe(0); + expect(result.mapLosses).toBe(2); + expect(result.type).toBe("ENEMY"); + }); + + test("calculates map results", () => { + const summary = summarize(); + const result = summary.mapResultDeltas.filter((r) => r.userId === 1); + expect(result.length).toBe(2); + expect(result.every((r) => r.wins === 1 && r.losses === 0)).toBeTruthy(); + }); }); - -TournamentSummary("calculates results of opponents", () => { - const summary = summarize(); - - const result = summary.playerResultDeltas.find( - (r) => r.ownerUserId === 1 && r.otherUserId === 5, - ); - - assert.ok(result); - - assert.equal(result.setWins, 1); - assert.equal(result.setLosses, 0); - assert.equal(result.mapWins, 2); - assert.equal(result.mapLosses, 0); - assert.equal(result.type, "ENEMY"); -}); - -TournamentSummary("calculates results of opponents (losing side)", () => { - const summary = summarize(); - - const result = summary.playerResultDeltas.find( - (r) => r.ownerUserId === 5 && r.otherUserId === 1, - ); - - assert.ok(result); - - assert.equal(result.setWins, 0); - assert.equal(result.setLosses, 1); - assert.equal(result.mapWins, 0); - assert.equal(result.mapLosses, 2); - assert.equal(result.type, "ENEMY"); -}); - -TournamentSummary("calculates map results", () => { - const summary = summarize(); - - const result = summary.mapResultDeltas.filter((r) => r.userId === 1); - - assert.equal(result.length, 2); - assert.ok(result.every((r) => r.wins === 1 && r.losses === 0)); -}); - -TournamentSummary.run(); diff --git a/app/features/tournament-bracket/queries/addSummary.server.ts b/app/features/tournament-bracket/queries/addSummary.server.ts index 144d897f1..7157cb8f2 100644 --- a/app/features/tournament-bracket/queries/addSummary.server.ts +++ b/app/features/tournament-bracket/queries/addSummary.server.ts @@ -119,10 +119,10 @@ export const addSummary = sql.transaction( mu: skill.mu, sigma: skill.sigma, ordinal: ordinal(skill), - userId: skill.userId, - identifier: skill.identifier, + userId: skill.userId ?? null, + identifier: skill.identifier ?? null, matchesCount: skill.matchesCount, - season, + season: season ?? null, }) as Skill; if (insertedSkill.identifier) { @@ -142,7 +142,7 @@ export const addSummary = sql.transaction( userId: mapResultDelta.userId, wins: mapResultDelta.wins, losses: mapResultDelta.losses, - season, + season: season ?? null, }); } @@ -155,7 +155,7 @@ export const addSummary = sql.transaction( setWins: playerResultDelta.setWins, setLosses: playerResultDelta.setLosses, type: playerResultDelta.type, - season, + season: season ?? null, }); } diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx index 0cea917e0..377f1e742 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx @@ -2,12 +2,10 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; import { eventStream } from "remix-utils/sse/server"; import { tournamentIdFromParams } from "~/features/tournament"; -import { ignoreTransaction } from "~/utils/newrelic.server"; import { emitter } from "../core/emitters.server"; import { bracketSubscriptionKey } from "../tournament-bracket-utils"; export const loader = ({ request, params }: LoaderFunctionArgs) => { - ignoreTransaction(); const tournamentId = tournamentIdFromParams(params); return eventStream(request.signal, (send) => { diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx b/app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx index 6e3f2520a..44b14028f 100644 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx @@ -2,14 +2,12 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; import { eventStream } from "remix-utils/sse/server"; import { getUserId } from "~/features/auth/core/user.server"; -import { ignoreTransaction } from "~/utils/newrelic.server"; import { parseParams } from "~/utils/remix"; import { emitter } from "../core/emitters.server"; import { matchPageParamsSchema } from "../tournament-bracket-schemas.server"; import { matchSubscriptionKey } from "../tournament-bracket-utils"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { - ignoreTransaction(); const loggedInUser = await getUserId(request); const matchId = parseParams({ params, diff --git a/app/features/tournament-bracket/tournament-bracket-utils.test.ts b/app/features/tournament-bracket/tournament-bracket-utils.test.ts index d978ac221..890b91dee 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.test.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.test.ts @@ -1,15 +1,9 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; +import { describe, expect, test } from "bun:test"; import { fillWithNullTillPowerOfTwo, mapCountPlayedInSetWithCertainty, } from "./tournament-bracket-utils"; -const MapCountPlayedInSetWithCertainty = suite( - "mapCountPlayedInSetWithCertainty()", -); -const FillWithNullTillPowerOfTwo = suite("fillWithNullTillPowerOfTwo()"); - const mapCountParamsToResult: { bestOf: number; scores: [number, number]; @@ -26,17 +20,15 @@ const mapCountParamsToResult: { { bestOf: 7, scores: [2, 2], expected: 6 }, ]; -for (const { bestOf, scores, expected } of mapCountParamsToResult) { - MapCountPlayedInSetWithCertainty( - `bestOf=${bestOf}, scores=${scores.join(",")} -> ${expected}`, - () => { - assert.equal( - mapCountPlayedInSetWithCertainty({ bestOf, scores }), +describe("mapCountPlayedInSetWithCertainty()", () => { + for (const { bestOf, scores, expected } of mapCountParamsToResult) { + test(`bestOf=${bestOf}, scores=${scores.join(",")} -> ${expected}`, () => { + expect(mapCountPlayedInSetWithCertainty({ bestOf, scores })).toBe( expected, ); - }, - ); -} + }); + } +}); const powerOfTwoParamsToResults: [ amountOfTeams: number, @@ -50,19 +42,14 @@ const powerOfTwoParamsToResults: [ [17, 15], ]; -for (const [amountOfTeams, expectedNullCount] of powerOfTwoParamsToResults) { - FillWithNullTillPowerOfTwo( - `amountOfTeams=${amountOfTeams} -> ${expectedNullCount}`, - () => { - assert.equal( +describe("fillWithNullTillPowerOfTwo()", () => { + for (const [amountOfTeams, expectedNullCount] of powerOfTwoParamsToResults) { + test(`amountOfTeams=${amountOfTeams} -> ${expectedNullCount}`, () => { + expect( fillWithNullTillPowerOfTwo(Array(amountOfTeams).fill("team")).filter( (x) => x === null, ).length, - expectedNullCount, - ); - }, - ); -} - -MapCountPlayedInSetWithCertainty.run(); -FillWithNullTillPowerOfTwo.run(); + ).toBe(expectedNullCount); + }); + } +}); diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index 308a1f1c6..1538e3a2b 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -180,7 +180,7 @@ export function pickInfoText({ teams, }: { map?: { stageId: StageId; mode: ModeShort; source: TournamentMaplistSource }; - t: TFunction; + t: TFunction<["tournament"]>; teams: [TournamentDataTeam, TournamentDataTeam]; }) { if (!map) return ""; diff --git a/app/features/tournament-subs/queries/findSubsByTournamentId.server.ts b/app/features/tournament-subs/queries/findSubsByTournamentId.server.ts index 8dc4399aa..d729bf4ce 100644 --- a/app/features/tournament-subs/queries/findSubsByTournamentId.server.ts +++ b/app/features/tournament-subs/queries/findSubsByTournamentId.server.ts @@ -58,7 +58,7 @@ export function findSubsByTournamentId({ tournamentId: number; userId?: number; }): SubByTournamentId[] { - const rows = stm.all({ tournamentId, userId }) as any[]; + const rows = stm.all({ tournamentId, userId: userId ?? null }) as any[]; return rows.map((row) => ({ ...row, diff --git a/app/features/tournament/queries/joinLeaveTeam.server.ts b/app/features/tournament/queries/joinLeaveTeam.server.ts index 3ede70e39..c9482188a 100644 --- a/app/features/tournament/queries/joinLeaveTeam.server.ts +++ b/app/features/tournament/queries/joinLeaveTeam.server.ts @@ -46,9 +46,9 @@ export const joinTeam = sql.transaction( checkOutTeam?: boolean; }) => { if (whatToDoWithPreviousTeam === "DELETE") { - deleteTeamStm.run({ tournamentTeamId: previousTeamId }); + deleteTeamStm.run({ tournamentTeamId: previousTeamId ?? null }); } else if (whatToDoWithPreviousTeam === "LEAVE") { - deleteMemberStm.run({ tournamentTeamId: previousTeamId, userId }); + deleteMemberStm.run({ tournamentTeamId: previousTeamId ?? null, userId }); } if (!previousTeamId) { diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index 0c8386a20..b1153a45b 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -132,7 +132,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { const tournament = await tournamentDataCached({ tournamentId, user }); const streams = - tournament.data.stage.length > 0 + tournament.data.stage.length > 0 && !tournament.ctx.isFinalized ? await streamsByTournamentId(tournament.ctx) : []; diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index d47b59f61..86fdc76e0 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -1,4 +1,4 @@ -import type { ExpressionBuilder, FunctionModule } from "kysely"; +import type { ExpressionBuilder, FunctionModule, NotNull } from "kysely"; import { sql } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { db, sql as dbDirect } from "~/db/sql"; @@ -21,10 +21,11 @@ const identifierToUserIdQuery = (identifier: string) => return eb("User.id", "=", parsedId); } - return eb.or([ - eb("User.discordId", "=", identifier), - eb("User.customUrl", "=", identifier), - ]); + if (/^\d+$/.test(identifier)) { + return eb("User.discordId", "=", identifier); + } + + return eb("User.customUrl", "=", identifier); }); export function identifierToUserId(identifier: string) { @@ -55,36 +56,93 @@ export async function identifierToBuildFields(identifier: string) { }; } -export function findByIdentifier(identifier: string) { +export function findLayoutDataByIdentifier( + identifier: string, + loggedInUserId?: number, +) { return identifierToUserIdQuery(identifier) - .leftJoin("PlusTier", "PlusTier.userId", "User.id") + .select((eb) => [ + ...COMMON_USER_FIELDS, + "User.commissionText", + "User.commissionsOpen", + sql | null>`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css", null)`.as( + "css", + ), + eb + .selectFrom("TournamentResult") + .whereRef("TournamentResult.userId", "=", "User.id") + .select(({ fn }) => fn.countAll().as("count")) + .as("tournamentResultsCount"), + eb + .selectFrom("CalendarEventResultPlayer") + .whereRef("CalendarEventResultPlayer.userId", "=", "User.id") + .select(({ fn }) => fn.countAll().as("count")) + .as("calendarEventResultsCount"), + eb + .selectFrom("Build") + .select(({ fn }) => fn.countAll().as("count")) + .whereRef("Build.ownerId", "=", "User.id") + .where((eb) => + eb.or( + [ + eb("Build.private", "=", 0), + loggedInUserId ? eb("Build.ownerId", "=", loggedInUserId) : null, + ].filter((filter) => filter !== null), + ), + ) + .as("buildsCount"), + eb + .selectFrom("VideoMatchPlayer") + .select(({ fn }) => fn.countAll().as("count")) + .whereRef("VideoMatchPlayer.playerUserId", "=", "User.id") + .as("vodsCount"), + eb + .selectFrom("Art") + .innerJoin("ArtUserMetadata", "ArtUserMetadata.artId", "Art.id") + .innerJoin("UserSubmittedImage", "UserSubmittedImage.id", "Art.imgId") + .select(({ fn }) => fn.count("Art.id").distinct().as("count")) + .where((innerEb) => + innerEb.or([ + innerEb("Art.authorId", "=", sql.raw("User.id")), + innerEb("ArtUserMetadata.userId", "=", sql.raw("User.id")), + ]), + ) + .as("artCount"), + ]) + .$narrowType<{ + calendarEventResultsCount: NotNull; + tournamentResultsCount: NotNull; + buildsCount: NotNull; + vodsCount: NotNull; + artCount: NotNull; + }>() + .executeTakeFirst(); +} + +export async function findProfileByIdentifier( + identifier: string, + forceShowDiscordUniqueName?: boolean, +) { + const row = await identifierToUserIdQuery(identifier) + .innerJoin("PlusTier", "PlusTier.userId", "User.id") .select(({ eb }) => [ - "User.discordAvatar", - "User.discordId", - "User.discordName", - "User.username", - "User.customName", - "User.showDiscordUniqueName", - "User.discordUniqueName", - "User.customUrl", - "User.inGameName", - "User.twitter", - "User.country", - "User.bio", - "User.motionSens", - "User.stickSens", - "User.css", "User.twitch", "User.twitter", "User.youtubeId", "User.battlefy", + "User.country", + "User.bio", + "User.motionSens", + "User.stickSens", + "User.inGameName", + "User.customName", + "User.discordName", + "User.showDiscordUniqueName", + "User.discordUniqueName", "User.favoriteBadgeId", - "User.banned", - "User.bannedReason", - "User.commissionText", - "User.commissionsOpen", - "User.patronTier", - "User.buildSorting", "PlusTier.tier as plusTier", jsonArrayFrom( eb @@ -110,8 +168,71 @@ export function findByIdentifier(identifier: string) { ]) .whereRef("TeamMember.userId", "=", "User.id"), ).as("team"), + jsonArrayFrom( + eb + .selectFrom("BadgeOwner") + .innerJoin("Badge", "Badge.id", "BadgeOwner.badgeId") + .select(({ fn }) => [ + fn.count("BadgeOwner.badgeId").as("count"), + "Badge.id", + "Badge.displayName", + "Badge.code", + "Badge.hue", + ]) + .whereRef("BadgeOwner.userId", "=", "User.id") + .groupBy(["BadgeOwner.badgeId", "BadgeOwner.userId"]), + ).as("badges"), + jsonArrayFrom( + eb + .selectFrom("SplatoonPlayer") + .innerJoin( + "XRankPlacement", + "XRankPlacement.playerId", + "SplatoonPlayer.id", + ) + .select(({ fn }) => [ + "XRankPlacement.mode", + fn.max("XRankPlacement.power").as("power"), + fn.min("XRankPlacement.rank").as("rank"), + "XRankPlacement.playerId", + ]) + .whereRef("SplatoonPlayer.userId", "=", "User.id") + .groupBy(["XRankPlacement.mode"]), + ).as("topPlacements"), ]) .executeTakeFirst(); + + if (!row) { + return null; + } + + return { + ...row, + // TODO: sort in SQL + badges: row.badges.sort((a, b) => { + if (a.id === row.favoriteBadgeId) { + return -1; + } + + if (b.id === row.favoriteBadgeId) { + return 1; + } + + return a.id - b.id; + }), + discordUniqueName: + forceShowDiscordUniqueName || row.showDiscordUniqueName + ? row.discordUniqueName + : null, + }; +} + +export function findBannedStatusByUserId(userId: number) { + return db + .selectFrom("User") + .select(["User.banned", "User.bannedReason"]) + .where("User.id", "=", userId) + .executeTakeFirst(); } export function findLeanById(id: number) { diff --git a/app/features/user-page/components/UserResultsTable.tsx b/app/features/user-page/components/UserResultsTable.tsx index 5507df728..92b745905 100644 --- a/app/features/user-page/components/UserResultsTable.tsx +++ b/app/features/user-page/components/UserResultsTable.tsx @@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next"; import { Avatar } from "~/components/Avatar"; import { Placement } from "~/components/Placement"; import { Table } from "~/components/Table"; -import type { UserPageLoaderData } from "~/features/user-page/routes/u.$identifier"; import { databaseTimestampToDate } from "~/utils/dates"; import { calendarEventPage, @@ -11,9 +10,10 @@ import { tournamentTeamPage, userPage, } from "~/utils/urls"; +import type { UserResultsLoaderData } from "../loaders/u.$identifier.results.server"; export type UserResultsTableProps = { - results: UserPageLoaderData["results"]; + results: UserResultsLoaderData["results"]; id: string; hasHighlightCheckboxes?: boolean; }; diff --git a/app/features/user-page/loaders/u.$identifier.index.server.ts b/app/features/user-page/loaders/u.$identifier.index.server.ts new file mode 100644 index 000000000..919be870e --- /dev/null +++ b/app/features/user-page/loaders/u.$identifier.index.server.ts @@ -0,0 +1,22 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { getUserId } from "~/features/auth/core/user.server"; +import { userIsBanned } from "~/features/ban/core/banned.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { isAdmin } from "~/permissions"; +import { notFoundIfFalsy } from "~/utils/remix"; + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const loggedInUser = await getUserId(request); + + const user = notFoundIfFalsy( + await UserRepository.findProfileByIdentifier(params.identifier!), + ); + + return { + user, + banned: + isAdmin(loggedInUser) && userIsBanned(user.id) + ? await UserRepository.findBannedStatusByUserId(user.id)! + : undefined, + }; +}; diff --git a/app/features/user-page/loaders/u.$identifier.results.server.ts b/app/features/user-page/loaders/u.$identifier.results.server.ts new file mode 100644 index 000000000..11639b551 --- /dev/null +++ b/app/features/user-page/loaders/u.$identifier.results.server.ts @@ -0,0 +1,16 @@ +import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { notFoundIfFalsy } from "~/utils/remix"; + +export type UserResultsLoaderData = SerializeFrom; + +// TODO: could further optimize by only loading highlighted results when needed +export const loader = async ({ params }: LoaderFunctionArgs) => { + const userId = notFoundIfFalsy( + await UserRepository.identifierToUserId(params.identifier!), + ).id; + + return { + results: await UserRepository.findResultsByUserId(userId), + }; +}; diff --git a/app/features/user-page/loaders/u.$identifier.vods.server.ts b/app/features/user-page/loaders/u.$identifier.vods.server.ts new file mode 100644 index 000000000..8925be94a --- /dev/null +++ b/app/features/user-page/loaders/u.$identifier.vods.server.ts @@ -0,0 +1,14 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { findVods } from "~/features/vods/queries/findVods.server"; +import { notFoundIfFalsy } from "~/utils/remix"; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const userId = notFoundIfFalsy( + await UserRepository.identifierToUserId(params.identifier!), + ).id; + + return { + vods: findVods({ userId }), + }; +}; diff --git a/app/features/user-page/routes/u.$identifier.art.tsx b/app/features/user-page/routes/u.$identifier.art.tsx index 197a7b83f..a7fcc2630 100644 --- a/app/features/user-page/routes/u.$identifier.art.tsx +++ b/app/features/user-page/routes/u.$identifier.art.tsx @@ -98,7 +98,7 @@ export default function UserArtPage() { }); const [, parentRoute] = useMatches(); invariant(parentRoute); - const userPageData = parentRoute.data as UserPageLoaderData; + const layoutData = parentRoute.data as UserPageLoaderData; const hasBothArtMadeByAndMadeOf = data.arts.some((a) => a.author) && data.arts.some((a) => !a.author); @@ -122,7 +122,7 @@ export default function UserArtPage() { ? t("art:pendingApproval", { count: data.unvalidatedArtCount }) : null} - {userPageData.id === user?.id ? ( + {layoutData.user.id === user?.id ? ( ) : null} @@ -189,9 +189,9 @@ export default function UserArtPage() { ) : null} - {userPageData.commissionsOpen || userPageData.commissionText ? ( + {layoutData.user.commissionsOpen || layoutData.user.commissionText ? (
- {userPageData.commissionsOpen ? ( + {layoutData.user.commissionsOpen ? ( {t("art:commissionsOpen")} {">>>"} @@ -200,14 +200,14 @@ export default function UserArtPage() { {t("art:commissionsClosed")} {">>>"} )}{" "} - {userPageData.commissionText} + {layoutData.user.commissionText}
) : null} ); diff --git a/app/features/user-page/routes/u.$identifier.builds.new.tsx b/app/features/user-page/routes/u.$identifier.builds.new.tsx index 7620780eb..aee2dd41b 100644 --- a/app/features/user-page/routes/u.$identifier.builds.new.tsx +++ b/app/features/user-page/routes/u.$identifier.builds.new.tsx @@ -37,7 +37,7 @@ export const handle: SendouRouteHandle = { export default function NewBuildPage() { const { buildToEdit } = useLoaderData(); - const { t } = useTranslation(); + const { t } = useTranslation(["builds", "common"]); const [searchParams] = useSearchParams(); const [abilities, setAbilities] = React.useState( @@ -72,7 +72,9 @@ export default function NewBuildPage() { - {t("actions.submit")} + + {t("common:actions.submit")} + ); diff --git a/app/features/user-page/routes/u.$identifier.builds.tsx b/app/features/user-page/routes/u.$identifier.builds.tsx index 0a0289779..dd0c7c616 100644 --- a/app/features/user-page/routes/u.$identifier.builds.tsx +++ b/app/features/user-page/routes/u.$identifier.builds.tsx @@ -37,9 +37,9 @@ export const handle: SendouRouteHandle = { type BuildFilter = "ALL" | "PUBLIC" | "PRIVATE" | MainWeaponId; export default function UserBuildsPage() { - const { t } = useTranslation("builds"); + const { t } = useTranslation(["builds", "user"]); const user = useUser(); - const parentPageData = atOrError(useMatches(), -2).data as UserPageLoaderData; + const layoutData = atOrError(useMatches(), -2).data as UserPageLoaderData; const data = useLoaderData(); const [weaponFilter, setWeaponFilter] = useSearchParamState({ defaultValue: "ALL", @@ -50,7 +50,7 @@ export default function UserBuildsPage() { : mainWeaponIds.find((id) => id === Number(value)), }); - const isOwnPage = user?.id === parentPageData.id; + const isOwnPage = user?.id === layoutData.user.id; const [changingSorting, setChangingSorting] = useSearchParamState({ defaultValue: false, name: "sorting", @@ -92,7 +92,7 @@ export default function UserBuildsPage() { {data.builds.length < BUILD.MAX_COUNT ? ( } @@ -143,7 +143,7 @@ function BuildsFilters({ const { t } = useTranslation(["weapons", "builds"]); const data = useLoaderData(); const user = useUser(); - const parentPageData = atOrError(useMatches(), -2).data as UserPageLoaderData; + const layoutData = atOrError(useMatches(), -2).data as UserPageLoaderData; if (data.builds.length === 0) return null; @@ -153,7 +153,7 @@ function BuildsFilters({ const publicBuildsCount = data.builds.length - privateBuildsCount; const showPublicPrivateFilters = - user?.id === parentPageData.id && privateBuildsCount > 0; + user?.id === layoutData.user.id && privateBuildsCount > 0; const WeaponFilterMenuButton = React.forwardRef((props, ref) => (