Phase 2: scoring engine with full branch coverage
Pure functions in src/lib/scoring implementing docs/SCORING.md: cell values (override > judge average), weighted raw totals, raw and placement points with tie averaging, and competition-ranked standings. Coverage thresholds are enforced at 100% for the module. Also silences the hydration warning caused by browser extensions modifying <html>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -3,7 +3,7 @@
|
||||
All implemented in `src/lib/scoring/` as pure functions. Tests must cover every rule below.
|
||||
|
||||
## 1. Cell value (team × criterion)
|
||||
- **Direct game:** the admin score row. Missing → not scored.
|
||||
- **Direct game:** the admin score row. Missing → not scored. Judge rows (e.g. left over from a mode switch) are ignored.
|
||||
- **Judged game:**
|
||||
- If an admin override row exists → use it.
|
||||
- Else average of non-revoked judges' values for that cell. Missing judges are ignored (not zero).
|
||||
@@ -12,6 +12,7 @@ All implemented in `src/lib/scoring/` as pure functions. Tests must cover every
|
||||
## 2. Team raw score for a game
|
||||
`raw = Σ (cellValue × criterion.weight)` over scored criteria.
|
||||
A team with zero scored cells is **unscored** for that game (not 0).
|
||||
`raw` is rounded to 6dp before ranking so float noise from averaging can't break a real tie.
|
||||
|
||||
## 3. Game points
|
||||
- **raw mode:** `points = round2(raw × multiplier)`.
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"check:rls": "node --env-file=.env.local scripts/check-rls.mjs",
|
||||
"gen:types": "node --env-file=.env.local scripts/gen-types.mjs"
|
||||
"gen:types": "node --env-file=.env.local scripts/gen-types.mjs",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.12.7",
|
||||
|
||||
+6
-1
@@ -26,7 +26,12 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html lang="en" className={`${bigShoulders.variable} ${atkinson.variable}`}>
|
||||
// suppressHydrationWarning: browser extensions add attributes to <html> before React loads.
|
||||
<html
|
||||
lang="en"
|
||||
className={`${bigShoulders.variable} ${atkinson.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-dvh font-sans antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CellResult, ScoreInput, ScoringMode } from "./types";
|
||||
|
||||
/**
|
||||
* SCORING.md §1: the value of one team × criterion cell.
|
||||
* `cellScores` are all score rows for that cell; `activeJudgeIds` are the game's non-revoked judges.
|
||||
*/
|
||||
export function computeCell(
|
||||
mode: ScoringMode,
|
||||
cellScores: ScoreInput[],
|
||||
activeJudgeIds: ReadonlySet<string>,
|
||||
): CellResult {
|
||||
const adminRow = cellScores.find((score) => score.judgeId === null);
|
||||
|
||||
if (mode === "direct") {
|
||||
// Judge rows are ignored in direct games (e.g. left over from a mode switch).
|
||||
return adminRow
|
||||
? { value: adminRow.value, source: "admin", judgeAverage: null, judgeCount: 0 }
|
||||
: { value: null, source: "none", judgeAverage: null, judgeCount: 0 };
|
||||
}
|
||||
|
||||
// Missing judges are ignored, not counted as zero.
|
||||
const judgeValues = cellScores
|
||||
.filter((score) => score.judgeId !== null && activeJudgeIds.has(score.judgeId))
|
||||
.map((score) => score.value);
|
||||
const judgeCount = judgeValues.length;
|
||||
const judgeAverage =
|
||||
judgeCount > 0 ? judgeValues.reduce((sum, value) => sum + value, 0) / judgeCount : null;
|
||||
|
||||
if (adminRow) {
|
||||
return { value: adminRow.value, source: "override", judgeAverage, judgeCount };
|
||||
}
|
||||
return {
|
||||
value: judgeAverage,
|
||||
source: judgeAverage === null ? "none" : "judges",
|
||||
judgeAverage,
|
||||
judgeCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { computeCell } from "./cell";
|
||||
import { round, round2 } from "./round";
|
||||
import type {
|
||||
CellResult,
|
||||
CriterionInput,
|
||||
GameInput,
|
||||
GameResult,
|
||||
JudgeInput,
|
||||
ScoreInput,
|
||||
TeamGameResult,
|
||||
TeamInput,
|
||||
} from "./types";
|
||||
|
||||
const cellKey = (teamId: string, criterionId: string) => `${teamId}|${criterionId}`;
|
||||
|
||||
/** SCORING.md §1–3 for one game. Pass the whole event's rows; they are filtered by game here. */
|
||||
export function computeGame(
|
||||
game: GameInput,
|
||||
teams: TeamInput[],
|
||||
criteria: CriterionInput[],
|
||||
judges: JudgeInput[],
|
||||
scores: ScoreInput[],
|
||||
): GameResult {
|
||||
const gameCriteria = criteria.filter((criterion) => criterion.gameId === game.id);
|
||||
const activeJudgeIds = new Set(
|
||||
judges.filter((judge) => judge.gameId === game.id && !judge.revoked).map((judge) => judge.id),
|
||||
);
|
||||
|
||||
const scoresByCell = new Map<string, ScoreInput[]>();
|
||||
for (const score of scores) {
|
||||
if (score.gameId !== game.id) continue;
|
||||
const key = cellKey(score.teamId, score.criterionId);
|
||||
const rows = scoresByCell.get(key);
|
||||
if (rows) rows.push(score);
|
||||
else scoresByCell.set(key, [score]);
|
||||
}
|
||||
|
||||
const results: TeamGameResult[] = teams.map((team) => {
|
||||
const cells: Record<string, CellResult> = {};
|
||||
let weighted = 0;
|
||||
let scoredCells = 0;
|
||||
for (const criterion of gameCriteria) {
|
||||
const cell = computeCell(
|
||||
game.scoringMode,
|
||||
scoresByCell.get(cellKey(team.id, criterion.id)) ?? [],
|
||||
activeJudgeIds,
|
||||
);
|
||||
cells[criterion.id] = cell;
|
||||
if (cell.value !== null) {
|
||||
weighted += cell.value * criterion.weight;
|
||||
scoredCells += 1;
|
||||
}
|
||||
}
|
||||
// Rounded to 6dp so float noise from averaging can't break a real tie.
|
||||
// Zero scored cells means unscored, which is not the same as scoring 0.
|
||||
const raw = scoredCells > 0 ? round(weighted, 6) : null;
|
||||
return { teamId: team.id, cells, raw, placement: null, points: 0 };
|
||||
});
|
||||
|
||||
const scored = results
|
||||
.filter((result): result is TeamGameResult & { raw: number } => result.raw !== null)
|
||||
.sort((a, b) => b.raw - a.raw);
|
||||
|
||||
// Walk groups of tied teams. Each group shares a rank and, in placement mode,
|
||||
// the average of the placement points for the places it spans.
|
||||
let start = 0;
|
||||
while (start < scored.length) {
|
||||
let end = start;
|
||||
while (end + 1 < scored.length && scored[end + 1].raw === scored[start].raw) end += 1;
|
||||
|
||||
let spannedPoints = 0;
|
||||
for (let place = start; place <= end; place += 1) {
|
||||
spannedPoints += game.placementPoints[place] ?? 0;
|
||||
}
|
||||
const sharedPoints = round2(spannedPoints / (end - start + 1));
|
||||
|
||||
for (let index = start; index <= end; index += 1) {
|
||||
const result = scored[index];
|
||||
result.placement = start + 1;
|
||||
result.points =
|
||||
game.pointsMode === "placement"
|
||||
? round2(sharedPoints * game.multiplier)
|
||||
: round2(result.raw * game.multiplier);
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
gameId: game.id,
|
||||
countsTowardTotal: game.status !== "upcoming",
|
||||
teams: Object.fromEntries(results.map((result) => [result.teamId, result])),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { computeGame } from "./game";
|
||||
import { computeStandings } from "./standings";
|
||||
import type { EventResult, ScoringInput } from "./types";
|
||||
|
||||
export { computeCell } from "./cell";
|
||||
export { computeGame } from "./game";
|
||||
export { round2 } from "./round";
|
||||
export { computeStandings } from "./standings";
|
||||
export type * from "./types";
|
||||
|
||||
/** The one entry point: admin view, scoreboard API and judge view all call this. */
|
||||
export function computeEvent(input: ScoringInput): EventResult {
|
||||
const gameResults = input.games.map((game) =>
|
||||
computeGame(game, input.teams, input.criteria, input.judges, input.scores),
|
||||
);
|
||||
return {
|
||||
games: Object.fromEntries(gameResults.map((result) => [result.gameId, result])),
|
||||
standings: computeStandings(input.teams, gameResults, input.adjustments),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Rounds to `places` decimals, nudging past float error so 1.005 rounds to 1.01. */
|
||||
export function round(value: number, places: number): number {
|
||||
const factor = 10 ** places;
|
||||
return Math.round((value + Math.sign(value) * Number.EPSILON) * factor) / factor;
|
||||
}
|
||||
|
||||
export const round2 = (value: number) => round(value, 2);
|
||||
@@ -0,0 +1,371 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeEvent, computeGame, round2 } from "./index";
|
||||
import type { CriterionInput, GameInput, JudgeInput, ScoreInput, ScoringInput, TeamInput } from "./index";
|
||||
|
||||
// ---------- builders ----------
|
||||
const teams = (...ids: string[]): TeamInput[] => ids.map((id, index) => ({ id, sortOrder: index }));
|
||||
|
||||
const game = (overrides: Partial<GameInput> = {}): GameInput => ({
|
||||
id: "g1",
|
||||
scoringMode: "direct",
|
||||
pointsMode: "raw",
|
||||
multiplier: 1,
|
||||
placementPoints: [10, 7, 5, 3],
|
||||
status: "live",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const criterion = (id: string, weight = 1, gameId = "g1"): CriterionInput => ({ id, gameId, weight });
|
||||
|
||||
const judge = (id: string, revoked = false, gameId = "g1"): JudgeInput => ({ id, gameId, revoked });
|
||||
|
||||
const score = (
|
||||
teamId: string,
|
||||
criterionId: string,
|
||||
value: number,
|
||||
judgeId: string | null = null,
|
||||
gameId = "g1",
|
||||
): ScoreInput => ({ gameId, teamId, criterionId, judgeId, value });
|
||||
|
||||
const event = (overrides: Partial<ScoringInput>): ScoringInput => ({
|
||||
teams: [],
|
||||
games: [],
|
||||
criteria: [],
|
||||
judges: [],
|
||||
scores: [],
|
||||
adjustments: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** One direct game with a single criterion "c" and the given team → value map. */
|
||||
const directGame = (values: Record<string, number>, overrides: Partial<GameInput> = {}) =>
|
||||
computeGame(
|
||||
game(overrides),
|
||||
teams(...Object.keys(values)),
|
||||
[criterion("c")],
|
||||
[],
|
||||
Object.entries(values)
|
||||
.filter(([, value]) => !Number.isNaN(value))
|
||||
.map(([teamId, value]) => score(teamId, "c", value)),
|
||||
);
|
||||
|
||||
const points = (result: ReturnType<typeof computeGame>) =>
|
||||
Object.fromEntries(Object.values(result.teams).map((team) => [team.teamId, team.points]));
|
||||
|
||||
// ---------- SCORING.md minimum cases ----------
|
||||
describe("SCORING.md test cases", () => {
|
||||
it("1. Karaoke: a judge missing a team is ignored, not counted as zero", () => {
|
||||
const karaoke = ["pitch", "energy", "lyrics", "stage", "crowd"];
|
||||
const scores = karaoke.flatMap((c) => [
|
||||
score("lions", c, 4, "j1"),
|
||||
score("lions", c, 5, "j2"), // j3 never scored Lions
|
||||
score("eagles", c, 3, "j1"),
|
||||
score("eagles", c, 4, "j2"),
|
||||
score("eagles", c, 5, "j3"),
|
||||
]);
|
||||
const result = computeGame(
|
||||
game({ scoringMode: "judged" }),
|
||||
teams("lions", "eagles"),
|
||||
karaoke.map((c) => criterion(c)),
|
||||
[judge("j1"), judge("j2"), judge("j3")],
|
||||
scores,
|
||||
);
|
||||
|
||||
expect(result.teams.lions.cells.pitch).toEqual({
|
||||
value: 4.5,
|
||||
source: "judges",
|
||||
judgeAverage: 4.5,
|
||||
judgeCount: 2,
|
||||
});
|
||||
expect(result.teams.lions.raw).toBe(22.5);
|
||||
expect(result.teams.eagles.cells.pitch.judgeCount).toBe(3);
|
||||
expect(result.teams.eagles.raw).toBe(20);
|
||||
expect(points(result)).toEqual({ lions: 22.5, eagles: 20 });
|
||||
});
|
||||
|
||||
it("2. An admin override beats the judge average", () => {
|
||||
const result = computeGame(
|
||||
game({ scoringMode: "judged" }),
|
||||
teams("a"),
|
||||
[criterion("c")],
|
||||
[judge("j1"), judge("j2")],
|
||||
[score("a", "c", 2, "j1"), score("a", "c", 4, "j2"), score("a", "c", 5)],
|
||||
);
|
||||
expect(result.teams.a.cells.c).toEqual({
|
||||
value: 5,
|
||||
source: "override",
|
||||
judgeAverage: 3,
|
||||
judgeCount: 2,
|
||||
});
|
||||
expect(result.teams.a.raw).toBe(5);
|
||||
});
|
||||
|
||||
it("3. A revoked judge is excluded", () => {
|
||||
const result = computeGame(
|
||||
game({ scoringMode: "judged" }),
|
||||
teams("a", "b"),
|
||||
[criterion("c")],
|
||||
[judge("j1"), judge("j2", true)],
|
||||
[score("a", "c", 5, "j1"), score("a", "c", 1, "j2"), score("b", "c", 4, "j2")],
|
||||
);
|
||||
expect(result.teams.a.cells.c.value).toBe(5);
|
||||
expect(result.teams.a.cells.c.judgeCount).toBe(1);
|
||||
// Team b was only scored by the revoked judge, so it is unscored.
|
||||
expect(result.teams.b.cells.c).toEqual({
|
||||
value: null,
|
||||
source: "none",
|
||||
judgeAverage: null,
|
||||
judgeCount: 0,
|
||||
});
|
||||
expect(result.teams.b.raw).toBeNull();
|
||||
});
|
||||
|
||||
it("4. Weights are applied", () => {
|
||||
const result = computeGame(
|
||||
game(),
|
||||
teams("a"),
|
||||
[criterion("c1", 2), criterion("c2", 0.5)],
|
||||
[],
|
||||
[score("a", "c1", 3), score("a", "c2", 4)],
|
||||
);
|
||||
expect(result.teams.a.raw).toBe(8);
|
||||
});
|
||||
|
||||
it("5. Placement: a 2-way tie at 1st shares the average of 1st and 2nd", () => {
|
||||
const result = directGame(
|
||||
{ a: 9, b: 9, c: 4 },
|
||||
{ pointsMode: "placement", placementPoints: [10, 7, 5] },
|
||||
);
|
||||
expect(points(result)).toEqual({ a: 8.5, b: 8.5, c: 5 });
|
||||
expect(result.teams.a.placement).toBe(1);
|
||||
expect(result.teams.b.placement).toBe(1);
|
||||
expect(result.teams.c.placement).toBe(3);
|
||||
});
|
||||
|
||||
it("6. Placement: teams beyond placement_points get 0", () => {
|
||||
const result = directGame({ a: 5, b: 4, c: 3, d: 2, e: 1 }, { pointsMode: "placement" });
|
||||
expect(points(result)).toEqual({ a: 10, b: 7, c: 5, d: 3, e: 0 });
|
||||
});
|
||||
|
||||
it("7. The multiplier applies in both modes", () => {
|
||||
expect(points(directGame({ a: 8 }, { multiplier: 1.5 }))).toEqual({ a: 12 });
|
||||
expect(
|
||||
points(directGame({ a: 9, b: 9, c: 4 }, { pointsMode: "placement", multiplier: 2 })),
|
||||
).toEqual({ a: 17, b: 17, c: 10 });
|
||||
});
|
||||
|
||||
it("8. An upcoming game is ignored in totals but still computed for preview", () => {
|
||||
const result = computeEvent(
|
||||
event({
|
||||
teams: teams("a"),
|
||||
games: [game({ id: "g1", status: "done" }), game({ id: "g2", status: "upcoming" })],
|
||||
criteria: [criterion("c1", 1, "g1"), criterion("c2", 1, "g2")],
|
||||
scores: [score("a", "c1", 6, null, "g1"), score("a", "c2", 9, null, "g2")],
|
||||
}),
|
||||
);
|
||||
expect(result.games.g2.countsTowardTotal).toBe(false);
|
||||
expect(result.games.g2.teams.a.points).toBe(9);
|
||||
expect(result.standings[0]).toMatchObject({
|
||||
total: 6,
|
||||
gamePoints: 6,
|
||||
perGame: [{ gameId: "g1", points: 6 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("9. Adjustments (+ and −) are added, even when the only game is upcoming", () => {
|
||||
const result = computeEvent(
|
||||
event({
|
||||
teams: teams("a", "b"),
|
||||
games: [game({ status: "upcoming" })],
|
||||
criteria: [criterion("c")],
|
||||
scores: [score("a", "c", 9)],
|
||||
adjustments: [
|
||||
{ teamId: "a", points: 5 },
|
||||
{ teamId: "a", points: -2 },
|
||||
{ teamId: "b", points: -1.5 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.standings).toMatchObject([
|
||||
{ teamId: "a", total: 3, gamePoints: 0, adjustmentPoints: 3 },
|
||||
{ teamId: "b", total: -1.5, adjustmentPoints: -1.5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("10. Tied totals share a rank (1, 1, 3) and display in team sort order", () => {
|
||||
const result = computeEvent(
|
||||
event({
|
||||
teams: [
|
||||
{ id: "a", sortOrder: 2 },
|
||||
{ id: "b", sortOrder: 1 },
|
||||
{ id: "c", sortOrder: 0 },
|
||||
],
|
||||
games: [game()],
|
||||
criteria: [criterion("c")],
|
||||
scores: [score("a", "c", 10), score("b", "c", 10), score("c", "c", 5)],
|
||||
}),
|
||||
);
|
||||
expect(result.standings.map(({ teamId, rank }) => [teamId, rank])).toEqual([
|
||||
["b", 1],
|
||||
["a", 1],
|
||||
["c", 3],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- edge cases ----------
|
||||
describe("cells", () => {
|
||||
it("direct game: missing admin row is not scored, and judge rows are ignored", () => {
|
||||
const result = computeGame(
|
||||
game(),
|
||||
teams("a", "b"),
|
||||
[criterion("c")],
|
||||
[judge("j1")],
|
||||
[score("a", "c", 3), score("a", "c", 5, "j1"), score("b", "c", 5, "j1")],
|
||||
);
|
||||
expect(result.teams.a.cells.c).toEqual({
|
||||
value: 3,
|
||||
source: "admin",
|
||||
judgeAverage: null,
|
||||
judgeCount: 0,
|
||||
});
|
||||
expect(result.teams.b.cells.c.source).toBe("none");
|
||||
expect(result.teams.b.raw).toBeNull();
|
||||
});
|
||||
|
||||
it("judged game: ignores scores from unknown judges and judges of other games", () => {
|
||||
const result = computeGame(
|
||||
game({ scoringMode: "judged" }),
|
||||
teams("a"),
|
||||
[criterion("c")],
|
||||
[judge("j1"), judge("other", false, "g2")],
|
||||
[score("a", "c", 4, "j1"), score("a", "c", 1, "other"), score("a", "c", 1, "ghost")],
|
||||
);
|
||||
expect(result.teams.a.cells.c.value).toBe(4);
|
||||
});
|
||||
|
||||
it("an override with no judge scores still counts", () => {
|
||||
const result = computeGame(
|
||||
game({ scoringMode: "judged" }),
|
||||
teams("a"),
|
||||
[criterion("c")],
|
||||
[],
|
||||
[score("a", "c", 2)],
|
||||
);
|
||||
expect(result.teams.a.cells.c).toEqual({
|
||||
value: 2,
|
||||
source: "override",
|
||||
judgeAverage: null,
|
||||
judgeCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("game points", () => {
|
||||
it("only sums scored criteria, and ignores scores from other games", () => {
|
||||
const result = computeGame(
|
||||
game(),
|
||||
teams("a"),
|
||||
[criterion("c1"), criterion("c2"), criterion("x", 1, "g2")],
|
||||
[],
|
||||
[score("a", "c1", 3), score("a", "x", 100, null, "g2")],
|
||||
);
|
||||
expect(result.teams.a.raw).toBe(3);
|
||||
expect(Object.keys(result.teams.a.cells)).toEqual(["c1", "c2"]);
|
||||
});
|
||||
|
||||
it("a real score of 0 is scored (ranked), unlike an unscored team", () => {
|
||||
const result = directGame({ a: 0, b: Number.NaN }, { pointsMode: "placement" });
|
||||
expect(result.teams.a).toMatchObject({ raw: 0, placement: 1, points: 10 });
|
||||
expect(result.teams.b).toMatchObject({ raw: null, placement: null, points: 0 });
|
||||
});
|
||||
|
||||
it("a tie that spans the end of placement_points shares what is left", () => {
|
||||
const result = directGame(
|
||||
{ a: 5, b: 3, c: 3 },
|
||||
{ pointsMode: "placement", placementPoints: [10, 7] },
|
||||
);
|
||||
expect(points(result)).toEqual({ a: 10, b: 3.5, c: 3.5 });
|
||||
});
|
||||
|
||||
it("a 3-way tie rounds the shared points to 2dp", () => {
|
||||
const result = directGame(
|
||||
{ a: 1, b: 1, c: 1 },
|
||||
{ pointsMode: "placement", placementPoints: [10, 7, 5] },
|
||||
);
|
||||
expect(points(result)).toEqual({ a: 7.33, b: 7.33, c: 7.33 });
|
||||
});
|
||||
|
||||
it("raw mode rounds points to 2dp", () => {
|
||||
const result = computeGame(
|
||||
game({ scoringMode: "judged" }),
|
||||
teams("a"),
|
||||
[criterion("c")],
|
||||
[judge("j1"), judge("j2"), judge("j3")],
|
||||
[score("a", "c", 3, "j1"), score("a", "c", 3, "j2"), score("a", "c", 4, "j3")],
|
||||
);
|
||||
expect(result.teams.a.points).toBe(3.33);
|
||||
});
|
||||
|
||||
it("float noise from averaging does not break a real tie", () => {
|
||||
// 1.1 + 2.2 + 3.3 and 3.3 + 2.2 + 1.1 differ in the last float digit.
|
||||
const result = computeGame(
|
||||
game({ scoringMode: "judged", pointsMode: "placement" }),
|
||||
teams("a", "b"),
|
||||
[criterion("c")],
|
||||
[judge("j1"), judge("j2"), judge("j3")],
|
||||
[
|
||||
score("a", "c", 1.1, "j1"),
|
||||
score("a", "c", 2.2, "j2"),
|
||||
score("a", "c", 3.3, "j3"),
|
||||
score("b", "c", 3.3, "j1"),
|
||||
score("b", "c", 2.2, "j2"),
|
||||
score("b", "c", 1.1, "j3"),
|
||||
],
|
||||
);
|
||||
expect(points(result)).toEqual({ a: 8.5, b: 8.5 });
|
||||
});
|
||||
|
||||
it("a game with no criteria leaves every team unscored", () => {
|
||||
const result = computeGame(game(), teams("a"), [], [], []);
|
||||
expect(result.teams.a).toMatchObject({ raw: null, points: 0, cells: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe("event", () => {
|
||||
it("handles an empty event", () => {
|
||||
expect(computeEvent(event({}))).toEqual({ games: {}, standings: [] });
|
||||
});
|
||||
|
||||
it("sums several games and gives every team rank 1 before anything is scored", () => {
|
||||
const empty = computeEvent(event({ teams: teams("a", "b"), games: [game()] }));
|
||||
expect(empty.standings.map((standing) => standing.rank)).toEqual([1, 1]);
|
||||
|
||||
const result = computeEvent(
|
||||
event({
|
||||
teams: teams("a", "b"),
|
||||
games: [game({ id: "g1" }), game({ id: "g2", pointsMode: "placement", status: "done" })],
|
||||
criteria: [criterion("c1", 1, "g1"), criterion("c2", 1, "g2")],
|
||||
scores: [
|
||||
score("a", "c1", 4, null, "g1"),
|
||||
score("b", "c1", 6, null, "g1"),
|
||||
score("a", "c2", 9, null, "g2"),
|
||||
score("b", "c2", 2, null, "g2"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.standings).toMatchObject([
|
||||
{ teamId: "a", rank: 1, total: 14 },
|
||||
{ teamId: "b", rank: 2, total: 13 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("round2", () => {
|
||||
it("rounds half up despite float representation", () => {
|
||||
expect(round2(1.005)).toBe(1.01);
|
||||
expect(round2(-1.005)).toBe(-1.01);
|
||||
expect(round2(0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { round2 } from "./round";
|
||||
import type { AdjustmentInput, GameResult, Standing, TeamInput } from "./types";
|
||||
|
||||
/** SCORING.md §4–5: event totals and ranked standings. */
|
||||
export function computeStandings(
|
||||
teams: TeamInput[],
|
||||
gameResults: GameResult[],
|
||||
adjustments: AdjustmentInput[],
|
||||
): Standing[] {
|
||||
const counted = gameResults.filter((result) => result.countsTowardTotal);
|
||||
const sortOrder = new Map(teams.map((team) => [team.id, team.sortOrder]));
|
||||
|
||||
const standings: Standing[] = teams.map((team) => {
|
||||
const perGame = counted.map((result) => ({
|
||||
gameId: result.gameId,
|
||||
points: result.teams[team.id].points,
|
||||
}));
|
||||
const gamePoints = round2(perGame.reduce((sum, game) => sum + game.points, 0));
|
||||
// Adjustments always count, even when tied to an upcoming game: they are explicit.
|
||||
const adjustmentPoints = round2(
|
||||
adjustments
|
||||
.filter((adjustment) => adjustment.teamId === team.id)
|
||||
.reduce((sum, adjustment) => sum + adjustment.points, 0),
|
||||
);
|
||||
return {
|
||||
teamId: team.id,
|
||||
rank: 0,
|
||||
total: round2(gamePoints + adjustmentPoints),
|
||||
gamePoints,
|
||||
adjustmentPoints,
|
||||
perGame,
|
||||
};
|
||||
});
|
||||
|
||||
standings.sort(
|
||||
(a, b) => b.total - a.total || sortOrder.get(a.teamId)! - sortOrder.get(b.teamId)!,
|
||||
);
|
||||
|
||||
// Standard competition ranking (1, 1, 3): a team's rank is the position of the
|
||||
// first team with the same total.
|
||||
for (const standing of standings) {
|
||||
standing.rank = standings.findIndex((other) => other.total === standing.total) + 1;
|
||||
}
|
||||
return standings;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Plain-data input and output of the scoring engine. No DB types here on purpose:
|
||||
// loaders map rows into these shapes, so the engine stays pure and easy to test.
|
||||
|
||||
export type ScoringMode = "judged" | "direct";
|
||||
export type PointsMode = "raw" | "placement";
|
||||
export type GameStatus = "upcoming" | "live" | "done";
|
||||
|
||||
export type TeamInput = { id: string; sortOrder: number };
|
||||
|
||||
export type GameInput = {
|
||||
id: string;
|
||||
scoringMode: ScoringMode;
|
||||
pointsMode: PointsMode;
|
||||
multiplier: number;
|
||||
placementPoints: number[];
|
||||
status: GameStatus;
|
||||
};
|
||||
|
||||
export type CriterionInput = { id: string; gameId: string; weight: number };
|
||||
|
||||
export type JudgeInput = { id: string; gameId: string; revoked: boolean };
|
||||
|
||||
export type ScoreInput = {
|
||||
gameId: string;
|
||||
teamId: string;
|
||||
criterionId: string;
|
||||
/** null = admin row: the score itself in a direct game, an override in a judged game. */
|
||||
judgeId: string | null;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type AdjustmentInput = { teamId: string; points: number };
|
||||
|
||||
export type ScoringInput = {
|
||||
teams: TeamInput[];
|
||||
games: GameInput[];
|
||||
criteria: CriterionInput[];
|
||||
judges: JudgeInput[];
|
||||
scores: ScoreInput[];
|
||||
adjustments: AdjustmentInput[];
|
||||
};
|
||||
|
||||
export type CellSource = "admin" | "override" | "judges" | "none";
|
||||
|
||||
export type CellResult = {
|
||||
/** The value that counts, or null when the cell is not scored. */
|
||||
value: number | null;
|
||||
source: CellSource;
|
||||
/** Average of non-revoked judges, kept even when an override wins so admin can compare. */
|
||||
judgeAverage: number | null;
|
||||
judgeCount: number;
|
||||
};
|
||||
|
||||
export type TeamGameResult = {
|
||||
teamId: string;
|
||||
cells: Record<string, CellResult>;
|
||||
/** Weighted criteria total, or null when the team has no scored cells. */
|
||||
raw: number | null;
|
||||
/** Rank within this game (ties share a rank), or null when unscored. */
|
||||
placement: number | null;
|
||||
points: number;
|
||||
};
|
||||
|
||||
export type GameResult = {
|
||||
gameId: string;
|
||||
/** False for upcoming games: results are computed for preview but excluded from totals. */
|
||||
countsTowardTotal: boolean;
|
||||
teams: Record<string, TeamGameResult>;
|
||||
};
|
||||
|
||||
export type Standing = {
|
||||
teamId: string;
|
||||
rank: number;
|
||||
total: number;
|
||||
gamePoints: number;
|
||||
adjustmentPoints: number;
|
||||
perGame: Array<{ gameId: string; points: number }>;
|
||||
};
|
||||
|
||||
export type EventResult = {
|
||||
games: Record<string, GameResult>;
|
||||
/** Sorted for display: total desc, ties in team sort order. */
|
||||
standings: Standing[];
|
||||
};
|
||||
@@ -12,6 +12,8 @@ export default defineConfig({
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/lib/scoring/**"],
|
||||
// The scoring engine decides who wins: every branch must be tested.
|
||||
thresholds: { branches: 100, functions: 100, lines: 100, statements: 100 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user