mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-10 13:15:47 -05:00
1513 lines
38 KiB
TypeScript
1513 lines
38 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import * as Progression from "./Progression";
|
|
import { progressions } from "./tests/test-utils";
|
|
|
|
type BracketInput = Omit<
|
|
Progression.InputBracket,
|
|
"id" | "name" | "requiresCheckIn"
|
|
> & { name?: string };
|
|
|
|
const ROUND_ROBIN: BracketInput = { type: "round_robin", settings: {} };
|
|
const ROUND_ROBIN_OF_8: BracketInput = {
|
|
type: "round_robin",
|
|
settings: { teamsPerGroup: 8 },
|
|
};
|
|
const SINGLE_ELIMINATION: BracketInput = {
|
|
type: "single_elimination",
|
|
settings: {},
|
|
};
|
|
const DOUBLE_ELIMINATION: BracketInput = {
|
|
type: "double_elimination",
|
|
settings: {},
|
|
};
|
|
const SWISS: BracketInput = { type: "swiss", settings: {} };
|
|
const SWISS_EARLY_ADVANCE: BracketInput = {
|
|
type: "swiss",
|
|
settings: { advanceThreshold: 3 },
|
|
};
|
|
|
|
/** Fills in the ids and names `validatedBrackets` needs. Bracket N is referenced as source bracketId `"N"`. */
|
|
const getValidatedBrackets = (brackets: BracketInput[]) =>
|
|
Progression.validatedBrackets(
|
|
brackets.map((bracket, i) => ({
|
|
id: String(i),
|
|
name: bracket.name ?? `Bracket ${i + 1}`,
|
|
requiresCheckIn: false,
|
|
...bracket,
|
|
})),
|
|
);
|
|
|
|
/** A single elimination bracket taking `placements` from one source bracket. */
|
|
const getValidatedBracketsFromPlacements = (
|
|
placements: string,
|
|
source: BracketInput = ROUND_ROBIN,
|
|
) =>
|
|
getValidatedBrackets([
|
|
source,
|
|
{
|
|
type: "single_elimination",
|
|
settings: {},
|
|
sources: [{ bracketId: "0", placements }],
|
|
},
|
|
]);
|
|
|
|
describe("bracketsToValidationError - valid formats", () => {
|
|
test.each([
|
|
["SE", progressions.singleElimination],
|
|
["RR->SE", progressions.roundRobinToSingleElimination],
|
|
["low ink", progressions.lowInk],
|
|
["many starter brackets", progressions.manyStartBrackets],
|
|
["swiss (one group)", progressions.swissOneGroup],
|
|
["a bracket with many source brackets", progressions.multiSourceTopCut],
|
|
] as const)("accepts %s", (_, progression) => {
|
|
expect(Progression.bracketsToValidationError(progression)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("validatedSources - placements parsing", () => {
|
|
const PARSED: {
|
|
why: string;
|
|
placements: string;
|
|
expected: Progression.DBSource;
|
|
source?: BracketInput;
|
|
}[] = [
|
|
{
|
|
why: "comma separated",
|
|
placements: "1,2,3,4",
|
|
expected: { bracketIdx: 0, placements: [1, 2, 3, 4] },
|
|
},
|
|
{
|
|
why: "a range",
|
|
placements: "1-4",
|
|
expected: { bracketIdx: 0, placements: [1, 2, 3, 4] },
|
|
},
|
|
{
|
|
why: "a mix of ranges and commas",
|
|
placements: "1,2,3-4",
|
|
expected: { bracketIdx: 0, placements: [1, 2, 3, 4] },
|
|
},
|
|
{
|
|
why: "extra white space",
|
|
placements: "1, 2, 3,4 ",
|
|
expected: { bracketIdx: 0, placements: [1, 2, 3, 4] },
|
|
},
|
|
{
|
|
why: "a range whose start and end are the same",
|
|
placements: "1-1",
|
|
expected: { bracketIdx: 0, placements: [1] },
|
|
},
|
|
{
|
|
why: "negative placements",
|
|
placements: "-1,-2",
|
|
expected: { bracketIdx: 0, placements: [-1, -2] },
|
|
source: DOUBLE_ELIMINATION,
|
|
},
|
|
{
|
|
why: "empty placements from a Swiss bracket with early advance",
|
|
placements: "",
|
|
expected: { bracketIdx: 0, placements: [] },
|
|
source: SWISS_EARLY_ADVANCE,
|
|
},
|
|
{
|
|
why: "a lone rest placement",
|
|
placements: "5+",
|
|
expected: { bracketIdx: 0, placements: [5], rest: true },
|
|
source: ROUND_ROBIN_OF_8,
|
|
},
|
|
{
|
|
why: "rest from the first placement",
|
|
placements: "1+",
|
|
expected: { bracketIdx: 0, placements: [1], rest: true },
|
|
source: ROUND_ROBIN_OF_8,
|
|
},
|
|
{
|
|
why: "rest combined with explicit placements",
|
|
placements: "1,2,3-4,5+",
|
|
expected: { bracketIdx: 0, placements: [1, 2, 3, 4, 5], rest: true },
|
|
source: ROUND_ROBIN_OF_8,
|
|
},
|
|
{
|
|
why: "a range followed by rest, as a single element",
|
|
placements: "1-5+",
|
|
expected: { bracketIdx: 0, placements: [1, 2, 3, 4, 5], rest: true },
|
|
source: ROUND_ROBIN_OF_8,
|
|
},
|
|
];
|
|
|
|
test.each(PARSED)("parses $why", ({ placements, expected, source }) => {
|
|
const result = getValidatedBracketsFromPlacements(
|
|
placements,
|
|
source,
|
|
) as Progression.ParsedBracket[];
|
|
|
|
expect(result[1].sources).toEqual([expected]);
|
|
});
|
|
|
|
const PARSE_ERRORS: {
|
|
why: string;
|
|
placements: string;
|
|
source?: BracketInput;
|
|
}[] = [
|
|
{ why: "invalid characters", placements: "1st,2nd,3rd,4th" },
|
|
{ why: "a zero placement", placements: "0", source: DOUBLE_ELIMINATION },
|
|
{ why: "empty placements from a non-Swiss bracket", placements: "" },
|
|
{
|
|
why: "empty placements from a Swiss bracket without early advance",
|
|
placements: "",
|
|
source: SWISS,
|
|
},
|
|
{
|
|
why: "a reversed range",
|
|
placements: "3-1",
|
|
source: SWISS_EARLY_ADVANCE,
|
|
},
|
|
{
|
|
why: "rest in a non-final position",
|
|
placements: "5+,6",
|
|
source: ROUND_ROBIN_OF_8,
|
|
},
|
|
{ why: "a double plus", placements: "5++", source: ROUND_ROBIN_OF_8 },
|
|
{ why: "a lone plus", placements: "+", source: ROUND_ROBIN_OF_8 },
|
|
{
|
|
why: "rest on a zero placement",
|
|
placements: "0+",
|
|
source: ROUND_ROBIN_OF_8,
|
|
},
|
|
{
|
|
why: "rest on a negative placement",
|
|
placements: "-1+",
|
|
source: DOUBLE_ELIMINATION,
|
|
},
|
|
];
|
|
|
|
test.each(PARSE_ERRORS)(
|
|
"flags PLACEMENTS_PARSE_ERROR on $why",
|
|
({ placements, source }) => {
|
|
const error = getValidatedBracketsFromPlacements(
|
|
placements,
|
|
source,
|
|
) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("PLACEMENTS_PARSE_ERROR");
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('validatedSources - rest "N+" syntax', () => {
|
|
test("round-trips lone rest via input format", () => {
|
|
const validated = getValidatedBracketsFromPlacements(
|
|
"5+",
|
|
ROUND_ROBIN_OF_8,
|
|
) as Progression.ParsedBracket[];
|
|
const inputFormat = Progression.validatedBracketsToInputFormat(validated);
|
|
expect(inputFormat[1].sources?.[0].placements).toBe("5+");
|
|
});
|
|
|
|
test("round-trips combined rest via input format", () => {
|
|
const validated = getValidatedBracketsFromPlacements(
|
|
"1,2,3-4,5+",
|
|
ROUND_ROBIN_OF_8,
|
|
) as Progression.ParsedBracket[];
|
|
const inputFormat = Progression.validatedBracketsToInputFormat(validated);
|
|
expect(inputFormat[1].sources?.[0].placements).toBe("1-4,5+");
|
|
});
|
|
|
|
test("destinationByPlacement routes placements beyond the rest threshold", () => {
|
|
const validated = getValidatedBracketsFromPlacements(
|
|
"5+",
|
|
ROUND_ROBIN_OF_8,
|
|
) as Progression.ParsedBracket[];
|
|
expect(
|
|
Progression.destinationByPlacement({
|
|
sourceBracketIdx: 0,
|
|
placement: 10,
|
|
progression: validated,
|
|
}),
|
|
).toBe(1);
|
|
expect(
|
|
Progression.destinationByPlacement({
|
|
sourceBracketIdx: 0,
|
|
placement: 4,
|
|
progression: validated,
|
|
}),
|
|
).toBe(null);
|
|
});
|
|
|
|
test("flags SAME_PLACEMENT_TO_MULTIPLE_BRACKETS when two rest sources share a bracket", () => {
|
|
const error = getValidatedBrackets([
|
|
ROUND_ROBIN_OF_8,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-4" }],
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "5+" }],
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "6+" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
expect(error.type).toBe("SAME_PLACEMENT_TO_MULTIPLE_BRACKETS");
|
|
});
|
|
|
|
test("flags SAME_PLACEMENT_TO_MULTIPLE_BRACKETS when rest overlaps an explicit placement", () => {
|
|
const error = getValidatedBrackets([
|
|
ROUND_ROBIN_OF_8,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-4,5+" }],
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "7" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
expect(error.type).toBe("SAME_PLACEMENT_TO_MULTIPLE_BRACKETS");
|
|
});
|
|
|
|
test("still flags TOO_MANY_PLACEMENTS when rest's explicit max exceeds teamsPerGroup", () => {
|
|
const error = getValidatedBrackets([
|
|
{
|
|
settings: { teamsPerGroup: 4 },
|
|
type: "round_robin",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-4" }],
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "5+" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
expect(error.type).toBe("TOO_MANY_PLACEMENTS");
|
|
});
|
|
});
|
|
|
|
describe("validatedSources - other rules", () => {
|
|
test("accepts a single round robin with no follow-ups", () => {
|
|
const result = getValidatedBrackets([ROUND_ROBIN]);
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
});
|
|
|
|
test("accepts a single A/B round robin with no follow-ups", () => {
|
|
const result = getValidatedBrackets([
|
|
{
|
|
settings: {
|
|
hasAbDivisions: true,
|
|
teamsPerGroup: 6,
|
|
},
|
|
type: "round_robin",
|
|
},
|
|
]);
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
});
|
|
|
|
test("accepts a swiss to round robin progression", () => {
|
|
const result = getValidatedBrackets([
|
|
SWISS,
|
|
{
|
|
settings: {},
|
|
type: "round_robin",
|
|
sources: [{ bracketId: "0", placements: "1,2" }],
|
|
},
|
|
]);
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
});
|
|
|
|
test("accepts a round robin to round robin progression", () => {
|
|
const result = getValidatedBrackets([
|
|
ROUND_ROBIN,
|
|
{
|
|
settings: {},
|
|
type: "round_robin",
|
|
sources: [{ bracketId: "0", placements: "1,2" }],
|
|
},
|
|
]);
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
});
|
|
|
|
test("handles NOT_RESOLVING_WINNER (swiss with many groups)", () => {
|
|
const error = getValidatedBrackets([
|
|
{
|
|
settings: {
|
|
groupCount: 2,
|
|
},
|
|
type: "swiss",
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("NOT_RESOLVING_WINNER");
|
|
});
|
|
|
|
test("handles SAME_PLACEMENT_TO_MULTIPLE_BRACKETS", () => {
|
|
const error = getValidatedBrackets([
|
|
ROUND_ROBIN,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-2" }],
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "2-3" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("SAME_PLACEMENT_TO_MULTIPLE_BRACKETS");
|
|
expect((error as any).bracketIdxs).toEqual([1, 2]);
|
|
});
|
|
|
|
test("handles GAP_IN_PLACEMENTS", () => {
|
|
const error = getValidatedBrackets([
|
|
ROUND_ROBIN,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1" }],
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "3" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("GAP_IN_PLACEMENTS");
|
|
expect((error as any).bracketIdxs).toEqual([1, 2]);
|
|
});
|
|
|
|
test("only flags GAP_IN_PLACEMENTS brackets sourcing from the problematic bracket", () => {
|
|
const error = getValidatedBrackets([
|
|
ROUND_ROBIN,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1" }],
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "3" }],
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "1", placements: "1" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("GAP_IN_PLACEMENTS");
|
|
// bracket 3 sources from bracket 1, not from the gap in bracket 0
|
|
expect((error as any).bracketIdxs).toEqual([1, 2]);
|
|
});
|
|
|
|
test("handles TOO_MANY_PLACEMENTS", () => {
|
|
const error = getValidatedBrackets([
|
|
{
|
|
settings: {
|
|
teamsPerGroup: 4,
|
|
},
|
|
type: "round_robin",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1,2,3,4,5" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("TOO_MANY_PLACEMENTS");
|
|
expect((error as any).bracketIdx).toEqual(1);
|
|
});
|
|
|
|
test("handles PLACEMENT_TOO_HIGH", () => {
|
|
const error = getValidatedBrackets([
|
|
{
|
|
settings: { teamsPerGroup: 200 },
|
|
type: "round_robin",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-101" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("PLACEMENT_TOO_HIGH");
|
|
expect((error as any).bracketIdx).toEqual(1);
|
|
});
|
|
|
|
test("does not flag PLACEMENT_TOO_HIGH at the max boundary", () => {
|
|
const result = getValidatedBrackets([
|
|
{
|
|
settings: { teamsPerGroup: 200 },
|
|
type: "round_robin",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-100" }],
|
|
},
|
|
]);
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
});
|
|
|
|
test("does not flag TOO_MANY_PLACEMENTS when larger round robin has valid high placements", () => {
|
|
const result = getValidatedBrackets([
|
|
{
|
|
settings: { teamsPerGroup: 6 },
|
|
type: "round_robin",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1,2,3,4,5,6" }],
|
|
},
|
|
{
|
|
settings: { teamsPerGroup: 4 },
|
|
type: "round_robin",
|
|
},
|
|
]);
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
});
|
|
|
|
test("flags TOO_MANY_PLACEMENTS on A/B divisions when placement exceeds per-division size", () => {
|
|
const error = getValidatedBrackets([
|
|
{
|
|
settings: {
|
|
hasAbDivisions: true,
|
|
teamsPerGroup: 6,
|
|
},
|
|
type: "round_robin",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1,2,3,4" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("TOO_MANY_PLACEMENTS");
|
|
expect((error as any).bracketIdx).toEqual(1);
|
|
});
|
|
|
|
test("accepts A/B divisions placements up to per-division size", () => {
|
|
const result = getValidatedBrackets([
|
|
{
|
|
settings: {
|
|
hasAbDivisions: true,
|
|
teamsPerGroup: 6,
|
|
},
|
|
type: "round_robin",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1,2,3" }],
|
|
},
|
|
]);
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
});
|
|
|
|
test("handles DUPLICATE_BRACKET_NAME", () => {
|
|
const error = getValidatedBrackets([
|
|
{
|
|
settings: {},
|
|
type: "round_robin",
|
|
name: "Bracket 1",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
name: "Bracket 1",
|
|
sources: [{ bracketId: "0", placements: "1-2" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("DUPLICATE_BRACKET_NAME");
|
|
expect((error as any).bracketIdxs).toEqual([0, 1]);
|
|
});
|
|
|
|
test("handles NAME_MISSING", () => {
|
|
const error = getValidatedBrackets([
|
|
{
|
|
settings: {},
|
|
type: "round_robin",
|
|
name: "",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-2" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("NAME_MISSING");
|
|
expect((error as any).bracketIdx).toEqual(0);
|
|
});
|
|
|
|
test("handles NEGATIVE_PROGRESSION", () => {
|
|
const error = getValidatedBrackets([
|
|
ROUND_ROBIN,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "-1,-2" }],
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("NEGATIVE_PROGRESSION");
|
|
expect((error as any).bracketIdx).toEqual(1);
|
|
});
|
|
|
|
test("allows single elimination positive progression", () => {
|
|
const result = getValidatedBrackets([
|
|
SINGLE_ELIMINATION,
|
|
{
|
|
settings: {},
|
|
type: "double_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-2" }],
|
|
},
|
|
]);
|
|
|
|
expect(Progression.isBrackets(result)).toBe(true);
|
|
});
|
|
|
|
test("handles MIXED_POSITIVE_NEGATIVE_PLACEMENTS", () => {
|
|
const error = getValidatedBrackets([
|
|
SINGLE_ELIMINATION,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1,-1" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("MIXED_POSITIVE_NEGATIVE_PLACEMENTS");
|
|
expect((error as any).bracketIdx).toEqual(1);
|
|
});
|
|
|
|
test("allows single elimination to source an underground bracket", () => {
|
|
const result = getValidatedBrackets([
|
|
SINGLE_ELIMINATION,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "-1" }],
|
|
},
|
|
]);
|
|
|
|
expect(Progression.isBrackets(result)).toBe(true);
|
|
});
|
|
|
|
test("allows double elimination positive progression", () => {
|
|
const result = getValidatedBrackets([
|
|
DOUBLE_ELIMINATION,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-2" }],
|
|
},
|
|
]);
|
|
|
|
expect(Progression.isBrackets(result)).toBe(true);
|
|
});
|
|
|
|
test("handles SWISS_EARLY_ADVANCE_NO_DESTINATION", () => {
|
|
// Swiss bracket with early advance but no destination
|
|
const error = getValidatedBrackets([
|
|
{
|
|
settings: {
|
|
advanceThreshold: 3,
|
|
},
|
|
type: "swiss",
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("SWISS_EARLY_ADVANCE_NO_DESTINATION");
|
|
expect((error as any).bracketIdx).toEqual(0);
|
|
});
|
|
|
|
test("allows Swiss early advance when bracket has destination", () => {
|
|
const result = getValidatedBrackets([
|
|
{
|
|
settings: {
|
|
advanceThreshold: 3,
|
|
},
|
|
type: "swiss",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-4" }],
|
|
},
|
|
]);
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
});
|
|
|
|
test("accepts A/B divisions on a round robin starting bracket with even teamsPerGroup", () => {
|
|
const result = getValidatedBrackets([
|
|
{
|
|
settings: {
|
|
hasAbDivisions: true,
|
|
teamsPerGroup: 6,
|
|
},
|
|
type: "round_robin",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-2" }],
|
|
},
|
|
]);
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
});
|
|
|
|
test("handles AB_DIVISIONS_NOT_ROUND_ROBIN", () => {
|
|
const error = getValidatedBrackets([
|
|
{
|
|
settings: {
|
|
hasAbDivisions: true,
|
|
},
|
|
type: "swiss",
|
|
name: "Swiss",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
name: "Finals",
|
|
sources: [{ bracketId: "0", placements: "1-2" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("AB_DIVISIONS_NOT_ROUND_ROBIN");
|
|
expect((error as any).bracketIdx).toEqual(0);
|
|
});
|
|
|
|
test("handles AB_DIVISIONS_NOT_STARTING", () => {
|
|
const error = getValidatedBrackets([
|
|
{
|
|
settings: {},
|
|
type: "round_robin",
|
|
name: "Group stage",
|
|
},
|
|
{
|
|
settings: {
|
|
hasAbDivisions: true,
|
|
teamsPerGroup: 4,
|
|
},
|
|
type: "round_robin",
|
|
name: "Second RR",
|
|
sources: [{ bracketId: "0", placements: "1-2" }],
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
name: "Finals",
|
|
sources: [{ bracketId: "1", placements: "1-2" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("AB_DIVISIONS_NOT_STARTING");
|
|
expect((error as any).bracketIdx).toEqual(1);
|
|
});
|
|
|
|
test("handles AB_DIVISIONS_ODD_TEAMS_PER_GROUP", () => {
|
|
const error = getValidatedBrackets([
|
|
{
|
|
settings: {
|
|
hasAbDivisions: true,
|
|
teamsPerGroup: 5,
|
|
},
|
|
type: "round_robin",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-2" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("AB_DIVISIONS_ODD_TEAMS_PER_GROUP");
|
|
expect((error as any).bracketIdx).toEqual(0);
|
|
});
|
|
|
|
test("accepts A/B divisions when teamsPerGroup is unset (default is even)", () => {
|
|
const result = getValidatedBrackets([
|
|
{
|
|
settings: {
|
|
hasAbDivisions: true,
|
|
},
|
|
type: "round_robin",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-2" }],
|
|
},
|
|
]);
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
});
|
|
|
|
test("does not apply A/B validation when hasAbDivisions is absent", () => {
|
|
const result = getValidatedBrackets([
|
|
{
|
|
settings: {
|
|
teamsPerGroup: 5,
|
|
},
|
|
type: "round_robin",
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-2" }],
|
|
},
|
|
]);
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
});
|
|
|
|
test("handles EMPTY_PLACEMENTS_ON_NON_SWISS (DE source with empty placements)", () => {
|
|
const error = Progression.bracketsToValidationError([
|
|
{
|
|
name: "Bracket 1",
|
|
type: "double_elimination",
|
|
settings: {},
|
|
requiresCheckIn: false,
|
|
},
|
|
{
|
|
name: "Bracket 2",
|
|
type: "double_elimination",
|
|
settings: {},
|
|
requiresCheckIn: false,
|
|
sources: [{ bracketIdx: 0, placements: [] }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("EMPTY_PLACEMENTS_ON_NON_SWISS");
|
|
});
|
|
|
|
test("allows empty placements when source is Swiss with advanceThreshold", () => {
|
|
const result = Progression.bracketsToValidationError([
|
|
{
|
|
name: "Swiss",
|
|
type: "swiss",
|
|
settings: { advanceThreshold: 3 },
|
|
requiresCheckIn: false,
|
|
},
|
|
{
|
|
name: "Finals",
|
|
type: "double_elimination",
|
|
settings: {},
|
|
requiresCheckIn: false,
|
|
sources: [{ bracketIdx: 0, placements: [] }],
|
|
},
|
|
]);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("isFinals", () => {
|
|
test("handles SE", () => {
|
|
expect(Progression.isFinals(0, progressions.singleElimination)).toBe(true);
|
|
});
|
|
|
|
test("handles RR->SE", () => {
|
|
expect(
|
|
Progression.isFinals(0, progressions.roundRobinToSingleElimination),
|
|
).toBe(false);
|
|
expect(
|
|
Progression.isFinals(1, progressions.roundRobinToSingleElimination),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("handles low ink", () => {
|
|
expect(Progression.isFinals(0, progressions.lowInk)).toBe(false);
|
|
expect(Progression.isFinals(1, progressions.lowInk)).toBe(false);
|
|
expect(Progression.isFinals(2, progressions.lowInk)).toBe(false);
|
|
expect(Progression.isFinals(3, progressions.lowInk)).toBe(true);
|
|
});
|
|
|
|
test("handles swiss (early advance", () => {
|
|
expect(Progression.isFinals(1, progressions.swissEarlyAdvance)).toBe(true);
|
|
});
|
|
|
|
test("many starter brackets", () => {
|
|
expect(Progression.isFinals(0, progressions.manyStartBrackets)).toBe(false);
|
|
expect(Progression.isFinals(1, progressions.manyStartBrackets)).toBe(false);
|
|
expect(Progression.isFinals(2, progressions.manyStartBrackets)).toBe(true);
|
|
expect(Progression.isFinals(3, progressions.manyStartBrackets)).toBe(false);
|
|
});
|
|
|
|
test("throws if given idx is out of bounds", () => {
|
|
expect(() =>
|
|
Progression.isFinals(1, progressions.singleElimination),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("isUnderground", () => {
|
|
test("handles SE", () => {
|
|
expect(Progression.isUnderground(0, progressions.singleElimination)).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("handles RR->SE", () => {
|
|
expect(
|
|
Progression.isUnderground(0, progressions.roundRobinToSingleElimination),
|
|
).toBe(false);
|
|
expect(
|
|
Progression.isUnderground(1, progressions.roundRobinToSingleElimination),
|
|
).toBe(false);
|
|
});
|
|
|
|
test("handles low ink", () => {
|
|
expect(Progression.isUnderground(0, progressions.lowInk)).toBe(false);
|
|
expect(Progression.isUnderground(1, progressions.lowInk)).toBe(true);
|
|
expect(Progression.isUnderground(2, progressions.lowInk)).toBe(false);
|
|
expect(Progression.isUnderground(3, progressions.lowInk)).toBe(false);
|
|
});
|
|
|
|
test("many starter brackets", () => {
|
|
expect(Progression.isUnderground(0, progressions.manyStartBrackets)).toBe(
|
|
false,
|
|
);
|
|
expect(Progression.isUnderground(1, progressions.manyStartBrackets)).toBe(
|
|
false,
|
|
);
|
|
expect(Progression.isUnderground(2, progressions.manyStartBrackets)).toBe(
|
|
false,
|
|
);
|
|
expect(Progression.isUnderground(3, progressions.manyStartBrackets)).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("handles SE w/ underground bracket", () => {
|
|
expect(
|
|
Progression.isUnderground(
|
|
0,
|
|
progressions.singleEliminationWithUnderground,
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
Progression.isUnderground(
|
|
1,
|
|
progressions.singleEliminationWithUnderground,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("redemption bracket feeding the finals is not underground", () => {
|
|
expect(Progression.isUnderground(0, progressions.multiSourceTopCut)).toBe(
|
|
false,
|
|
);
|
|
expect(Progression.isUnderground(1, progressions.multiSourceTopCut)).toBe(
|
|
false,
|
|
);
|
|
expect(Progression.isUnderground(2, progressions.multiSourceTopCut)).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("throws if given idx is out of bounds", () => {
|
|
expect(() =>
|
|
Progression.isUnderground(1, progressions.singleElimination),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("changedBracketProgression", () => {
|
|
test("reports changed bracket indexes", () => {
|
|
const withChanges = structuredClone(progressions.lowInk);
|
|
withChanges[0].name = "New name";
|
|
withChanges[1].type = "swiss";
|
|
|
|
expect(
|
|
Progression.changedBracketProgression(progressions.lowInk, withChanges),
|
|
).toEqual([0, 1]);
|
|
});
|
|
|
|
test("returns an empty array if nothing changed", () => {
|
|
expect(
|
|
Progression.changedBracketProgression(
|
|
progressions.lowInk,
|
|
progressions.lowInk,
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("bracketIdxsForStandings", () => {
|
|
test("handles SE", () => {
|
|
expect(
|
|
Progression.bracketIdxsForStandings(progressions.singleElimination),
|
|
).toEqual([0]);
|
|
});
|
|
|
|
test("handles RR->SE", () => {
|
|
expect(
|
|
Progression.bracketIdxsForStandings(
|
|
progressions.roundRobinToSingleElimination,
|
|
),
|
|
).toEqual([1, 0]);
|
|
});
|
|
|
|
test("handles low ink", () => {
|
|
expect(Progression.bracketIdxsForStandings(progressions.lowInk)).toEqual([
|
|
3, 2, 1,
|
|
0,
|
|
// NOTE: 2 is included so that teams eliminated in it are not dropped down to the starting bracket
|
|
]);
|
|
});
|
|
|
|
test("handles many starter brackets", () => {
|
|
expect(
|
|
Progression.bracketIdxsForStandings(progressions.manyStartBrackets),
|
|
).toEqual([2, 0]); // NOTE, 3,1 excluded because they are not in the main progression
|
|
});
|
|
|
|
test("handles swiss (one group)", () => {
|
|
expect(
|
|
Progression.bracketIdxsForStandings(progressions.swissOneGroup),
|
|
).toEqual([0]);
|
|
});
|
|
|
|
test("handles DE w/ underground bracket", () => {
|
|
expect(
|
|
Progression.bracketIdxsForStandings(
|
|
progressions.doubleEliminationWithUnderground,
|
|
),
|
|
).toEqual([0]); // missing 1 because it's underground when DE is the source
|
|
});
|
|
|
|
test("handles SE w/ underground bracket", () => {
|
|
expect(
|
|
Progression.bracketIdxsForStandings(
|
|
progressions.singleEliminationWithUnderground,
|
|
),
|
|
).toEqual([0]); // missing 1 because it's underground when SE is the source
|
|
});
|
|
|
|
test("does not treat a bracket as intermediate just because an underground bracket sources from it", () => {
|
|
expect(
|
|
Progression.bracketIdxsForStandings(
|
|
progressions.swissToTwoSingleEliminationsWithUnderground,
|
|
),
|
|
).toEqual([1, 2, 0]); // missing 3 because it's underground
|
|
});
|
|
|
|
test("keeps a finals bracket sourced positively from a SE redemption bracket", () => {
|
|
expect(
|
|
Progression.bracketIdxsForStandings(progressions.multiSourceTopCut),
|
|
).toEqual([2, 1, 0]);
|
|
});
|
|
|
|
test("places a redemption bracket above the brackets taking lower placements from the same source", () => {
|
|
expect(
|
|
Progression.bracketIdxsForStandings(
|
|
progressions.multiSourceTopCutWithConsolation,
|
|
),
|
|
).toEqual([2, 1, 3, 0]);
|
|
});
|
|
|
|
test("orders brackets by the placement of their teams in the shared ancestor bracket", () => {
|
|
expect(
|
|
Progression.bracketIdxsForStandings(
|
|
progressions.poolsToBracketsViaIntermediateBrackets,
|
|
),
|
|
).toEqual([
|
|
2, // Alpha (pools 1)
|
|
3, // Beta (pools 2-4, via Redemption)
|
|
1, // Redemption (pools 2-4)
|
|
4, // Gamma (pools 5-6)
|
|
5, // Delta (pools 7-8)
|
|
7, // Epsilon (pools 9-11, via Epsilon Seeding)
|
|
6, // Epsilon Seeding (pools 9-11)
|
|
0, // Day 1 Pools
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("startingBrackets", () => {
|
|
test("handles SE", () => {
|
|
expect(
|
|
Progression.startingBrackets(progressions.singleElimination),
|
|
).toEqual([0]);
|
|
});
|
|
|
|
test("handles many starter brackets", () => {
|
|
expect(
|
|
Progression.startingBrackets(progressions.manyStartBrackets),
|
|
).toEqual([0, 1]);
|
|
});
|
|
|
|
test("handles swiss (one group)", () => {
|
|
expect(Progression.startingBrackets(progressions.swissOneGroup)).toEqual([
|
|
0,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("destinationsFromBracketIdx", () => {
|
|
test("returns correct destination (one destination)", () => {
|
|
expect(
|
|
Progression.destinationsFromBracketIdx(
|
|
0,
|
|
progressions.roundRobinToSingleElimination,
|
|
),
|
|
).toEqual([1]);
|
|
});
|
|
|
|
test("returns correct destination (many destinations)", () => {
|
|
expect(
|
|
Progression.destinationsFromBracketIdx(0, progressions.lowInk),
|
|
).toEqual([1, 2]);
|
|
});
|
|
|
|
test("returns an empty array if no destinations", () => {
|
|
expect(
|
|
Progression.destinationsFromBracketIdx(0, progressions.singleElimination),
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("destinationByPlacement", () => {
|
|
test("returns correct destination for a given placement", () => {
|
|
const result = Progression.destinationByPlacement({
|
|
sourceBracketIdx: 0,
|
|
placement: 1,
|
|
progression: progressions.roundRobinToSingleElimination,
|
|
});
|
|
expect(result).toBe(1);
|
|
});
|
|
|
|
test("returns null if no destination for the given placement", () => {
|
|
const result = Progression.destinationByPlacement({
|
|
sourceBracketIdx: 0,
|
|
placement: 5,
|
|
progression: progressions.roundRobinToSingleElimination,
|
|
});
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("returns correct destination for negative placements", () => {
|
|
const result = Progression.destinationByPlacement({
|
|
sourceBracketIdx: 0,
|
|
placement: -1,
|
|
progression: progressions.doubleEliminationWithUnderground,
|
|
});
|
|
expect(result).toBe(1);
|
|
});
|
|
|
|
test("returns correct destination for many start brackets", () => {
|
|
const result = Progression.destinationByPlacement({
|
|
sourceBracketIdx: 1,
|
|
placement: 1,
|
|
progression: progressions.manyStartBrackets,
|
|
});
|
|
expect(result).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("validatedBracketsToInputFormat", () => {
|
|
test("converts empty placements back to empty string", () => {
|
|
const parsedBrackets: Progression.ParsedBracket[] = [
|
|
{
|
|
type: "swiss",
|
|
settings: { advanceThreshold: 3 },
|
|
name: "Swiss Bracket",
|
|
requiresCheckIn: false,
|
|
},
|
|
{
|
|
type: "single_elimination",
|
|
settings: {},
|
|
name: "Final Bracket",
|
|
requiresCheckIn: false,
|
|
sources: [
|
|
{
|
|
bracketIdx: 0,
|
|
placements: [],
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const result = Progression.validatedBracketsToInputFormat(parsedBrackets);
|
|
|
|
expect(result[1].sources).toEqual([
|
|
{
|
|
bracketId: "0",
|
|
placements: "",
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("bracketDepth", () => {
|
|
test("returns 0 for starting bracket with no sources", () => {
|
|
expect(Progression.bracketDepth(0, progressions.singleElimination)).toBe(0);
|
|
});
|
|
|
|
test("returns 0 for starting bracket and 1 for bracket sourced from it", () => {
|
|
expect(
|
|
Progression.bracketDepth(0, progressions.roundRobinToSingleElimination),
|
|
).toBe(0);
|
|
expect(
|
|
Progression.bracketDepth(1, progressions.roundRobinToSingleElimination),
|
|
).toBe(1);
|
|
});
|
|
|
|
test("handles complex progression with multiple depth levels", () => {
|
|
expect(Progression.bracketDepth(0, progressions.lowInk)).toBe(0);
|
|
expect(Progression.bracketDepth(1, progressions.lowInk)).toBe(1);
|
|
expect(Progression.bracketDepth(2, progressions.lowInk)).toBe(1);
|
|
expect(Progression.bracketDepth(3, progressions.lowInk)).toBe(2);
|
|
});
|
|
|
|
test("handles multiple starting brackets", () => {
|
|
expect(Progression.bracketDepth(0, progressions.manyStartBrackets)).toBe(0);
|
|
expect(Progression.bracketDepth(1, progressions.manyStartBrackets)).toBe(0);
|
|
expect(Progression.bracketDepth(2, progressions.manyStartBrackets)).toBe(1);
|
|
expect(Progression.bracketDepth(3, progressions.manyStartBrackets)).toBe(1);
|
|
});
|
|
|
|
test("handles underground brackets", () => {
|
|
expect(
|
|
Progression.bracketDepth(
|
|
0,
|
|
progressions.doubleEliminationWithUnderground,
|
|
),
|
|
).toBe(0);
|
|
expect(
|
|
Progression.bracketDepth(
|
|
1,
|
|
progressions.doubleEliminationWithUnderground,
|
|
),
|
|
).toBe(1);
|
|
});
|
|
|
|
test("throws if given idx is out of bounds", () => {
|
|
expect(() =>
|
|
Progression.bracketDepth(1, progressions.singleElimination),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("validatedSources - DUPLICATE_SOURCE_BRACKET", () => {
|
|
test("flags a destination sourcing the same bracket twice", () => {
|
|
const error = getValidatedBrackets([
|
|
ROUND_ROBIN,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [
|
|
{ bracketId: "0", placements: "1-2" },
|
|
{ bracketId: "0", placements: "3-4" },
|
|
],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("DUPLICATE_SOURCE_BRACKET");
|
|
expect((error as any).bracketIdx).toBe(1);
|
|
});
|
|
|
|
test("accepts different destinations sourcing the same bracket", () => {
|
|
expect(
|
|
Progression.bracketsToValidationError(progressions.lowInk),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("validatedSources - CYCLIC_PROGRESSION", () => {
|
|
test("flags two brackets sourcing each other", () => {
|
|
const error = getValidatedBrackets([
|
|
ROUND_ROBIN,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [
|
|
{ bracketId: "0", placements: "1-2" },
|
|
{ bracketId: "2", placements: "1" },
|
|
],
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "1", placements: "1" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("CYCLIC_PROGRESSION");
|
|
expect((error as any).bracketIdxs).toEqual([1, 2]);
|
|
});
|
|
|
|
test("flags a bracket sourcing itself", () => {
|
|
const error = getValidatedBrackets([
|
|
ROUND_ROBIN,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "1", placements: "1" }],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("CYCLIC_PROGRESSION");
|
|
expect((error as any).bracketIdxs).toEqual([1]);
|
|
});
|
|
|
|
test("accepts a bracket sourcing one that comes later in the list", () => {
|
|
const result = getValidatedBrackets([
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "1", placements: "1-4" }],
|
|
},
|
|
ROUND_ROBIN,
|
|
]);
|
|
|
|
expect(Progression.isBrackets(result)).toBe(true);
|
|
});
|
|
|
|
test("accepts brackets sharing a source (diamond shaped progression)", () => {
|
|
expect(
|
|
Progression.bracketsToValidationError(progressions.lowInk),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("validatedSources - MERGED_STARTING_BRACKETS", () => {
|
|
test("flags a bracket sourcing two starting brackets", () => {
|
|
const error = getValidatedBrackets([
|
|
ROUND_ROBIN,
|
|
ROUND_ROBIN,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [
|
|
{ bracketId: "0", placements: "1-2" },
|
|
{ bracketId: "1", placements: "1-2" },
|
|
],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("MERGED_STARTING_BRACKETS");
|
|
expect((error as any).bracketIdx).toBe(2);
|
|
});
|
|
|
|
test("flags a merge that happens through intermediate brackets", () => {
|
|
const error = getValidatedBrackets([
|
|
ROUND_ROBIN,
|
|
ROUND_ROBIN,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "0", placements: "1-2" }],
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "1", placements: "1-2" }],
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [
|
|
{ bracketId: "2", placements: "1" },
|
|
{ bracketId: "3", placements: "1" },
|
|
],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("MERGED_STARTING_BRACKETS");
|
|
expect((error as any).bracketIdx).toBe(4);
|
|
});
|
|
|
|
test("reports the bracket where the merge happens, not the ones after it", () => {
|
|
const error = getValidatedBrackets([
|
|
ROUND_ROBIN,
|
|
ROUND_ROBIN,
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [{ bracketId: "3", placements: "1-2" }],
|
|
},
|
|
{
|
|
settings: {},
|
|
type: "single_elimination",
|
|
sources: [
|
|
{ bracketId: "0", placements: "1-2" },
|
|
{ bracketId: "1", placements: "1-2" },
|
|
],
|
|
},
|
|
]) as Progression.ValidationError;
|
|
|
|
expect(error.type).toBe("MERGED_STARTING_BRACKETS");
|
|
expect((error as any).bracketIdx).toBe(3);
|
|
});
|
|
|
|
test("accepts many starting brackets that never merge", () => {
|
|
expect(
|
|
Progression.bracketsToValidationError(progressions.manyStartBrackets),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("accepts many sources that all come from the same starting bracket", () => {
|
|
expect(
|
|
Progression.bracketsToValidationError(progressions.multiSourceTopCut),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("sortedSourcesForSeeding", () => {
|
|
test("orders a direct source above one that took a redemption route", () => {
|
|
const topCut: Progression.ParsedBracket = progressions.multiSourceTopCut[2];
|
|
|
|
const sorted = Progression.sortedSourcesForSeeding(
|
|
topCut.sources!,
|
|
progressions.multiSourceTopCut,
|
|
);
|
|
|
|
expect(sorted.map((source) => source.bracketIdx)).toEqual([0, 1]);
|
|
});
|
|
|
|
test("keeps the original order when sources share no ancestor bracket", () => {
|
|
const progression: Progression.ParsedBracket[] = [
|
|
{
|
|
name: "Group A",
|
|
type: "round_robin",
|
|
settings: {},
|
|
requiresCheckIn: false,
|
|
},
|
|
{
|
|
name: "Group B",
|
|
type: "round_robin",
|
|
settings: {},
|
|
requiresCheckIn: false,
|
|
},
|
|
{
|
|
name: "Finals",
|
|
type: "single_elimination",
|
|
settings: {},
|
|
requiresCheckIn: false,
|
|
sources: [
|
|
{ bracketIdx: 1, placements: [1, 2] },
|
|
{ bracketIdx: 0, placements: [1, 2] },
|
|
],
|
|
},
|
|
];
|
|
|
|
const sorted = Progression.sortedSourcesForSeeding(
|
|
progression[2].sources!,
|
|
progression,
|
|
);
|
|
|
|
expect(sorted.map((source) => source.bracketIdx)).toEqual([1, 0]);
|
|
});
|
|
|
|
test("compares at the deepest common ancestor", () => {
|
|
const progression: Progression.ParsedBracket[] = [
|
|
{
|
|
name: "Pools",
|
|
type: "round_robin",
|
|
settings: {},
|
|
requiresCheckIn: false,
|
|
},
|
|
{
|
|
name: "Redemption 1",
|
|
type: "round_robin",
|
|
settings: {},
|
|
requiresCheckIn: false,
|
|
sources: [{ bracketIdx: 0, placements: [5, 6, 7, 8] }],
|
|
},
|
|
{
|
|
name: "Redemption 2",
|
|
type: "round_robin",
|
|
settings: {},
|
|
requiresCheckIn: false,
|
|
sources: [{ bracketIdx: 1, placements: [3, 4] }],
|
|
},
|
|
{
|
|
name: "Finals",
|
|
type: "single_elimination",
|
|
settings: {},
|
|
requiresCheckIn: false,
|
|
sources: [
|
|
{ bracketIdx: 2, placements: [1, 2] },
|
|
{ bracketIdx: 1, placements: [1, 2] },
|
|
],
|
|
},
|
|
];
|
|
|
|
const sorted = Progression.sortedSourcesForSeeding(
|
|
progression[3].sources!,
|
|
progression,
|
|
);
|
|
|
|
expect(sorted.map((source) => source.bracketIdx)).toEqual([1, 2]);
|
|
});
|
|
|
|
test("orders teams eliminated from a follow-up bracket above lower direct placements", () => {
|
|
const progression: Progression.ParsedBracket[] = [
|
|
{
|
|
name: "Pools",
|
|
type: "round_robin",
|
|
settings: {},
|
|
requiresCheckIn: false,
|
|
},
|
|
{
|
|
name: "Top Cut",
|
|
type: "single_elimination",
|
|
settings: {},
|
|
requiresCheckIn: false,
|
|
sources: [{ bracketIdx: 0, placements: [1, 2, 3, 4, 5, 6, 7, 8] }],
|
|
},
|
|
{
|
|
name: "Consolation",
|
|
type: "single_elimination",
|
|
settings: {},
|
|
requiresCheckIn: false,
|
|
sources: [
|
|
{ bracketIdx: 0, placements: [9, 10] },
|
|
{ bracketIdx: 1, placements: [-1] },
|
|
],
|
|
},
|
|
];
|
|
|
|
const sorted = Progression.sortedSourcesForSeeding(
|
|
progression[2].sources!,
|
|
progression,
|
|
);
|
|
|
|
expect(sorted.map((source) => source.bracketIdx)).toEqual([1, 0]);
|
|
});
|
|
});
|