Plus Voting to 60% criteria + leaderboard access (#1537)

* Update PlusVotingResult

* Logic

* Replace plus tiers

* Testing initial

* Test progress

* Working one test

* Tests

* Schema different file

* Dynamic test

* Fix test util
This commit is contained in:
Kalle 2023-11-11 12:19:57 +02:00 committed by GitHub
parent 0ee7c2569f
commit 9a126f543d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 462 additions and 58 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@ translation-progress.md
notes.md
db*.sqlite3*
!db-test.sqlite3
dump
.DS_Store

View File

@ -3,8 +3,14 @@ import invariant from "tiny-invariant";
import { Kysely, ParseJSONResultsPlugin, SqliteDialect } from "kysely";
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");
export const sql = new Database(process.env["DB_PATH"]);
const isInMemoryDB = process.env["DB_PATH"] === ":memory:";
export const sql = new Database(
isInMemoryDB ? migratedEmptyDb : process.env["DB_PATH"],
);
sql.pragma("journal_mode = WAL");
sql.pragma("foreign_keys = ON");

View File

@ -164,8 +164,8 @@ export interface CalendarEventResultTeam {
}
export interface FreshPlusTier {
tier: string | null;
userId: number | null;
tier: number | null;
userId: number;
}
export interface Group {

View File

@ -2,6 +2,7 @@ import { db, sql } from "~/db/sql";
import { syncXPBadges } from "../badges";
import type { Tables } from "~/db/tables";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "tiny-invariant";
const removeOldLikesStm = sql.prepare(/*sql*/ `
delete from
@ -48,23 +49,25 @@ export function migrate(args: { newUserId: number; oldUserId: number }) {
});
}
export function refreshPlusTiers() {
export function replacePlusTiers(
plusTiers: Array<{ userId: number; tier: number }>,
) {
invariant(plusTiers.length > 0, "plusTiers must not be empty");
return db.transaction().execute(async (trx) => {
await trx.deleteFrom("PlusTier").execute();
await trx
.insertInto("PlusTier")
.columns(["userId", "tier"])
.expression((eb) =>
eb
.selectFrom("FreshPlusTier")
.select(["FreshPlusTier.userId", "FreshPlusTier.tier"])
.where("FreshPlusTier.tier", "is not", null),
)
.execute();
await trx.insertInto("PlusTier").values(plusTiers).execute();
});
}
export function allPlusTiersFromLatestVoting() {
return db
.selectFrom("FreshPlusTier")
.select(["FreshPlusTier.userId", "FreshPlusTier.tier"])
.where("FreshPlusTier.tier", "is not", null)
.execute() as Promise<{ userId: number; tier: number }[]>;
}
export function makeVideoAdderByUserId(userId: number) {
return db
.updateTable("User")

View File

@ -0,0 +1,35 @@
import { _action, actualNumber } from "~/utils/zod";
import { z } from "zod";
export const adminActionSchema = z.union([
z.object({
_action: _action("MIGRATE"),
"old-user": z.preprocess(actualNumber, z.number().positive()),
"new-user": z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("REFRESH"),
}),
z.object({
_action: _action("CLEAN_UP"),
}),
z.object({
_action: _action("FORCE_PATRON"),
user: z.preprocess(actualNumber, z.number().positive()),
patronTier: z.preprocess(actualNumber, z.number()),
patronTill: z.string(),
}),
z.object({
_action: _action("VIDEO_ADDER"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("ARTIST"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("LINK_PLAYER"),
user: z.preprocess(actualNumber, z.number().positive()),
playerId: z.preprocess(actualNumber, z.number().positive()),
}),
]);

View File

@ -0,0 +1,44 @@
import invariant from "tiny-invariant";
import { addPendingPlusTiers } from "~/features/leaderboards/core/leaderboards.server";
import { userSPLeaderboard } from "~/features/leaderboards/queries/userSPLeaderboard.server";
import { previousSeason } from "~/features/mmr/season";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
export async function plusTiersFromVotingAndLeaderboard() {
const newMembersFromLeaderboard = fromLeaderboard();
const newMembersFromVoting =
await AdminRepository.allPlusTiersFromLatestVoting();
return [
...newMembersFromLeaderboard,
// filter to ensure that user gets their highest tier
...newMembersFromVoting.filter(
(member) =>
!newMembersFromLeaderboard.some(
(leaderboardMember) => leaderboardMember.userId === member.userId,
),
),
];
}
function fromLeaderboard() {
const now = new Date();
const lastCompletedSeason = previousSeason(now);
invariant(lastCompletedSeason, "No previous season found");
// there has been voting after this season ended, the results no longer apply
if (now.getMonth() !== lastCompletedSeason.ends.getMonth()) return [];
const leaderboard = addPendingPlusTiers(
userSPLeaderboard(lastCompletedSeason.nth),
);
return leaderboard.flatMap((entry) => {
if (!entry.pendingPlusTier) return [];
return {
userId: entry.id,
tier: entry.pendingPlusTier,
};
});
}

View File

@ -0,0 +1,221 @@
import { suite } from "uvu";
import * as assert from "uvu/assert";
import * as Test from "~/utils/Test";
import { action } from "./admin";
import { db } from "~/db/sql";
import MockDate from "mockdate";
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import type { adminActionSchema } from "../admin-schemas.server";
const PlusVoting = suite("Plus voting");
const adminAction = Test.wrappedAction<typeof adminActionSchema>({ action });
const voteArgs = ({
score,
votedId,
authorId = 1,
month = 6,
year = 2021,
}: {
score: number;
votedId: number;
authorId?: number;
month?: number;
year?: number;
}) => ({
score,
votedId,
authorId,
month,
tier: 1,
validAfter: dateToDatabaseTimestamp(new Date("2021-12-11T00:00:00.000Z")),
year,
});
const countPlusTierMembers = (tier = 1) =>
db
.selectFrom("PlusTier")
.where("PlusTier.tier", "=", tier)
.select(({ fn }) => fn.count<number>("PlusTier.tier").as("count"))
.executeTakeFirstOrThrow()
.then((row) => row.count);
const createLeaderboard = (userIds: number[]) =>
db
.insertInto("Skill")
.values(
userIds.map((userId, i) => ({
matchesCount: 10,
mu: 25,
sigma: 8.333333333333334,
ordinal: 0.5 - i * 0.001,
userId,
season: 1,
})),
)
.execute();
PlusVoting.after.each(() => {
MockDate.reset();
Test.database.reset();
});
PlusVoting("gives correct amount of plus tiers", async () => {
MockDate.set(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;
return voteArgs({
score: id <= 5 ? -1 : 1,
votedId: id,
});
}),
);
await adminAction({ _action: "REFRESH" }, { user: "admin" });
assert.equal(await countPlusTierMembers(), 5);
});
PlusVoting("60% is the criteria to pass voting", async () => {
MockDate.set(new Date("2023-12-12T00:00:00.000Z"));
await Test.database.insertUsers(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,
});
}),
);
await adminAction({ _action: "REFRESH" }, { user: "admin" });
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);
});
PlusVoting(
"combines leaderboard and voting results (after season over)",
async () => {
MockDate.set(new Date("2023-11-29T00:00:00.000Z"));
await Test.database.insertUsers(2);
await PlusVotingRepository.upsertMany([
voteArgs({
score: 1,
votedId: 1,
}),
]);
await createLeaderboard([2]);
await adminAction({ _action: "REFRESH" }, { user: "admin" });
assert.equal(await countPlusTierMembers(), 2);
},
);
PlusVoting("ignores leaderboard while season is ongoing", async () => {
MockDate.set(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 adminAction({ _action: "REFRESH" }, { user: "admin" });
assert.equal(await countPlusTierMembers(), 1);
assert.equal(await countPlusTierMembers(2), 0);
});
PlusVoting("leaderboard gives members to all tiers", async () => {
MockDate.set(new Date("2023-11-20T00:00:00.000Z"));
await Test.database.insertUsers(60);
await createLeaderboard(Array.from({ length: 60 }, (_, i) => i + 1));
await adminAction({ _action: "REFRESH" }, { user: "admin" });
assert.ok((await countPlusTierMembers()) > 0);
assert.ok((await countPlusTierMembers(2)) > 0);
assert.ok((await countPlusTierMembers(3)) > 0);
});
PlusVoting(
"gives membership if failed voting and is on the leaderboard",
async () => {
MockDate.set(new Date("2023-11-29T00:00:00.000Z"));
await Test.database.insertUsers(1);
await PlusVotingRepository.upsertMany([
voteArgs({
score: -1,
votedId: 1,
}),
]);
await createLeaderboard([1]);
await adminAction({ _action: "REFRESH" }, { user: "admin" });
assert.equal(await countPlusTierMembers(1), 1);
},
);
PlusVoting("members who fails voting drops one tier", async () => {
MockDate.set(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 PlusVotingRepository.upsertMany([
voteArgs({
score: -1,
votedId: 1,
month: 2,
year: 2024,
}),
]);
await adminAction({ _action: "REFRESH" }, { user: "admin" });
assert.equal(await countPlusTierMembers(2), 1);
});
PlusVoting.run();

View File

@ -1,17 +1,17 @@
import type {
ActionFunction,
ActionArgs,
LoaderFunction,
V2_MetaFunction,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useFetcher, useLoaderData, useNavigation } from "@remix-run/react";
import * as React from "react";
import { z } from "zod";
import { Button } from "~/components/Button";
import { Catcher } from "~/components/Catcher";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { UserSearch } from "~/components/UserSearch";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
import { makeArtist } from "~/features/art";
import { useUser } from "~/features/auth/core";
import {
@ -27,48 +27,15 @@ import {
} from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import { impersonateUrl, SEED_URL, STOP_IMPERSONATING_URL } from "~/utils/urls";
import { _action, actualNumber } from "~/utils/zod";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
import { SEED_URL, STOP_IMPERSONATING_URL, impersonateUrl } from "~/utils/urls";
import { adminActionSchema } from "../admin-schemas.server";
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
export const meta: V2_MetaFunction = () => {
return [{ title: makeTitle("Admin page") }];
};
const adminActionSchema = z.union([
z.object({
_action: _action("MIGRATE"),
"old-user": z.preprocess(actualNumber, z.number().positive()),
"new-user": z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("REFRESH"),
}),
z.object({
_action: _action("CLEAN_UP"),
}),
z.object({
_action: _action("FORCE_PATRON"),
user: z.preprocess(actualNumber, z.number().positive()),
patronTier: z.preprocess(actualNumber, z.number()),
patronTill: z.string(),
}),
z.object({
_action: _action("VIDEO_ADDER"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("ARTIST"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("LINK_PLAYER"),
user: z.preprocess(actualNumber, z.number().positive()),
playerId: z.preprocess(actualNumber, z.number().positive()),
}),
]);
export const action: ActionFunction = async ({ request }) => {
export const action = async ({ request }: ActionArgs) => {
const data = await parseRequestFormData({
request,
schema: adminActionSchema,
@ -88,7 +55,9 @@ export const action: ActionFunction = async ({ request }) => {
case "REFRESH": {
validate(isAdmin(user));
await AdminRepository.refreshPlusTiers();
await AdminRepository.replacePlusTiers(
await plusTiersFromVotingAndLeaderboard(),
);
break;
}
case "FORCE_PATRON": {
@ -136,7 +105,7 @@ export const action: ActionFunction = async ({ request }) => {
}
}
return null;
return { ok: true };
};
interface AdminPageLoaderData {

View File

@ -31,9 +31,13 @@ export function currentOrPreviousSeason(date: Date) {
const _currentSeason = currentSeason(date);
if (_currentSeason) return _currentSeason;
return previousSeason(date);
}
export function previousSeason(date: Date) {
let latestPreviousSeason;
for (const season of SEASONS) {
if (date >= season.ends) latestPreviousSeason = season;
if (date > season.ends) latestPreviousSeason = season;
}
return latestPreviousSeason;

70
app/utils/Test.ts Normal file
View File

@ -0,0 +1,70 @@
import type { ActionArgs } from "@remix-run/node";
import type { z } from "zod";
import { ADMIN_ID } from "~/constants";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { db, sql } from "~/db/sql";
import { SESSION_KEY } from "~/features/auth/core/authenticator.server";
import { authSessionStorage } from "~/features/auth/core/session.server";
export function wrappedAction<T extends z.ZodTypeAny>({
action,
}: {
// TODO: strongly type this
action: (args: ActionArgs) => any;
}) {
return async (
args: z.infer<T>,
{ user }: { user?: "admin" | "regular" } = {},
) => {
const params = new URLSearchParams(args);
const request = new Request("http://app.com/path", {
method: "POST",
body: params,
headers: await authHeader(user),
});
return action({
request,
context: {},
params: {},
});
};
}
async function authHeader(user?: "admin" | "regular"): Promise<HeadersInit> {
if (!user) return [];
const session = await authSessionStorage.getSession();
session.set(SESSION_KEY, user === "admin" ? ADMIN_ID : NZAP_TEST_ID);
return [["Cookie", await authSessionStorage.commitSession(session)]];
}
export const database = {
reset: () => {
const tables = sql
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'migrations';",
)
.all() as { name: string }[];
sql.prepare("PRAGMA foreign_keys = OFF").run();
for (const table of tables) {
sql.prepare(`DELETE FROM "${table.name}"`).run();
}
sql.prepare("PRAGMA foreign_keys = ON").run();
},
insertUsers: (count: number) =>
db
.insertInto("User")
.values(
Array.from({ length: count }).map((_, i) => ({
id: i + 1,
discordName: `user${i + 1}`,
discordDiscriminator: "0",
discordId: String(i),
})),
)
.execute(),
};

BIN
db-test.sqlite3 Normal file

Binary file not shown.

View File

@ -0,0 +1,37 @@
module.exports.up = function (db) {
db.transaction(() => {
db.prepare(`drop view "PlusVotingResult"`).run();
// same as 000-initial.js except 60% criteria
db.prepare(
/* sql */ `
create view "PlusVotingResult" as
select
"votedId",
"tier",
avg("score") as "score",
avg("score") >= 0.2 as "passedVoting",
"month",
"year",
exists (
select
1
from
"PlusSuggestion"
where
"PlusSuggestion"."month" = "PlusVote"."month"
and "PlusSuggestion"."year" = "PlusVote"."year"
and "PlusSuggestion"."suggestedId" = "PlusVote"."votedId"
and "PlusSuggestion"."tier" = "PlusVote"."tier"
) as "wasSuggested"
from
"PlusVote"
group by
"votedId",
"tier",
"month",
"year";
`,
).run();
})();
};

13
package-lock.json generated
View File

@ -82,6 +82,7 @@
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"ley": "^0.8.1",
"mockdate": "^3.0.5",
"prettier": "3.0.2",
"stylelint": "^15.10.3",
"stylelint-config-standard": "^34.0.0",
@ -12554,6 +12555,12 @@
"ufo": "^1.1.1"
}
},
"node_modules/mockdate": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz",
"integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==",
"dev": true
},
"node_modules/morgan": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz",
@ -25941,6 +25948,12 @@
"ufo": "^1.1.1"
}
},
"mockdate": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz",
"integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==",
"dev": true
},
"morgan": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz",

View File

@ -35,7 +35,7 @@
"prettier:check": "prettier --check . --log-level warn",
"prettier:write": "prettier --write . --log-level warn",
"typecheck": "tsc --noEmit",
"test:unit": "uvu -r tsm -r tsconfig-paths/register -i e2e",
"test:unit": "cross-env DB_PATH=:memory: NODE_ENV=test BASE_URL=https://example.com uvu -r tsm -r tsconfig-paths/register -i e2e",
"test:e2e": "npx playwright test",
"checks": "npm run test:unit && npm run lint:css && npm run lint:ts && npm run prettier:check && npm run typecheck",
"cf": "npm run test:unit && npm run check-translation-jsons && npm run lint:css -- --fix && npm run lint:ts -- --fix && npm run prettier:write && npm run typecheck && npm run test:e2e",
@ -116,6 +116,7 @@
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"ley": "^0.8.1",
"mockdate": "^3.0.5",
"prettier": "3.0.2",
"stylelint": "^15.10.3",
"stylelint-config-standard": "^34.0.0",