One deck form per session; rename leagues; e2e test script
- A player's deck for a night is edited in a single form and written to their match rows and their standings row, so the two can't disagree. Matches, standings and hand imports all fill blank decks from the decks already entered, whatever order the files are imported in. The imported standings table shows decks read-only. - Admins can rename a league. - test/e2e.sh runs end-to-end checks against the local podman stack (fixtures by default, or real exports: test/e2e.sh sampledata). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a99997b96d
commit
022d161934
6 files changed
+147
-36
No files matched your search
@@ -15,7 +15,8 @@ add `ALTER TABLE ... IF NOT EXISTS` statements to `init` or introduce a migratio
|
|||||||
- `src/standings.ts` pure leaderboard + deck-stats logic
|
- `src/standings.ts` pure leaderboard + deck-stats logic
|
||||||
- `src/db.ts` pool, `tx()` helper, schema
|
- `src/db.ts` pool, `tx()` helper, schema
|
||||||
- `views/*.ejs` five pages. Every link/form action is prefixed with `<%= base %>` (BASE_PATH support)
|
- `views/*.ejs` five pages. Every link/form action is prefixed with `<%= base %>` (BASE_PATH support)
|
||||||
- `test/` `npm test` (node:test) with anonymised fixtures. `npm run check` type-checks.
|
- `test/` `npm test` (node:test) with anonymised fixtures. `npm run check` type-checks. `test/e2e.sh [sampledata]`
|
||||||
|
runs end-to-end checks (imports, decks, hand import, league rename/delete) against `scripts/local.sh up`.
|
||||||
|
|
||||||
## Rules and decisions
|
## Rules and decisions
|
||||||
- Scoring: match win 3, draw 1, loss 0, bye = 2-0 win. Session score = sum of that night's match points.
|
- Scoring: match win 3, draw 1, loss 0, bye = 2-0 win. Session score = sum of that night's match points.
|
||||||
@@ -27,7 +28,8 @@ add `ALTER TABLE ... IF NOT EXISTS` statements to `init` or introduce a migratio
|
|||||||
- Players are identified by real name (Melee `Team.Players[0].Name`, falling back to DisplayName, then Username),
|
- Players are identified by real name (Melee `Team.Players[0].Name`, falling back to DisplayName, then Username),
|
||||||
matched case-insensitively and created on first sight. Spelling differences create duplicate players.
|
matched case-insensitively and created on first sight. Spelling differences create duplicate players.
|
||||||
- No top cut will ever be used, so the standings importer takes the last round of the export.
|
- No top cut will ever be used, so the standings importer takes the last round of the export.
|
||||||
- Decks are free text, stored per player per session (Melee exports contain no deck names). `known` decks feed a datalist
|
- Decks are free text, one per player per session (Melee exports contain no deck names), edited in one form and kept
|
||||||
|
identical on the player's match rows and standings row; every import fills blanks from the decks already entered. `known` decks feed a datalist
|
||||||
to avoid "Burn"/"burn" duplicates. Deck stats count whole-night results against the deck.
|
to avoid "Burn"/"burn" duplicates. Deck stats count whole-night results against the deck.
|
||||||
- 1-on-1 matches only. Unreported matches (`HasResult` false) are skipped.
|
- 1-on-1 matches only. Unreported matches (`HasResult` false) are skipped.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Sub-path hosting: set `BASE_PATH=/mtg` and proxy with Caddy `handle_path /mtg/*`
|
|||||||
npm install
|
npm install
|
||||||
npm test # parser + standings tests
|
npm test # parser + standings tests
|
||||||
npm run check # type-check
|
npm run check # type-check
|
||||||
|
scripts/local.sh up && test/e2e.sh [sampledata] # end-to-end checks against the local podman stack
|
||||||
DATABASE_URL=postgres://... SECRET_KEY=... ADMIN_PASSWORD=... npm run dev
|
DATABASE_URL=postgres://... SECRET_KEY=... ADMIN_PASSWORD=... npm run dev
|
||||||
```
|
```
|
||||||
Whole stack locally with podman (own `.env.local` with generated secrets, own project/volume, port 8889):
|
Whole stack locally with podman (own `.env.local` with generated secrets, own project/volume, port 8889):
|
||||||
|
|||||||
+37
-25
@@ -82,6 +82,16 @@ async function standingsFor(l: { id: number; best_n: number }) {
|
|||||||
return computeStandings(ms, rs, new Map(ps.map((p) => [p.id, p.name])), l.best_n);
|
return computeStandings(ms, rs, new Map(ps.map((p) => [p.id, p.name])), l.best_n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Everyone in a session (from its matches and imported standings) with their deck. A deck is per player per night
|
||||||
|
// and is kept identical on their match rows and their standings row.
|
||||||
|
const SESSION_PLAYERS = `SELECT p.id, p.name, MAX(x.deck) AS deck FROM (
|
||||||
|
SELECT p1_id AS pid, p1_deck AS deck FROM matches WHERE session_id=$1
|
||||||
|
UNION ALL SELECT p2_id, p2_deck FROM matches WHERE session_id=$1 AND p2_id IS NOT NULL
|
||||||
|
UNION ALL SELECT player_id, deck FROM session_results WHERE session_id=$1) x
|
||||||
|
JOIN players p ON p.id=x.pid GROUP BY p.id, p.name ORDER BY p.name`;
|
||||||
|
const sessionDecks = async (c: Q, sid: number) =>
|
||||||
|
new Map<number, string | null>((await c.query(SESSION_PLAYERS, [sid])).rows.map((r) => [r.id, r.deck]));
|
||||||
|
|
||||||
/** name -> player id; players are created on first sight, names match case-insensitively. */
|
/** name -> player id; players are created on first sight, names match case-insensitively. */
|
||||||
async function resolver(c: Q) {
|
async function resolver(c: Q) {
|
||||||
const cache = new Map<string, number>((await c.query("SELECT id, name FROM players")).rows.map((p) => [p.name.toLowerCase(), p.id]));
|
const cache = new Map<string, number>((await c.query("SELECT id, name FROM players")).rows.map((p) => [p.name.toLowerCase(), p.id]));
|
||||||
@@ -132,12 +142,8 @@ app.get("/session/:id", async (req, res) => {
|
|||||||
const known = (await q(
|
const known = (await q(
|
||||||
`SELECT deck FROM (SELECT deck FROM session_results UNION SELECT p1_deck FROM matches
|
`SELECT deck FROM (SELECT deck FROM session_results UNION SELECT p1_deck FROM matches
|
||||||
UNION SELECT p2_deck FROM matches) d WHERE deck IS NOT NULL ORDER BY 1`)).map((r) => r.deck);
|
UNION SELECT p2_deck FROM matches) d WHERE deck IS NOT NULL ORDER BY 1`)).map((r) => r.deck);
|
||||||
// one deck per player per night, stored on each of their match rows
|
const players = await q(SESSION_PLAYERS, [sid]);
|
||||||
const matchPlayers = await q(
|
res.render("session", { gs, lg, rounds, csvtext, results, known, players });
|
||||||
`SELECT p.id, p.name, MAX(x.deck) AS deck FROM (SELECT p1_id AS pid, p1_deck AS deck FROM matches WHERE session_id=$1
|
|
||||||
UNION ALL SELECT p2_id, p2_deck FROM matches WHERE session_id=$1 AND p2_id IS NOT NULL) x
|
|
||||||
JOIN players p ON p.id=x.pid GROUP BY p.id, p.name ORDER BY p.name`, [sid]);
|
|
||||||
res.render("session", { gs, lg, rounds, csvtext, results, known, matchPlayers });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- auth ----------
|
// ---------- auth ----------
|
||||||
@@ -176,6 +182,14 @@ app.post("/admin/league/:id/session", admin, async (req, res) => {
|
|||||||
res.redirect(303, BASE + `/session/${s.id}`);
|
res.redirect(303, BASE + `/session/${s.id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/admin/league/:id/rename", admin, async (req, res) => {
|
||||||
|
const name = String(req.body.name ?? "").trim();
|
||||||
|
if (!name) throw new HttpError(400, "Name required");
|
||||||
|
const [l] = await q("UPDATE leagues SET name=$1 WHERE id=$2 RETURNING id", [name, idOf(req)]);
|
||||||
|
if (!l) throw new HttpError(404, "Not found");
|
||||||
|
res.redirect(303, BASE + `/league/${l.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/admin/league/:id/delete", admin, async (req, res) => {
|
app.post("/admin/league/:id/delete", admin, async (req, res) => {
|
||||||
const id = idOf(req);
|
const id = idOf(req);
|
||||||
await tx(async (c) => {
|
await tx(async (c) => {
|
||||||
@@ -205,15 +219,22 @@ app.post("/admin/session/:id/import", admin, async (req, res) => {
|
|||||||
return [{ rnd, a: c[1], da: c[2] || null, b: c[3], db: c[4] || null, w1, w2, dr }];
|
return [{ rnd, a: c[1], da: c[2] || null, b: c[3], db: c[4] || null, w1, w2, dr }];
|
||||||
});
|
});
|
||||||
await tx(async (c) => {
|
await tx(async (c) => {
|
||||||
|
const old = await sessionDecks(c, sid); // a blank deck keeps the one already entered
|
||||||
const pid = await resolver(c);
|
const pid = await resolver(c);
|
||||||
await c.query("DELETE FROM matches WHERE session_id=$1", [sid]);
|
await c.query("DELETE FROM matches WHERE session_id=$1", [sid]);
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const bye = !r.b;
|
const bye = !r.b;
|
||||||
|
const [a, b] = [(await pid(r.a))!, await pid(r.b)];
|
||||||
await c.query(
|
await c.query(
|
||||||
`INSERT INTO matches(session_id, round, p1_id, p2_id, p1_deck, p2_deck, p1_wins, p2_wins, draws)
|
`INSERT INTO matches(session_id, round, p1_id, p2_id, p1_deck, p2_deck, p1_wins, p2_wins, draws)
|
||||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||||
[sid, r.rnd, await pid(r.a), await pid(r.b), r.da, r.db, bye ? 2 : r.w1, bye ? 0 : r.w2, bye ? 0 : r.dr]);
|
[sid, r.rnd, a, b, r.da ?? old.get(a) ?? null, b == null ? null : r.db ?? old.get(b) ?? null, bye ? 2 : r.w1, bye ? 0 : r.w2, bye ? 0 : r.dr]);
|
||||||
}
|
}
|
||||||
|
await c.query(
|
||||||
|
`UPDATE session_results r SET deck=m.deck FROM (SELECT pid, MAX(deck) AS deck FROM (
|
||||||
|
SELECT p1_id AS pid, p1_deck AS deck FROM matches WHERE session_id=$1
|
||||||
|
UNION ALL SELECT p2_id, p2_deck FROM matches WHERE session_id=$1) x GROUP BY pid) m
|
||||||
|
WHERE r.session_id=$1 AND r.player_id=m.pid AND m.deck IS NOT NULL`, [sid]);
|
||||||
});
|
});
|
||||||
res.redirect(303, BASE + `/session/${sid}`);
|
res.redirect(303, BASE + `/session/${sid}`);
|
||||||
});
|
});
|
||||||
@@ -250,9 +271,7 @@ app.post("/admin/session/:id/import-melee", admin, upload.array("files", 50), as
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function importMatches(c: Q, sid: number, matches: Match[]) {
|
async function importMatches(c: Q, sid: number, matches: Match[]) {
|
||||||
const old = new Map<number, string>((await c.query(
|
const old = await sessionDecks(c, sid);
|
||||||
`SELECT p1_id AS pid, p1_deck AS deck FROM matches WHERE session_id=$1 AND p1_deck IS NOT NULL
|
|
||||||
UNION SELECT p2_id, p2_deck FROM matches WHERE session_id=$1 AND p2_deck IS NOT NULL`, [sid])).rows.map((r) => [r.pid, r.deck]));
|
|
||||||
const pid = await resolver(c);
|
const pid = await resolver(c);
|
||||||
await c.query("DELETE FROM matches WHERE session_id=$1 AND round = ANY($2::int[])", [sid, [...new Set(matches.map((m) => m.round))]]);
|
await c.query("DELETE FROM matches WHERE session_id=$1 AND round = ANY($2::int[])", [sid, [...new Set(matches.map((m) => m.round))]]);
|
||||||
for (const m of matches) {
|
for (const m of matches) {
|
||||||
@@ -265,7 +284,7 @@ async function importMatches(c: Q, sid: number, matches: Match[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function importStandings(c: Q, sid: number, rows: Row[]) {
|
async function importStandings(c: Q, sid: number, rows: Row[]) {
|
||||||
const old = new Map<number, string | null>((await c.query("SELECT player_id, deck FROM session_results WHERE session_id=$1", [sid])).rows.map((r) => [r.player_id, r.deck]));
|
const old = await sessionDecks(c, sid);
|
||||||
const pid = await resolver(c);
|
const pid = await resolver(c);
|
||||||
await c.query("DELETE FROM session_results WHERE session_id=$1", [sid]);
|
await c.query("DELETE FROM session_results WHERE session_id=$1", [sid]);
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
@@ -277,29 +296,22 @@ async function importStandings(c: Q, sid: number, rows: Row[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.post("/admin/session/:id/match-decks", admin, async (req, res) => {
|
// One deck per player per night: deck_<player id>, written to their match rows and their standings row
|
||||||
|
app.post("/admin/session/:id/decks", admin, async (req, res) => {
|
||||||
const sid = idOf(req);
|
const sid = idOf(req);
|
||||||
await tx(async (c) => {
|
await tx(async (c) => {
|
||||||
for (const [k, v] of Object.entries(req.body)) {
|
for (const [k, v] of Object.entries(req.body)) {
|
||||||
const m = /^mdeck_(\d+)$/.exec(k);
|
const pid = Number(/^deck_(\d{1,10})$/.exec(k)?.[1]);
|
||||||
if (!m) continue;
|
if (!pid || pid > 2 ** 31 - 1) continue;
|
||||||
const deck = String(v).trim() || null;
|
const deck = String(v).trim() || null;
|
||||||
await c.query("UPDATE matches SET p1_deck=$1 WHERE session_id=$2 AND p1_id=$3", [deck, sid, Number(m[1])]);
|
await c.query("UPDATE matches SET p1_deck=$1 WHERE session_id=$2 AND p1_id=$3", [deck, sid, pid]);
|
||||||
await c.query("UPDATE matches SET p2_deck=$1 WHERE session_id=$2 AND p2_id=$3", [deck, sid, Number(m[1])]);
|
await c.query("UPDATE matches SET p2_deck=$1 WHERE session_id=$2 AND p2_id=$3", [deck, sid, pid]);
|
||||||
|
await c.query("UPDATE session_results SET deck=$1 WHERE session_id=$2 AND player_id=$3", [deck, sid, pid]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
res.redirect(303, BASE + `/session/${sid}`);
|
res.redirect(303, BASE + `/session/${sid}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/session/:id/decks", admin, async (req, res) => {
|
|
||||||
const sid = idOf(req);
|
|
||||||
for (const [k, v] of Object.entries(req.body)) {
|
|
||||||
const m = /^deck_(\d+)$/.exec(k);
|
|
||||||
if (m) await q("UPDATE session_results SET deck=$1 WHERE id=$2 AND session_id=$3", [String(v).trim() || null, Number(m[1]), sid]);
|
|
||||||
}
|
|
||||||
res.redirect(303, BASE + `/session/${sid}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
if (!err.status) console.error(err);
|
if (!err.status) console.error(err);
|
||||||
res.status(err.status ?? 500).type("text/plain").send(err.status ? err.message : "Server error");
|
res.status(err.status ?? 500).type("text/plain").send(err.status ? err.message : "Server error");
|
||||||
|
|||||||
Executable
+99
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end checks against the local podman stack: imports, the single deck form, hand import, league rename/delete.
|
||||||
|
# usage: scripts/local.sh up && test/e2e.sh [dir]
|
||||||
|
# dir holds Melee exports named *matches*.json / *tandings*.json (default test/fixtures; real ones: test/e2e.sh sampledata).
|
||||||
|
# Creates a throwaway league and deletes it again at the end.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
DIR=${1:-test/fixtures}
|
||||||
|
set -a; source .env.local; set +a
|
||||||
|
B=http://127.0.0.1:$HOST_PORT
|
||||||
|
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
||||||
|
fails=0
|
||||||
|
|
||||||
|
check() { if [[ "$2" == "$3" ]]; then echo "ok $1"; else echo "FAIL $1: got '$2', want '$3'"; fails=$((fails + 1)); fi; }
|
||||||
|
req() { curl -s -b "$T/jar" -c "$T/jar" -o "$T/out" -w '%{http_code}' "$@"; }
|
||||||
|
anon() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
|
||||||
|
db() { podman exec mtg-local-db-1 psql -U league -tAc "$1"; }
|
||||||
|
files() { for f in "$DIR"/$1; do printf -- '-F\nfiles=@%s\n' "$f"; done; }
|
||||||
|
mapfile -t MATCHES < <(files '*atches*.json')
|
||||||
|
mapfile -t STANDINGS < <(files '*tandings*.json')
|
||||||
|
decks_of() { # distinct decks of player $2 in session $1, across match rows and the standings row
|
||||||
|
db "SELECT string_agg(DISTINCT coalesce(d, '-'), ',') FROM (SELECT p1_deck d FROM matches WHERE session_id=$1 AND p1_id=$2
|
||||||
|
UNION ALL SELECT p2_deck FROM matches WHERE session_id=$1 AND p2_id=$2
|
||||||
|
UNION ALL SELECT deck FROM session_results WHERE session_id=$1 AND player_id=$2) x"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== auth and leagues"
|
||||||
|
check "admin route needs login" "$(anon -d name=x "$B/admin/league")" 403
|
||||||
|
check "login" "$(req -d "password=$ADMIN_PASSWORD" "$B/login")" 303
|
||||||
|
req -d name=e2e-league -d best_n=6 "$B/admin/league" >/dev/null
|
||||||
|
LG=$(db "SELECT max(id) FROM leagues")
|
||||||
|
check "rename needs login" "$(anon -d name=x "$B/admin/league/$LG/rename")" 403
|
||||||
|
check "rename rejects empty name" "$(req -d 'name= ' "$B/admin/league/$LG/rename")" 400
|
||||||
|
check "rename" "$(req -d 'name=e2e renamed' "$B/admin/league/$LG/rename")" 303
|
||||||
|
check "renamed in db" "$(db "SELECT name FROM leagues WHERE id=$LG")" "e2e renamed"
|
||||||
|
req "$B/league/$LG" >/dev/null; check "league page shows new name" "$(grep -c '<h1>e2e renamed</h1>' "$T/out")" 1
|
||||||
|
check "rename unknown league" "$(req -d name=x "$B/admin/league/2147483647/rename")" 404
|
||||||
|
|
||||||
|
echo "== session A: standings, then a deck, then matches"
|
||||||
|
req -d date=2026-10-14 "$B/admin/league/$LG/session" >/dev/null; SA=$(db "SELECT max(id) FROM sessions")
|
||||||
|
check "import standings" "$(req "${STANDINGS[@]}" "$B/admin/session/$SA/import-melee")" 303
|
||||||
|
P1=$(db "SELECT player_id FROM session_results r JOIN players p ON p.id=r.player_id WHERE session_id=$SA ORDER BY p.name LIMIT 1")
|
||||||
|
check "deck form saves to standings" "$(req -d "deck_$P1=Burn" "$B/admin/session/$SA/decks")" 303
|
||||||
|
check "standings deck" "$(decks_of "$SA" "$P1")" Burn
|
||||||
|
check "import all matches at once" "$(req "${MATCHES[@]}" "$B/admin/session/$SA/import-melee")" 303
|
||||||
|
check "matches picked up the standings deck" "$(decks_of "$SA" "$P1")" Burn
|
||||||
|
req "$B/session/$SA" >/dev/null
|
||||||
|
check "one deck form on the page" "$(grep -c '/decks"' "$T/out")" 1
|
||||||
|
check "one deck input per session player" "$(grep -o 'name="deck_' "$T/out" | wc -l)" "$(db "SELECT count(*) FROM ($(sed 's/\$1/'"$SA"'/g' <<<"SELECT pid FROM (SELECT p1_id pid FROM matches WHERE session_id=\$1 UNION SELECT p2_id FROM matches WHERE session_id=\$1 AND p2_id IS NOT NULL UNION SELECT player_id FROM session_results WHERE session_id=\$1) x")) y")"
|
||||||
|
check "deck form saves to matches and standings" "$(req -d "deck_$P1=Control" "$B/admin/session/$SA/decks")" 303
|
||||||
|
check "both updated" "$(decks_of "$SA" "$P1")" Control
|
||||||
|
check "clearing a deck" "$(req -d "deck_$P1=" "$B/admin/session/$SA/decks")" 303
|
||||||
|
check "cleared everywhere" "$(decks_of "$SA" "$P1")" -
|
||||||
|
req -d "deck_$P1=Control" "$B/admin/session/$SA/decks" >/dev/null
|
||||||
|
|
||||||
|
echo "== session A: hand import"
|
||||||
|
csv() { db "COPY (SELECT m.round, a.name, CASE WHEN m.p1_id=$P1 THEN '$1' ELSE '' END, coalesce(b.name, ''),
|
||||||
|
CASE WHEN m.p2_id=$P1 THEN '$1' ELSE '' END, m.p1_wins, m.p2_wins, m.draws FROM matches m JOIN players a ON a.id=m.p1_id
|
||||||
|
LEFT JOIN players b ON b.id=m.p2_id WHERE m.session_id=$SA ORDER BY m.round, m.id) TO STDOUT WITH CSV"; }
|
||||||
|
before=$(db "SELECT count(*) FROM matches WHERE session_id=$SA")
|
||||||
|
check "hand import with blank decks" "$(req --data-urlencode "data=$(csv '')" "$B/admin/session/$SA/import")" 303
|
||||||
|
check "match count unchanged" "$(db "SELECT count(*) FROM matches WHERE session_id=$SA")" "$before"
|
||||||
|
check "blank keeps the entered deck" "$(decks_of "$SA" "$P1")" Control
|
||||||
|
check "hand import with a new deck" "$(req --data-urlencode "data=$(csv Elves)" "$B/admin/session/$SA/import")" 303
|
||||||
|
check "new deck reaches standings too" "$(decks_of "$SA" "$P1")" Elves
|
||||||
|
|
||||||
|
echo "== session B: matches, then a deck, then standings"
|
||||||
|
req -d date=2026-10-15 "$B/admin/league/$LG/session" >/dev/null; SB=$(db "SELECT max(id) FROM sessions")
|
||||||
|
check "import matches" "$(req "${MATCHES[@]}" "$B/admin/session/$SB/import-melee")" 303
|
||||||
|
P2=$(db "SELECT p1_id FROM matches WHERE session_id=$SB AND p2_id IS NOT NULL ORDER BY id LIMIT 1")
|
||||||
|
check "deck on matches" "$(req -d "deck_$P2=Elves" "$B/admin/session/$SB/decks")" 303
|
||||||
|
check "import standings" "$(req "${STANDINGS[@]}" "$B/admin/session/$SB/import-melee")" 303
|
||||||
|
if [[ -n "$(db "SELECT 1 FROM session_results WHERE session_id=$SB AND player_id=$P2")" ]]; then
|
||||||
|
check "standings picked up the match deck" "$(decks_of "$SB" "$P2")" Elves
|
||||||
|
else echo "skip standings deck check: player not in the standings export"; fi
|
||||||
|
|
||||||
|
echo "== scores"
|
||||||
|
# only meaningful when the matches exports cover every round of the standings export
|
||||||
|
python3 -I - "$DIR" "$(curl -s "$B/api/league/$LG/leaderboard")" "$(db "SELECT json_agg(json_build_object('name', p.name, 'points', r.points))
|
||||||
|
FROM session_results r JOIN players p ON p.id=r.player_id WHERE r.session_id=$SA")" <<'EOF' || fails=$((fails + 1))
|
||||||
|
import glob, json, sys
|
||||||
|
d, board, final = sys.argv[1], json.loads(sys.argv[2]), json.loads(sys.argv[3])
|
||||||
|
rounds = {m["RoundNumber"] for f in glob.glob(d + "/*atches*.json") for m in json.load(open(f))}
|
||||||
|
last = max(r["RoundNumber"] for f in glob.glob(d + "/*tandings*.json") for r in json.load(open(f)))
|
||||||
|
if rounds != set(range(1, last + 1)):
|
||||||
|
print("skip leaderboard check: matches exports don't cover rounds 1 to", last); sys.exit(0)
|
||||||
|
got = {r["name"]: r["total"] / 2 for r in board} # sessions A and B hold the same night
|
||||||
|
bad = [f"{r['name']} {got.get(r['name'])} != {r['points']}" for r in final if got.get(r["name"]) != r["points"]]
|
||||||
|
if bad: print("FAIL leaderboard vs Melee standings:", bad); sys.exit(1)
|
||||||
|
print("ok leaderboard matches Melee's points for", len(final), "players")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "== delete league"
|
||||||
|
check "delete league" "$(req -X POST "$B/admin/league/$LG/delete")" 303
|
||||||
|
check "league gone" "$(db "SELECT count(*) FROM leagues WHERE id=$LG")" 0
|
||||||
|
check "its sessions gone" "$(db "SELECT count(*) FROM sessions WHERE id IN ($SA, $SB)")" 0
|
||||||
|
check "its matches gone" "$(db "SELECT count(*) FROM matches WHERE session_id IN ($SA, $SB)")" 0
|
||||||
|
|
||||||
|
echo; [[ $fails == 0 ]] && echo "all e2e checks passed" || { echo "$fails e2e check(s) failed"; exit 1; }
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<%- include('head') %>
|
<%- include('head') %>
|
||||||
<h1><%= lg.name %></h1>
|
<h1><%= lg.name %></h1>
|
||||||
|
<% if (admin) { %><form method="post" action="<%= base %>/admin/league/<%= lg.id %>/rename"><input name="name" value="<%= lg.name %>" required> <button>Rename league</button></form><% } %>
|
||||||
<h2>Leaderboard <small>(best <%= lg.best_n %> sessions)</small></h2>
|
<h2>Leaderboard <small>(best <%= lg.best_n %> sessions)</small></h2>
|
||||||
<table><tr><th>#</th><th>Player</th><th>Counted</th><th>All sessions</th><th>Played</th><th>W-D-L</th><th>OMW%</th><th>GW%</th><th>OGW%</th></tr>
|
<table><tr><th>#</th><th>Player</th><th>Counted</th><th>All sessions</th><th>Played</th><th>W-D-L</th><th>OMW%</th><th>GW%</th><th>OGW%</th></tr>
|
||||||
<% const pct = (r, k) => r.tb ? (r.tb[k] * 100).toFixed(2) : '–' %>
|
<% const pct = (r, k) => r.tb ? (r.tb[k] * 100).toFixed(2) : '–' %>
|
||||||
|
|||||||
+5
-9
@@ -9,17 +9,13 @@
|
|||||||
<td><b><%= m.p1_wins %>–<%= m.p2_wins %><%= m.draws ? '–' + m.draws : '' %></b></td></tr>
|
<td><b><%= m.p1_wins %>–<%= m.p2_wins %><%= m.draws ? '–' + m.draws : '' %></b></td></tr>
|
||||||
<% }) %></table><% }) %>
|
<% }) %></table><% }) %>
|
||||||
<% if (!rounds.length && !results.length) { %><p>No results yet.</p><% } %>
|
<% if (!rounds.length && !results.length) { %><p>No results yet.</p><% } %>
|
||||||
<% if (admin && matchPlayers.length) { %><h2>Decks played</h2>
|
<% if (admin && players.length) { %><h2>Decks played</h2>
|
||||||
<form method="post" action="<%= base %>/admin/session/<%= gs.id %>/match-decks"><table><tr><th>Player</th><th>Deck</th></tr>
|
<form method="post" action="<%= base %>/admin/session/<%= gs.id %>/decks"><table><tr><th>Player</th><th>Deck</th></tr>
|
||||||
<% matchPlayers.forEach(p => { %><tr><td><%= p.name %></td><td><input name="mdeck_<%= p.id %>" value="<%= p.deck || '' %>" list="decks"></td></tr><% }) %></table>
|
<% players.forEach(p => { %><tr><td><%= p.name %></td><td><input name="deck_<%= p.id %>" value="<%= p.deck || '' %>" list="decks"></td></tr><% }) %></table>
|
||||||
<button>Save decks</button></form><% } %>
|
<button>Save decks</button></form><% } %>
|
||||||
<% if (results.length) { %><h2>Standings <small>(imported)</small></h2>
|
<% if (results.length) { %><h2>Standings <small>(imported)</small></h2>
|
||||||
<form method="post" action="<%= base %>/admin/session/<%= gs.id %>/decks">
|
|
||||||
<table><tr><th>Player</th><th>Points</th><th>W-D-L</th><th>Deck</th></tr>
|
<table><tr><th>Player</th><th>Points</th><th>W-D-L</th><th>Deck</th></tr>
|
||||||
<% results.forEach(r => { %><tr><td><%= r.name %></td><td><b><%= r.points %></b></td><td><%= r.rec %></td>
|
<% results.forEach(r => { %><tr><td><%= r.name %></td><td><b><%= r.points %></b></td><td><%= r.rec %></td><td><%= r.deck || '' %></td></tr><% }) %></table>
|
||||||
<td><% if (admin) { %><input name="deck_<%= r.id %>" value="<%= r.deck || '' %>" list="decks"><% } else { %><%= r.deck || '' %><% } %></td></tr><% }) %></table>
|
|
||||||
<% if (admin) { %><button>Save decks</button><% } %>
|
|
||||||
</form>
|
|
||||||
<p><small>If this session also has match rows, those are used for scoring instead.</small></p><% } %>
|
<p><small>If this session also has match rows, those are used for scoring instead.</small></p><% } %>
|
||||||
<% if (admin) { %>
|
<% if (admin) { %>
|
||||||
<h2>Import Melee export</h2>
|
<h2>Import Melee export</h2>
|
||||||
@@ -31,7 +27,7 @@ If anything in the batch is invalid, nothing is imported. If a session has match
|
|||||||
<textarea name="data" rows="5" style="width:100%;font-family:monospace" placeholder="...or paste here"></textarea><br><button>Import</button></form>
|
<textarea name="data" rows="5" style="width:100%;font-family:monospace" placeholder="...or paste here"></textarea><br><button>Import</button></form>
|
||||||
<h2>Import matches by hand</h2>
|
<h2>Import matches by hand</h2>
|
||||||
<p>One match per line: <code>round, player1, deck1, player2, deck2, p1 wins, p2 wins, draws</code>. Leave player 2 blank for a bye.
|
<p>One match per line: <code>round, player1, deck1, player2, deck2, p1 wins, p2 wins, draws</code>. Leave player 2 blank for a bye.
|
||||||
Comma- or tab-separated. Saving <b>replaces</b> this session's matches.</p>
|
Comma- or tab-separated. Saving <b>replaces</b> this session's matches; a blank deck keeps the one already entered.</p>
|
||||||
<form method="post" action="<%= base %>/admin/session/<%= gs.id %>/import">
|
<form method="post" action="<%= base %>/admin/session/<%= gs.id %>/import">
|
||||||
<textarea name="data" rows="12" style="width:100%;font-family:monospace"><%= csvtext %></textarea><br><button>Save matches</button></form>
|
<textarea name="data" rows="12" style="width:100%;font-family:monospace"><%= csvtext %></textarea><br><button>Save matches</button></form>
|
||||||
<form method="post" action="<%= base %>/admin/session/<%= gs.id %>/delete" data-confirm="Delete this session and its results?"><button>Delete session</button></form>
|
<form method="post" action="<%= base %>/admin/session/<%= gs.id %>/delete" data-confirm="Delete this session and its results?"><button>Delete session</button></form>
|
||||||
|
|||||||
Reference in new issue
Block a user