wip
This commit is contained in:
449
tests/unit/racing-application/SeasonUseCases.test.ts
Normal file
449
tests/unit/racing-application/SeasonUseCases.test.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
InMemorySeasonRepository,
|
||||
} from '@gridpilot/racing/infrastructure/repositories/InMemoryScoringRepositories';
|
||||
import { Season } from '@gridpilot/racing/domain/entities/Season';
|
||||
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
||||
import {
|
||||
CreateSeasonForLeagueUseCase,
|
||||
ListSeasonsForLeagueUseCase,
|
||||
GetSeasonDetailsUseCase,
|
||||
ManageSeasonLifecycleUseCase,
|
||||
type CreateSeasonForLeagueCommand,
|
||||
type ManageSeasonLifecycleCommand,
|
||||
} from '@gridpilot/racing/application/use-cases/SeasonUseCases';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application/dto/LeagueConfigFormDTO';
|
||||
|
||||
function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository {
|
||||
return {
|
||||
findById: async (id: string) => seed.find((l) => l.id === id) ?? null,
|
||||
findAll: async () => seed,
|
||||
create: async (league: any) => league,
|
||||
update: async (league: any) => league,
|
||||
} as unknown as ILeagueRepository;
|
||||
}
|
||||
|
||||
function createLeagueConfigFormModel(overrides?: Partial<LeagueConfigFormModel>): LeagueConfigFormModel {
|
||||
return {
|
||||
basics: {
|
||||
name: 'Test League',
|
||||
visibility: 'ranked',
|
||||
gameId: 'iracing',
|
||||
...overrides?.basics,
|
||||
},
|
||||
structure: {
|
||||
mode: 'solo',
|
||||
maxDrivers: 30,
|
||||
...overrides?.structure,
|
||||
},
|
||||
championships: {
|
||||
enableDriverChampionship: true,
|
||||
enableTeamChampionship: false,
|
||||
enableNationsChampionship: false,
|
||||
enableTrophyChampionship: false,
|
||||
...overrides?.championships,
|
||||
},
|
||||
scoring: {
|
||||
patternId: 'sprint-main-driver',
|
||||
customScoringEnabled: false,
|
||||
...overrides?.scoring,
|
||||
},
|
||||
dropPolicy: {
|
||||
strategy: 'bestNResults',
|
||||
n: 3,
|
||||
...overrides?.dropPolicy,
|
||||
},
|
||||
timings: {
|
||||
qualifyingMinutes: 10,
|
||||
mainRaceMinutes: 30,
|
||||
sessionCount: 8,
|
||||
seasonStartDate: '2025-01-01',
|
||||
raceStartTime: '20:00',
|
||||
timezoneId: 'UTC',
|
||||
recurrenceStrategy: 'weekly',
|
||||
weekdays: ['Mon'],
|
||||
...overrides?.timings,
|
||||
},
|
||||
stewarding: {
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
...overrides?.stewarding,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('InMemorySeasonRepository', () => {
|
||||
it('add and findById provide a roundtrip for Season', async () => {
|
||||
const repo = new InMemorySeasonRepository();
|
||||
const season = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Test Season',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
await repo.add(season);
|
||||
const loaded = await repo.findById(season.id);
|
||||
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.id).toBe(season.id);
|
||||
expect(loaded!.leagueId).toBe(season.leagueId);
|
||||
expect(loaded!.status).toBe('planned');
|
||||
});
|
||||
|
||||
it('update persists changed Season state', async () => {
|
||||
const repo = new InMemorySeasonRepository();
|
||||
const season = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Initial Season',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
await repo.add(season);
|
||||
const activated = season.activate();
|
||||
|
||||
await repo.update(activated);
|
||||
|
||||
const loaded = await repo.findById(season.id);
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.status).toBe('active');
|
||||
});
|
||||
|
||||
it('listByLeague returns only seasons for that league', async () => {
|
||||
const repo = new InMemorySeasonRepository();
|
||||
const s1 = Season.create({
|
||||
id: 's1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'L1 S1',
|
||||
status: 'planned',
|
||||
});
|
||||
const s2 = Season.create({
|
||||
id: 's2',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'L1 S2',
|
||||
status: 'active',
|
||||
});
|
||||
const s3 = Season.create({
|
||||
id: 's3',
|
||||
leagueId: 'league-2',
|
||||
gameId: 'iracing',
|
||||
name: 'L2 S1',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
await repo.add(s1);
|
||||
await repo.add(s2);
|
||||
await repo.add(s3);
|
||||
|
||||
const league1Seasons = await repo.listByLeague('league-1');
|
||||
const league2Seasons = await repo.listByLeague('league-2');
|
||||
|
||||
expect(league1Seasons.map((s) => s.id).sort()).toEqual(['s1', 's2']);
|
||||
expect(league2Seasons.map((s) => s.id)).toEqual(['s3']);
|
||||
});
|
||||
|
||||
it('listActiveByLeague returns only active seasons for a league', async () => {
|
||||
const repo = new InMemorySeasonRepository();
|
||||
const s1 = Season.create({
|
||||
id: 's1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Planned',
|
||||
status: 'planned',
|
||||
});
|
||||
const s2 = Season.create({
|
||||
id: 's2',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Active',
|
||||
status: 'active',
|
||||
});
|
||||
const s3 = Season.create({
|
||||
id: 's3',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Completed',
|
||||
status: 'completed',
|
||||
});
|
||||
const s4 = Season.create({
|
||||
id: 's4',
|
||||
leagueId: 'league-2',
|
||||
gameId: 'iracing',
|
||||
name: 'Other League Active',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repo.add(s1);
|
||||
await repo.add(s2);
|
||||
await repo.add(s3);
|
||||
await repo.add(s4);
|
||||
|
||||
const activeInLeague1 = await repo.listActiveByLeague('league-1');
|
||||
|
||||
expect(activeInLeague1.map((s) => s.id)).toEqual(['s2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateSeasonForLeagueUseCase', () => {
|
||||
it('creates a planned Season for an existing league with config-derived props', async () => {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
const config = createLeagueConfigFormModel({
|
||||
basics: {
|
||||
name: 'League With Config',
|
||||
visibility: 'ranked',
|
||||
gameId: 'iracing',
|
||||
},
|
||||
scoring: {
|
||||
patternId: 'club-default',
|
||||
customScoringEnabled: true,
|
||||
},
|
||||
dropPolicy: {
|
||||
strategy: 'dropWorstN',
|
||||
n: 2,
|
||||
},
|
||||
// Intentionally omit seasonStartDate / raceStartTime to avoid schedule derivation,
|
||||
// focusing this test on scoring/drop/stewarding/maxDrivers mapping.
|
||||
timings: {
|
||||
qualifyingMinutes: 10,
|
||||
mainRaceMinutes: 30,
|
||||
sessionCount: 8,
|
||||
},
|
||||
});
|
||||
|
||||
const command: CreateSeasonForLeagueCommand = {
|
||||
leagueId: 'league-1',
|
||||
name: 'Season from Config',
|
||||
gameId: 'iracing',
|
||||
config,
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.seasonId).toBeDefined();
|
||||
|
||||
const created = await seasonRepo.findById(result.seasonId);
|
||||
expect(created).not.toBeNull();
|
||||
const season = created!;
|
||||
|
||||
expect(season.leagueId).toBe('league-1');
|
||||
expect(season.gameId).toBe('iracing');
|
||||
expect(season.name).toBe('Season from Config');
|
||||
expect(season.status).toBe('planned');
|
||||
|
||||
// Schedule is optional when timings lack seasonStartDate / raceStartTime.
|
||||
expect(season.schedule).toBeUndefined();
|
||||
expect(season.scoringConfig).toBeDefined();
|
||||
expect(season.scoringConfig!.scoringPresetId).toBe('club-default');
|
||||
expect(season.scoringConfig!.customScoringEnabled).toBe(true);
|
||||
|
||||
expect(season.dropPolicy).toBeDefined();
|
||||
expect(season.dropPolicy!.strategy).toBe('dropWorstN');
|
||||
expect(season.dropPolicy!.n).toBe(2);
|
||||
|
||||
expect(season.stewardingConfig).toBeDefined();
|
||||
expect(season.maxDrivers).toBe(30);
|
||||
});
|
||||
|
||||
it('clones configuration from a source season when sourceSeasonId is provided', async () => {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const sourceSeason = Season.create({
|
||||
id: 'source-season',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Source Season',
|
||||
status: 'planned',
|
||||
}).withMaxDrivers(40);
|
||||
|
||||
await seasonRepo.add(sourceSeason);
|
||||
|
||||
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
const command: CreateSeasonForLeagueCommand = {
|
||||
leagueId: 'league-1',
|
||||
name: 'Cloned Season',
|
||||
gameId: 'iracing',
|
||||
sourceSeasonId: 'source-season',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
const created = await seasonRepo.findById(result.seasonId);
|
||||
|
||||
expect(created).not.toBeNull();
|
||||
const season = created!;
|
||||
|
||||
expect(season.id).not.toBe(sourceSeason.id);
|
||||
expect(season.leagueId).toBe(sourceSeason.leagueId);
|
||||
expect(season.gameId).toBe(sourceSeason.gameId);
|
||||
expect(season.status).toBe('planned');
|
||||
expect(season.maxDrivers).toBe(sourceSeason.maxDrivers);
|
||||
expect(season.schedule).toBe(sourceSeason.schedule);
|
||||
expect(season.scoringConfig).toBe(sourceSeason.scoringConfig);
|
||||
expect(season.dropPolicy).toBe(sourceSeason.dropPolicy);
|
||||
expect(season.stewardingConfig).toBe(sourceSeason.stewardingConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListSeasonsForLeagueUseCase', () => {
|
||||
it('lists seasons for a league with summaries', async () => {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const s1 = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Season One',
|
||||
status: 'planned',
|
||||
});
|
||||
const s2 = Season.create({
|
||||
id: 'season-2',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Season Two',
|
||||
status: 'active',
|
||||
});
|
||||
const sOtherLeague = Season.create({
|
||||
id: 'season-3',
|
||||
leagueId: 'league-2',
|
||||
gameId: 'iracing',
|
||||
name: 'Season Other',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
await seasonRepo.add(s1);
|
||||
await seasonRepo.add(s2);
|
||||
await seasonRepo.add(sOtherLeague);
|
||||
|
||||
const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
const result = await useCase.execute({ leagueId: 'league-1' });
|
||||
|
||||
expect(result.items.map((i) => i.seasonId).sort()).toEqual([
|
||||
'season-1',
|
||||
'season-2',
|
||||
]);
|
||||
expect(result.items.every((i) => i.leagueId === 'league-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetSeasonDetailsUseCase', () => {
|
||||
it('returns full details for a season belonging to the league', async () => {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const season = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Detailed Season',
|
||||
status: 'planned',
|
||||
}).withMaxDrivers(24);
|
||||
|
||||
await seasonRepo.add(season);
|
||||
|
||||
const useCase = new GetSeasonDetailsUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
const dto = await useCase.execute({
|
||||
leagueId: 'league-1',
|
||||
seasonId: 'season-1',
|
||||
});
|
||||
|
||||
expect(dto.seasonId).toBe('season-1');
|
||||
expect(dto.leagueId).toBe('league-1');
|
||||
expect(dto.gameId).toBe('iracing');
|
||||
expect(dto.name).toBe('Detailed Season');
|
||||
expect(dto.status).toBe('planned');
|
||||
expect(dto.maxDrivers).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ManageSeasonLifecycleUseCase', () => {
|
||||
function setupLifecycleTest() {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const season = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Lifecycle Season',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
seasonRepo.seed(season);
|
||||
|
||||
const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
return { leagueRepo, seasonRepo, useCase, season };
|
||||
}
|
||||
|
||||
it('applies activate → complete → archive transitions and persists state', async () => {
|
||||
const { useCase, seasonRepo, season } = setupLifecycleTest();
|
||||
|
||||
const activateCommand: ManageSeasonLifecycleCommand = {
|
||||
leagueId: 'league-1',
|
||||
seasonId: season.id,
|
||||
transition: 'activate',
|
||||
};
|
||||
|
||||
const activated = await useCase.execute(activateCommand);
|
||||
expect(activated.status).toBe('active');
|
||||
|
||||
const completeCommand: ManageSeasonLifecycleCommand = {
|
||||
leagueId: 'league-1',
|
||||
seasonId: season.id,
|
||||
transition: 'complete',
|
||||
};
|
||||
|
||||
const completed = await useCase.execute(completeCommand);
|
||||
expect(completed.status).toBe('completed');
|
||||
|
||||
const archiveCommand: ManageSeasonLifecycleCommand = {
|
||||
leagueId: 'league-1',
|
||||
seasonId: season.id,
|
||||
transition: 'archive',
|
||||
};
|
||||
|
||||
const archived = await useCase.execute(archiveCommand);
|
||||
expect(archived.status).toBe('archived');
|
||||
|
||||
const persisted = await seasonRepo.findById(season.id);
|
||||
expect(persisted!.status).toBe('archived');
|
||||
});
|
||||
|
||||
it('propagates domain invariant errors for invalid transitions', async () => {
|
||||
const { useCase, seasonRepo, season } = setupLifecycleTest();
|
||||
|
||||
const completeCommand: ManageSeasonLifecycleCommand = {
|
||||
leagueId: 'league-1',
|
||||
seasonId: season.id,
|
||||
transition: 'complete',
|
||||
};
|
||||
|
||||
await expect(useCase.execute(completeCommand)).rejects.toThrow();
|
||||
|
||||
const persisted = await seasonRepo.findById(season.id);
|
||||
expect(persisted!.status).toBe('planned');
|
||||
});
|
||||
});
|
||||
509
tests/unit/racing/domain/Season.test.ts
Normal file
509
tests/unit/racing/domain/Season.test.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
RacingDomainInvariantError,
|
||||
RacingDomainValidationError,
|
||||
} from '@gridpilot/racing/domain/errors/RacingDomainError';
|
||||
import {
|
||||
Season,
|
||||
type SeasonStatus,
|
||||
} from '@gridpilot/racing/domain/entities/Season';
|
||||
import { SeasonScoringConfig } from '@gridpilot/racing/domain/value-objects/SeasonScoringConfig';
|
||||
import {
|
||||
SeasonDropPolicy,
|
||||
type SeasonDropStrategy,
|
||||
} from '@gridpilot/racing/domain/value-objects/SeasonDropPolicy';
|
||||
import { SeasonStewardingConfig } from '@gridpilot/racing/domain/value-objects/SeasonStewardingConfig';
|
||||
|
||||
function createMinimalSeason(overrides?: Partial<Season> & { status?: SeasonStatus }) {
|
||||
return Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Test Season',
|
||||
status: overrides?.status ?? 'planned',
|
||||
});
|
||||
}
|
||||
|
||||
describe('Season aggregate lifecycle', () => {
|
||||
it('transitions Planned → Active → Completed → Archived with timestamps', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
|
||||
const activated = planned.activate();
|
||||
expect(activated.status).toBe('active');
|
||||
expect(activated.startDate).toBeInstanceOf(Date);
|
||||
expect(activated.endDate).toBeUndefined();
|
||||
|
||||
const completed = activated.complete();
|
||||
expect(completed.status).toBe('completed');
|
||||
expect(completed.startDate).toEqual(activated.startDate);
|
||||
expect(completed.endDate).toBeInstanceOf(Date);
|
||||
|
||||
const archived = completed.archive();
|
||||
expect(archived.status).toBe('archived');
|
||||
expect(archived.startDate).toEqual(completed.startDate);
|
||||
expect(archived.endDate).toEqual(completed.endDate);
|
||||
});
|
||||
|
||||
it('throws when activating a non-planned season', () => {
|
||||
const active = createMinimalSeason({ status: 'active' });
|
||||
const completed = createMinimalSeason({ status: 'completed' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
const cancelled = createMinimalSeason({ status: 'cancelled' });
|
||||
|
||||
expect(() => active.activate()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => completed.activate()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => archived.activate()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => cancelled.activate()).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('throws when completing a non-active season', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const completed = createMinimalSeason({ status: 'completed' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
const cancelled = createMinimalSeason({ status: 'cancelled' });
|
||||
|
||||
expect(() => planned.complete()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => completed.complete()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => archived.complete()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => cancelled.complete()).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('throws when archiving a non-completed season', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const active = createMinimalSeason({ status: 'active' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
const cancelled = createMinimalSeason({ status: 'cancelled' });
|
||||
|
||||
expect(() => planned.archive()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => active.archive()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => archived.archive()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => cancelled.archive()).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('allows cancelling planned or active seasons and rejects completed/archived', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const active = createMinimalSeason({ status: 'active' });
|
||||
const completed = createMinimalSeason({ status: 'completed' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
|
||||
const cancelledFromPlanned = planned.cancel();
|
||||
expect(cancelledFromPlanned.status).toBe('cancelled');
|
||||
expect(cancelledFromPlanned.startDate).toBe(planned.startDate);
|
||||
expect(cancelledFromPlanned.endDate).toBeInstanceOf(Date);
|
||||
|
||||
const cancelledFromActive = active.cancel();
|
||||
expect(cancelledFromActive.status).toBe('cancelled');
|
||||
expect(cancelledFromActive.startDate).toBe(active.startDate);
|
||||
expect(cancelledFromActive.endDate).toBeInstanceOf(Date);
|
||||
|
||||
expect(() => completed.cancel()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => archived.cancel()).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('cancel is idempotent for already cancelled seasons', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const cancelled = planned.cancel();
|
||||
|
||||
const cancelledAgain = cancelled.cancel();
|
||||
expect(cancelledAgain).toBe(cancelled);
|
||||
});
|
||||
|
||||
it('canWithdrawFromWallet only when completed', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const active = createMinimalSeason({ status: 'active' });
|
||||
const completed = createMinimalSeason({ status: 'completed' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
const cancelled = createMinimalSeason({ status: 'cancelled' });
|
||||
|
||||
expect(planned.canWithdrawFromWallet()).toBe(false);
|
||||
expect(active.canWithdrawFromWallet()).toBe(false);
|
||||
expect(completed.canWithdrawFromWallet()).toBe(true);
|
||||
expect(archived.canWithdrawFromWallet()).toBe(false);
|
||||
expect(cancelled.canWithdrawFromWallet()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Season configuration updates', () => {
|
||||
function createBaseSeason() {
|
||||
return Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Config Season',
|
||||
status: 'planned',
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: undefined,
|
||||
maxDrivers: 24,
|
||||
});
|
||||
}
|
||||
|
||||
it('withScoringConfig returns a new Season with updated scoringConfig only', () => {
|
||||
const season = createBaseSeason();
|
||||
const scoringConfig = new SeasonScoringConfig({
|
||||
scoringPresetId: 'sprint-main-driver',
|
||||
customScoringEnabled: true,
|
||||
});
|
||||
|
||||
const updated = season.withScoringConfig(scoringConfig);
|
||||
|
||||
expect(updated).not.toBe(season);
|
||||
expect(updated.scoringConfig).toBe(scoringConfig);
|
||||
expect(updated.schedule).toBe(season.schedule);
|
||||
expect(updated.dropPolicy).toBe(season.dropPolicy);
|
||||
expect(updated.stewardingConfig).toBe(season.stewardingConfig);
|
||||
expect(updated.maxDrivers).toBe(season.maxDrivers);
|
||||
});
|
||||
|
||||
it('withDropPolicy returns a new Season with updated dropPolicy only', () => {
|
||||
const season = createBaseSeason();
|
||||
const dropPolicy = new SeasonDropPolicy({
|
||||
strategy: 'bestNResults',
|
||||
n: 3,
|
||||
});
|
||||
|
||||
const updated = season.withDropPolicy(dropPolicy);
|
||||
|
||||
expect(updated).not.toBe(season);
|
||||
expect(updated.dropPolicy).toBe(dropPolicy);
|
||||
expect(updated.scoringConfig).toBe(season.scoringConfig);
|
||||
expect(updated.schedule).toBe(season.schedule);
|
||||
expect(updated.stewardingConfig).toBe(season.stewardingConfig);
|
||||
expect(updated.maxDrivers).toBe(season.maxDrivers);
|
||||
});
|
||||
|
||||
it('withStewardingConfig returns a new Season with updated stewardingConfig only', () => {
|
||||
const season = createBaseSeason();
|
||||
const stewardingConfig = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
});
|
||||
|
||||
const updated = season.withStewardingConfig(stewardingConfig);
|
||||
|
||||
expect(updated).not.toBe(season);
|
||||
expect(updated.stewardingConfig).toBe(stewardingConfig);
|
||||
expect(updated.scoringConfig).toBe(season.scoringConfig);
|
||||
expect(updated.schedule).toBe(season.schedule);
|
||||
expect(updated.dropPolicy).toBe(season.dropPolicy);
|
||||
expect(updated.maxDrivers).toBe(season.maxDrivers);
|
||||
});
|
||||
|
||||
it('withMaxDrivers updates maxDrivers when positive', () => {
|
||||
const season = createBaseSeason();
|
||||
|
||||
const updated = season.withMaxDrivers(30);
|
||||
|
||||
expect(updated.maxDrivers).toBe(30);
|
||||
expect(updated.id).toBe(season.id);
|
||||
expect(updated.leagueId).toBe(season.leagueId);
|
||||
expect(updated.gameId).toBe(season.gameId);
|
||||
});
|
||||
|
||||
it('withMaxDrivers allows undefined to clear value', () => {
|
||||
const season = createBaseSeason();
|
||||
|
||||
const updated = season.withMaxDrivers(undefined);
|
||||
|
||||
expect(updated.maxDrivers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('withMaxDrivers rejects non-positive values', () => {
|
||||
const season = createBaseSeason();
|
||||
|
||||
expect(() => season.withMaxDrivers(0)).toThrow(
|
||||
RacingDomainValidationError,
|
||||
);
|
||||
expect(() => season.withMaxDrivers(-5)).toThrow(
|
||||
RacingDomainValidationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SeasonScoringConfig', () => {
|
||||
it('constructs from preset id and customScoringEnabled', () => {
|
||||
const config = new SeasonScoringConfig({
|
||||
scoringPresetId: 'club-default',
|
||||
customScoringEnabled: true,
|
||||
});
|
||||
|
||||
expect(config.scoringPresetId).toBe('club-default');
|
||||
expect(config.customScoringEnabled).toBe(true);
|
||||
expect(config.props.scoringPresetId).toBe('club-default');
|
||||
expect(config.props.customScoringEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes customScoringEnabled to false when omitted', () => {
|
||||
const config = new SeasonScoringConfig({
|
||||
scoringPresetId: 'sprint-main-driver',
|
||||
});
|
||||
|
||||
expect(config.customScoringEnabled).toBe(false);
|
||||
expect(config.props.customScoringEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when scoringPresetId is empty', () => {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonScoringConfig({
|
||||
// @ts-expect-error intentional invalid input
|
||||
scoringPresetId: ' ',
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('equals compares by preset id and customScoringEnabled', () => {
|
||||
const a = new SeasonScoringConfig({
|
||||
scoringPresetId: 'club-default',
|
||||
customScoringEnabled: false,
|
||||
});
|
||||
const b = new SeasonScoringConfig({
|
||||
scoringPresetId: 'club-default',
|
||||
customScoringEnabled: false,
|
||||
});
|
||||
const c = new SeasonScoringConfig({
|
||||
scoringPresetId: 'club-default',
|
||||
customScoringEnabled: true,
|
||||
});
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SeasonDropPolicy', () => {
|
||||
it('allows strategy "none" with undefined n', () => {
|
||||
const policy = new SeasonDropPolicy({ strategy: 'none' });
|
||||
|
||||
expect(policy.strategy).toBe('none');
|
||||
expect(policy.n).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when strategy "none" has n defined', () => {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonDropPolicy({
|
||||
strategy: 'none',
|
||||
n: 1,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('requires positive integer n for "bestNResults" and "dropWorstN"', () => {
|
||||
const strategies: SeasonDropStrategy[] = ['bestNResults', 'dropWorstN'];
|
||||
|
||||
for (const strategy of strategies) {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonDropPolicy({
|
||||
strategy,
|
||||
n: 0,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new SeasonDropPolicy({
|
||||
strategy,
|
||||
n: -1,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
}
|
||||
|
||||
const okBest = new SeasonDropPolicy({
|
||||
strategy: 'bestNResults',
|
||||
n: 3,
|
||||
});
|
||||
const okDrop = new SeasonDropPolicy({
|
||||
strategy: 'dropWorstN',
|
||||
n: 2,
|
||||
});
|
||||
|
||||
expect(okBest.n).toBe(3);
|
||||
expect(okDrop.n).toBe(2);
|
||||
});
|
||||
|
||||
it('equals compares strategy and n', () => {
|
||||
const a = new SeasonDropPolicy({ strategy: 'none' });
|
||||
const b = new SeasonDropPolicy({ strategy: 'none' });
|
||||
const c = new SeasonDropPolicy({ strategy: 'bestNResults', n: 3 });
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SeasonStewardingConfig', () => {
|
||||
it('creates a valid config with voting mode and requiredVotes', () => {
|
||||
const config = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
});
|
||||
|
||||
expect(config.decisionMode).toBe('steward_vote');
|
||||
expect(config.requiredVotes).toBe(3);
|
||||
expect(config.requireDefense).toBe(true);
|
||||
expect(config.defenseTimeLimit).toBe(24);
|
||||
expect(config.voteTimeLimit).toBe(24);
|
||||
expect(config.protestDeadlineHours).toBe(48);
|
||||
expect(config.stewardingClosesHours).toBe(72);
|
||||
expect(config.notifyAccusedOnProtest).toBe(true);
|
||||
expect(config.notifyOnVoteRequired).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when decisionMode is missing', () => {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
// @ts-expect-error intentional invalid
|
||||
decisionMode: undefined,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('requires requiredVotes for voting/veto modes', () => {
|
||||
const votingModes = [
|
||||
'steward_vote',
|
||||
'member_vote',
|
||||
'steward_veto',
|
||||
'member_veto',
|
||||
] as const;
|
||||
|
||||
for (const mode of votingModes) {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: mode,
|
||||
// @ts-expect-error intentional invalid
|
||||
requiredVotes: undefined,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates numeric limits as non-negative / positive integers', () => {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: -1,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 0,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 0,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 0,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('equals compares all props', () => {
|
||||
const a = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
});
|
||||
|
||||
const b = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
});
|
||||
|
||||
const c = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: false,
|
||||
defenseTimeLimit: 0,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 24,
|
||||
stewardingClosesHours: 48,
|
||||
notifyAccusedOnProtest: false,
|
||||
notifyOnVoteRequired: false,
|
||||
});
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user