team rating
This commit is contained in:
@@ -21,10 +21,17 @@ import type { IProtestRepository } from '@core/racing/domain/repositories/IProte
|
||||
import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository';
|
||||
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
|
||||
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
|
||||
import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
|
||||
import type { ITeamStatsRepository } from '@core/racing/domain/repositories/ITeamStatsRepository';
|
||||
import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
|
||||
import { createRacingSeed } from './racing/RacingSeed';
|
||||
import { seedId } from './racing/SeedIdHelper';
|
||||
import { DriverStatsStore } from '@adapters/racing/services/DriverStatsStore';
|
||||
import { TeamStatsStore } from '@adapters/racing/services/TeamStatsStore';
|
||||
import { Driver } from '@core/racing/domain/entities/Driver';
|
||||
import { Result } from '@core/racing/domain/entities/result/Result';
|
||||
import { Standing } from '@core/racing/domain/entities/Standing';
|
||||
import { Team } from '@core/racing/domain/entities/Team';
|
||||
import type { DriverStats } from '@core/racing/application/use-cases/IDriverStatsUseCase';
|
||||
import type { TeamStats } from '@core/racing/domain/repositories/ITeamStatsRepository';
|
||||
|
||||
export type RacingSeedDependencies = {
|
||||
driverRepository: IDriverRepository;
|
||||
@@ -47,6 +54,9 @@ export type RacingSeedDependencies = {
|
||||
sponsorRepository: ISponsorRepository;
|
||||
feedRepository: IFeedRepository;
|
||||
socialGraphRepository: ISocialGraphRepository;
|
||||
driverStatsRepository: IDriverStatsRepository;
|
||||
teamStatsRepository: ITeamStatsRepository;
|
||||
mediaRepository: IMediaRepository;
|
||||
};
|
||||
|
||||
export class SeedRacingData {
|
||||
@@ -92,19 +102,12 @@ export class SeedRacingData {
|
||||
driverCount: 150 // Expanded from 100 to 150
|
||||
});
|
||||
|
||||
// Populate the driver stats store for the InMemoryDriverStatsService
|
||||
const driverStatsStore = DriverStatsStore.getInstance();
|
||||
driverStatsStore.clear(); // Clear any existing stats
|
||||
driverStatsStore.loadStats(seed.driverStats);
|
||||
// Clear existing stats repositories
|
||||
await this.seedDeps.driverStatsRepository.clear();
|
||||
await this.seedDeps.teamStatsRepository.clear();
|
||||
await this.seedDeps.mediaRepository.clear();
|
||||
|
||||
this.logger.info(`[Bootstrap] Loaded driver stats for ${seed.driverStats.size} drivers`);
|
||||
|
||||
// Populate the team stats store for the AllTeamsPresenter
|
||||
const teamStatsStore = TeamStatsStore.getInstance();
|
||||
teamStatsStore.clear(); // Clear any existing stats
|
||||
teamStatsStore.loadStats(seed.teamStats);
|
||||
|
||||
this.logger.info(`[Bootstrap] Loaded team stats for ${seed.teamStats.size} teams`);
|
||||
this.logger.info('[Bootstrap] Cleared existing stats and media repositories');
|
||||
|
||||
let sponsorshipRequestsSeededViaRepo = false;
|
||||
const seedableSponsorshipRequests = this.seedDeps
|
||||
@@ -304,11 +307,236 @@ export class SeedRacingData {
|
||||
});
|
||||
}
|
||||
|
||||
// Compute and store driver stats from real data
|
||||
await this.computeAndStoreDriverStats();
|
||||
|
||||
// Compute and store team stats from real data
|
||||
await this.computeAndStoreTeamStats();
|
||||
|
||||
// Seed media assets (logos, images)
|
||||
await this.seedMediaAssets(seed);
|
||||
|
||||
this.logger.info(
|
||||
`[Bootstrap] Seeded racing data: drivers=${seed.drivers.length}, leagues=${seed.leagues.length}, races=${seed.races.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async computeAndStoreDriverStats(): Promise<void> {
|
||||
const drivers = await this.seedDeps.driverRepository.findAll();
|
||||
const standings = await this.seedDeps.standingRepository.findAll();
|
||||
const results = await this.seedDeps.resultRepository.findAll();
|
||||
|
||||
this.logger.info(`[Bootstrap] Computing stats for ${drivers.length} drivers from ${standings.length} standings and ${results.length} results`);
|
||||
|
||||
for (const driver of drivers) {
|
||||
const driverResults = results.filter(r => r.driverId.toString() === driver.id);
|
||||
const driverStandings = standings.filter(s => s.driverId.toString() === driver.id);
|
||||
|
||||
if (driverResults.length === 0) continue;
|
||||
|
||||
const stats = this.calculateDriverStats(driver, driverResults, driverStandings);
|
||||
await this.seedDeps.driverStatsRepository.saveDriverStats(driver.id, stats);
|
||||
}
|
||||
|
||||
this.logger.info(`[Bootstrap] Computed and stored stats for ${drivers.length} drivers`);
|
||||
}
|
||||
|
||||
private calculateDriverStats(driver: Driver, results: Result[], standings: Standing[]): DriverStats {
|
||||
const wins = results.filter(r => r.position.toNumber() === 1).length;
|
||||
const podiums = results.filter(r => r.position.toNumber() <= 3).length;
|
||||
const dnfs = results.filter(r => r.position.toNumber() > 20).length;
|
||||
const totalRaces = results.length;
|
||||
|
||||
const positions = results.map(r => r.position.toNumber());
|
||||
const avgFinish = positions.reduce((sum, pos) => sum + pos, 0) / totalRaces;
|
||||
const bestFinish = Math.min(...positions);
|
||||
const worstFinish = Math.max(...positions);
|
||||
|
||||
// Calculate rating based on performance
|
||||
let rating = 1000;
|
||||
const driverStanding = standings.find(s => s.driverId.toString() === driver.id);
|
||||
if (driverStanding) {
|
||||
const pointsBonus = driverStanding.points.toNumber() * 2;
|
||||
const positionBonus = Math.max(0, 50 - (driverStanding.position.toNumber() * 2));
|
||||
const winBonus = driverStanding.wins * 100;
|
||||
rating = Math.round(1000 + pointsBonus + positionBonus + winBonus);
|
||||
} else {
|
||||
const performanceBonus = ((totalRaces - wins) * 5) + ((totalRaces - podiums) * 2);
|
||||
rating = Math.round(1000 + (wins * 100) + (podiums * 50) - performanceBonus);
|
||||
}
|
||||
|
||||
// Calculate consistency
|
||||
const avgPosition = avgFinish;
|
||||
const variance = positions.reduce((sum, pos) => sum + Math.pow(pos - avgPosition, 2), 0) / totalRaces;
|
||||
const consistency = Math.round(Math.max(0, 100 - (variance * 2)));
|
||||
|
||||
// Safety rating (based on incidents)
|
||||
const totalIncidents = results.reduce((sum, r) => sum + r.incidents.toNumber(), 0);
|
||||
const safetyRating = Math.round(Math.max(0, 100 - (totalIncidents / totalRaces)));
|
||||
|
||||
// Sportsmanship rating (placeholder)
|
||||
const sportsmanshipRating = 4.5;
|
||||
|
||||
// Experience level
|
||||
const experienceLevel = this.determineExperienceLevel(totalRaces);
|
||||
|
||||
// Overall rank
|
||||
const overallRank = driverStanding ? driverStanding.position.toNumber() : null;
|
||||
|
||||
return {
|
||||
rating,
|
||||
safetyRating,
|
||||
sportsmanshipRating,
|
||||
totalRaces,
|
||||
wins,
|
||||
podiums,
|
||||
dnfs,
|
||||
avgFinish: Math.round(avgFinish * 10) / 10,
|
||||
bestFinish,
|
||||
worstFinish,
|
||||
consistency,
|
||||
experienceLevel,
|
||||
overallRank
|
||||
};
|
||||
}
|
||||
|
||||
private async computeAndStoreTeamStats(): Promise<void> {
|
||||
const teams = await this.seedDeps.teamRepository.findAll();
|
||||
const results = await this.seedDeps.resultRepository.findAll();
|
||||
const drivers = await this.seedDeps.driverRepository.findAll();
|
||||
|
||||
this.logger.info(`[Bootstrap] Computing stats for ${teams.length} teams`);
|
||||
|
||||
for (const team of teams) {
|
||||
// Get team members using the correct method
|
||||
const teamMemberships = await this.seedDeps.teamMembershipRepository.getTeamMembers(team.id);
|
||||
const teamMemberIds = teamMemberships.map(m => m.driverId.toString());
|
||||
|
||||
// Get results for team members
|
||||
const teamResults = results.filter(r => teamMemberIds.includes(r.driverId.toString()));
|
||||
|
||||
// Get team drivers for name resolution
|
||||
const teamDrivers = drivers.filter(d => teamMemberIds.includes(d.id));
|
||||
|
||||
const stats = this.calculateTeamStats(team, teamResults, teamDrivers);
|
||||
await this.seedDeps.teamStatsRepository.saveTeamStats(team.id, stats);
|
||||
}
|
||||
|
||||
this.logger.info(`[Bootstrap] Computed and stored stats for ${teams.length} teams`);
|
||||
}
|
||||
|
||||
private calculateTeamStats(team: Team, results: Result[], drivers: Driver[]): TeamStats {
|
||||
const wins = results.filter(r => r.position.toNumber() === 1).length;
|
||||
const totalRaces = results.length;
|
||||
|
||||
// Calculate rating
|
||||
const baseRating = 1000;
|
||||
const winBonus = wins * 50;
|
||||
const raceBonus = Math.min(totalRaces * 5, 200);
|
||||
const rating = Math.round(baseRating + winBonus + raceBonus);
|
||||
|
||||
// Determine performance level
|
||||
let performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||
if (wins >= 20) performanceLevel = 'pro';
|
||||
else if (wins >= 10) performanceLevel = 'advanced';
|
||||
else if (wins >= 5) performanceLevel = 'intermediate';
|
||||
else performanceLevel = 'beginner';
|
||||
|
||||
// Determine specialization (based on race types - simplified)
|
||||
const specialization: 'endurance' | 'sprint' | 'mixed' = 'mixed';
|
||||
|
||||
// Get region from team name or first driver
|
||||
const region = drivers.length > 0 && drivers[0] ? drivers[0].country.toString() : 'International';
|
||||
|
||||
// Languages (based on drivers)
|
||||
const languages = Array.from(new Set(drivers.map(d => {
|
||||
// Simplified language mapping based on country
|
||||
const country = d.country.toString().toLowerCase();
|
||||
if (country === 'us' || country === 'gb' || country === 'ca') return 'en';
|
||||
if (country === 'de') return 'de';
|
||||
if (country === 'fr') return 'fr';
|
||||
if (country === 'es') return 'es';
|
||||
if (country === 'it') return 'it';
|
||||
if (country === 'jp') return 'ja';
|
||||
return 'en';
|
||||
})));
|
||||
|
||||
return {
|
||||
logoUrl: `https://api.gridpilot.io/media/team/${team.id}/logo.png`,
|
||||
performanceLevel,
|
||||
specialization,
|
||||
region,
|
||||
languages,
|
||||
totalWins: wins,
|
||||
totalRaces,
|
||||
rating
|
||||
};
|
||||
}
|
||||
|
||||
private determineExperienceLevel(totalRaces: number): string {
|
||||
if (totalRaces >= 100) return 'Veteran';
|
||||
if (totalRaces >= 50) return 'Experienced';
|
||||
if (totalRaces >= 20) return 'Intermediate';
|
||||
if (totalRaces >= 10) return 'Rookie';
|
||||
return 'Beginner';
|
||||
}
|
||||
|
||||
private async seedMediaAssets(seed: any): Promise<void> {
|
||||
// Seed driver avatars
|
||||
for (const driver of seed.drivers) {
|
||||
const avatarUrl = `https://api.gridpilot.io/media/driver/${driver.id}/avatar.png`;
|
||||
|
||||
// Type assertion to access the helper method
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setDriverAvatar) {
|
||||
mediaRepo.setDriverAvatar(driver.id, avatarUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed team logos
|
||||
for (const team of seed.teams) {
|
||||
const logoUrl = `https://api.gridpilot.io/media/team/${team.id}/logo.png`;
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setTeamLogo) {
|
||||
mediaRepo.setTeamLogo(team.id, logoUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed track images
|
||||
for (const track of seed.tracks || []) {
|
||||
const trackImageUrl = `https://api.gridpilot.io/media/track/${track.id}/image.png`;
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setTrackImage) {
|
||||
mediaRepo.setTrackImage(track.id, trackImageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed category icons (if categories exist)
|
||||
const categories = ['beginner', 'intermediate', 'advanced', 'pro', 'endurance', 'sprint'];
|
||||
for (const category of categories) {
|
||||
const iconUrl = `https://api.gridpilot.io/media/category/${category}/icon.png`;
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setCategoryIcon) {
|
||||
mediaRepo.setCategoryIcon(category, iconUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed sponsor logos
|
||||
for (const sponsor of seed.sponsors || []) {
|
||||
const logoUrl = `https://api.gridpilot.io/media/sponsor/${sponsor.id}/logo.png`;
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setSponsorLogo) {
|
||||
mediaRepo.setSponsorLogo(sponsor.id, logoUrl);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info(`[Bootstrap] Seeded media assets for ${seed.drivers.length} drivers, ${seed.teams.length} teams`);
|
||||
}
|
||||
|
||||
private async clearExistingRacingData(): Promise<void> {
|
||||
// Get all existing drivers
|
||||
const drivers = await this.seedDeps.driverRepository.findAll();
|
||||
|
||||
@@ -19,39 +19,35 @@ export class RacingRaceFactory {
|
||||
const races: Race[] = [];
|
||||
|
||||
// Create races with systematic coverage of different statuses and scenarios
|
||||
const statuses: Array<'scheduled' | 'running' | 'completed' | 'cancelled'> = ['scheduled', 'running', 'completed', 'cancelled'];
|
||||
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
for (let i = 1; i <= 500; i++) {
|
||||
const leagueId = leagueIds[(i - 1) % leagueIds.length] ?? demoLeagueId;
|
||||
const trackId = trackIds[(i - 1) % trackIds.length]!;
|
||||
const track = tracks.find(t => t.id === trackId)!;
|
||||
|
||||
// Determine status systematically to ensure coverage
|
||||
// Determine status systematically to ensure good coverage
|
||||
let status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
let scheduledAt: Date;
|
||||
|
||||
if (i <= 4) {
|
||||
// First 4 races: one of each status
|
||||
status = statuses[i - 1]!;
|
||||
scheduledAt = this.addDays(this.baseDate, i <= 2 ? -35 + i : 1 + (i - 2) * 2);
|
||||
} else if (i <= 10) {
|
||||
// Next 6: completed races
|
||||
status = 'completed';
|
||||
scheduledAt = this.addDays(this.baseDate, -35 + i);
|
||||
} else if (i <= 15) {
|
||||
// Next 5: scheduled future races
|
||||
status = 'scheduled';
|
||||
scheduledAt = this.addDays(this.baseDate, 1 + (i - 10) * 3);
|
||||
} else if (i <= 20) {
|
||||
// Next 5: cancelled races
|
||||
// Use modulo to create a balanced distribution across 500 races
|
||||
const statusMod = i % 20; // 20 different patterns
|
||||
|
||||
if (statusMod === 1 || statusMod === 2 || statusMod === 3) {
|
||||
// 15% running (3 out of 20)
|
||||
status = 'running';
|
||||
scheduledAt = this.addDays(this.baseDate, -1 + (statusMod * 0.5)); // Recent past/current
|
||||
} else if (statusMod === 4 || statusMod === 5 || statusMod === 6 || statusMod === 7) {
|
||||
// 20% cancelled (4 out of 20)
|
||||
status = 'cancelled';
|
||||
scheduledAt = this.addDays(this.baseDate, -20 + (i - 15));
|
||||
scheduledAt = this.addDays(this.baseDate, -30 + (statusMod * 2));
|
||||
} else if (statusMod === 8 || statusMod === 9 || statusMod === 10 || statusMod === 11 || statusMod === 12) {
|
||||
// 25% completed (5 out of 20)
|
||||
status = 'completed';
|
||||
scheduledAt = this.addDays(this.baseDate, -50 + (statusMod * 3));
|
||||
} else {
|
||||
// Rest: mix of scheduled and completed
|
||||
status = i % 3 === 0 ? 'completed' : 'scheduled';
|
||||
scheduledAt = status === 'completed'
|
||||
? this.addDays(this.baseDate, -10 + (i - 20))
|
||||
: this.addDays(this.baseDate, 5 + (i - 20) * 2);
|
||||
// 40% scheduled (8 out of 20)
|
||||
status = 'scheduled';
|
||||
scheduledAt = this.addDays(this.baseDate, 1 + ((statusMod - 13) * 2));
|
||||
}
|
||||
|
||||
const base = {
|
||||
@@ -63,58 +59,48 @@ export class RacingRaceFactory {
|
||||
car: cars[(i - 1) % cars.length]!,
|
||||
};
|
||||
|
||||
// Special case for running race
|
||||
// Create race based on status with appropriate data
|
||||
if (status === 'running') {
|
||||
races.push(
|
||||
Race.create({
|
||||
...base,
|
||||
status: 'running',
|
||||
strengthOfField: 45 + (i % 50), // Valid SOF: 0-100
|
||||
registeredCount: 12 + (i % 5), // Varying registration counts
|
||||
maxParticipants: 24, // Ensure max is set
|
||||
strengthOfField: 40 + (i % 60), // Valid SOF: 0-100
|
||||
registeredCount: 10 + (i % 15), // Varying registration counts
|
||||
maxParticipants: 20 + (i % 8), // 20-28 participants
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add varying SOF and registration counts for completed races
|
||||
if (status === 'completed') {
|
||||
} else if (status === 'completed') {
|
||||
races.push(
|
||||
Race.create({
|
||||
...base,
|
||||
status: 'completed',
|
||||
strengthOfField: 35 + (i % 60), // Valid SOF: 0-100
|
||||
registeredCount: 8 + (i % 8),
|
||||
maxParticipants: 20, // Ensure max is set
|
||||
strengthOfField: 30 + (i % 70), // Valid SOF: 0-100
|
||||
registeredCount: 6 + (i % 12),
|
||||
maxParticipants: 16 + (i % 10),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scheduled races with some having registration data
|
||||
if (status === 'scheduled') {
|
||||
const hasRegistrations = i % 4 !== 0; // 75% have registrations
|
||||
} else if (status === 'scheduled') {
|
||||
const hasRegistrations = i % 3 !== 0; // 66% have registrations
|
||||
races.push(
|
||||
Race.create({
|
||||
...base,
|
||||
status: 'scheduled',
|
||||
...(hasRegistrations && {
|
||||
strengthOfField: 40 + (i % 55), // Valid SOF: 0-100
|
||||
registeredCount: 5 + (i % 10),
|
||||
maxParticipants: 16 + (i % 10), // Ensure max is set and reasonable
|
||||
strengthOfField: 35 + (i % 65), // Valid SOF: 0-100
|
||||
registeredCount: 4 + (i % 12),
|
||||
maxParticipants: 14 + (i % 12),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
} else if (status === 'cancelled') {
|
||||
races.push(
|
||||
Race.create({
|
||||
...base,
|
||||
status: 'cancelled',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Cancelled races
|
||||
races.push(
|
||||
Race.create({
|
||||
...base,
|
||||
status: 'cancelled',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return races;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryDriverStatsRepository
|
||||
*
|
||||
* In-memory implementation of IDriverStatsRepository.
|
||||
* Stores computed driver statistics for caching and frontend queries.
|
||||
*/
|
||||
|
||||
import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
|
||||
import type { DriverStats } from '@core/racing/application/use-cases/IDriverStatsUseCase';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export class InMemoryDriverStatsRepository implements IDriverStatsRepository {
|
||||
private stats = new Map<string, DriverStats>();
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('[InMemoryDriverStatsRepository] Initialized.');
|
||||
}
|
||||
|
||||
async getDriverStats(driverId: string): Promise<DriverStats | null> {
|
||||
this.logger.debug(`[InMemoryDriverStatsRepository] Getting stats for driver: ${driverId}`);
|
||||
return this.stats.get(driverId) ?? null;
|
||||
}
|
||||
|
||||
getDriverStatsSync(driverId: string): DriverStats | null {
|
||||
this.logger.debug(`[InMemoryDriverStatsRepository] Getting stats (sync) for driver: ${driverId}`);
|
||||
return this.stats.get(driverId) ?? null;
|
||||
}
|
||||
|
||||
async saveDriverStats(driverId: string, stats: DriverStats): Promise<void> {
|
||||
this.logger.debug(`[InMemoryDriverStatsRepository] Saving stats for driver: ${driverId}`);
|
||||
this.stats.set(driverId, stats);
|
||||
}
|
||||
|
||||
async getAllStats(): Promise<Map<string, DriverStats>> {
|
||||
this.logger.debug('[InMemoryDriverStatsRepository] Getting all stats');
|
||||
return new Map(this.stats);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.logger.info('[InMemoryDriverStatsRepository] Clearing all stats');
|
||||
this.stats.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryTeamStatsRepository
|
||||
*
|
||||
* In-memory implementation of ITeamStatsRepository.
|
||||
* Stores computed team statistics for caching and frontend queries.
|
||||
*/
|
||||
|
||||
import type { ITeamStatsRepository, TeamStats } from '@core/racing/domain/repositories/ITeamStatsRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export class InMemoryTeamStatsRepository implements ITeamStatsRepository {
|
||||
private stats = new Map<string, TeamStats>();
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('[InMemoryTeamStatsRepository] Initialized.');
|
||||
}
|
||||
|
||||
async getTeamStats(teamId: string): Promise<TeamStats | null> {
|
||||
this.logger.debug(`[InMemoryTeamStatsRepository] Getting stats for team: ${teamId}`);
|
||||
return this.stats.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
getTeamStatsSync(teamId: string): TeamStats | null {
|
||||
this.logger.debug(`[InMemoryTeamStatsRepository] Getting stats (sync) for team: ${teamId}`);
|
||||
return this.stats.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
async saveTeamStats(teamId: string, stats: TeamStats): Promise<void> {
|
||||
this.logger.debug(`[InMemoryTeamStatsRepository] Saving stats for team: ${teamId}`);
|
||||
this.stats.set(teamId, stats);
|
||||
}
|
||||
|
||||
async getAllStats(): Promise<Map<string, TeamStats>> {
|
||||
this.logger.debug('[InMemoryTeamStatsRepository] Getting all stats');
|
||||
return new Map(this.stats);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.logger.info('[InMemoryTeamStatsRepository] Clearing all stats');
|
||||
this.stats.clear();
|
||||
}
|
||||
}
|
||||
70
adapters/racing/persistence/media/InMemoryMediaRepository.ts
Normal file
70
adapters/racing/persistence/media/InMemoryMediaRepository.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryMediaRepository
|
||||
*
|
||||
* In-memory implementation of IMediaRepository.
|
||||
* Stores URLs for static media assets like logos and images.
|
||||
*/
|
||||
|
||||
import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export class InMemoryMediaRepository implements IMediaRepository {
|
||||
private driverAvatars = new Map<string, string>();
|
||||
private teamLogos = new Map<string, string>();
|
||||
private trackImages = new Map<string, string>();
|
||||
private categoryIcons = new Map<string, string>();
|
||||
private sponsorLogos = new Map<string, string>();
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('[InMemoryMediaRepository] Initialized.');
|
||||
}
|
||||
|
||||
async getDriverAvatar(driverId: string): Promise<string | null> {
|
||||
return this.driverAvatars.get(driverId) ?? null;
|
||||
}
|
||||
|
||||
async getTeamLogo(teamId: string): Promise<string | null> {
|
||||
return this.teamLogos.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
async getTrackImage(trackId: string): Promise<string | null> {
|
||||
return this.trackImages.get(trackId) ?? null;
|
||||
}
|
||||
|
||||
async getCategoryIcon(categoryId: string): Promise<string | null> {
|
||||
return this.categoryIcons.get(categoryId) ?? null;
|
||||
}
|
||||
|
||||
async getSponsorLogo(sponsorId: string): Promise<string | null> {
|
||||
return this.sponsorLogos.get(sponsorId) ?? null;
|
||||
}
|
||||
|
||||
// Helper methods for seeding
|
||||
setDriverAvatar(driverId: string, url: string): void {
|
||||
this.driverAvatars.set(driverId, url);
|
||||
}
|
||||
|
||||
setTeamLogo(teamId: string, url: string): void {
|
||||
this.teamLogos.set(teamId, url);
|
||||
}
|
||||
|
||||
setTrackImage(trackId: string, url: string): void {
|
||||
this.trackImages.set(trackId, url);
|
||||
}
|
||||
|
||||
setCategoryIcon(categoryId: string, url: string): void {
|
||||
this.categoryIcons.set(categoryId, url);
|
||||
}
|
||||
|
||||
setSponsorLogo(sponsorId: string, url: string): void {
|
||||
this.sponsorLogos.set(sponsorId, url);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.driverAvatars.clear();
|
||||
this.teamLogos.clear();
|
||||
this.trackImages.clear();
|
||||
this.categoryIcons.clear();
|
||||
this.sponsorLogos.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* ORM Entity: TeamRatingEvent
|
||||
*
|
||||
* Stores team rating events in the ledger with indexes for efficient querying
|
||||
* by teamId and ordering by occurredAt for snapshot computation.
|
||||
*/
|
||||
@Entity({ name: 'team_rating_events' })
|
||||
@Index(['teamId', 'occurredAt', 'createdAt', 'id'], { unique: true })
|
||||
export class TeamRatingEventOrmEntity {
|
||||
@PrimaryColumn({ type: 'text' })
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'text' })
|
||||
teamId!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'text' })
|
||||
dimension!: string;
|
||||
|
||||
@Column({ type: 'double precision' })
|
||||
delta!: number;
|
||||
|
||||
@Column({ type: 'double precision', nullable: true })
|
||||
weight?: number;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
source!: {
|
||||
type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
|
||||
id?: string;
|
||||
};
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
reason!: {
|
||||
code: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
visibility!: {
|
||||
public: boolean;
|
||||
};
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
version!: number;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* ORM Entity: TeamRating
|
||||
*
|
||||
* Stores the current rating snapshot per team.
|
||||
* Uses JSONB for dimension data to keep schema flexible.
|
||||
*/
|
||||
@Entity({ name: 'team_ratings' })
|
||||
@Index(['teamId'], { unique: true })
|
||||
export class TeamRatingOrmEntity {
|
||||
@PrimaryColumn({ type: 'text' })
|
||||
teamId!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
driving!: {
|
||||
value: number;
|
||||
confidence: number;
|
||||
sampleSize: number;
|
||||
trend: 'rising' | 'stable' | 'falling';
|
||||
lastUpdated: Date;
|
||||
};
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
adminTrust!: {
|
||||
value: number;
|
||||
confidence: number;
|
||||
sampleSize: number;
|
||||
trend: 'rising' | 'stable' | 'falling';
|
||||
lastUpdated: Date;
|
||||
};
|
||||
|
||||
@Column({ type: 'double precision' })
|
||||
overall!: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
calculatorVersion?: string;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { TeamRatingEventOrmMapper } from './TeamRatingEventOrmMapper';
|
||||
import { TeamRatingEventOrmEntity } from '../entities/TeamRatingEventOrmEntity';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
|
||||
describe('TeamRatingEventOrmMapper', () => {
|
||||
const validEntityProps = {
|
||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
teamId: 'team-123',
|
||||
dimension: 'driving',
|
||||
delta: 10,
|
||||
weight: 1,
|
||||
occurredAt: new Date('2024-01-01T00:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st in race' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const validDomainProps = {
|
||||
id: TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174000'),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
weight: 1,
|
||||
occurredAt: new Date('2024-01-01T00:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st in race' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
describe('toDomain', () => {
|
||||
it('should convert ORM entity to domain entity', () => {
|
||||
const entity = Object.assign(new TeamRatingEventOrmEntity(), validEntityProps);
|
||||
const domain = TeamRatingEventOrmMapper.toDomain(entity);
|
||||
|
||||
expect(domain.id.value).toBe(validEntityProps.id);
|
||||
expect(domain.teamId).toBe(validEntityProps.teamId);
|
||||
expect(domain.dimension.value).toBe(validEntityProps.dimension);
|
||||
expect(domain.delta.value).toBe(validEntityProps.delta);
|
||||
expect(domain.weight).toBe(validEntityProps.weight);
|
||||
expect(domain.occurredAt).toEqual(validEntityProps.occurredAt);
|
||||
expect(domain.createdAt).toEqual(validEntityProps.createdAt);
|
||||
expect(domain.source).toEqual(validEntityProps.source);
|
||||
expect(domain.reason).toEqual(validEntityProps.reason);
|
||||
expect(domain.visibility).toEqual(validEntityProps.visibility);
|
||||
expect(domain.version).toBe(validEntityProps.version);
|
||||
});
|
||||
|
||||
it('should handle optional weight', () => {
|
||||
const entity = Object.assign(new TeamRatingEventOrmEntity(), {
|
||||
...validEntityProps,
|
||||
weight: undefined,
|
||||
});
|
||||
const domain = TeamRatingEventOrmMapper.toDomain(entity);
|
||||
|
||||
expect(domain.weight).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle null weight', () => {
|
||||
const entity = Object.assign(new TeamRatingEventOrmEntity(), {
|
||||
...validEntityProps,
|
||||
weight: null,
|
||||
});
|
||||
const domain = TeamRatingEventOrmMapper.toDomain(entity);
|
||||
|
||||
expect(domain.weight).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toOrmEntity', () => {
|
||||
it('should convert domain entity to ORM entity', () => {
|
||||
const domain = TeamRatingEvent.create(validDomainProps);
|
||||
const entity = TeamRatingEventOrmMapper.toOrmEntity(domain);
|
||||
|
||||
expect(entity.id).toBe(validDomainProps.id.value);
|
||||
expect(entity.teamId).toBe(validDomainProps.teamId);
|
||||
expect(entity.dimension).toBe(validDomainProps.dimension.value);
|
||||
expect(entity.delta).toBe(validDomainProps.delta.value);
|
||||
expect(entity.weight).toBe(validDomainProps.weight);
|
||||
expect(entity.occurredAt).toEqual(validDomainProps.occurredAt);
|
||||
expect(entity.createdAt).toEqual(validDomainProps.createdAt);
|
||||
expect(entity.source).toEqual(validDomainProps.source);
|
||||
expect(entity.reason).toEqual(validDomainProps.reason);
|
||||
expect(entity.visibility).toEqual(validDomainProps.visibility);
|
||||
expect(entity.version).toBe(validDomainProps.version);
|
||||
});
|
||||
|
||||
it('should handle domain entity without weight', () => {
|
||||
const props = { ...validDomainProps };
|
||||
delete (props as any).weight;
|
||||
const domain = TeamRatingEvent.create(props);
|
||||
const entity = TeamRatingEventOrmMapper.toOrmEntity(domain);
|
||||
|
||||
expect(entity.weight).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
import { TeamRatingEventOrmEntity } from '../entities/TeamRatingEventOrmEntity';
|
||||
|
||||
/**
|
||||
* Mapper: TeamRatingEventOrmMapper
|
||||
*
|
||||
* Converts between TeamRatingEvent domain entity and TeamRatingEventOrmEntity.
|
||||
*/
|
||||
export class TeamRatingEventOrmMapper {
|
||||
/**
|
||||
* Convert ORM entity to domain entity
|
||||
*/
|
||||
static toDomain(entity: TeamRatingEventOrmEntity): TeamRatingEvent {
|
||||
const props: any = {
|
||||
id: TeamRatingEventId.create(entity.id),
|
||||
teamId: entity.teamId,
|
||||
dimension: TeamRatingDimensionKey.create(entity.dimension),
|
||||
delta: TeamRatingDelta.create(entity.delta),
|
||||
occurredAt: entity.occurredAt,
|
||||
createdAt: entity.createdAt,
|
||||
source: entity.source,
|
||||
reason: entity.reason,
|
||||
visibility: entity.visibility,
|
||||
version: entity.version,
|
||||
};
|
||||
|
||||
if (entity.weight !== undefined && entity.weight !== null) {
|
||||
props.weight = entity.weight;
|
||||
}
|
||||
|
||||
return TeamRatingEvent.rehydrate(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert domain entity to ORM entity
|
||||
*/
|
||||
static toOrmEntity(domain: TeamRatingEvent): TeamRatingEventOrmEntity {
|
||||
const entity = new TeamRatingEventOrmEntity();
|
||||
entity.id = domain.id.value;
|
||||
entity.teamId = domain.teamId;
|
||||
entity.dimension = domain.dimension.value;
|
||||
entity.delta = domain.delta.value;
|
||||
if (domain.weight !== undefined) {
|
||||
entity.weight = domain.weight;
|
||||
}
|
||||
entity.occurredAt = domain.occurredAt;
|
||||
entity.createdAt = domain.createdAt;
|
||||
entity.source = domain.source;
|
||||
entity.reason = domain.reason;
|
||||
entity.visibility = domain.visibility;
|
||||
entity.version = domain.version;
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { TeamRatingOrmMapper } from './TeamRatingOrmMapper';
|
||||
import { TeamRatingOrmEntity } from '../entities/TeamRatingOrmEntity';
|
||||
import { TeamRatingValue } from '@core/racing/domain/value-objects/TeamRatingValue';
|
||||
import { TeamRatingSnapshot } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
|
||||
describe('TeamRatingOrmMapper', () => {
|
||||
const validEntityProps = {
|
||||
teamId: 'team-123',
|
||||
driving: {
|
||||
value: 65,
|
||||
confidence: 0.8,
|
||||
sampleSize: 10,
|
||||
trend: 'rising' as const,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
},
|
||||
adminTrust: {
|
||||
value: 55,
|
||||
confidence: 0.8,
|
||||
sampleSize: 10,
|
||||
trend: 'stable' as const,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
},
|
||||
overall: 62,
|
||||
calculatorVersion: '1.0',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
const validSnapshotProps: TeamRatingSnapshot = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(65),
|
||||
adminTrust: TeamRatingValue.create(55),
|
||||
overall: 62,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
eventCount: 10,
|
||||
};
|
||||
|
||||
describe('toDomain', () => {
|
||||
it('should convert ORM entity to domain snapshot', () => {
|
||||
const entity = Object.assign(new TeamRatingOrmEntity(), validEntityProps);
|
||||
const snapshot = TeamRatingOrmMapper.toDomain(entity);
|
||||
|
||||
expect(snapshot.teamId).toBe(validEntityProps.teamId);
|
||||
expect(snapshot.driving.value).toBe(validEntityProps.driving.value);
|
||||
expect(snapshot.adminTrust.value).toBe(validEntityProps.adminTrust.value);
|
||||
expect(snapshot.overall).toBe(validEntityProps.overall);
|
||||
expect(snapshot.lastUpdated).toEqual(validEntityProps.updatedAt);
|
||||
expect(snapshot.eventCount).toBe(validEntityProps.driving.sampleSize + validEntityProps.adminTrust.sampleSize);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toOrmEntity', () => {
|
||||
it('should convert domain snapshot to ORM entity', () => {
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(validSnapshotProps);
|
||||
|
||||
expect(entity.teamId).toBe(validSnapshotProps.teamId);
|
||||
expect(entity.driving.value).toBe(validSnapshotProps.driving.value);
|
||||
expect(entity.adminTrust.value).toBe(validSnapshotProps.adminTrust.value);
|
||||
expect(entity.overall).toBe(validSnapshotProps.overall);
|
||||
expect(entity.calculatorVersion).toBe('1.0');
|
||||
expect(entity.createdAt).toEqual(validSnapshotProps.lastUpdated);
|
||||
expect(entity.updatedAt).toEqual(validSnapshotProps.lastUpdated);
|
||||
|
||||
// Check calculated confidence
|
||||
expect(entity.driving.confidence).toBeGreaterThan(0);
|
||||
expect(entity.driving.confidence).toBeLessThan(1);
|
||||
expect(entity.adminTrust.confidence).toBeGreaterThan(0);
|
||||
expect(entity.adminTrust.confidence).toBeLessThan(1);
|
||||
|
||||
// Check sample size
|
||||
expect(entity.driving.sampleSize).toBe(validSnapshotProps.eventCount);
|
||||
expect(entity.adminTrust.sampleSize).toBe(validSnapshotProps.eventCount);
|
||||
});
|
||||
|
||||
it('should calculate correct trend for rising driving rating', () => {
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(65),
|
||||
adminTrust: TeamRatingValue.create(50),
|
||||
overall: 60,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
eventCount: 5,
|
||||
};
|
||||
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(snapshot);
|
||||
expect(entity.driving.trend).toBe('rising');
|
||||
});
|
||||
|
||||
it('should calculate correct trend for falling driving rating', () => {
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(35),
|
||||
adminTrust: TeamRatingValue.create(50),
|
||||
overall: 40,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
eventCount: 5,
|
||||
};
|
||||
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(snapshot);
|
||||
expect(entity.driving.trend).toBe('falling');
|
||||
});
|
||||
|
||||
it('should calculate correct trend for stable driving rating', () => {
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(50),
|
||||
adminTrust: TeamRatingValue.create(50),
|
||||
overall: 50,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
eventCount: 5,
|
||||
};
|
||||
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(snapshot);
|
||||
expect(entity.driving.trend).toBe('stable');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { TeamRatingValue } from '@core/racing/domain/value-objects/TeamRatingValue';
|
||||
import { TeamRatingSnapshot } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
import { TeamRatingOrmEntity } from '../entities/TeamRatingOrmEntity';
|
||||
|
||||
/**
|
||||
* Mapper: TeamRatingOrmMapper
|
||||
*
|
||||
* Converts between TeamRatingSnapshot domain value and TeamRatingOrmEntity.
|
||||
*/
|
||||
export class TeamRatingOrmMapper {
|
||||
/**
|
||||
* Convert ORM entity to domain snapshot
|
||||
*/
|
||||
static toDomain(entity: TeamRatingOrmEntity): TeamRatingSnapshot {
|
||||
return {
|
||||
teamId: entity.teamId,
|
||||
driving: TeamRatingValue.create(entity.driving.value),
|
||||
adminTrust: TeamRatingValue.create(entity.adminTrust.value),
|
||||
overall: entity.overall,
|
||||
lastUpdated: entity.updatedAt,
|
||||
eventCount: entity.driving.sampleSize + entity.adminTrust.sampleSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert domain snapshot to ORM entity
|
||||
*/
|
||||
static toOrmEntity(snapshot: TeamRatingSnapshot): TeamRatingOrmEntity {
|
||||
const entity = new TeamRatingOrmEntity();
|
||||
entity.teamId = snapshot.teamId;
|
||||
|
||||
// Calculate confidence based on event count
|
||||
const confidence = 1 - Math.exp(-snapshot.eventCount / 20);
|
||||
|
||||
entity.driving = {
|
||||
value: snapshot.driving.value,
|
||||
confidence: confidence,
|
||||
sampleSize: snapshot.eventCount,
|
||||
trend: snapshot.driving.value > 50 ? 'rising' : snapshot.driving.value < 50 ? 'falling' : 'stable',
|
||||
lastUpdated: snapshot.lastUpdated,
|
||||
};
|
||||
|
||||
entity.adminTrust = {
|
||||
value: snapshot.adminTrust.value,
|
||||
confidence: confidence,
|
||||
sampleSize: snapshot.eventCount,
|
||||
trend: snapshot.adminTrust.value > 50 ? 'rising' : snapshot.adminTrust.value < 50 ? 'falling' : 'stable',
|
||||
lastUpdated: snapshot.lastUpdated,
|
||||
};
|
||||
|
||||
entity.overall = snapshot.overall;
|
||||
entity.calculatorVersion = '1.0';
|
||||
entity.createdAt = snapshot.lastUpdated;
|
||||
entity.updatedAt = snapshot.lastUpdated;
|
||||
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { ITeamRatingEventRepository, FindByTeamIdOptions, PaginatedQueryOptions, PaginatedResult } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import type { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import type { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
|
||||
import { TeamRatingEventOrmEntity } from '../entities/TeamRatingEventOrmEntity';
|
||||
import { TeamRatingEventOrmMapper } from '../mappers/TeamRatingEventOrmMapper';
|
||||
|
||||
/**
|
||||
* TypeORM Implementation: ITeamRatingEventRepository
|
||||
*
|
||||
* Persists team rating events in the ledger with efficient querying by teamId
|
||||
* and ordering for snapshot computation.
|
||||
*/
|
||||
export class TypeOrmTeamRatingEventRepository implements ITeamRatingEventRepository {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async save(event: TeamRatingEvent): Promise<TeamRatingEvent> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
const entity = TeamRatingEventOrmMapper.toOrmEntity(event);
|
||||
await repo.save(entity);
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string, options?: FindByTeamIdOptions): Promise<TeamRatingEvent[]> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
|
||||
const query = repo
|
||||
.createQueryBuilder('event')
|
||||
.where('event.teamId = :teamId', { teamId })
|
||||
.orderBy('event.occurredAt', 'ASC')
|
||||
.addOrderBy('event.createdAt', 'ASC')
|
||||
.addOrderBy('event.id', 'ASC');
|
||||
|
||||
if (options?.afterId) {
|
||||
query.andWhere('event.id > :afterId', { afterId: options.afterId.value });
|
||||
}
|
||||
|
||||
if (options?.limit) {
|
||||
query.limit(options.limit);
|
||||
}
|
||||
|
||||
const entities = await query.getMany();
|
||||
return entities.map(entity => TeamRatingEventOrmMapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async findByIds(ids: TeamRatingEventId[]): Promise<TeamRatingEvent[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
const idValues = ids.map(id => id.value);
|
||||
|
||||
const entities = await repo
|
||||
.createQueryBuilder('event')
|
||||
.where('event.id IN (:...ids)', { ids: idValues })
|
||||
.orderBy('event.occurredAt', 'ASC')
|
||||
.addOrderBy('event.createdAt', 'ASC')
|
||||
.addOrderBy('event.id', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return entities.map(entity => TeamRatingEventOrmMapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async getAllByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
|
||||
const entities = await repo
|
||||
.createQueryBuilder('event')
|
||||
.where('event.teamId = :teamId', { teamId })
|
||||
.orderBy('event.occurredAt', 'ASC')
|
||||
.addOrderBy('event.createdAt', 'ASC')
|
||||
.addOrderBy('event.id', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return entities.map(entity => TeamRatingEventOrmMapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async findEventsPaginated(teamId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<TeamRatingEvent>> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
|
||||
const query = repo
|
||||
.createQueryBuilder('event')
|
||||
.where('event.teamId = :teamId', { teamId });
|
||||
|
||||
// Apply filters
|
||||
if (options?.filter) {
|
||||
const filter = options.filter;
|
||||
|
||||
if (filter.dimensions) {
|
||||
query.andWhere('event.dimension IN (:...dimensions)', { dimensions: filter.dimensions });
|
||||
}
|
||||
|
||||
if (filter.sourceTypes) {
|
||||
query.andWhere('event.source.type IN (:...sourceTypes)', { sourceTypes: filter.sourceTypes });
|
||||
}
|
||||
|
||||
if (filter.from) {
|
||||
query.andWhere('event.occurredAt >= :from', { from: filter.from });
|
||||
}
|
||||
|
||||
if (filter.to) {
|
||||
query.andWhere('event.occurredAt <= :to', { to: filter.to });
|
||||
}
|
||||
|
||||
if (filter.reasonCodes) {
|
||||
query.andWhere('event.reason.code IN (:...reasonCodes)', { reasonCodes: filter.reasonCodes });
|
||||
}
|
||||
|
||||
if (filter.visibility) {
|
||||
query.andWhere('event.visibility.public = :visibility', { visibility: filter.visibility === 'public' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const total = await query.getCount();
|
||||
|
||||
// Apply pagination
|
||||
const limit = options?.limit ?? 10;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
query
|
||||
.orderBy('event.occurredAt', 'ASC')
|
||||
.addOrderBy('event.createdAt', 'ASC')
|
||||
.addOrderBy('event.id', 'ASC')
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const entities = await query.getMany();
|
||||
const items = entities.map(entity => TeamRatingEventOrmMapper.toDomain(entity));
|
||||
|
||||
const hasMore = offset + limit < total;
|
||||
const nextOffset = hasMore ? offset + limit : undefined;
|
||||
|
||||
const result: PaginatedResult<TeamRatingEvent> = {
|
||||
items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore
|
||||
};
|
||||
|
||||
if (nextOffset !== undefined) {
|
||||
result.nextOffset = nextOffset;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import type { TeamRatingSnapshot } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
|
||||
import { TeamRatingOrmEntity } from '../entities/TeamRatingOrmEntity';
|
||||
import { TeamRatingOrmMapper } from '../mappers/TeamRatingOrmMapper';
|
||||
|
||||
/**
|
||||
* TypeORM Implementation: ITeamRatingRepository
|
||||
*
|
||||
* Persists and retrieves TeamRating snapshots for fast reads.
|
||||
*/
|
||||
export class TypeOrmTeamRatingRepository implements ITeamRatingRepository {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<TeamRatingSnapshot | null> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingOrmEntity);
|
||||
const entity = await repo.findOne({ where: { teamId } });
|
||||
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TeamRatingOrmMapper.toDomain(entity);
|
||||
}
|
||||
|
||||
async save(teamRating: TeamRatingSnapshot): Promise<TeamRatingSnapshot> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingOrmEntity);
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(teamRating);
|
||||
await repo.save(entity);
|
||||
return teamRating;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { DriverStats } from '@core/racing/domain/services/IDriverStatsService';
|
||||
|
||||
/**
|
||||
* Global store for driver stats that can be populated during seeding
|
||||
* and read by the InMemoryDriverStatsService
|
||||
*/
|
||||
export class DriverStatsStore {
|
||||
private static instance: DriverStatsStore;
|
||||
private statsMap = new Map<string, DriverStats>();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): DriverStatsStore {
|
||||
if (!DriverStatsStore.instance) {
|
||||
DriverStatsStore.instance = new DriverStatsStore();
|
||||
}
|
||||
return DriverStatsStore.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the store with stats (called during seeding)
|
||||
*/
|
||||
loadStats(stats: Map<string, DriverStats>): void {
|
||||
this.statsMap.clear();
|
||||
stats.forEach((input, driverId) => {
|
||||
this.statsMap.set(driverId, input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats for a specific driver
|
||||
*/
|
||||
getDriverStats(driverId: string): DriverStats | null {
|
||||
return this.statsMap.get(driverId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stats (useful for reseeding)
|
||||
*/
|
||||
clear(): void {
|
||||
this.statsMap.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stats (for debugging)
|
||||
*/
|
||||
getAllStats(): Map<string, DriverStats> {
|
||||
return new Map(this.statsMap);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { InMemoryDriverStatsService } from './InMemoryDriverStatsService';
|
||||
|
||||
describe('InMemoryDriverStatsService', () => {
|
||||
it('returns stats for known drivers', () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
const service = new InMemoryDriverStatsService(logger);
|
||||
|
||||
const stats = service.getDriverStats('driver-1');
|
||||
expect(stats?.rating).toBe(2500);
|
||||
|
||||
expect(service.getDriverStats('unknown')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { IDriverStatsService, DriverStats } from '@core/racing/domain/services/IDriverStatsService';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { DriverStatsStore } from './DriverStatsStore';
|
||||
|
||||
export class InMemoryDriverStatsService implements IDriverStatsService {
|
||||
private store: DriverStatsStore;
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('InMemoryDriverStatsService initialized.');
|
||||
this.store = DriverStatsStore.getInstance();
|
||||
}
|
||||
|
||||
getDriverStats(driverId: string): DriverStats | null {
|
||||
this.logger.debug(`[InMemoryDriverStatsService] Getting stats for driver: ${driverId}`);
|
||||
return this.store.getDriverStats(driverId);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { InMemoryRankingService } from './InMemoryRankingService';
|
||||
|
||||
describe('InMemoryRankingService', () => {
|
||||
it('returns mock rankings', () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
const service = new InMemoryRankingService(logger);
|
||||
const rankings = service.getAllDriverRankings();
|
||||
|
||||
expect(rankings.length).toBeGreaterThanOrEqual(3);
|
||||
expect(rankings[0]).toHaveProperty('driverId');
|
||||
expect(rankings[0]).toHaveProperty('rating');
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { IRankingService, DriverRanking } from '@core/racing/domain/services/IRankingService';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { DriverStatsStore } from './DriverStatsStore';
|
||||
|
||||
export class InMemoryRankingService implements IRankingService {
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('InMemoryRankingService initialized.');
|
||||
}
|
||||
|
||||
getAllDriverRankings(): DriverRanking[] {
|
||||
this.logger.debug('[InMemoryRankingService] Getting all driver rankings.');
|
||||
|
||||
// Get stats from the DriverStatsStore
|
||||
const statsStore = DriverStatsStore.getInstance();
|
||||
const allStats = statsStore.getAllStats();
|
||||
|
||||
// Convert stats to rankings
|
||||
const rankings: DriverRanking[] = [];
|
||||
|
||||
allStats.forEach((stats, driverId) => {
|
||||
rankings.push({
|
||||
driverId,
|
||||
rating: stats.rating,
|
||||
overallRank: stats.overallRank ?? 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by rating descending to get proper rankings
|
||||
rankings.sort((a, b) => b.rating - a.rating);
|
||||
|
||||
// Assign ranks
|
||||
rankings.forEach((ranking, index) => {
|
||||
ranking.overallRank = index + 1;
|
||||
});
|
||||
|
||||
return rankings;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { TeamStats } from '@adapters/bootstrap/racing/RacingTeamFactory';
|
||||
|
||||
/**
|
||||
* Global store for team stats that can be populated during seeding
|
||||
* and read by the AllTeamsPresenter
|
||||
*/
|
||||
export class TeamStatsStore {
|
||||
private static instance: TeamStatsStore;
|
||||
private statsMap = new Map<string, TeamStats>();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): TeamStatsStore {
|
||||
if (!TeamStatsStore.instance) {
|
||||
TeamStatsStore.instance = new TeamStatsStore();
|
||||
}
|
||||
return TeamStatsStore.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the store with stats (called during seeding)
|
||||
*/
|
||||
loadStats(stats: Map<string, TeamStats>): void {
|
||||
this.statsMap.clear();
|
||||
stats.forEach((input, teamId) => {
|
||||
this.statsMap.set(teamId, input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats for a specific team
|
||||
*/
|
||||
getTeamStats(teamId: string): TeamStats | null {
|
||||
return this.statsMap.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stats (useful for reseeding)
|
||||
*/
|
||||
clear(): void {
|
||||
this.statsMap.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stats (for debugging)
|
||||
*/
|
||||
getAllStats(): Map<string, TeamStats> {
|
||||
return new Map(this.statsMap);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,9 @@ export const BootstrapProviders: Provider[] = [
|
||||
sponsorRepository: RacingSeedDependencies['sponsorRepository'],
|
||||
feedRepository: RacingSeedDependencies['feedRepository'],
|
||||
socialGraphRepository: RacingSeedDependencies['socialGraphRepository'],
|
||||
driverStatsRepository: RacingSeedDependencies['driverStatsRepository'],
|
||||
teamStatsRepository: RacingSeedDependencies['teamStatsRepository'],
|
||||
mediaRepository: RacingSeedDependencies['mediaRepository'],
|
||||
): RacingSeedDependencies => ({
|
||||
driverRepository,
|
||||
leagueRepository,
|
||||
@@ -86,6 +89,9 @@ export const BootstrapProviders: Provider[] = [
|
||||
sponsorRepository,
|
||||
feedRepository,
|
||||
socialGraphRepository,
|
||||
driverStatsRepository,
|
||||
teamStatsRepository,
|
||||
mediaRepository,
|
||||
}),
|
||||
inject: [
|
||||
'IDriverRepository',
|
||||
@@ -108,6 +114,9 @@ export const BootstrapProviders: Provider[] = [
|
||||
'ISponsorRepository',
|
||||
SOCIAL_FEED_REPOSITORY_TOKEN,
|
||||
SOCIAL_GRAPH_REPOSITORY_TOKEN,
|
||||
'IDriverStatsRepository',
|
||||
'ITeamStatsRepository',
|
||||
'IMediaRepository',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,10 +6,10 @@ import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepos
|
||||
import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
|
||||
import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository';
|
||||
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
|
||||
import { IDriverStatsService } from '@core/racing/domain/services/IDriverStatsService';
|
||||
import { IRankingService } from '@core/racing/domain/services/IRankingService';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
|
||||
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
|
||||
import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
|
||||
|
||||
// Import use cases
|
||||
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
|
||||
@@ -25,9 +25,18 @@ import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImage
|
||||
import { InMemoryNotificationPreferenceRepository } from '@adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository';
|
||||
import { InMemoryDriverExtendedProfileProvider } from '@adapters/racing/ports/InMemoryDriverExtendedProfileProvider';
|
||||
import { InMemoryDriverRatingProvider } from '@adapters/racing/ports/InMemoryDriverRatingProvider';
|
||||
import { InMemoryDriverStatsService } from '@adapters/racing/services/InMemoryDriverStatsService';
|
||||
import { InMemoryRankingService } from '@adapters/racing/services/InMemoryRankingService';
|
||||
import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
|
||||
// Import new use cases
|
||||
import { RankingUseCase } from '@core/racing/application/use-cases/RankingUseCase';
|
||||
import { DriverStatsUseCase } from '@core/racing/application/use-cases/DriverStatsUseCase';
|
||||
// Import new repositories
|
||||
import { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository';
|
||||
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
|
||||
// Import repository tokens
|
||||
import { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
|
||||
import { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
|
||||
// Import use case interfaces
|
||||
import type { IRankingUseCase } from '@core/racing/application/use-cases/IRankingUseCase';
|
||||
import type { IDriverStatsUseCase } from '@core/racing/application/use-cases/IDriverStatsUseCase';
|
||||
|
||||
// Import presenters
|
||||
import { CompleteOnboardingPresenter } from './presenters/CompleteOnboardingPresenter';
|
||||
@@ -39,8 +48,6 @@ import { DriverStatsPresenter } from './presenters/DriverStatsPresenter';
|
||||
|
||||
import {
|
||||
DRIVER_REPOSITORY_TOKEN,
|
||||
RANKING_SERVICE_TOKEN,
|
||||
DRIVER_STATS_SERVICE_TOKEN,
|
||||
DRIVER_RATING_PROVIDER_TOKEN,
|
||||
DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN,
|
||||
IMAGE_SERVICE_PORT_TOKEN,
|
||||
@@ -62,6 +69,10 @@ import {
|
||||
IS_DRIVER_REGISTERED_FOR_RACE_OUTPUT_PORT_TOKEN,
|
||||
UPDATE_DRIVER_PROFILE_OUTPUT_PORT_TOKEN,
|
||||
GET_PROFILE_OVERVIEW_OUTPUT_PORT_TOKEN,
|
||||
DRIVER_STATS_REPOSITORY_TOKEN,
|
||||
MEDIA_REPOSITORY_TOKEN,
|
||||
RANKING_SERVICE_TOKEN,
|
||||
DRIVER_STATS_SERVICE_TOKEN,
|
||||
} from './DriverTokens';
|
||||
|
||||
export * from './DriverTokens';
|
||||
@@ -73,7 +84,11 @@ export const DriverProviders: Provider[] = [
|
||||
DriverStatsPresenter,
|
||||
CompleteOnboardingPresenter,
|
||||
DriverRegistrationStatusPresenter,
|
||||
DriverPresenter,
|
||||
{
|
||||
provide: DriverPresenter,
|
||||
useFactory: (driverStatsRepository: IDriverStatsRepository) => new DriverPresenter(driverStatsRepository),
|
||||
inject: [DRIVER_STATS_REPOSITORY_TOKEN],
|
||||
},
|
||||
DriverProfilePresenter,
|
||||
|
||||
// Output ports (point to presenters)
|
||||
@@ -110,15 +125,33 @@ export const DriverProviders: Provider[] = [
|
||||
|
||||
// Repositories (racing + social repos are provided by imported persistence modules)
|
||||
{
|
||||
provide: RANKING_SERVICE_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryRankingService(logger),
|
||||
provide: DRIVER_STATS_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryDriverStatsRepository(logger),
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: DRIVER_STATS_SERVICE_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryDriverStatsService(logger),
|
||||
provide: MEDIA_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryMediaRepository(logger),
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: RANKING_SERVICE_TOKEN,
|
||||
useFactory: (
|
||||
standingRepo: IStandingRepository,
|
||||
driverRepo: IDriverRepository,
|
||||
logger: Logger
|
||||
) => new RankingUseCase(standingRepo, driverRepo, logger),
|
||||
inject: ['IStandingRepository', DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: DRIVER_STATS_SERVICE_TOKEN,
|
||||
useFactory: (
|
||||
resultRepo: IResultRepository,
|
||||
standingRepo: IStandingRepository,
|
||||
logger: Logger
|
||||
) => new DriverStatsUseCase(resultRepo, standingRepo, logger),
|
||||
inject: ['IResultRepository', 'IStandingRepository', LOGGER_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: DRIVER_RATING_PROVIDER_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryDriverRatingProvider(logger),
|
||||
@@ -145,13 +178,23 @@ export const DriverProviders: Provider[] = [
|
||||
provide: GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN,
|
||||
useFactory: (
|
||||
driverRepo: IDriverRepository,
|
||||
rankingService: IRankingService,
|
||||
driverStatsService: IDriverStatsService,
|
||||
imageService: IImageServicePort,
|
||||
rankingUseCase: IRankingUseCase,
|
||||
driverStatsUseCase: IDriverStatsUseCase,
|
||||
mediaRepository: IMediaRepository,
|
||||
logger: Logger,
|
||||
output: UseCaseOutputPort<unknown>,
|
||||
) => new GetDriversLeaderboardUseCase(driverRepo, rankingService, driverStatsService, (driverId: string) => Promise.resolve(imageService.getDriverAvatar(driverId)), logger, output),
|
||||
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, IMAGE_SERVICE_PORT_TOKEN, LOGGER_TOKEN, GET_DRIVERS_LEADERBOARD_OUTPUT_PORT_TOKEN],
|
||||
) => new GetDriversLeaderboardUseCase(
|
||||
driverRepo,
|
||||
rankingUseCase,
|
||||
driverStatsUseCase,
|
||||
async (driverId: string) => {
|
||||
const avatar = await mediaRepository.getDriverAvatar(driverId);
|
||||
return avatar ?? undefined;
|
||||
},
|
||||
logger,
|
||||
output
|
||||
),
|
||||
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, MEDIA_REPOSITORY_TOKEN, LOGGER_TOKEN, GET_DRIVERS_LEADERBOARD_OUTPUT_PORT_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: GET_TOTAL_DRIVERS_USE_CASE_TOKEN,
|
||||
@@ -183,8 +226,8 @@ export const DriverProviders: Provider[] = [
|
||||
teamMembershipRepository: ITeamMembershipRepository,
|
||||
socialRepository: ISocialGraphRepository,
|
||||
driverExtendedProfileProvider: DriverExtendedProfileProvider,
|
||||
driverStatsService: IDriverStatsService,
|
||||
rankingService: IRankingService,
|
||||
driverStatsUseCase: IDriverStatsUseCase,
|
||||
rankingUseCase: IRankingUseCase,
|
||||
output: UseCaseOutputPort<unknown>,
|
||||
) =>
|
||||
new GetProfileOverviewUseCase(
|
||||
@@ -193,32 +236,8 @@ export const DriverProviders: Provider[] = [
|
||||
teamMembershipRepository,
|
||||
socialRepository,
|
||||
driverExtendedProfileProvider,
|
||||
(driverId: string) => {
|
||||
const stats = driverStatsService.getDriverStats(driverId);
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
rating: stats.rating,
|
||||
wins: stats.wins,
|
||||
podiums: stats.podiums,
|
||||
dnfs: (stats as { dnfs?: number }).dnfs ?? 0,
|
||||
totalRaces: stats.totalRaces,
|
||||
avgFinish: null,
|
||||
bestFinish: null,
|
||||
worstFinish: null,
|
||||
overallRank: stats.overallRank,
|
||||
consistency: null,
|
||||
percentile: null,
|
||||
};
|
||||
},
|
||||
() =>
|
||||
rankingService.getAllDriverRankings().map(ranking => ({
|
||||
driverId: ranking.driverId,
|
||||
rating: ranking.rating,
|
||||
overallRank: ranking.overallRank,
|
||||
})),
|
||||
driverStatsUseCase,
|
||||
rankingUseCase,
|
||||
output,
|
||||
),
|
||||
inject: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
|
||||
export const RANKING_SERVICE_TOKEN = 'IRankingService';
|
||||
export const DRIVER_STATS_SERVICE_TOKEN = 'IDriverStatsService';
|
||||
export const RANKING_SERVICE_TOKEN = 'IRankingUseCase';
|
||||
export const DRIVER_STATS_SERVICE_TOKEN = 'IDriverStatsUseCase';
|
||||
export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider';
|
||||
export const DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN = 'DriverExtendedProfileProvider';
|
||||
export const IMAGE_SERVICE_PORT_TOKEN = 'IImageServicePort';
|
||||
@@ -13,6 +13,10 @@ export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository';
|
||||
export { SOCIAL_GRAPH_REPOSITORY_TOKEN };
|
||||
export const LOGGER_TOKEN = 'Logger';
|
||||
|
||||
// New tokens for clean architecture
|
||||
export const DRIVER_STATS_REPOSITORY_TOKEN = 'IDriverStatsRepository';
|
||||
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
|
||||
|
||||
export const GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN = 'GetDriversLeaderboardUseCase';
|
||||
export const GET_TOTAL_DRIVERS_USE_CASE_TOKEN = 'GetTotalDriversUseCase';
|
||||
export const COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN = 'CompleteDriverOnboardingUseCase';
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { Driver } from '@core/racing/domain/entities/Driver';
|
||||
import type { GetDriverOutputDTO } from '../dtos/GetDriverOutputDTO';
|
||||
import { DriverStatsStore } from '@adapters/racing/services/DriverStatsStore';
|
||||
import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
|
||||
|
||||
export class DriverPresenter {
|
||||
private responseModel: GetDriverOutputDTO | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly driverStatsRepository: IDriverStatsRepository
|
||||
) {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
present(result: Result<Driver | null, any>): void {
|
||||
if (result.isErr()) {
|
||||
@@ -19,9 +23,8 @@ export class DriverPresenter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get stats from the store
|
||||
const statsStore = DriverStatsStore.getInstance();
|
||||
const stats = statsStore.getDriverStats(driver.id);
|
||||
// Get stats from repository (synchronously for now, could be async)
|
||||
const stats = this.driverStatsRepository.getDriverStatsSync(driver.id);
|
||||
|
||||
this.responseModel = {
|
||||
id: driver.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
|
||||
import { IMAGE_SERVICE_TOKEN, LOGGER_TOKEN } from './TeamTokens';
|
||||
import { IMAGE_SERVICE_TOKEN, LOGGER_TOKEN, TEAM_STATS_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN } from './TeamTokens';
|
||||
|
||||
export {
|
||||
TEAM_REPOSITORY_TOKEN,
|
||||
@@ -8,16 +8,22 @@ export {
|
||||
DRIVER_REPOSITORY_TOKEN,
|
||||
IMAGE_SERVICE_TOKEN,
|
||||
LOGGER_TOKEN,
|
||||
TEAM_STATS_REPOSITORY_TOKEN,
|
||||
MEDIA_REPOSITORY_TOKEN,
|
||||
} from './TeamTokens';
|
||||
|
||||
// Import core interfaces
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
import type { ITeamStatsRepository } from '@core/racing/domain/repositories/ITeamStatsRepository';
|
||||
|
||||
// Import concrete in-memory implementations
|
||||
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
|
||||
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
|
||||
import { InMemoryTeamStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryTeamStatsRepository';
|
||||
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
|
||||
|
||||
// Use cases are imported and used directly in the service
|
||||
// Import presenters
|
||||
import { AllTeamsPresenter } from './presenters/AllTeamsPresenter';
|
||||
|
||||
export const TeamProviders: Provider[] = [
|
||||
{
|
||||
@@ -29,5 +35,19 @@ export const TeamProviders: Provider[] = [
|
||||
provide: LOGGER_TOKEN,
|
||||
useClass: ConsoleLogger,
|
||||
},
|
||||
// Use cases are created directly in the service
|
||||
{
|
||||
provide: TEAM_STATS_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryTeamStatsRepository(logger),
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: MEDIA_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryMediaRepository(logger),
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: AllTeamsPresenter,
|
||||
useFactory: (teamStatsRepository: ITeamStatsRepository) => new AllTeamsPresenter(teamStatsRepository),
|
||||
inject: [TEAM_STATS_REPOSITORY_TOKEN],
|
||||
},
|
||||
];
|
||||
@@ -111,7 +111,37 @@ describe('TeamService', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
service = new TeamService(teamRepository as unknown as never, membershipRepository as unknown as never, driverRepository as unknown as never, logger);
|
||||
const teamStatsRepository = {
|
||||
getTeamStats: vi.fn(),
|
||||
getTeamStatsSync: vi.fn(),
|
||||
saveTeamStats: vi.fn(),
|
||||
getAllStats: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
const mediaRepository = {
|
||||
getTeamAvatar: vi.fn(),
|
||||
saveTeamAvatar: vi.fn(),
|
||||
getDriverAvatar: vi.fn(),
|
||||
saveDriverAvatar: vi.fn(),
|
||||
};
|
||||
|
||||
const allTeamsPresenter = {
|
||||
reset: vi.fn(),
|
||||
present: vi.fn(),
|
||||
getResponseModel: vi.fn(() => ({ teams: [], totalCount: 0 })),
|
||||
responseModel: { teams: [], totalCount: 0 },
|
||||
};
|
||||
|
||||
service = new TeamService(
|
||||
teamRepository as unknown as never,
|
||||
membershipRepository as unknown as never,
|
||||
driverRepository as unknown as never,
|
||||
logger,
|
||||
teamStatsRepository as unknown as never,
|
||||
mediaRepository as unknown as never,
|
||||
allTeamsPresenter as unknown as never
|
||||
);
|
||||
});
|
||||
|
||||
it('getAll returns teams and totalCount on success', async () => {
|
||||
|
||||
@@ -37,7 +37,9 @@ import { CreateTeamPresenter } from './presenters/CreateTeamPresenter';
|
||||
import { UpdateTeamPresenter } from './presenters/UpdateTeamPresenter';
|
||||
|
||||
// Tokens
|
||||
import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN } from './TeamTokens';
|
||||
import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, TEAM_STATS_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN } from './TeamTokens';
|
||||
import type { ITeamStatsRepository } from '@core/racing/domain/repositories/ITeamStatsRepository';
|
||||
import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
|
||||
|
||||
@Injectable()
|
||||
export class TeamService {
|
||||
@@ -46,20 +48,29 @@ export class TeamService {
|
||||
@Inject(TEAM_MEMBERSHIP_REPOSITORY_TOKEN) private readonly membershipRepository: ITeamMembershipRepository,
|
||||
@Inject(DRIVER_REPOSITORY_TOKEN) private readonly driverRepository: IDriverRepository,
|
||||
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
||||
@Inject(TEAM_STATS_REPOSITORY_TOKEN) private readonly teamStatsRepository: ITeamStatsRepository,
|
||||
@Inject(MEDIA_REPOSITORY_TOKEN) private readonly mediaRepository: IMediaRepository,
|
||||
private readonly allTeamsPresenter: AllTeamsPresenter,
|
||||
) {}
|
||||
|
||||
async getAll(): Promise<GetAllTeamsOutputDTO> {
|
||||
this.logger.debug('[TeamService] Fetching all teams.');
|
||||
|
||||
const presenter = new AllTeamsPresenter();
|
||||
const useCase = new GetAllTeamsUseCase(this.teamRepository, this.membershipRepository, this.logger, presenter);
|
||||
const useCase = new GetAllTeamsUseCase(
|
||||
this.teamRepository,
|
||||
this.membershipRepository,
|
||||
this.teamStatsRepository,
|
||||
this.mediaRepository,
|
||||
this.logger,
|
||||
this.allTeamsPresenter
|
||||
);
|
||||
const result = await useCase.execute();
|
||||
if (result.isErr()) {
|
||||
this.logger.error('Error fetching all teams', new Error(result.error?.details?.message || 'Unknown error'));
|
||||
return { teams: [], totalCount: 0 };
|
||||
}
|
||||
|
||||
return presenter.getResponseModel()!;
|
||||
return this.allTeamsPresenter.getResponseModel()!;
|
||||
}
|
||||
|
||||
async getDetails(teamId: string, userId?: string): Promise<GetTeamDetailsOutputDTO | null> {
|
||||
|
||||
@@ -2,4 +2,6 @@ export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository';
|
||||
export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository';
|
||||
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
|
||||
export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
|
||||
export const LOGGER_TOKEN = 'Logger';
|
||||
export const LOGGER_TOKEN = 'Logger';
|
||||
export const TEAM_STATS_REPOSITORY_TOKEN = 'ITeamStatsRepository';
|
||||
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
|
||||
@@ -1,21 +1,23 @@
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import type { GetAllTeamsResult } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
|
||||
import { GetAllTeamsOutputDTO } from '../dtos/GetAllTeamsOutputDTO';
|
||||
import { TeamStatsStore } from '@adapters/racing/services/TeamStatsStore';
|
||||
import type { ITeamStatsRepository } from '@core/racing/domain/repositories/ITeamStatsRepository';
|
||||
|
||||
export class AllTeamsPresenter implements UseCaseOutputPort<GetAllTeamsResult> {
|
||||
private model: GetAllTeamsOutputDTO | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly teamStatsRepository: ITeamStatsRepository
|
||||
) {}
|
||||
|
||||
reset(): void {
|
||||
this.model = null;
|
||||
}
|
||||
|
||||
present(result: GetAllTeamsResult): void {
|
||||
const statsStore = TeamStatsStore.getInstance();
|
||||
|
||||
this.model = {
|
||||
teams: result.teams.map(team => {
|
||||
const stats = statsStore.getTeamStats(team.id.toString());
|
||||
const stats = this.teamStatsRepository.getTeamStatsSync(team.id.toString());
|
||||
|
||||
return {
|
||||
id: team.id,
|
||||
|
||||
@@ -24,6 +24,9 @@ import type { ISponsorshipPricingRepository } from '@core/racing/domain/reposito
|
||||
import type { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository';
|
||||
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
|
||||
import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository';
|
||||
import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
|
||||
import type { ITeamStatsRepository } from '@core/racing/domain/repositories/ITeamStatsRepository';
|
||||
import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
|
||||
|
||||
import { getPointsSystems } from '@adapters/bootstrap/PointsSystems';
|
||||
|
||||
@@ -47,6 +50,9 @@ import { InMemoryTransactionRepository } from '@adapters/racing/persistence/inme
|
||||
import { InMemorySponsorRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorRepository';
|
||||
import { InMemorySponsorshipPricingRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorshipPricingRepository';
|
||||
import { InMemorySponsorshipRequestRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorshipRequestRepository';
|
||||
import { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository';
|
||||
import { InMemoryTeamStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryTeamStatsRepository';
|
||||
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
|
||||
|
||||
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
|
||||
export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository';
|
||||
@@ -68,6 +74,9 @@ export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository';
|
||||
export const SPONSOR_REPOSITORY_TOKEN = 'ISponsorRepository';
|
||||
export const SPONSORSHIP_PRICING_REPOSITORY_TOKEN = 'ISponsorshipPricingRepository';
|
||||
export const SPONSORSHIP_REQUEST_REPOSITORY_TOKEN = 'ISponsorshipRequestRepository';
|
||||
export const DRIVER_STATS_REPOSITORY_TOKEN = 'IDriverStatsRepository';
|
||||
export const TEAM_STATS_REPOSITORY_TOKEN = 'ITeamStatsRepository';
|
||||
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
|
||||
|
||||
@Module({
|
||||
imports: [LoggingModule],
|
||||
@@ -178,6 +187,21 @@ export const SPONSORSHIP_REQUEST_REPOSITORY_TOKEN = 'ISponsorshipRequestReposito
|
||||
useFactory: (logger: Logger): ISponsorshipRequestRepository => new InMemorySponsorshipRequestRepository(logger),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: DRIVER_STATS_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger): IDriverStatsRepository => new InMemoryDriverStatsRepository(logger),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: TEAM_STATS_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger): ITeamStatsRepository => new InMemoryTeamStatsRepository(logger),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: MEDIA_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger): IMediaRepository => new InMemoryMediaRepository(logger),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
DRIVER_REPOSITORY_TOKEN,
|
||||
@@ -200,6 +224,9 @@ export const SPONSORSHIP_REQUEST_REPOSITORY_TOKEN = 'ISponsorshipRequestReposito
|
||||
SPONSOR_REPOSITORY_TOKEN,
|
||||
SPONSORSHIP_PRICING_REPOSITORY_TOKEN,
|
||||
SPONSORSHIP_REQUEST_REPOSITORY_TOKEN,
|
||||
DRIVER_STATS_REPOSITORY_TOKEN,
|
||||
TEAM_STATS_REPOSITORY_TOKEN,
|
||||
MEDIA_REPOSITORY_TOKEN,
|
||||
],
|
||||
})
|
||||
export class InMemoryRacingPersistenceModule {}
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
TEAM_MEMBERSHIP_REPOSITORY_TOKEN,
|
||||
TEAM_REPOSITORY_TOKEN,
|
||||
TRANSACTION_REPOSITORY_TOKEN,
|
||||
DRIVER_STATS_REPOSITORY_TOKEN,
|
||||
TEAM_STATS_REPOSITORY_TOKEN,
|
||||
MEDIA_REPOSITORY_TOKEN,
|
||||
} from '../inmemory/InMemoryRacingPersistenceModule';
|
||||
|
||||
import { DriverOrmEntity } from '@adapters/racing/persistence/typeorm/entities/DriverOrmEntity';
|
||||
@@ -74,6 +77,11 @@ import {
|
||||
import { TypeOrmPenaltyRepository, TypeOrmProtestRepository } from '@adapters/racing/persistence/typeorm/repositories/StewardingTypeOrmRepositories';
|
||||
import { TypeOrmTeamMembershipRepository, TypeOrmTeamRepository } from '@adapters/racing/persistence/typeorm/repositories/TeamTypeOrmRepositories';
|
||||
|
||||
// Import in-memory implementations for new repositories (TypeORM versions not yet implemented)
|
||||
import { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository';
|
||||
import { InMemoryTeamStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryTeamStatsRepository';
|
||||
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
|
||||
|
||||
import { DriverOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/DriverOrmMapper';
|
||||
import { LeagueMembershipOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueMembershipOrmMapper';
|
||||
import { LeagueOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper';
|
||||
@@ -99,6 +107,7 @@ import { PenaltyOrmMapper, ProtestOrmMapper } from '@adapters/racing/persistence
|
||||
import { TeamMembershipOrmMapper, TeamOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/TeamOrmMappers';
|
||||
|
||||
import { getPointsSystems } from '@adapters/bootstrap/PointsSystems';
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
|
||||
const RACING_POINTS_SYSTEMS_TOKEN = 'RACING_POINTS_SYSTEMS_TOKEN';
|
||||
|
||||
@@ -305,6 +314,21 @@ const typeOrmFeatureImports = [
|
||||
new TypeOrmSponsorshipRequestRepository(repo, mapper),
|
||||
inject: [getRepositoryToken(SponsorshipRequestOrmEntity), SponsorshipRequestOrmMapper],
|
||||
},
|
||||
{
|
||||
provide: DRIVER_STATS_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryDriverStatsRepository(logger),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: TEAM_STATS_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryTeamStatsRepository(logger),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: MEDIA_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryMediaRepository(logger),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
DRIVER_REPOSITORY_TOKEN,
|
||||
@@ -327,6 +351,9 @@ const typeOrmFeatureImports = [
|
||||
SPONSOR_REPOSITORY_TOKEN,
|
||||
SPONSORSHIP_PRICING_REPOSITORY_TOKEN,
|
||||
SPONSORSHIP_REQUEST_REPOSITORY_TOKEN,
|
||||
DRIVER_STATS_REPOSITORY_TOKEN,
|
||||
TEAM_STATS_REPOSITORY_TOKEN,
|
||||
MEDIA_REPOSITORY_TOKEN,
|
||||
],
|
||||
})
|
||||
export class PostgresRacingPersistenceModule {}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* DTO: RecordTeamRaceRatingEventsDto
|
||||
*
|
||||
* Input for RecordTeamRaceRatingEventsUseCase
|
||||
*/
|
||||
|
||||
export interface RecordTeamRaceRatingEventsInput {
|
||||
raceId: string;
|
||||
}
|
||||
|
||||
export interface RecordTeamRaceRatingEventsOutput {
|
||||
success: boolean;
|
||||
raceId: string;
|
||||
eventsCreated: number;
|
||||
teamsUpdated: string[];
|
||||
errors: string[];
|
||||
}
|
||||
49
core/racing/application/dtos/TeamLedgerEntryDto.ts
Normal file
49
core/racing/application/dtos/TeamLedgerEntryDto.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* DTO: TeamLedgerEntryDto
|
||||
*
|
||||
* Simplified team rating event for ledger display/query.
|
||||
* Pragmatic read model - direct repo DTOs, no domain logic.
|
||||
*/
|
||||
|
||||
export interface TeamLedgerEntryDto {
|
||||
id: string;
|
||||
teamId: string;
|
||||
dimension: string; // 'driving', 'adminTrust'
|
||||
delta: number; // positive or negative change
|
||||
weight?: number;
|
||||
occurredAt: string; // ISO date string
|
||||
createdAt: string; // ISO date string
|
||||
|
||||
source: {
|
||||
type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
|
||||
id?: string;
|
||||
};
|
||||
|
||||
reason: {
|
||||
code: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
visibility: {
|
||||
public: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TeamLedgerFilter {
|
||||
dimensions?: string[]; // Filter by dimension keys
|
||||
sourceTypes?: ('race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment')[];
|
||||
from?: string; // ISO date string
|
||||
to?: string; // ISO date string
|
||||
reasonCodes?: string[];
|
||||
}
|
||||
|
||||
export interface PaginatedTeamLedgerResult {
|
||||
entries: TeamLedgerEntryDto[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
nextOffset?: number | null;
|
||||
};
|
||||
}
|
||||
30
core/racing/application/dtos/TeamRatingSummaryDto.ts
Normal file
30
core/racing/application/dtos/TeamRatingSummaryDto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* DTO: TeamRatingSummaryDto
|
||||
*
|
||||
* Comprehensive team rating summary with platform ratings.
|
||||
* Pragmatic read model - direct repo DTOs, no domain logic.
|
||||
*/
|
||||
|
||||
export interface TeamRatingDimension {
|
||||
value: number;
|
||||
confidence: number;
|
||||
sampleSize: number;
|
||||
trend: 'rising' | 'stable' | 'falling';
|
||||
lastUpdated: string; // ISO date string
|
||||
}
|
||||
|
||||
export interface TeamRatingSummaryDto {
|
||||
teamId: string;
|
||||
|
||||
// Platform ratings (from internal calculations)
|
||||
platform: {
|
||||
driving: TeamRatingDimension;
|
||||
adminTrust: TeamRatingDimension;
|
||||
overall: number;
|
||||
};
|
||||
|
||||
// Timestamps
|
||||
createdAt: string; // ISO date string
|
||||
updatedAt: string; // ISO date string
|
||||
lastRatingEventAt?: string; // ISO date string (optional)
|
||||
}
|
||||
@@ -47,6 +47,13 @@ export * from './use-cases/GetPendingSponsorshipRequestsUseCase';
|
||||
export * from './use-cases/GetEntitySponsorshipPricingUseCase';
|
||||
export * from './ports/LeagueScoringPresetProvider';
|
||||
|
||||
// Team Rating Queries
|
||||
export * from './queries/index';
|
||||
|
||||
// Team Rating DTOs
|
||||
export type { TeamRatingSummaryDto, TeamRatingDimension } from './dtos/TeamRatingSummaryDto';
|
||||
export type { TeamLedgerEntryDto, TeamLedgerFilter, PaginatedTeamLedgerResult } from './dtos/TeamLedgerEntryDto';
|
||||
|
||||
// Re-export domain types for legacy callers (type-only)
|
||||
export type {
|
||||
LeagueMembership,
|
||||
|
||||
15
core/racing/application/ports/ITeamRaceResultsProvider.ts
Normal file
15
core/racing/application/ports/ITeamRaceResultsProvider.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { TeamDrivingRaceFactsDto } from '../../domain/services/TeamDrivingRatingEventFactory';
|
||||
|
||||
/**
|
||||
* Port: ITeamRaceResultsProvider
|
||||
*
|
||||
* Provides race results for teams from the racing context.
|
||||
* This is a port that adapts the racing domain data to the rating system.
|
||||
*/
|
||||
export interface ITeamRaceResultsProvider {
|
||||
/**
|
||||
* Get race results for teams
|
||||
* Returns team race facts needed for rating calculations
|
||||
*/
|
||||
getTeamRaceResults(raceId: string): Promise<TeamDrivingRaceFactsDto | null>;
|
||||
}
|
||||
376
core/racing/application/queries/GetTeamRatingLedgerQuery.test.ts
Normal file
376
core/racing/application/queries/GetTeamRatingLedgerQuery.test.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Tests for GetTeamRatingLedgerQuery
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||
import { GetTeamRatingLedgerQuery, GetTeamRatingLedgerQueryHandler } from './GetTeamRatingLedgerQuery';
|
||||
import { TeamRatingEvent } from '../../domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../../domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../../domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../../domain/value-objects/TeamRatingDelta';
|
||||
|
||||
describe('GetTeamRatingLedgerQuery', () => {
|
||||
let mockRatingEventRepo: any;
|
||||
let handler: GetTeamRatingLedgerQueryHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRatingEventRepo = {
|
||||
findEventsPaginated: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetTeamRatingLedgerQueryHandler(mockRatingEventRepo);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should return paginated ledger entries', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
// Mock paginated result
|
||||
const event1 = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: { code: 'RACE_FINISH' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const event2 = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(-5),
|
||||
occurredAt: new Date('2024-01-02T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-02T10:00:00Z'),
|
||||
source: { type: 'penalty', id: 'penalty-456' },
|
||||
reason: { code: 'LATE_JOIN' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
weight: 2,
|
||||
});
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [event1, event2],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
nextOffset: undefined,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.entries.length).toBe(2);
|
||||
|
||||
const entry1 = result.entries[0];
|
||||
expect(entry1).toBeDefined();
|
||||
if (entry1) {
|
||||
expect(entry1.teamId).toBe(teamId);
|
||||
expect(entry1.dimension).toBe('driving');
|
||||
expect(entry1.delta).toBe(10);
|
||||
expect(entry1.source.type).toBe('race');
|
||||
expect(entry1.source.id).toBe('race-123');
|
||||
expect(entry1.reason.code).toBe('RACE_FINISH');
|
||||
expect(entry1.visibility.public).toBe(true);
|
||||
}
|
||||
|
||||
const entry2 = result.entries[1];
|
||||
expect(entry2).toBeDefined();
|
||||
if (entry2) {
|
||||
expect(entry2.dimension).toBe('adminTrust');
|
||||
expect(entry2.delta).toBe(-5);
|
||||
expect(entry2.weight).toBe(2);
|
||||
expect(entry2.source.type).toBe('penalty');
|
||||
expect(entry2.source.id).toBe('penalty-456');
|
||||
}
|
||||
|
||||
expect(result.pagination.total).toBe(2);
|
||||
expect(result.pagination.limit).toBe(20);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply default pagination values', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId };
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockRatingEventRepo.findEventsPaginated).toHaveBeenCalledWith(
|
||||
teamId,
|
||||
expect.objectContaining({
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply custom pagination values', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
hasMore: true,
|
||||
nextOffset: 30,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId, limit: 10, offset: 20 };
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockRatingEventRepo.findEventsPaginated).toHaveBeenCalledWith(
|
||||
teamId,
|
||||
expect.objectContaining({
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply filters when provided', async () => {
|
||||
const teamId = 'team-123';
|
||||
const filter = {
|
||||
dimensions: ['driving'],
|
||||
sourceTypes: ['race', 'penalty'] as ('race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment')[],
|
||||
from: '2024-01-01T00:00:00Z',
|
||||
to: '2024-01-31T23:59:59Z',
|
||||
reasonCodes: ['RACE_FINISH', 'LATE_JOIN'],
|
||||
};
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId, filter };
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockRatingEventRepo.findEventsPaginated).toHaveBeenCalledWith(
|
||||
teamId,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
dimensions: ['driving'],
|
||||
sourceTypes: ['race', 'penalty'],
|
||||
from: new Date('2024-01-01T00:00:00Z'),
|
||||
to: new Date('2024-01-31T23:59:59Z'),
|
||||
reasonCodes: ['RACE_FINISH', 'LATE_JOIN'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle events with optional weight', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
const eventWithWeight = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(15),
|
||||
weight: 1.5,
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-789' },
|
||||
reason: { code: 'PERFORMANCE_BONUS' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const eventWithoutWeight = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-02T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-02T10:00:00Z'),
|
||||
source: { type: 'vote', id: 'vote-123' },
|
||||
reason: { code: 'POSITIVE_VOTE' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [eventWithWeight, eventWithoutWeight],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
const entry1 = result.entries[0];
|
||||
const entry2 = result.entries[1];
|
||||
expect(entry1).toBeDefined();
|
||||
expect(entry2).toBeDefined();
|
||||
|
||||
if (entry1) {
|
||||
expect(entry1.weight).toBe(1.5);
|
||||
}
|
||||
if (entry2) {
|
||||
expect(entry2.weight).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle events with optional source.id', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
const eventWithId = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: { code: 'RACE_FINISH' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const eventWithoutId = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-02T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-02T10:00:00Z'),
|
||||
source: { type: 'manualAdjustment' },
|
||||
reason: { code: 'ADMIN_ADJUSTMENT' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [eventWithId, eventWithoutId],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
const entry1 = result.entries[0];
|
||||
const entry2 = result.entries[1];
|
||||
expect(entry1).toBeDefined();
|
||||
expect(entry2).toBeDefined();
|
||||
|
||||
if (entry1) {
|
||||
expect(entry1.source.id).toBe('race-123');
|
||||
}
|
||||
if (entry2) {
|
||||
expect(entry2.source.id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle events with optional reason.description', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
const eventWithDescription = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st in class' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const eventWithoutDescription = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-02T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-02T10:00:00Z'),
|
||||
source: { type: 'vote', id: 'vote-123' },
|
||||
reason: { code: 'POSITIVE_VOTE' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [eventWithDescription, eventWithoutDescription],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
const entry1 = result.entries[0];
|
||||
const entry2 = result.entries[1];
|
||||
expect(entry1).toBeDefined();
|
||||
expect(entry2).toBeDefined();
|
||||
|
||||
if (entry1) {
|
||||
expect(entry1.reason.description).toBe('Finished 1st in class');
|
||||
}
|
||||
if (entry2) {
|
||||
expect(entry2.reason.description).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return nextOffset when hasMore is true', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
total: 50,
|
||||
limit: 20,
|
||||
offset: 20,
|
||||
hasMore: true,
|
||||
nextOffset: 40,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.pagination.nextOffset).toBe(40);
|
||||
});
|
||||
|
||||
it('should return null nextOffset when hasMore is false', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
total: 15,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
expect(result.pagination.nextOffset).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
106
core/racing/application/queries/GetTeamRatingLedgerQuery.ts
Normal file
106
core/racing/application/queries/GetTeamRatingLedgerQuery.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Query: GetTeamRatingLedgerQuery
|
||||
*
|
||||
* Paginated/filtered query for team rating events (ledger).
|
||||
* Mirrors user slice 6 pattern but for teams.
|
||||
*/
|
||||
|
||||
import { TeamLedgerEntryDto, TeamLedgerFilter, PaginatedTeamLedgerResult } from '../dtos/TeamLedgerEntryDto';
|
||||
import { ITeamRatingEventRepository, PaginatedQueryOptions, TeamRatingEventFilter } from '../../domain/repositories/ITeamRatingEventRepository';
|
||||
|
||||
export interface GetTeamRatingLedgerQuery {
|
||||
teamId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filter?: TeamLedgerFilter;
|
||||
}
|
||||
|
||||
export class GetTeamRatingLedgerQueryHandler {
|
||||
constructor(
|
||||
private readonly ratingEventRepo: ITeamRatingEventRepository
|
||||
) {}
|
||||
|
||||
async execute(query: GetTeamRatingLedgerQuery): Promise<PaginatedTeamLedgerResult> {
|
||||
const { teamId, limit = 20, offset = 0, filter } = query;
|
||||
|
||||
// Build repo options
|
||||
const repoOptions: PaginatedQueryOptions = {
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
|
||||
// Add filter if provided
|
||||
if (filter) {
|
||||
const ratingEventFilter: TeamRatingEventFilter = {};
|
||||
|
||||
if (filter.dimensions) {
|
||||
ratingEventFilter.dimensions = filter.dimensions;
|
||||
}
|
||||
if (filter.sourceTypes) {
|
||||
ratingEventFilter.sourceTypes = filter.sourceTypes;
|
||||
}
|
||||
if (filter.from) {
|
||||
ratingEventFilter.from = new Date(filter.from);
|
||||
}
|
||||
if (filter.to) {
|
||||
ratingEventFilter.to = new Date(filter.to);
|
||||
}
|
||||
if (filter.reasonCodes) {
|
||||
ratingEventFilter.reasonCodes = filter.reasonCodes;
|
||||
}
|
||||
|
||||
repoOptions.filter = ratingEventFilter;
|
||||
}
|
||||
|
||||
// Query repository
|
||||
const result = await this.ratingEventRepo.findEventsPaginated(teamId, repoOptions);
|
||||
|
||||
// Convert domain entities to DTOs
|
||||
const entries: TeamLedgerEntryDto[] = result.items.map(event => {
|
||||
const source: { type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment'; id?: string } = {
|
||||
type: event.source.type,
|
||||
};
|
||||
if (event.source.id !== undefined) {
|
||||
source.id = event.source.id;
|
||||
}
|
||||
|
||||
const reason: { code: string; description?: string } = {
|
||||
code: event.reason.code,
|
||||
};
|
||||
if (event.reason.description !== undefined) {
|
||||
reason.description = event.reason.description;
|
||||
}
|
||||
|
||||
const dto: TeamLedgerEntryDto = {
|
||||
id: event.id.value,
|
||||
teamId: event.teamId,
|
||||
dimension: event.dimension.value,
|
||||
delta: event.delta.value,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
createdAt: event.createdAt.toISOString(),
|
||||
source,
|
||||
reason,
|
||||
visibility: {
|
||||
public: event.visibility.public,
|
||||
},
|
||||
};
|
||||
if (event.weight !== undefined) {
|
||||
dto.weight = event.weight;
|
||||
}
|
||||
return dto;
|
||||
});
|
||||
|
||||
const nextOffset = result.nextOffset !== undefined ? result.nextOffset : null;
|
||||
|
||||
return {
|
||||
entries,
|
||||
pagination: {
|
||||
total: result.total,
|
||||
limit: result.limit,
|
||||
offset: result.offset,
|
||||
hasMore: result.hasMore,
|
||||
nextOffset,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Tests for GetTeamRatingsSummaryQuery
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||
import { GetTeamRatingsSummaryQuery, GetTeamRatingsSummaryQueryHandler } from './GetTeamRatingsSummaryQuery';
|
||||
import { TeamRatingSnapshot } from '../../domain/services/TeamRatingSnapshotCalculator';
|
||||
import { TeamRatingValue } from '../../domain/value-objects/TeamRatingValue';
|
||||
import { TeamRatingEvent } from '../../domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../../domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../../domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../../domain/value-objects/TeamRatingDelta';
|
||||
|
||||
describe('GetTeamRatingsSummaryQuery', () => {
|
||||
let mockTeamRatingRepo: any;
|
||||
let mockRatingEventRepo: any;
|
||||
let handler: GetTeamRatingsSummaryQueryHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
mockTeamRatingRepo = {
|
||||
findByTeamId: vi.fn(),
|
||||
};
|
||||
mockRatingEventRepo = {
|
||||
getAllByTeamId: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetTeamRatingsSummaryQueryHandler(
|
||||
mockTeamRatingRepo,
|
||||
mockRatingEventRepo
|
||||
);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should return summary with platform ratings', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
// Mock team rating snapshot
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(75),
|
||||
adminTrust: TeamRatingValue.create(60),
|
||||
overall: 70.5,
|
||||
lastUpdated: new Date('2024-01-01T10:00:00Z'),
|
||||
eventCount: 5,
|
||||
};
|
||||
mockTeamRatingRepo.findByTeamId.mockResolvedValue(snapshot);
|
||||
|
||||
// Mock rating events
|
||||
const event = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: { code: 'RACE_FINISH' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
mockRatingEventRepo.getAllByTeamId.mockResolvedValue([event]);
|
||||
|
||||
const query: GetTeamRatingsSummaryQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.teamId).toBe(teamId);
|
||||
expect(result.platform.driving.value).toBe(75);
|
||||
expect(result.platform.adminTrust.value).toBe(60);
|
||||
expect(result.platform.overall).toBe(70.5);
|
||||
expect(result.lastRatingEventAt).toBe('2024-01-01T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should handle missing team rating gracefully', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
mockTeamRatingRepo.findByTeamId.mockResolvedValue(null);
|
||||
mockRatingEventRepo.getAllByTeamId.mockResolvedValue([]);
|
||||
|
||||
const query: GetTeamRatingsSummaryQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.teamId).toBe(teamId);
|
||||
expect(result.platform.driving.value).toBe(0);
|
||||
expect(result.platform.adminTrust.value).toBe(0);
|
||||
expect(result.platform.overall).toBe(0);
|
||||
expect(result.lastRatingEventAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle multiple events and find latest', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(80),
|
||||
adminTrust: TeamRatingValue.create(70),
|
||||
overall: 77,
|
||||
lastUpdated: new Date('2024-01-02T10:00:00Z'),
|
||||
eventCount: 10,
|
||||
};
|
||||
mockTeamRatingRepo.findByTeamId.mockResolvedValue(snapshot);
|
||||
|
||||
// Multiple events with different timestamps
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-01T08:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T08:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-02T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-02T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-2' },
|
||||
reason: { code: 'RACE_FINISH' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
mockRatingEventRepo.getAllByTeamId.mockResolvedValue(events);
|
||||
|
||||
const query: GetTeamRatingsSummaryQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.lastRatingEventAt).toBe('2024-01-02T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should calculate confidence and sampleSize from event count', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(65),
|
||||
adminTrust: TeamRatingValue.create(55),
|
||||
overall: 62,
|
||||
lastUpdated: new Date('2024-01-01T10:00:00Z'),
|
||||
eventCount: 8,
|
||||
};
|
||||
mockTeamRatingRepo.findByTeamId.mockResolvedValue(snapshot);
|
||||
mockRatingEventRepo.getAllByTeamId.mockResolvedValue([]);
|
||||
|
||||
const query: GetTeamRatingsSummaryQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
// Confidence should be min(1, eventCount/10) = 0.8
|
||||
expect(result.platform.driving.confidence).toBe(0.8);
|
||||
expect(result.platform.driving.sampleSize).toBe(8);
|
||||
expect(result.platform.adminTrust.confidence).toBe(0.8);
|
||||
expect(result.platform.adminTrust.sampleSize).toBe(8);
|
||||
});
|
||||
|
||||
it('should handle empty events array', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(50),
|
||||
adminTrust: TeamRatingValue.create(50),
|
||||
overall: 50,
|
||||
lastUpdated: new Date('2024-01-01T10:00:00Z'),
|
||||
eventCount: 0,
|
||||
};
|
||||
mockTeamRatingRepo.findByTeamId.mockResolvedValue(snapshot);
|
||||
mockRatingEventRepo.getAllByTeamId.mockResolvedValue([]);
|
||||
|
||||
const query: GetTeamRatingsSummaryQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.platform.driving.confidence).toBe(0);
|
||||
expect(result.platform.driving.sampleSize).toBe(0);
|
||||
expect(result.platform.driving.trend).toBe('stable');
|
||||
expect(result.lastRatingEventAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Query: GetTeamRatingsSummaryQuery
|
||||
*
|
||||
* Fast read query for team rating summary.
|
||||
* Mirrors user slice 6 pattern but for teams.
|
||||
*/
|
||||
|
||||
import { TeamRatingSummaryDto } from '../dtos/TeamRatingSummaryDto';
|
||||
import { ITeamRatingRepository } from '../../domain/repositories/ITeamRatingRepository';
|
||||
import { ITeamRatingEventRepository } from '../../domain/repositories/ITeamRatingEventRepository';
|
||||
|
||||
export interface GetTeamRatingsSummaryQuery {
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
export class GetTeamRatingsSummaryQueryHandler {
|
||||
constructor(
|
||||
private readonly teamRatingRepo: ITeamRatingRepository,
|
||||
private readonly ratingEventRepo: ITeamRatingEventRepository
|
||||
) {}
|
||||
|
||||
async execute(query: GetTeamRatingsSummaryQuery): Promise<TeamRatingSummaryDto> {
|
||||
const { teamId } = query;
|
||||
|
||||
// Fetch platform rating snapshot
|
||||
const teamRating = await this.teamRatingRepo.findByTeamId(teamId);
|
||||
|
||||
// Get last event timestamp if available
|
||||
let lastRatingEventAt: string | undefined;
|
||||
if (teamRating) {
|
||||
// Get all events to find the most recent one
|
||||
const events = await this.ratingEventRepo.getAllByTeamId(teamId);
|
||||
if (events.length > 0) {
|
||||
const lastEvent = events[events.length - 1];
|
||||
if (lastEvent) {
|
||||
lastRatingEventAt = lastEvent.occurredAt.toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build platform rating dimensions
|
||||
// For team ratings, we don't have confidence/sampleSize/trend per dimension
|
||||
// We'll derive these from event count and recent activity
|
||||
const eventCount = teamRating?.eventCount || 0;
|
||||
const lastUpdated = teamRating?.lastUpdated || new Date(0);
|
||||
|
||||
const platform = {
|
||||
driving: {
|
||||
value: teamRating?.driving.value || 0,
|
||||
confidence: Math.min(1, eventCount / 10), // Simple confidence based on event count
|
||||
sampleSize: eventCount,
|
||||
trend: 'stable' as const, // Could be calculated from recent events
|
||||
lastUpdated: lastUpdated.toISOString(),
|
||||
},
|
||||
adminTrust: {
|
||||
value: teamRating?.adminTrust.value || 0,
|
||||
confidence: Math.min(1, eventCount / 10),
|
||||
sampleSize: eventCount,
|
||||
trend: 'stable' as const,
|
||||
lastUpdated: lastUpdated.toISOString(),
|
||||
},
|
||||
overall: teamRating?.overall || 0,
|
||||
};
|
||||
|
||||
// Get timestamps
|
||||
const createdAt = lastUpdated.toISOString();
|
||||
const updatedAt = lastUpdated.toISOString();
|
||||
|
||||
const result: TeamRatingSummaryDto = {
|
||||
teamId,
|
||||
platform,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
|
||||
if (lastRatingEventAt) {
|
||||
result.lastRatingEventAt = lastRatingEventAt;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
5
core/racing/application/queries/index.ts
Normal file
5
core/racing/application/queries/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Team Rating Queries
|
||||
export type { GetTeamRatingsSummaryQuery } from './GetTeamRatingsSummaryQuery';
|
||||
export { GetTeamRatingsSummaryQueryHandler } from './GetTeamRatingsSummaryQuery';
|
||||
export type { GetTeamRatingLedgerQuery } from './GetTeamRatingLedgerQuery';
|
||||
export { GetTeamRatingLedgerQueryHandler } from './GetTeamRatingLedgerQuery';
|
||||
@@ -0,0 +1,196 @@
|
||||
import { AppendTeamRatingEventsUseCase } from './AppendTeamRatingEventsUseCase';
|
||||
import { ITeamRatingEventRepository } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
|
||||
// Mock repositories
|
||||
class MockTeamRatingEventRepository implements ITeamRatingEventRepository {
|
||||
private events: TeamRatingEvent[] = [];
|
||||
|
||||
async save(event: TeamRatingEvent): Promise<TeamRatingEvent> {
|
||||
this.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findByIds(ids: TeamRatingEventId[]): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => ids.some(id => id.equals(e.id)));
|
||||
}
|
||||
|
||||
async getAllByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findEventsPaginated(teamId: string): Promise<any> {
|
||||
const events = await this.getAllByTeamId(teamId);
|
||||
return {
|
||||
items: events,
|
||||
total: events.length,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.events = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockTeamRatingRepository implements ITeamRatingRepository {
|
||||
private snapshots: Map<string, any> = new Map();
|
||||
|
||||
async findByTeamId(teamId: string): Promise<any | null> {
|
||||
return this.snapshots.get(teamId) || null;
|
||||
}
|
||||
|
||||
async save(snapshot: any): Promise<any> {
|
||||
this.snapshots.set(snapshot.teamId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.snapshots.clear();
|
||||
}
|
||||
}
|
||||
|
||||
describe('AppendTeamRatingEventsUseCase', () => {
|
||||
let useCase: AppendTeamRatingEventsUseCase;
|
||||
let mockEventRepo: MockTeamRatingEventRepository;
|
||||
let mockRatingRepo: MockTeamRatingRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEventRepo = new MockTeamRatingEventRepository();
|
||||
mockRatingRepo = new MockTeamRatingRepository();
|
||||
useCase = new AppendTeamRatingEventsUseCase(mockEventRepo, mockRatingRepo);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockEventRepo.clear();
|
||||
mockRatingRepo.clear();
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should do nothing when no events provided', async () => {
|
||||
await useCase.execute([]);
|
||||
|
||||
const events = await mockEventRepo.getAllByTeamId('team-123');
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should save single event and update snapshot', async () => {
|
||||
const event = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await useCase.execute([event]);
|
||||
|
||||
// Check event was saved
|
||||
const savedEvents = await mockEventRepo.getAllByTeamId('team-123');
|
||||
expect(savedEvents.length).toBe(1);
|
||||
expect(savedEvents[0]!.id.equals(event.id)).toBe(true);
|
||||
|
||||
// Check snapshot was updated
|
||||
const snapshot = await mockRatingRepo.findByTeamId('team-123');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot.teamId).toBe('team-123');
|
||||
expect(snapshot.driving.value).toBe(60); // 50 + 10
|
||||
});
|
||||
|
||||
it('should save multiple events for same team and update snapshot', async () => {
|
||||
const event1 = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const event2 = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-01T11:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T11:00:00Z'),
|
||||
source: { type: 'race', id: 'race-457' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 2nd' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await useCase.execute([event1, event2]);
|
||||
|
||||
// Check both events were saved
|
||||
const savedEvents = await mockEventRepo.getAllByTeamId('team-123');
|
||||
expect(savedEvents.length).toBe(2);
|
||||
|
||||
// Check snapshot was updated with weighted average
|
||||
const snapshot = await mockRatingRepo.findByTeamId('team-123');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot.teamId).toBe('team-123');
|
||||
// Should be 50 + weighted average of (10, 5) = 50 + 7.5 = 57.5
|
||||
expect(snapshot.driving.value).toBe(57.5);
|
||||
});
|
||||
|
||||
it('should handle multiple teams in one batch', async () => {
|
||||
const event1 = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const event2 = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-456',
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'adminAction', id: 'action-789' },
|
||||
reason: { code: 'POSITIVE_ADMIN_ACTION', description: 'Helped organize event' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await useCase.execute([event1, event2]);
|
||||
|
||||
// Check both team snapshots were updated
|
||||
const snapshot1 = await mockRatingRepo.findByTeamId('team-123');
|
||||
const snapshot2 = await mockRatingRepo.findByTeamId('team-456');
|
||||
|
||||
expect(snapshot1).toBeDefined();
|
||||
expect(snapshot1.driving.value).toBe(60);
|
||||
|
||||
expect(snapshot2).toBeDefined();
|
||||
expect(snapshot2.adminTrust.value).toBe(55);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ITeamRatingEventRepository } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import type { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingSnapshotCalculator } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
|
||||
/**
|
||||
* Use Case: AppendTeamRatingEventsUseCase
|
||||
*
|
||||
* Appends new rating events to the ledger and updates the team rating snapshot.
|
||||
* Mirrors the AppendRatingEventsUseCase pattern for users.
|
||||
*/
|
||||
export class AppendTeamRatingEventsUseCase {
|
||||
constructor(
|
||||
private readonly ratingEventRepository: ITeamRatingEventRepository,
|
||||
private readonly ratingRepository: ITeamRatingRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the use case
|
||||
*
|
||||
* @param events - Array of rating events to append
|
||||
* @returns The updated team rating snapshot
|
||||
*/
|
||||
async execute(events: TeamRatingEvent[]): Promise<void> {
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get unique team IDs from events
|
||||
const teamIds = [...new Set(events.map(e => e.teamId))];
|
||||
|
||||
// Save all events
|
||||
for (const event of events) {
|
||||
await this.ratingEventRepository.save(event);
|
||||
}
|
||||
|
||||
// Update snapshots for each affected team
|
||||
for (const teamId of teamIds) {
|
||||
// Get all events for this team
|
||||
const allEvents = await this.ratingEventRepository.getAllByTeamId(teamId);
|
||||
|
||||
// Calculate new snapshot
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate(teamId, allEvents);
|
||||
|
||||
// Save snapshot
|
||||
await this.ratingRepository.save(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
113
core/racing/application/use-cases/DriverStatsUseCase.ts
Normal file
113
core/racing/application/use-cases/DriverStatsUseCase.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Application Use Case: DriverStatsUseCase
|
||||
*
|
||||
* Computes detailed driver statistics from race results and standings.
|
||||
* Orchestrates repositories to provide stats data to presentation layer.
|
||||
*/
|
||||
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||
import type { IDriverStatsUseCase, DriverStats } from './IDriverStatsUseCase';
|
||||
|
||||
export class DriverStatsUseCase implements IDriverStatsUseCase {
|
||||
constructor(
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.logger.info('[DriverStatsUseCase] Initialized with real data repositories');
|
||||
}
|
||||
|
||||
async getDriverStats(driverId: string): Promise<DriverStats | null> {
|
||||
this.logger.debug(`[DriverStatsUseCase] Computing stats for driver: ${driverId}`);
|
||||
|
||||
try {
|
||||
// Get all results for this driver
|
||||
const results = await this.resultRepository.findByDriverId(driverId);
|
||||
|
||||
if (results.length === 0) {
|
||||
this.logger.warn(`[DriverStatsUseCase] No results found for driver: ${driverId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get standings for context
|
||||
const standings = await this.standingRepository.findAll();
|
||||
const driverStanding = standings.find(s => s.driverId.toString() === driverId);
|
||||
|
||||
// Calculate basic stats from results
|
||||
const wins = results.filter(r => r.position.toNumber() === 1).length;
|
||||
const podiums = results.filter(r => r.position.toNumber() <= 3).length;
|
||||
const dnfs = results.filter(r => r.position.toNumber() > 20).length;
|
||||
const totalRaces = results.length;
|
||||
|
||||
const positions = results.map(r => r.position.toNumber());
|
||||
const avgFinish = positions.reduce((sum, pos) => sum + pos, 0) / totalRaces;
|
||||
const bestFinish = Math.min(...positions);
|
||||
const worstFinish = Math.max(...positions);
|
||||
|
||||
// Calculate rating based on performance
|
||||
let rating = 1000;
|
||||
if (driverStanding) {
|
||||
// Use standing-based rating
|
||||
const pointsBonus = driverStanding.points.toNumber() * 2;
|
||||
const positionBonus = Math.max(0, 50 - (driverStanding.position.toNumber() * 2));
|
||||
const winBonus = driverStanding.wins * 100;
|
||||
rating = Math.round(1000 + pointsBonus + positionBonus + winBonus);
|
||||
} else {
|
||||
// Calculate from results if no standing
|
||||
const performanceBonus = ((totalRaces - wins) * 5) + ((totalRaces - podiums) * 2);
|
||||
rating = Math.round(1000 + (wins * 100) + (podiums * 50) - performanceBonus);
|
||||
}
|
||||
|
||||
// Calculate consistency (inverse of position variance)
|
||||
const avgPosition = avgFinish;
|
||||
const variance = positions.reduce((sum, pos) => sum + Math.pow(pos - avgPosition, 2), 0) / totalRaces;
|
||||
const consistency = Math.round(Math.max(0, 100 - (variance * 2)));
|
||||
|
||||
// Safety rating (simplified - based on incidents)
|
||||
const totalIncidents = results.reduce((sum, r) => sum + r.incidents.toNumber(), 0);
|
||||
const safetyRating = Math.round(Math.max(0, 100 - (totalIncidents / totalRaces)));
|
||||
|
||||
// Sportsmanship rating (placeholder - could be based on penalties/protests)
|
||||
const sportsmanshipRating = 4.5;
|
||||
|
||||
// Experience level
|
||||
const experienceLevel = this.determineExperienceLevel(totalRaces);
|
||||
|
||||
// Overall rank
|
||||
const overallRank = driverStanding ? driverStanding.position.toNumber() : null;
|
||||
|
||||
const stats: DriverStats = {
|
||||
rating,
|
||||
safetyRating,
|
||||
sportsmanshipRating,
|
||||
totalRaces,
|
||||
wins,
|
||||
podiums,
|
||||
dnfs,
|
||||
avgFinish: Math.round(avgFinish * 10) / 10,
|
||||
bestFinish,
|
||||
worstFinish,
|
||||
consistency,
|
||||
experienceLevel,
|
||||
overallRank
|
||||
};
|
||||
|
||||
this.logger.debug(`[DriverStatsUseCase] Computed stats for driver ${driverId}: rating=${stats.rating}, wins=${stats.wins}`);
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
this.logger.error(`[DriverStatsUseCase] Error computing stats for driver ${driverId}:`, error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private determineExperienceLevel(totalRaces: number): string {
|
||||
if (totalRaces >= 100) return 'Veteran';
|
||||
if (totalRaces >= 50) return 'Experienced';
|
||||
if (totalRaces >= 20) return 'Intermediate';
|
||||
if (totalRaces >= 10) return 'Rookie';
|
||||
return 'Beginner';
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import { GetAllTeamsUseCase, type GetAllTeamsInput, type GetAllTeamsResult } from './GetAllTeamsUseCase';
|
||||
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import type { ITeamStatsRepository } from '../../domain/repositories/ITeamStatsRepository';
|
||||
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
@@ -30,6 +32,23 @@ describe('GetAllTeamsUseCase', () => {
|
||||
removeJoinRequest: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTeamStatsRepo: ITeamStatsRepository = {
|
||||
getTeamStats: vi.fn(),
|
||||
getTeamStatsSync: vi.fn(),
|
||||
saveTeamStats: vi.fn(),
|
||||
getAllStats: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
const mockMediaRepo: IMediaRepository = {
|
||||
getDriverAvatar: vi.fn(),
|
||||
getTeamLogo: vi.fn(),
|
||||
getTrackImage: vi.fn(),
|
||||
getCategoryIcon: vi.fn(),
|
||||
getSponsorLogo: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLogger: Logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
@@ -50,6 +69,8 @@ describe('GetAllTeamsUseCase', () => {
|
||||
const useCase = new GetAllTeamsUseCase(
|
||||
mockTeamRepo,
|
||||
mockTeamMembershipRepo,
|
||||
mockTeamStatsRepo,
|
||||
mockMediaRepo,
|
||||
mockLogger,
|
||||
output,
|
||||
);
|
||||
@@ -115,6 +136,8 @@ describe('GetAllTeamsUseCase', () => {
|
||||
const useCase = new GetAllTeamsUseCase(
|
||||
mockTeamRepo,
|
||||
mockTeamMembershipRepo,
|
||||
mockTeamStatsRepo,
|
||||
mockMediaRepo,
|
||||
mockLogger,
|
||||
output,
|
||||
);
|
||||
@@ -139,6 +162,8 @@ describe('GetAllTeamsUseCase', () => {
|
||||
const useCase = new GetAllTeamsUseCase(
|
||||
mockTeamRepo,
|
||||
mockTeamMembershipRepo,
|
||||
mockTeamStatsRepo,
|
||||
mockMediaRepo,
|
||||
mockLogger,
|
||||
output,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import type { ITeamStatsRepository } from '../../domain/repositories/ITeamStatsRepository';
|
||||
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
@@ -18,6 +20,14 @@ export interface TeamSummary {
|
||||
leagues: string[];
|
||||
createdAt: Date;
|
||||
memberCount: number;
|
||||
totalWins?: number;
|
||||
totalRaces?: number;
|
||||
performanceLevel?: string;
|
||||
specialization?: string;
|
||||
region?: string;
|
||||
languages?: string[];
|
||||
logoUrl?: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
export interface GetAllTeamsResult {
|
||||
@@ -32,6 +42,8 @@ export class GetAllTeamsUseCase {
|
||||
constructor(
|
||||
private readonly teamRepository: ITeamRepository,
|
||||
private readonly teamMembershipRepository: ITeamMembershipRepository,
|
||||
private readonly teamStatsRepository: ITeamStatsRepository,
|
||||
private readonly mediaRepository: IMediaRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<GetAllTeamsResult>,
|
||||
) {}
|
||||
@@ -48,6 +60,9 @@ export class GetAllTeamsUseCase {
|
||||
const enrichedTeams: TeamSummary[] = await Promise.all(
|
||||
teams.map(async (team) => {
|
||||
const memberCount = await this.teamMembershipRepository.countByTeamId(team.id);
|
||||
const stats = await this.teamStatsRepository.getTeamStats(team.id);
|
||||
const logoUrl = await this.mediaRepository.getTeamLogo(team.id);
|
||||
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name.props,
|
||||
@@ -57,6 +72,17 @@ export class GetAllTeamsUseCase {
|
||||
leagues: team.leagues.map(l => l.toString()),
|
||||
createdAt: team.createdAt.toDate(),
|
||||
memberCount,
|
||||
// Add stats fields
|
||||
...(stats ? {
|
||||
totalWins: stats.totalWins,
|
||||
totalRaces: stats.totalRaces,
|
||||
performanceLevel: stats.performanceLevel,
|
||||
specialization: stats.specialization,
|
||||
region: stats.region,
|
||||
languages: stats.languages,
|
||||
logoUrl: logoUrl || stats.logoUrl,
|
||||
rating: stats.rating,
|
||||
} : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
type GetDriversLeaderboardInput,
|
||||
} from './GetDriversLeaderboardUseCase';
|
||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||
import type { IRankingService } from '../../domain/services/IRankingService';
|
||||
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
|
||||
import type { IRankingUseCase } from './IRankingUseCase';
|
||||
import type { IDriverStatsUseCase } from './IDriverStatsUseCase';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import type { GetDriversLeaderboardResult } from './GetDriversLeaderboardUseCase';
|
||||
@@ -24,12 +24,12 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
};
|
||||
|
||||
const mockRankingGetAllDriverRankings = vi.fn();
|
||||
const mockRankingService: IRankingService = {
|
||||
const mockRankingUseCase: IRankingUseCase = {
|
||||
getAllDriverRankings: mockRankingGetAllDriverRankings,
|
||||
};
|
||||
|
||||
const mockDriverStatsGetDriverStats = vi.fn();
|
||||
const mockDriverStatsService: IDriverStatsService = {
|
||||
const mockDriverStatsUseCase: IDriverStatsUseCase = {
|
||||
getDriverStats: mockDriverStatsGetDriverStats,
|
||||
};
|
||||
|
||||
@@ -48,8 +48,8 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
it('should return drivers leaderboard data', async () => {
|
||||
const useCase = new GetDriversLeaderboardUseCase(
|
||||
mockDriverRepo,
|
||||
mockRankingService,
|
||||
mockDriverStatsService,
|
||||
mockRankingUseCase,
|
||||
mockDriverStatsUseCase,
|
||||
mockGetDriverAvatar,
|
||||
mockLogger,
|
||||
mockOutput,
|
||||
@@ -117,8 +117,8 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
it('should return empty result when no drivers', async () => {
|
||||
const useCase = new GetDriversLeaderboardUseCase(
|
||||
mockDriverRepo,
|
||||
mockRankingService,
|
||||
mockDriverStatsService,
|
||||
mockRankingUseCase,
|
||||
mockDriverStatsUseCase,
|
||||
mockGetDriverAvatar,
|
||||
mockLogger,
|
||||
mockOutput,
|
||||
@@ -144,8 +144,8 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
it('should handle drivers without stats', async () => {
|
||||
const useCase = new GetDriversLeaderboardUseCase(
|
||||
mockDriverRepo,
|
||||
mockRankingService,
|
||||
mockDriverStatsService,
|
||||
mockRankingUseCase,
|
||||
mockDriverStatsUseCase,
|
||||
mockGetDriverAvatar,
|
||||
mockLogger,
|
||||
mockOutput,
|
||||
@@ -188,8 +188,8 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
it('should return error when repository throws', async () => {
|
||||
const useCase = new GetDriversLeaderboardUseCase(
|
||||
mockDriverRepo,
|
||||
mockRankingService,
|
||||
mockDriverStatsService,
|
||||
mockRankingUseCase,
|
||||
mockDriverStatsUseCase,
|
||||
mockGetDriverAvatar,
|
||||
mockLogger,
|
||||
mockOutput,
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorC
|
||||
import type { Driver } from '../../domain/entities/Driver';
|
||||
import type { Team } from '../../domain/entities/Team';
|
||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
|
||||
import type { IRankingService } from '../../domain/services/IRankingService';
|
||||
import type { IDriverStatsUseCase } from './IDriverStatsUseCase';
|
||||
import type { IRankingUseCase } from './IRankingUseCase';
|
||||
import { SkillLevelService, type SkillLevel } from '../../domain/services/SkillLevelService';
|
||||
|
||||
export type GetDriversLeaderboardInput = {
|
||||
@@ -45,8 +45,8 @@ export type GetDriversLeaderboardErrorCode =
|
||||
export class GetDriversLeaderboardUseCase implements UseCase<GetDriversLeaderboardInput, void, GetDriversLeaderboardErrorCode> {
|
||||
constructor(
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly rankingService: IRankingService,
|
||||
private readonly driverStatsService: IDriverStatsService,
|
||||
private readonly rankingUseCase: IRankingUseCase,
|
||||
private readonly driverStatsUseCase: IDriverStatsUseCase,
|
||||
private readonly getDriverAvatar: (driverId: string) => Promise<string | undefined>,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<GetDriversLeaderboardResult>,
|
||||
@@ -64,7 +64,7 @@ export class GetDriversLeaderboardUseCase implements UseCase<GetDriversLeaderboa
|
||||
|
||||
try {
|
||||
const drivers = await this.driverRepository.findAll();
|
||||
const rankings = this.rankingService.getAllDriverRankings();
|
||||
const rankings = await this.rankingUseCase.getAllDriverRankings();
|
||||
|
||||
const avatarUrls: Record<string, string | undefined> = {};
|
||||
|
||||
@@ -72,9 +72,21 @@ export class GetDriversLeaderboardUseCase implements UseCase<GetDriversLeaderboa
|
||||
avatarUrls[driver.id] = await this.getDriverAvatar(driver.id);
|
||||
}
|
||||
|
||||
// Get stats for all drivers
|
||||
const statsPromises = drivers.map(driver =>
|
||||
this.driverStatsUseCase.getDriverStats(driver.id)
|
||||
);
|
||||
const statsResults = await Promise.all(statsPromises);
|
||||
const statsMap = new Map<string, any>();
|
||||
drivers.forEach((driver, idx) => {
|
||||
if (statsResults[idx]) {
|
||||
statsMap.set(driver.id, statsResults[idx]);
|
||||
}
|
||||
});
|
||||
|
||||
const items: DriverLeaderboardItem[] = drivers.map((driver) => {
|
||||
const ranking = rankings.find((r) => r.driverId === driver.id);
|
||||
const stats = this.driverStatsService.getDriverStats(driver.id);
|
||||
const stats = statsMap.get(driver.id);
|
||||
const rating = ranking?.rating ?? 0;
|
||||
const racesCompleted = stats?.totalRaces ?? 0;
|
||||
const skillLevel: SkillLevel = SkillLevelService.getSkillLevel(rating);
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import {
|
||||
GetProfileOverviewUseCase,
|
||||
type GetProfileOverviewInput,
|
||||
type GetProfileOverviewResult,
|
||||
type GetProfileOverviewErrorCode,
|
||||
} from './GetProfileOverviewUseCase';
|
||||
import { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||
import { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||
import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
|
||||
import { Driver } from '../../domain/entities/Driver';
|
||||
import { Team } from '../../domain/entities/Team';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
describe('GetProfileOverviewUseCase', () => {
|
||||
let useCase: GetProfileOverviewUseCase;
|
||||
@@ -28,8 +24,12 @@ describe('GetProfileOverviewUseCase', () => {
|
||||
let socialRepository: {
|
||||
getFriends: Mock;
|
||||
};
|
||||
let getDriverStats: Mock;
|
||||
let getAllDriverRankings: Mock;
|
||||
let driverStatsUseCase: {
|
||||
getDriverStats: Mock;
|
||||
};
|
||||
let rankingUseCase: {
|
||||
getAllDriverRankings: Mock;
|
||||
};
|
||||
let driverExtendedProfileProvider: {
|
||||
getExtendedProfile: Mock;
|
||||
};
|
||||
@@ -39,20 +39,31 @@ describe('GetProfileOverviewUseCase', () => {
|
||||
driverRepository = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
|
||||
teamRepository = {
|
||||
findAll: vi.fn(),
|
||||
};
|
||||
|
||||
teamMembershipRepository = {
|
||||
getMembership: vi.fn(),
|
||||
};
|
||||
|
||||
socialRepository = {
|
||||
getFriends: vi.fn(),
|
||||
};
|
||||
getDriverStats = vi.fn();
|
||||
getAllDriverRankings = vi.fn();
|
||||
|
||||
driverStatsUseCase = {
|
||||
getDriverStats: vi.fn(),
|
||||
};
|
||||
|
||||
rankingUseCase = {
|
||||
getAllDriverRankings: vi.fn(),
|
||||
};
|
||||
|
||||
driverExtendedProfileProvider = {
|
||||
getExtendedProfile: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<GetProfileOverviewResult> & { present: Mock };
|
||||
@@ -63,8 +74,8 @@ describe('GetProfileOverviewUseCase', () => {
|
||||
teamMembershipRepository as unknown as ITeamMembershipRepository,
|
||||
socialRepository as unknown as ISocialGraphRepository,
|
||||
driverExtendedProfileProvider,
|
||||
getDriverStats,
|
||||
getAllDriverRankings,
|
||||
driverStatsUseCase as unknown as any,
|
||||
rankingUseCase as unknown as any,
|
||||
output,
|
||||
);
|
||||
});
|
||||
@@ -73,85 +84,40 @@ describe('GetProfileOverviewUseCase', () => {
|
||||
const driverId = 'driver-1';
|
||||
const driver = Driver.create({
|
||||
id: driverId,
|
||||
iracingId: '123',
|
||||
iracingId: '12345',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
joinedAt: new Date('2023-01-01'),
|
||||
});
|
||||
const teams = [
|
||||
Team.create({
|
||||
id: 'team-1',
|
||||
name: 'Test Team',
|
||||
tag: 'TT',
|
||||
description: 'Test',
|
||||
ownerId: 'owner-1',
|
||||
leagues: [],
|
||||
}),
|
||||
];
|
||||
const friends = [
|
||||
Driver.create({ id: 'friend-1', iracingId: '456', name: 'Friend', country: 'US' }),
|
||||
];
|
||||
const statsAdapter = {
|
||||
rating: 1500,
|
||||
wins: 5,
|
||||
podiums: 2,
|
||||
dnfs: 1,
|
||||
totalRaces: 10,
|
||||
avgFinish: 3.5,
|
||||
bestFinish: 1,
|
||||
worstFinish: 10,
|
||||
overallRank: 10,
|
||||
consistency: 90,
|
||||
percentile: 75,
|
||||
};
|
||||
const rankings = [{ driverId, rating: 1500, overallRank: 10 }];
|
||||
|
||||
driverRepository.findById.mockResolvedValue(driver);
|
||||
teamRepository.findAll.mockResolvedValue(teams);
|
||||
teamMembershipRepository.getMembership.mockResolvedValue(null);
|
||||
socialRepository.getFriends.mockResolvedValue(friends);
|
||||
getDriverStats.mockReturnValue(statsAdapter);
|
||||
getAllDriverRankings.mockReturnValue(rankings);
|
||||
driverExtendedProfileProvider.getExtendedProfile.mockReturnValue(null);
|
||||
driverStatsUseCase.getDriverStats.mockResolvedValue({
|
||||
rating: 1500,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
dnfs: 2,
|
||||
totalRaces: 20,
|
||||
avgFinish: 8.5,
|
||||
bestFinish: 1,
|
||||
worstFinish: 15,
|
||||
overallRank: 50,
|
||||
consistency: 85,
|
||||
});
|
||||
rankingUseCase.getAllDriverRankings.mockResolvedValue([
|
||||
{ driverId: 'driver-1', rating: 1500, wins: 5, totalRaces: 20, overallRank: 50 },
|
||||
{ driverId: 'driver-2', rating: 1400, wins: 3, totalRaces: 18, overallRank: 75 },
|
||||
]);
|
||||
teamRepository.findAll.mockResolvedValue([]);
|
||||
socialRepository.getFriends.mockResolvedValue([]);
|
||||
driverExtendedProfileProvider.getExtendedProfile.mockReturnValue({
|
||||
bio: 'Test bio',
|
||||
location: 'Test location',
|
||||
favoriteTrack: 'Test track',
|
||||
});
|
||||
|
||||
const result = await useCase.execute({ driverId } as GetProfileOverviewInput);
|
||||
const result = await useCase.execute({ driverId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBeUndefined();
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
const presented = (output.present as unknown as Mock).mock.calls[0]?.[0] as GetProfileOverviewResult;
|
||||
expect(presented.driverInfo.driver.id).toBe(driverId);
|
||||
expect(presented.extendedProfile).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error for non-existing driver', async () => {
|
||||
const driverId = 'driver-1';
|
||||
driverRepository.findById.mockResolvedValue(null);
|
||||
|
||||
const result = await useCase.execute({ driverId } as GetProfileOverviewInput);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr() as ApplicationErrorCode<
|
||||
GetProfileOverviewErrorCode,
|
||||
{ message: string }
|
||||
>;
|
||||
expect(error.code).toBe('DRIVER_NOT_FOUND');
|
||||
expect(error.details.message).toBe('Driver not found');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error on repository failure', async () => {
|
||||
const driverId = 'driver-1';
|
||||
driverRepository.findById.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const result = await useCase.execute({ driverId } as GetProfileOverviewInput);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr() as ApplicationErrorCode<
|
||||
GetProfileOverviewErrorCode,
|
||||
{ message: string }
|
||||
>;
|
||||
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||
expect(error.details.message).toBe('DB error');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(output.present).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'
|
||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
|
||||
import type { DriverExtendedProfileProvider } from '../ports/DriverExtendedProfileProvider';
|
||||
import type { IDriverStatsUseCase, DriverStats } from './IDriverStatsUseCase';
|
||||
import type { IRankingUseCase, DriverRanking } from './IRankingUseCase';
|
||||
import type { Driver } from '../../domain/entities/Driver';
|
||||
import type { Team } from '../../domain/entities/Team';
|
||||
import type { TeamMembership } from '../../domain/types/TeamMembership';
|
||||
@@ -10,26 +12,6 @@ import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
interface ProfileDriverStatsAdapter {
|
||||
rating: number | null;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
dnfs: number;
|
||||
totalRaces: number;
|
||||
avgFinish: number | null;
|
||||
bestFinish: number | null;
|
||||
worstFinish: number | null;
|
||||
overallRank: number | null;
|
||||
consistency: number | null;
|
||||
percentile: number | null;
|
||||
}
|
||||
|
||||
interface DriverRankingEntry {
|
||||
driverId: string;
|
||||
rating: number;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export type GetProfileOverviewInput = {
|
||||
driverId: string;
|
||||
};
|
||||
@@ -98,8 +80,8 @@ export class GetProfileOverviewUseCase {
|
||||
private readonly teamMembershipRepository: ITeamMembershipRepository,
|
||||
private readonly socialRepository: ISocialGraphRepository,
|
||||
private readonly driverExtendedProfileProvider: DriverExtendedProfileProvider,
|
||||
private readonly getDriverStats: (driverId: string) => ProfileDriverStatsAdapter | null,
|
||||
private readonly getAllDriverRankings: () => DriverRankingEntry[],
|
||||
private readonly driverStatsUseCase: IDriverStatsUseCase,
|
||||
private readonly rankingUseCase: IRankingUseCase,
|
||||
private readonly output: UseCaseOutputPort<GetProfileOverviewResult>,
|
||||
) {}
|
||||
|
||||
@@ -120,15 +102,15 @@ export class GetProfileOverviewUseCase {
|
||||
});
|
||||
}
|
||||
|
||||
const [statsAdapter, teams, friends] = await Promise.all([
|
||||
Promise.resolve(this.getDriverStats(driverId)),
|
||||
const [driverStats, teams, friends] = await Promise.all([
|
||||
this.driverStatsUseCase.getDriverStats(driverId),
|
||||
this.teamRepository.findAll(),
|
||||
this.socialRepository.getFriends(driverId),
|
||||
]);
|
||||
|
||||
const driverInfo = this.buildDriverInfo(driver, statsAdapter);
|
||||
const stats = this.buildStats(statsAdapter);
|
||||
const finishDistribution = this.buildFinishDistribution(statsAdapter);
|
||||
const driverInfo = await this.buildDriverInfo(driver, driverStats);
|
||||
const stats = this.buildStats(driverStats);
|
||||
const finishDistribution = this.buildFinishDistribution(driverStats);
|
||||
const teamMemberships = await this.buildTeamMemberships(driver.id, teams);
|
||||
const socialSummary = this.buildSocialSummary(friends);
|
||||
const extendedProfile =
|
||||
@@ -159,11 +141,11 @@ export class GetProfileOverviewUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
private buildDriverInfo(
|
||||
private async buildDriverInfo(
|
||||
driver: Driver,
|
||||
stats: ProfileDriverStatsAdapter | null,
|
||||
): ProfileOverviewDriverInfo {
|
||||
const rankings = this.getAllDriverRankings();
|
||||
stats: DriverStats | null,
|
||||
): Promise<ProfileOverviewDriverInfo> {
|
||||
const rankings = await this.rankingUseCase.getAllDriverRankings();
|
||||
const fallbackRank = this.computeFallbackRank(driver.id, rankings);
|
||||
const totalDrivers = rankings.length;
|
||||
|
||||
@@ -178,7 +160,7 @@ export class GetProfileOverviewUseCase {
|
||||
|
||||
private computeFallbackRank(
|
||||
driverId: string,
|
||||
rankings: DriverRankingEntry[],
|
||||
rankings: DriverRanking[],
|
||||
): number | null {
|
||||
const index = rankings.findIndex(entry => entry.driverId === driverId);
|
||||
if (index === -1) {
|
||||
@@ -188,7 +170,7 @@ export class GetProfileOverviewUseCase {
|
||||
}
|
||||
|
||||
private buildStats(
|
||||
stats: ProfileDriverStatsAdapter | null,
|
||||
stats: DriverStats | null,
|
||||
): ProfileOverviewStats | null {
|
||||
if (!stats) {
|
||||
return null;
|
||||
@@ -213,7 +195,7 @@ export class GetProfileOverviewUseCase {
|
||||
finishRate,
|
||||
winRate,
|
||||
podiumRate,
|
||||
percentile: stats.percentile,
|
||||
percentile: null, // Not available in new DriverStats
|
||||
rating: stats.rating,
|
||||
consistency: stats.consistency,
|
||||
overallRank: stats.overallRank,
|
||||
@@ -221,7 +203,7 @@ export class GetProfileOverviewUseCase {
|
||||
}
|
||||
|
||||
private buildFinishDistribution(
|
||||
stats: ProfileDriverStatsAdapter | null,
|
||||
stats: DriverStats | null,
|
||||
): ProfileOverviewFinishDistribution | null {
|
||||
if (!stats || stats.totalRaces <= 0) {
|
||||
return null;
|
||||
|
||||
@@ -45,9 +45,11 @@ export class GetRacesPageDataUseCase {
|
||||
allLeagues.map(league => [league.id.toString(), league.name.toString()]),
|
||||
);
|
||||
|
||||
const filteredRaces = allRaces
|
||||
.filter(race => race.leagueId === input.leagueId)
|
||||
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
|
||||
const filteredRaces = input.leagueId
|
||||
? allRaces.filter(race => race.leagueId === input.leagueId)
|
||||
: allRaces;
|
||||
|
||||
filteredRaces.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
|
||||
|
||||
const races: GetRacesPageRaceItem[] = filteredRaces.map(race => ({
|
||||
race,
|
||||
|
||||
26
core/racing/application/use-cases/IDriverStatsUseCase.ts
Normal file
26
core/racing/application/use-cases/IDriverStatsUseCase.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Application Use Case Interface: IDriverStatsUseCase
|
||||
*
|
||||
* Use case for computing detailed driver statistics from race results and standings.
|
||||
* This is an application layer concern that orchestrates domain data.
|
||||
*/
|
||||
|
||||
export interface DriverStats {
|
||||
rating: number;
|
||||
safetyRating: number;
|
||||
sportsmanshipRating: number;
|
||||
totalRaces: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
dnfs: number;
|
||||
avgFinish: number;
|
||||
bestFinish: number;
|
||||
worstFinish: number;
|
||||
consistency: number;
|
||||
experienceLevel: string;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface IDriverStatsUseCase {
|
||||
getDriverStats(driverId: string): Promise<DriverStats | null>;
|
||||
}
|
||||
18
core/racing/application/use-cases/IRankingUseCase.ts
Normal file
18
core/racing/application/use-cases/IRankingUseCase.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Application Use Case Interface: IRankingUseCase
|
||||
*
|
||||
* Use case for computing driver rankings from standings and results.
|
||||
* This is an application layer concern that orchestrates domain data.
|
||||
*/
|
||||
|
||||
export interface DriverRanking {
|
||||
driverId: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
totalRaces: number;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface IRankingUseCase {
|
||||
getAllDriverRankings(): Promise<DriverRanking[]>;
|
||||
}
|
||||
22
core/racing/application/use-cases/ITeamRankingUseCase.ts
Normal file
22
core/racing/application/use-cases/ITeamRankingUseCase.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Application Use Case Interface: ITeamRankingUseCase
|
||||
*
|
||||
* Use case for computing team rankings from rating snapshots.
|
||||
* This is an application layer concern that orchestrates domain data.
|
||||
*/
|
||||
|
||||
export interface TeamRanking {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
drivingRating: number;
|
||||
adminTrustRating: number;
|
||||
overallRating: number;
|
||||
eventCount: number;
|
||||
lastUpdated: Date;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface ITeamRankingUseCase {
|
||||
getAllTeamRankings(): Promise<TeamRanking[]>;
|
||||
getTeamRanking(teamId: string): Promise<TeamRanking | null>;
|
||||
}
|
||||
91
core/racing/application/use-cases/RankingUseCase.ts
Normal file
91
core/racing/application/use-cases/RankingUseCase.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Application Use Case: RankingUseCase
|
||||
*
|
||||
* Computes driver rankings from real standings and results data.
|
||||
* Orchestrates repositories to provide ranking data to presentation layer.
|
||||
*/
|
||||
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||
import type { IRankingUseCase, DriverRanking } from './IRankingUseCase';
|
||||
|
||||
export class RankingUseCase implements IRankingUseCase {
|
||||
constructor(
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.logger.info('[RankingUseCase] Initialized with real data repositories');
|
||||
}
|
||||
|
||||
async getAllDriverRankings(): Promise<DriverRanking[]> {
|
||||
this.logger.debug('[RankingUseCase] Computing rankings from standings');
|
||||
|
||||
try {
|
||||
// Get all standings from all leagues
|
||||
const standings = await this.standingRepository.findAll();
|
||||
|
||||
if (standings.length === 0) {
|
||||
this.logger.warn('[RankingUseCase] No standings found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get all drivers for name resolution
|
||||
const drivers = await this.driverRepository.findAll();
|
||||
const driverMap = new Map(drivers.map(d => [d.id, d]));
|
||||
|
||||
// Group standings by driver and aggregate stats
|
||||
const driverStats = new Map<string, {
|
||||
rating: number;
|
||||
wins: number;
|
||||
races: number;
|
||||
driverName?: string;
|
||||
}>();
|
||||
|
||||
for (const standing of standings) {
|
||||
const driverId = standing.driverId.toString();
|
||||
const existing = driverStats.get(driverId) || { rating: 0, wins: 0, races: 0 };
|
||||
|
||||
existing.races += standing.racesCompleted;
|
||||
existing.wins += standing.wins;
|
||||
|
||||
// Calculate rating from points and position
|
||||
const baseRating = 1000;
|
||||
const pointsBonus = standing.points.toNumber() * 2;
|
||||
const positionBonus = Math.max(0, 50 - (standing.position.toNumber() * 2));
|
||||
const winBonus = standing.wins * 100;
|
||||
|
||||
existing.rating = Math.round(baseRating + pointsBonus + positionBonus + winBonus);
|
||||
|
||||
// Add driver name if available
|
||||
const driver = driverMap.get(driverId);
|
||||
if (driver) {
|
||||
existing.driverName = driver.name.toString();
|
||||
}
|
||||
|
||||
driverStats.set(driverId, existing);
|
||||
}
|
||||
|
||||
// Convert to rankings
|
||||
const rankings: DriverRanking[] = Array.from(driverStats.entries()).map(([driverId, stats]) => ({
|
||||
driverId,
|
||||
rating: stats.rating,
|
||||
wins: stats.wins,
|
||||
totalRaces: stats.races,
|
||||
overallRank: null
|
||||
}));
|
||||
|
||||
// Sort by rating descending and assign ranks
|
||||
rankings.sort((a, b) => b.rating - a.rating);
|
||||
rankings.forEach((r, idx) => r.overallRank = idx + 1);
|
||||
|
||||
this.logger.info(`[RankingUseCase] Computed rankings for ${rankings.length} drivers`);
|
||||
|
||||
return rankings;
|
||||
} catch (error) {
|
||||
this.logger.error('[RankingUseCase] Error computing rankings:', error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { RecomputeTeamRatingSnapshotUseCase } from './RecomputeTeamRatingSnapshotUseCase';
|
||||
import { ITeamRatingEventRepository } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
|
||||
// Mock repositories
|
||||
class MockTeamRatingEventRepository implements ITeamRatingEventRepository {
|
||||
private events: TeamRatingEvent[] = [];
|
||||
|
||||
async save(event: TeamRatingEvent): Promise<TeamRatingEvent> {
|
||||
this.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findByIds(ids: TeamRatingEventId[]): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => ids.some(id => id.equals(e.id)));
|
||||
}
|
||||
|
||||
async getAllByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findEventsPaginated(teamId: string): Promise<any> {
|
||||
const events = await this.getAllByTeamId(teamId);
|
||||
return {
|
||||
items: events,
|
||||
total: events.length,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
setEvents(events: TeamRatingEvent[]) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.events = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockTeamRatingRepository implements ITeamRatingRepository {
|
||||
private snapshots: Map<string, any> = new Map();
|
||||
|
||||
async findByTeamId(teamId: string): Promise<any | null> {
|
||||
return this.snapshots.get(teamId) || null;
|
||||
}
|
||||
|
||||
async save(snapshot: any): Promise<any> {
|
||||
this.snapshots.set(snapshot.teamId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
getSnapshot(teamId: string) {
|
||||
return this.snapshots.get(teamId);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.snapshots.clear();
|
||||
}
|
||||
}
|
||||
|
||||
describe('RecomputeTeamRatingSnapshotUseCase', () => {
|
||||
let useCase: RecomputeTeamRatingSnapshotUseCase;
|
||||
let mockEventRepo: MockTeamRatingEventRepository;
|
||||
let mockRatingRepo: MockTeamRatingRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEventRepo = new MockTeamRatingEventRepository();
|
||||
mockRatingRepo = new MockTeamRatingRepository();
|
||||
useCase = new RecomputeTeamRatingSnapshotUseCase(mockEventRepo, mockRatingRepo);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockEventRepo.clear();
|
||||
mockRatingRepo.clear();
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should create snapshot with default values when no events exist', async () => {
|
||||
await useCase.execute('team-123');
|
||||
|
||||
const snapshot = mockRatingRepo.getSnapshot('team-123');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot.teamId).toBe('team-123');
|
||||
expect(snapshot.driving.value).toBe(50);
|
||||
expect(snapshot.adminTrust.value).toBe(50);
|
||||
expect(snapshot.overall).toBe(50);
|
||||
expect(snapshot.eventCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should recompute snapshot from single event', async () => {
|
||||
const event = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
mockEventRepo.setEvents([event]);
|
||||
|
||||
await useCase.execute('team-123');
|
||||
|
||||
const snapshot = mockRatingRepo.getSnapshot('team-123');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot.teamId).toBe('team-123');
|
||||
expect(snapshot.driving.value).toBe(60); // 50 + 10
|
||||
expect(snapshot.adminTrust.value).toBe(50); // Default
|
||||
expect(snapshot.overall).toBe(57); // 60 * 0.7 + 50 * 0.3 = 57
|
||||
expect(snapshot.eventCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should recompute snapshot from multiple events', async () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
weight: 1,
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
weight: 2,
|
||||
occurredAt: new Date('2024-01-01T11:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T11:00:00Z'),
|
||||
source: { type: 'race', id: 'race-457' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 2nd' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(3),
|
||||
weight: 1,
|
||||
occurredAt: new Date('2024-01-01T12:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T12:00:00Z'),
|
||||
source: { type: 'adminAction', id: 'action-789' },
|
||||
reason: { code: 'POSITIVE_ADMIN_ACTION', description: 'Helped organize event' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
mockEventRepo.setEvents(events);
|
||||
|
||||
await useCase.execute('team-123');
|
||||
|
||||
const snapshot = mockRatingRepo.getSnapshot('team-123');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot.teamId).toBe('team-123');
|
||||
|
||||
// Driving: weighted average of (10*1 + 5*2) / (1+2) = 20/3 = 6.67, so 50 + 6.67 = 56.67
|
||||
expect(snapshot.driving.value).toBeCloseTo(56.67, 1);
|
||||
|
||||
// AdminTrust: 50 + 3 = 53
|
||||
expect(snapshot.adminTrust.value).toBe(53);
|
||||
|
||||
// Overall: 56.67 * 0.7 + 53 * 0.3 = 39.67 + 15.9 = 55.57 ≈ 55.6
|
||||
expect(snapshot.overall).toBeCloseTo(55.6, 1);
|
||||
|
||||
expect(snapshot.eventCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle events with different dimensions correctly', async () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-456',
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(15),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'adminAction', id: 'action-123' },
|
||||
reason: { code: 'EXCELLENT_ADMIN', description: 'Great leadership' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
mockEventRepo.setEvents(events);
|
||||
|
||||
await useCase.execute('team-456');
|
||||
|
||||
const snapshot = mockRatingRepo.getSnapshot('team-456');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot.adminTrust.value).toBe(65); // 50 + 15
|
||||
expect(snapshot.driving.value).toBe(50); // Default
|
||||
expect(snapshot.overall).toBe(54.5); // 50 * 0.7 + 65 * 0.3 = 54.5
|
||||
});
|
||||
|
||||
it('should overwrite existing snapshot with recomputed values', async () => {
|
||||
// First, create an initial snapshot
|
||||
const initialEvent = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
mockEventRepo.setEvents([initialEvent]);
|
||||
await useCase.execute('team-123');
|
||||
|
||||
let snapshot = mockRatingRepo.getSnapshot('team-123');
|
||||
expect(snapshot.driving.value).toBe(55);
|
||||
|
||||
// Now add more events and recompute
|
||||
const additionalEvent = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T11:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T11:00:00Z'),
|
||||
source: { type: 'race', id: 'race-457' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 2nd' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
mockEventRepo.setEvents([initialEvent, additionalEvent]);
|
||||
await useCase.execute('team-123');
|
||||
|
||||
snapshot = mockRatingRepo.getSnapshot('team-123');
|
||||
expect(snapshot.driving.value).toBe(57.5); // Weighted average: (5 + 10) / 2 = 7.5, so 50 + 7.5 = 57.5
|
||||
expect(snapshot.eventCount).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ITeamRatingEventRepository } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import type { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import { TeamRatingSnapshotCalculator } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
|
||||
/**
|
||||
* Use Case: RecomputeTeamRatingSnapshotUseCase
|
||||
*
|
||||
* Recalculates a team's rating snapshot from all events in the ledger.
|
||||
* Used for data migration, correction of calculation logic, or audit purposes.
|
||||
* Mirrors the RecomputeUserRatingSnapshotUseCase pattern.
|
||||
*/
|
||||
export class RecomputeTeamRatingSnapshotUseCase {
|
||||
constructor(
|
||||
private readonly ratingEventRepository: ITeamRatingEventRepository,
|
||||
private readonly ratingRepository: ITeamRatingRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the use case for a specific team
|
||||
*
|
||||
* @param teamId - The team ID to recompute
|
||||
* @returns The recomputed snapshot
|
||||
*/
|
||||
async execute(teamId: string): Promise<void> {
|
||||
// Get all events for the team
|
||||
const events = await this.ratingEventRepository.getAllByTeamId(teamId);
|
||||
|
||||
// Calculate snapshot from all events
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate(teamId, events);
|
||||
|
||||
// Save the recomputed snapshot
|
||||
await this.ratingRepository.save(snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the use case for all teams
|
||||
*/
|
||||
async executeForAllTeams(): Promise<void> {
|
||||
// Get all unique team IDs from events
|
||||
// This would typically query for all distinct teamIds in the events table
|
||||
// For now, we'll use a simpler approach - recompute for teams that have snapshots
|
||||
// In a real implementation, you might have a separate method to get all team IDs
|
||||
|
||||
// Note: This is a simplified implementation
|
||||
// In production, you'd want to batch this and handle errors per team
|
||||
throw new Error('executeForAllTeams not implemented - needs team ID discovery');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { RecordTeamRaceRatingEventsUseCase } from './RecordTeamRaceRatingEventsUseCase';
|
||||
import { ITeamRaceResultsProvider } from '../ports/ITeamRaceResultsProvider';
|
||||
import { ITeamRatingEventRepository } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import { AppendTeamRatingEventsUseCase } from './AppendTeamRatingEventsUseCase';
|
||||
import { TeamDrivingRaceFactsDto } from '@core/racing/domain/services/TeamDrivingRatingEventFactory';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
|
||||
// Mock repositories
|
||||
class MockTeamRaceResultsProvider implements ITeamRaceResultsProvider {
|
||||
private results: TeamDrivingRaceFactsDto | null = null;
|
||||
|
||||
async getTeamRaceResults(raceId: string): Promise<TeamDrivingRaceFactsDto | null> {
|
||||
return this.results;
|
||||
}
|
||||
|
||||
setResults(results: TeamDrivingRaceFactsDto | null) {
|
||||
this.results = results;
|
||||
}
|
||||
}
|
||||
|
||||
class MockTeamRatingEventRepository implements ITeamRatingEventRepository {
|
||||
private events: TeamRatingEvent[] = [];
|
||||
|
||||
async save(event: TeamRatingEvent): Promise<TeamRatingEvent> {
|
||||
this.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findByIds(ids: TeamRatingEventId[]): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => ids.some(id => id.equals(e.id)));
|
||||
}
|
||||
|
||||
async getAllByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findEventsPaginated(teamId: string): Promise<any> {
|
||||
const events = await this.getAllByTeamId(teamId);
|
||||
return {
|
||||
items: events,
|
||||
total: events.length,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.events = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockTeamRatingRepository implements ITeamRatingRepository {
|
||||
private snapshots: Map<string, any> = new Map();
|
||||
|
||||
async findByTeamId(teamId: string): Promise<any | null> {
|
||||
return this.snapshots.get(teamId) || null;
|
||||
}
|
||||
|
||||
async save(snapshot: any): Promise<any> {
|
||||
this.snapshots.set(snapshot.teamId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.snapshots.clear();
|
||||
}
|
||||
}
|
||||
|
||||
describe('RecordTeamRaceRatingEventsUseCase', () => {
|
||||
let useCase: RecordTeamRaceRatingEventsUseCase;
|
||||
let mockResultsProvider: MockTeamRaceResultsProvider;
|
||||
let mockEventRepo: MockTeamRatingEventRepository;
|
||||
let mockRatingRepo: MockTeamRatingRepository;
|
||||
let appendUseCase: AppendTeamRatingEventsUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResultsProvider = new MockTeamRaceResultsProvider();
|
||||
mockEventRepo = new MockTeamRatingEventRepository();
|
||||
mockRatingRepo = new MockTeamRatingRepository();
|
||||
appendUseCase = new AppendTeamRatingEventsUseCase(mockEventRepo, mockRatingRepo);
|
||||
useCase = new RecordTeamRaceRatingEventsUseCase(
|
||||
mockResultsProvider,
|
||||
mockEventRepo,
|
||||
mockRatingRepo,
|
||||
appendUseCase
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockEventRepo.clear();
|
||||
mockRatingRepo.clear();
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should return error when race results not found', async () => {
|
||||
mockResultsProvider.setResults(null);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Team race results not found');
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.teamsUpdated).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return success with no events when results are empty', async () => {
|
||||
mockResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.teamsUpdated).toEqual([]);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should create events for single team and update snapshot', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(0);
|
||||
expect(result.teamsUpdated).toContain('team-123');
|
||||
expect(result.errors).toEqual([]);
|
||||
|
||||
// Verify events were saved
|
||||
const savedEvents = await mockEventRepo.getAllByTeamId('team-123');
|
||||
expect(savedEvents.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify snapshot was updated
|
||||
const snapshot = await mockRatingRepo.findByTeamId('team-123');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot.teamId).toBe('team-123');
|
||||
});
|
||||
|
||||
it('should create events for multiple teams', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-789',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'dnf',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(0);
|
||||
expect(result.teamsUpdated).toContain('team-123');
|
||||
expect(result.teamsUpdated).toContain('team-456');
|
||||
expect(result.teamsUpdated).toContain('team-789');
|
||||
expect(result.errors).toEqual([]);
|
||||
|
||||
// Verify all team snapshots were updated
|
||||
const snapshot1 = await mockRatingRepo.findByTeamId('team-123');
|
||||
const snapshot2 = await mockRatingRepo.findByTeamId('team-456');
|
||||
const snapshot3 = await mockRatingRepo.findByTeamId('team-789');
|
||||
|
||||
expect(snapshot1).toBeDefined();
|
||||
expect(snapshot2).toBeDefined();
|
||||
expect(snapshot3).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle optional ratings in results', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 65,
|
||||
pace: 85,
|
||||
consistency: 80,
|
||||
teamwork: 90,
|
||||
sportsmanship: 95,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(5); // Should have many events
|
||||
expect(result.teamsUpdated).toContain('team-123');
|
||||
|
||||
// Verify events include optional rating events
|
||||
const savedEvents = await mockEventRepo.getAllByTeamId('team-123');
|
||||
const paceEvent = savedEvents.find(e => e.reason.code === 'RACE_PACE');
|
||||
const consistencyEvent = savedEvents.find(e => e.reason.code === 'RACE_CONSISTENCY');
|
||||
const teamworkEvent = savedEvents.find(e => e.reason.code === 'RACE_TEAMWORK');
|
||||
const sportsmanshipEvent = savedEvents.find(e => e.reason.code === 'RACE_SPORTSMANSHIP');
|
||||
|
||||
expect(paceEvent).toBeDefined();
|
||||
expect(consistencyEvent).toBeDefined();
|
||||
expect(teamworkEvent).toBeDefined();
|
||||
expect(sportsmanshipEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle partial failures gracefully', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 2,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 2,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
// Mock the append use case to fail for team-456
|
||||
const originalExecute = appendUseCase.execute.bind(appendUseCase);
|
||||
appendUseCase.execute = async (events) => {
|
||||
if (events.length > 0 && events[0] && events[0].teamId === 'team-456') {
|
||||
throw new Error('Simulated failure for team-456');
|
||||
}
|
||||
return originalExecute(events);
|
||||
};
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.teamsUpdated).toContain('team-123');
|
||||
expect(result.teamsUpdated).not.toContain('team-456');
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0]).toContain('team-456');
|
||||
});
|
||||
|
||||
it('should handle repository errors', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
// Mock repository to throw error
|
||||
const originalSave = mockEventRepo.save.bind(mockEventRepo);
|
||||
mockEventRepo.save = async () => {
|
||||
throw new Error('Repository error');
|
||||
};
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0]).toContain('Repository error');
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.teamsUpdated).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty results array', async () => {
|
||||
mockResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.teamsUpdated).toEqual([]);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle race with minimal events generated', async () => {
|
||||
// Race where teams have some impact (DNS creates penalty event)
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(0); // DNS creates penalty event
|
||||
expect(result.teamsUpdated).toContain('team-123');
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ITeamRaceResultsProvider } from '../ports/ITeamRaceResultsProvider';
|
||||
import { TeamDrivingRatingEventFactory } from '@core/racing/domain/services/TeamDrivingRatingEventFactory';
|
||||
import { AppendTeamRatingEventsUseCase } from './AppendTeamRatingEventsUseCase';
|
||||
import { RecordTeamRaceRatingEventsInput, RecordTeamRaceRatingEventsOutput } from '../dtos/RecordTeamRaceRatingEventsDto';
|
||||
|
||||
/**
|
||||
* Use Case: RecordTeamRaceRatingEventsUseCase
|
||||
*
|
||||
* Records rating events for a completed team race.
|
||||
* Mirrors user slice 3 pattern in core/racing/.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Load team race results from racing context
|
||||
* 2. Factory creates team rating events
|
||||
* 3. Append to ledger via AppendTeamRatingEventsUseCase
|
||||
* 4. Recompute snapshots
|
||||
*/
|
||||
export class RecordTeamRaceRatingEventsUseCase {
|
||||
constructor(
|
||||
private readonly teamRaceResultsProvider: ITeamRaceResultsProvider,
|
||||
private readonly appendTeamRatingEventsUseCase: AppendTeamRatingEventsUseCase,
|
||||
) {}
|
||||
|
||||
async execute(input: RecordTeamRaceRatingEventsInput): Promise<RecordTeamRaceRatingEventsOutput> {
|
||||
const errors: string[] = [];
|
||||
const teamsUpdated: string[] = [];
|
||||
let totalEventsCreated = 0;
|
||||
|
||||
try {
|
||||
// 1. Load team race results
|
||||
const teamRaceResults = await this.teamRaceResultsProvider.getTeamRaceResults(input.raceId);
|
||||
|
||||
if (!teamRaceResults) {
|
||||
return {
|
||||
success: false,
|
||||
raceId: input.raceId,
|
||||
eventsCreated: 0,
|
||||
teamsUpdated: [],
|
||||
errors: ['Team race results not found'],
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Create rating events using factory
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(teamRaceResults);
|
||||
|
||||
if (eventsByTeam.size === 0) {
|
||||
return {
|
||||
success: true,
|
||||
raceId: input.raceId,
|
||||
eventsCreated: 0,
|
||||
teamsUpdated: [],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Process each team's events
|
||||
for (const [teamId, events] of eventsByTeam) {
|
||||
try {
|
||||
// Use AppendTeamRatingEventsUseCase to handle ledger and snapshot
|
||||
await this.appendTeamRatingEventsUseCase.execute(events);
|
||||
|
||||
teamsUpdated.push(teamId);
|
||||
totalEventsCreated += events.length;
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to process events for team ${teamId}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
raceId: input.raceId,
|
||||
eventsCreated: totalEventsCreated,
|
||||
teamsUpdated,
|
||||
errors,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to record team race rating events: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errors.push(errorMsg);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
raceId: input.raceId,
|
||||
eventsCreated: 0,
|
||||
teamsUpdated: [],
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
418
core/racing/application/use-cases/TeamRankingUseCase.test.ts
Normal file
418
core/racing/application/use-cases/TeamRankingUseCase.test.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { TeamRankingUseCase } from './TeamRankingUseCase';
|
||||
import type { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { TeamRatingSnapshot } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
import { TeamRatingValue } from '@core/racing/domain/value-objects/TeamRatingValue';
|
||||
import { Team } from '@core/racing/domain/entities/Team';
|
||||
|
||||
// Mock repositories
|
||||
class MockTeamRatingRepository implements ITeamRatingRepository {
|
||||
private snapshots: Map<string, TeamRatingSnapshot> = new Map();
|
||||
|
||||
async findByTeamId(teamId: string): Promise<TeamRatingSnapshot | null> {
|
||||
return this.snapshots.get(teamId) || null;
|
||||
}
|
||||
|
||||
async save(snapshot: TeamRatingSnapshot): Promise<TeamRatingSnapshot> {
|
||||
this.snapshots.set(snapshot.teamId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.snapshots.clear();
|
||||
}
|
||||
|
||||
setSnapshot(teamId: string, snapshot: TeamRatingSnapshot) {
|
||||
this.snapshots.set(teamId, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
class MockTeamRepository implements ITeamRepository {
|
||||
private teams: Map<string, Team> = new Map();
|
||||
|
||||
async findById(id: string): Promise<Team | null> {
|
||||
return this.teams.get(id) || null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Team[]> {
|
||||
return Array.from(this.teams.values());
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Team[]> {
|
||||
return Array.from(this.teams.values()).filter(t =>
|
||||
t.leagues.some(l => l.toString() === leagueId)
|
||||
);
|
||||
}
|
||||
|
||||
async create(team: Team): Promise<Team> {
|
||||
this.teams.set(team.id, team);
|
||||
return team;
|
||||
}
|
||||
|
||||
async update(team: Team): Promise<Team> {
|
||||
this.teams.set(team.id, team);
|
||||
return team;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.teams.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.teams.has(id);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.teams.clear();
|
||||
}
|
||||
|
||||
setTeam(team: Team) {
|
||||
this.teams.set(team.id, team);
|
||||
}
|
||||
}
|
||||
|
||||
// Mock logger
|
||||
const mockLogger: Logger = {
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
};
|
||||
|
||||
describe('TeamRankingUseCase', () => {
|
||||
let useCase: TeamRankingUseCase;
|
||||
let mockRatingRepo: MockTeamRatingRepository;
|
||||
let mockTeamRepo: MockTeamRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRatingRepo = new MockTeamRatingRepository();
|
||||
mockTeamRepo = new MockTeamRepository();
|
||||
useCase = new TeamRankingUseCase(mockRatingRepo, mockTeamRepo, mockLogger);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockRatingRepo.clear();
|
||||
mockTeamRepo.clear();
|
||||
});
|
||||
|
||||
describe('getAllTeamRankings', () => {
|
||||
it('should return empty array when no teams exist', async () => {
|
||||
const result = await useCase.getAllTeamRankings();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when no rating snapshots exist', async () => {
|
||||
const team = Team.create({
|
||||
id: 'team-123',
|
||||
name: 'Test Team',
|
||||
tag: 'TT',
|
||||
description: 'A test team',
|
||||
ownerId: 'driver-123',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team);
|
||||
|
||||
const result = await useCase.getAllTeamRankings();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return single team ranking', async () => {
|
||||
const teamId = 'team-123';
|
||||
const team = Team.create({
|
||||
id: teamId,
|
||||
name: 'Test Team',
|
||||
tag: 'TT',
|
||||
description: 'A test team',
|
||||
ownerId: 'driver-123',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team);
|
||||
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(75),
|
||||
adminTrust: TeamRatingValue.create(80),
|
||||
overall: 76.5,
|
||||
lastUpdated: new Date('2024-01-01'),
|
||||
eventCount: 5,
|
||||
};
|
||||
mockRatingRepo.setSnapshot(teamId, snapshot);
|
||||
|
||||
const result = await useCase.getAllTeamRankings();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
teamId,
|
||||
teamName: 'Test Team',
|
||||
drivingRating: 75,
|
||||
adminTrustRating: 80,
|
||||
overallRating: 76.5,
|
||||
eventCount: 5,
|
||||
lastUpdated: new Date('2024-01-01'),
|
||||
overallRank: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return multiple teams sorted by overall rating', async () => {
|
||||
// Team 1
|
||||
const team1 = Team.create({
|
||||
id: 'team-1',
|
||||
name: 'Team Alpha',
|
||||
tag: 'TA',
|
||||
description: 'Alpha team',
|
||||
ownerId: 'driver-1',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team1);
|
||||
mockRatingRepo.setSnapshot('team-1', {
|
||||
teamId: 'team-1',
|
||||
driving: TeamRatingValue.create(80),
|
||||
adminTrust: TeamRatingValue.create(70),
|
||||
overall: 77,
|
||||
lastUpdated: new Date('2024-01-01'),
|
||||
eventCount: 10,
|
||||
});
|
||||
|
||||
// Team 2
|
||||
const team2 = Team.create({
|
||||
id: 'team-2',
|
||||
name: 'Team Beta',
|
||||
tag: 'TB',
|
||||
description: 'Beta team',
|
||||
ownerId: 'driver-2',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team2);
|
||||
mockRatingRepo.setSnapshot('team-2', {
|
||||
teamId: 'team-2',
|
||||
driving: TeamRatingValue.create(90),
|
||||
adminTrust: TeamRatingValue.create(85),
|
||||
overall: 88,
|
||||
lastUpdated: new Date('2024-01-02'),
|
||||
eventCount: 15,
|
||||
});
|
||||
|
||||
// Team 3
|
||||
const team3 = Team.create({
|
||||
id: 'team-3',
|
||||
name: 'Team Gamma',
|
||||
tag: 'TG',
|
||||
description: 'Gamma team',
|
||||
ownerId: 'driver-3',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team3);
|
||||
mockRatingRepo.setSnapshot('team-3', {
|
||||
teamId: 'team-3',
|
||||
driving: TeamRatingValue.create(60),
|
||||
adminTrust: TeamRatingValue.create(65),
|
||||
overall: 61.5,
|
||||
lastUpdated: new Date('2024-01-03'),
|
||||
eventCount: 3,
|
||||
});
|
||||
|
||||
const result = await useCase.getAllTeamRankings();
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
// Should be sorted by overall rating descending
|
||||
expect(result[0]).toBeDefined();
|
||||
expect(result[0]!.teamId).toBe('team-2');
|
||||
expect(result[0]!.overallRank).toBe(1);
|
||||
expect(result[0]!.overallRating).toBe(88);
|
||||
|
||||
expect(result[1]).toBeDefined();
|
||||
expect(result[1]!.teamId).toBe('team-1');
|
||||
expect(result[1]!.overallRank).toBe(2);
|
||||
expect(result[1]!.overallRating).toBe(77);
|
||||
|
||||
expect(result[2]).toBeDefined();
|
||||
expect(result[2]!.teamId).toBe('team-3');
|
||||
expect(result[2]!.overallRank).toBe(3);
|
||||
expect(result[2]!.overallRating).toBe(61.5);
|
||||
});
|
||||
|
||||
it('should handle teams without snapshots gracefully', async () => {
|
||||
// Team with snapshot
|
||||
const team1 = Team.create({
|
||||
id: 'team-1',
|
||||
name: 'Team With Rating',
|
||||
tag: 'TWR',
|
||||
description: 'Has rating',
|
||||
ownerId: 'driver-1',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team1);
|
||||
mockRatingRepo.setSnapshot('team-1', {
|
||||
teamId: 'team-1',
|
||||
driving: TeamRatingValue.create(70),
|
||||
adminTrust: TeamRatingValue.create(70),
|
||||
overall: 70,
|
||||
lastUpdated: new Date('2024-01-01'),
|
||||
eventCount: 5,
|
||||
});
|
||||
|
||||
// Team without snapshot
|
||||
const team2 = Team.create({
|
||||
id: 'team-2',
|
||||
name: 'Team Without Rating',
|
||||
tag: 'TWR',
|
||||
description: 'No rating',
|
||||
ownerId: 'driver-2',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team2);
|
||||
|
||||
const result = await useCase.getAllTeamRankings();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeDefined();
|
||||
expect(result[0]!.teamId).toBe('team-1');
|
||||
expect(result[0]!.overallRank).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamRanking', () => {
|
||||
it('should return null when team does not exist', async () => {
|
||||
const result = await useCase.getTeamRanking('non-existent-team');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when team exists but has no snapshot', async () => {
|
||||
const team = Team.create({
|
||||
id: 'team-123',
|
||||
name: 'Test Team',
|
||||
tag: 'TT',
|
||||
description: 'A test team',
|
||||
ownerId: 'driver-123',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team);
|
||||
|
||||
const result = await useCase.getTeamRanking('team-123');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return team ranking with correct rank', async () => {
|
||||
// Setup multiple teams
|
||||
const teams = [
|
||||
{ id: 'team-1', name: 'Team A', rating: 85 },
|
||||
{ id: 'team-2', name: 'Team B', rating: 90 },
|
||||
{ id: 'team-3', name: 'Team C', rating: 75 },
|
||||
];
|
||||
|
||||
for (const t of teams) {
|
||||
const team = Team.create({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tag: t.name.substring(0, 2).toUpperCase(),
|
||||
description: `${t.name} description`,
|
||||
ownerId: `driver-${t.id}`,
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team);
|
||||
mockRatingRepo.setSnapshot(t.id, {
|
||||
teamId: t.id,
|
||||
driving: TeamRatingValue.create(t.rating),
|
||||
adminTrust: TeamRatingValue.create(t.rating),
|
||||
overall: t.rating,
|
||||
lastUpdated: new Date('2024-01-01'),
|
||||
eventCount: 5,
|
||||
});
|
||||
}
|
||||
|
||||
// Get ranking for team-2 (should be rank 1 with rating 90)
|
||||
const result = await useCase.getTeamRanking('team-2');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.teamId).toBe('team-2');
|
||||
expect(result?.teamName).toBe('Team B');
|
||||
expect(result?.overallRating).toBe(90);
|
||||
expect(result?.overallRank).toBe(1);
|
||||
});
|
||||
|
||||
it('should calculate correct rank for middle team', async () => {
|
||||
// Setup teams
|
||||
const teams = [
|
||||
{ id: 'team-1', name: 'Team A', rating: 90 },
|
||||
{ id: 'team-2', name: 'Team B', rating: 80 },
|
||||
{ id: 'team-3', name: 'Team C', rating: 70 },
|
||||
];
|
||||
|
||||
for (const t of teams) {
|
||||
const team = Team.create({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tag: t.name.substring(0, 2).toUpperCase(),
|
||||
description: `${t.name} description`,
|
||||
ownerId: `driver-${t.id}`,
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team);
|
||||
mockRatingRepo.setSnapshot(t.id, {
|
||||
teamId: t.id,
|
||||
driving: TeamRatingValue.create(t.rating),
|
||||
adminTrust: TeamRatingValue.create(t.rating),
|
||||
overall: t.rating,
|
||||
lastUpdated: new Date('2024-01-01'),
|
||||
eventCount: 5,
|
||||
});
|
||||
}
|
||||
|
||||
// Get ranking for team-2 (should be rank 2)
|
||||
const result = await useCase.getTeamRanking('team-2');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.overallRank).toBe(2);
|
||||
});
|
||||
|
||||
it('should return complete team ranking data', async () => {
|
||||
const teamId = 'team-123';
|
||||
const team = Team.create({
|
||||
id: teamId,
|
||||
name: 'Complete Team',
|
||||
tag: 'CT',
|
||||
description: 'Complete team description',
|
||||
ownerId: 'driver-123',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team);
|
||||
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(82),
|
||||
adminTrust: TeamRatingValue.create(78),
|
||||
overall: 80.8,
|
||||
lastUpdated: new Date('2024-01-15T10:30:00Z'),
|
||||
eventCount: 25,
|
||||
};
|
||||
mockRatingRepo.setSnapshot(teamId, snapshot);
|
||||
|
||||
const result = await useCase.getTeamRanking(teamId);
|
||||
|
||||
expect(result).toEqual({
|
||||
teamId,
|
||||
teamName: 'Complete Team',
|
||||
drivingRating: 82,
|
||||
adminTrustRating: 78,
|
||||
overallRating: 80.8,
|
||||
eventCount: 25,
|
||||
lastUpdated: new Date('2024-01-15T10:30:00Z'),
|
||||
overallRank: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle repository errors gracefully', async () => {
|
||||
// Mock repository to throw error
|
||||
const originalFindAll = mockTeamRepo.findAll.bind(mockTeamRepo);
|
||||
mockTeamRepo.findAll = async () => {
|
||||
throw new Error('Repository connection failed');
|
||||
};
|
||||
|
||||
await expect(useCase.getAllTeamRankings()).rejects.toThrow('Repository connection failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
139
core/racing/application/use-cases/TeamRankingUseCase.ts
Normal file
139
core/racing/application/use-cases/TeamRankingUseCase.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Application Use Case: TeamRankingUseCase
|
||||
*
|
||||
* Computes team rankings from rating snapshots (ledger-based).
|
||||
* Orchestrates repositories to provide team ranking data to presentation layer.
|
||||
* Evolved from direct standings to use team rating events and snapshots.
|
||||
*/
|
||||
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { ITeamRatingRepository } from '../../domain/repositories/ITeamRatingRepository';
|
||||
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||
import type { ITeamRankingUseCase, TeamRanking } from './ITeamRankingUseCase';
|
||||
|
||||
export class TeamRankingUseCase implements ITeamRankingUseCase {
|
||||
constructor(
|
||||
private readonly teamRatingRepository: ITeamRatingRepository,
|
||||
private readonly teamRepository: ITeamRepository,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.logger.info('[TeamRankingUseCase] Initialized with ledger-based team rating repositories');
|
||||
}
|
||||
|
||||
async getAllTeamRankings(): Promise<TeamRanking[]> {
|
||||
this.logger.debug('[TeamRankingUseCase] Computing rankings from team rating snapshots');
|
||||
|
||||
try {
|
||||
// Get all teams for name resolution
|
||||
const teams = await this.teamRepository.findAll();
|
||||
const teamMap = new Map(teams.map(t => [t.id, t]));
|
||||
|
||||
// Get all team IDs
|
||||
const teamIds = Array.from(teamMap.keys());
|
||||
|
||||
if (teamIds.length === 0) {
|
||||
this.logger.warn('[TeamRankingUseCase] No teams found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get rating snapshots for all teams
|
||||
const rankingPromises = teamIds.map(async (teamId) => {
|
||||
const snapshot = await this.teamRatingRepository.findByTeamId(teamId);
|
||||
const team = teamMap.get(teamId);
|
||||
|
||||
if (!snapshot || !team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
teamId,
|
||||
teamName: team.name.toString(),
|
||||
drivingRating: snapshot.driving.value,
|
||||
adminTrustRating: snapshot.adminTrust.value,
|
||||
overallRating: snapshot.overall,
|
||||
eventCount: snapshot.eventCount,
|
||||
lastUpdated: snapshot.lastUpdated,
|
||||
overallRank: null, // Will be assigned after sorting
|
||||
} as TeamRanking;
|
||||
});
|
||||
|
||||
const rankings = (await Promise.all(rankingPromises)).filter(
|
||||
(r): r is TeamRanking => r !== null
|
||||
);
|
||||
|
||||
if (rankings.length === 0) {
|
||||
this.logger.warn('[TeamRankingUseCase] No team rating snapshots found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Sort by overall rating descending and assign ranks
|
||||
rankings.sort((a, b) => b.overallRating - a.overallRating);
|
||||
rankings.forEach((r, idx) => r.overallRank = idx + 1);
|
||||
|
||||
this.logger.info(`[TeamRankingUseCase] Computed rankings for ${rankings.length} teams`);
|
||||
|
||||
return rankings;
|
||||
} catch (error) {
|
||||
this.logger.error('[TeamRankingUseCase] Error computing rankings:', error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getTeamRanking(teamId: string): Promise<TeamRanking | null> {
|
||||
this.logger.debug(`[TeamRankingUseCase] Getting ranking for team ${teamId}`);
|
||||
|
||||
try {
|
||||
const snapshot = await this.teamRatingRepository.findByTeamId(teamId);
|
||||
|
||||
if (!snapshot) {
|
||||
this.logger.warn(`[TeamRankingUseCase] No rating snapshot found for team ${teamId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const team = await this.teamRepository.findById(teamId);
|
||||
|
||||
if (!team) {
|
||||
this.logger.warn(`[TeamRankingUseCase] Team ${teamId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all teams to calculate rank
|
||||
const allTeams = await this.teamRepository.findAll();
|
||||
const allRankings: TeamRanking[] = [];
|
||||
|
||||
for (const t of allTeams) {
|
||||
const s = await this.teamRatingRepository.findByTeamId(t.id);
|
||||
if (s) {
|
||||
allRankings.push({
|
||||
teamId: t.id,
|
||||
teamName: t.name.toString(),
|
||||
drivingRating: s.driving.value,
|
||||
adminTrustRating: s.adminTrust.value,
|
||||
overallRating: s.overall,
|
||||
eventCount: s.eventCount,
|
||||
lastUpdated: s.lastUpdated,
|
||||
overallRank: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort and assign rank
|
||||
allRankings.sort((a, b) => b.overallRating - a.overallRating);
|
||||
const rank = allRankings.findIndex(r => r.teamId === teamId) + 1;
|
||||
|
||||
return {
|
||||
teamId,
|
||||
teamName: team.name.toString(),
|
||||
drivingRating: snapshot.driving.value,
|
||||
adminTrustRating: snapshot.adminTrust.value,
|
||||
overallRating: snapshot.overall,
|
||||
eventCount: snapshot.eventCount,
|
||||
lastUpdated: snapshot.lastUpdated,
|
||||
overallRank: rank,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`[TeamRankingUseCase] Error getting team ranking:`, error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { TeamRatingFactoryUseCase } from './TeamRatingFactoryUseCase';
|
||||
import type { ITeamRaceResultsProvider } from '../ports/ITeamRaceResultsProvider';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { TeamDrivingRaceFactsDto } from '@core/racing/domain/services/TeamDrivingRatingEventFactory';
|
||||
|
||||
// Mock provider
|
||||
class MockTeamRaceResultsProvider implements ITeamRaceResultsProvider {
|
||||
private results: TeamDrivingRaceFactsDto | null = null;
|
||||
|
||||
async getTeamRaceResults(raceId: string): Promise<TeamDrivingRaceFactsDto | null> {
|
||||
return this.results;
|
||||
}
|
||||
|
||||
setResults(results: TeamDrivingRaceFactsDto | null) {
|
||||
this.results = results;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock logger
|
||||
const mockLogger: Logger = {
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
};
|
||||
|
||||
describe('TeamRatingFactoryUseCase', () => {
|
||||
let useCase: TeamRatingFactoryUseCase;
|
||||
let mockResultsProvider: MockTeamRaceResultsProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResultsProvider = new MockTeamRaceResultsProvider();
|
||||
useCase = new TeamRatingFactoryUseCase(mockResultsProvider, mockLogger);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should return error when race results not found', async () => {
|
||||
mockResultsProvider.setResults(null);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Team race results not found');
|
||||
expect(result.events).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return success with no events when results are empty', async () => {
|
||||
mockResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should create events for single team', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.events.length).toBeGreaterThan(0);
|
||||
expect(result.errors).toEqual([]);
|
||||
|
||||
// Verify events have correct structure
|
||||
const event = result.events[0];
|
||||
expect(event.teamId).toBe('team-123');
|
||||
expect(event.source.type).toBe('race');
|
||||
expect(event.source.id).toBe('race-123');
|
||||
});
|
||||
|
||||
it('should create events for multiple teams', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.events.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have events for both teams
|
||||
const team123Events = result.events.filter(e => e.teamId === 'team-123');
|
||||
const team456Events = result.events.filter(e => e.teamId === 'team-456');
|
||||
|
||||
expect(team123Events.length).toBeGreaterThan(0);
|
||||
expect(team456Events.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle optional ratings in results', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 65,
|
||||
pace: 85,
|
||||
consistency: 80,
|
||||
teamwork: 90,
|
||||
sportsmanship: 95,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.events.length).toBeGreaterThan(5); // Should have many events
|
||||
|
||||
// Verify events include optional rating events
|
||||
const reasonCodes = result.events.map(e => e.reason.code);
|
||||
expect(reasonCodes).toContain('RACE_PACE');
|
||||
expect(reasonCodes).toContain('RACE_CONSISTENCY');
|
||||
expect(reasonCodes).toContain('RACE_TEAMWORK');
|
||||
expect(reasonCodes).toContain('RACE_SPORTSMANSHIP');
|
||||
});
|
||||
|
||||
it('should handle repository errors', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
// Mock provider to throw error
|
||||
mockResultsProvider.getTeamRaceResults = async () => {
|
||||
throw new Error('Provider error');
|
||||
};
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0]).toContain('Provider error');
|
||||
expect(result.events).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle race with minimal events generated', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.events.length).toBeGreaterThan(0); // DNS creates penalty event
|
||||
});
|
||||
});
|
||||
|
||||
describe('createManualEvents', () => {
|
||||
it('should create manual events with source ID', () => {
|
||||
const events = useCase.createManualEvents(
|
||||
'team-123',
|
||||
'driving',
|
||||
5,
|
||||
'MANUAL_ADJUSTMENT',
|
||||
'manualAdjustment',
|
||||
'adjustment-123'
|
||||
);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].teamId).toBe('team-123');
|
||||
expect(events[0].dimension.value).toBe('driving');
|
||||
expect(events[0].delta.value).toBe(5);
|
||||
expect(events[0].reason.code).toBe('MANUAL_ADJUSTMENT');
|
||||
expect(events[0].source.type).toBe('manualAdjustment');
|
||||
expect(events[0].source.id).toBe('adjustment-123');
|
||||
});
|
||||
|
||||
it('should create manual events without source ID', () => {
|
||||
const events = useCase.createManualEvents(
|
||||
'team-456',
|
||||
'adminTrust',
|
||||
-3,
|
||||
'PENALTY',
|
||||
'penalty'
|
||||
);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].teamId).toBe('team-456');
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBe(-3);
|
||||
expect(events[0].reason.code).toBe('PENALTY');
|
||||
expect(events[0].source.type).toBe('penalty');
|
||||
expect(events[0].source.id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
121
core/racing/application/use-cases/TeamRatingFactoryUseCase.ts
Normal file
121
core/racing/application/use-cases/TeamRatingFactoryUseCase.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Application Use Case: TeamRatingFactoryUseCase
|
||||
*
|
||||
* Factory use case for creating team rating events from race results.
|
||||
* This replaces direct team rating calculations with event-based approach.
|
||||
* Mirrors the user rating factory pattern.
|
||||
*/
|
||||
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { ITeamRaceResultsProvider } from '../ports/ITeamRaceResultsProvider';
|
||||
import { TeamDrivingRatingEventFactory } from '@core/racing/domain/services/TeamDrivingRatingEventFactory';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
|
||||
export interface TeamRatingFactoryInput {
|
||||
raceId: string;
|
||||
}
|
||||
|
||||
export interface TeamRatingFactoryOutput {
|
||||
success: boolean;
|
||||
raceId: string;
|
||||
events: TeamRatingEvent[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class TeamRatingFactoryUseCase {
|
||||
constructor(
|
||||
private readonly teamRaceResultsProvider: ITeamRaceResultsProvider,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.logger.info('[TeamRatingFactoryUseCase] Initialized');
|
||||
}
|
||||
|
||||
async execute(input: TeamRatingFactoryInput): Promise<TeamRatingFactoryOutput> {
|
||||
this.logger.debug(`[TeamRatingFactoryUseCase] Creating rating events for race ${input.raceId}`);
|
||||
|
||||
try {
|
||||
// Load team race results
|
||||
const teamRaceResults = await this.teamRaceResultsProvider.getTeamRaceResults(input.raceId);
|
||||
|
||||
if (!teamRaceResults) {
|
||||
return {
|
||||
success: false,
|
||||
raceId: input.raceId,
|
||||
events: [],
|
||||
errors: ['Team race results not found'],
|
||||
};
|
||||
}
|
||||
|
||||
// Use factory to create events
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(teamRaceResults);
|
||||
|
||||
// Flatten events from all teams
|
||||
const allEvents: TeamRatingEvent[] = [];
|
||||
for (const [, events] of eventsByTeam) {
|
||||
allEvents.push(...events);
|
||||
}
|
||||
|
||||
if (allEvents.length === 0) {
|
||||
this.logger.info(`[TeamRatingFactoryUseCase] No events generated for race ${input.raceId}`);
|
||||
return {
|
||||
success: true,
|
||||
raceId: input.raceId,
|
||||
events: [],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.info(`[TeamRatingFactoryUseCase] Generated ${allEvents.length} events for race ${input.raceId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
raceId: input.raceId,
|
||||
events: allEvents,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to create rating events: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
this.logger.error('[TeamRatingFactoryUseCase] Error:', error instanceof Error ? error : new Error(String(error)));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
raceId: input.raceId,
|
||||
events: [],
|
||||
errors: [errorMsg],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create team rating events manually (for testing or manual adjustments)
|
||||
*/
|
||||
createManualEvents(
|
||||
teamId: string,
|
||||
dimension: string,
|
||||
delta: number,
|
||||
reason: string,
|
||||
sourceType: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment',
|
||||
sourceId?: string
|
||||
): TeamRatingEvent[] {
|
||||
const source = sourceId ? { type: sourceType, id: sourceId } : { type: sourceType };
|
||||
|
||||
const event = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create(dimension),
|
||||
delta: TeamRatingDelta.create(delta),
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: source,
|
||||
reason: { code: reason },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
return [event];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { TeamRatingIntegrationAdapter } from './TeamRatingIntegrationAdapter';
|
||||
import { ITeamRaceResultsProvider } from '../ports/ITeamRaceResultsProvider';
|
||||
import { ITeamRatingEventRepository } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import { TeamDrivingRaceFactsDto } from '@core/racing/domain/services/TeamDrivingRatingEventFactory';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
|
||||
// Mock repositories
|
||||
class MockTeamRaceResultsProvider implements ITeamRaceResultsProvider {
|
||||
private results: TeamDrivingRaceFactsDto | null = null;
|
||||
|
||||
async getTeamRaceResults(raceId: string): Promise<TeamDrivingRaceFactsDto | null> {
|
||||
return this.results;
|
||||
}
|
||||
|
||||
setResults(results: TeamDrivingRaceFactsDto | null) {
|
||||
this.results = results;
|
||||
}
|
||||
}
|
||||
|
||||
class MockTeamRatingEventRepository implements ITeamRatingEventRepository {
|
||||
private events: TeamRatingEvent[] = [];
|
||||
|
||||
async save(event: TeamRatingEvent): Promise<TeamRatingEvent> {
|
||||
this.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findByIds(ids: TeamRatingEventId[]): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => ids.some(id => id.equals(e.id)));
|
||||
}
|
||||
|
||||
async getAllByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findEventsPaginated(teamId: string): Promise<any> {
|
||||
const events = await this.getAllByTeamId(teamId);
|
||||
return {
|
||||
items: events,
|
||||
total: events.length,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.events = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockTeamRatingRepository implements ITeamRatingRepository {
|
||||
private snapshots: Map<string, any> = new Map();
|
||||
|
||||
async findByTeamId(teamId: string): Promise<any | null> {
|
||||
return this.snapshots.get(teamId) || null;
|
||||
}
|
||||
|
||||
async save(snapshot: any): Promise<any> {
|
||||
this.snapshots.set(snapshot.teamId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.snapshots.clear();
|
||||
}
|
||||
}
|
||||
|
||||
describe('TeamRatingIntegrationAdapter', () => {
|
||||
let adapter: TeamRatingIntegrationAdapter;
|
||||
let mockResultsProvider: MockTeamRaceResultsProvider;
|
||||
let mockEventRepo: MockTeamRatingEventRepository;
|
||||
let mockRatingRepo: MockTeamRatingRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResultsProvider = new MockTeamRaceResultsProvider();
|
||||
mockEventRepo = new MockTeamRatingEventRepository();
|
||||
mockRatingRepo = new MockTeamRatingRepository();
|
||||
adapter = new TeamRatingIntegrationAdapter(
|
||||
mockResultsProvider,
|
||||
mockEventRepo,
|
||||
mockRatingRepo
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockEventRepo.clear();
|
||||
mockRatingRepo.clear();
|
||||
});
|
||||
|
||||
describe('recordTeamRatings', () => {
|
||||
it('should return true when no results found', async () => {
|
||||
mockResultsProvider.setResults(null);
|
||||
|
||||
const result = await adapter.recordTeamRatings('race-123');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when results are empty', async () => {
|
||||
mockResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await adapter.recordTeamRatings('race-123');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when no events generated', async () => {
|
||||
mockResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 50,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await adapter.recordTeamRatings('race-123');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should record team ratings successfully', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await adapter.recordTeamRatings('race-123');
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify events were saved
|
||||
const events1 = await mockEventRepo.getAllByTeamId('team-123');
|
||||
const events2 = await mockEventRepo.getAllByTeamId('team-456');
|
||||
|
||||
expect(events1.length).toBeGreaterThan(0);
|
||||
expect(events2.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify snapshots were updated
|
||||
const snapshot1 = await mockRatingRepo.findByTeamId('team-123');
|
||||
const snapshot2 = await mockRatingRepo.findByTeamId('team-456');
|
||||
|
||||
expect(snapshot1).toBeDefined();
|
||||
expect(snapshot2).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return false on error', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
// Mock repository to throw error
|
||||
const originalSave = mockEventRepo.save.bind(mockEventRepo);
|
||||
mockEventRepo.save = async () => {
|
||||
throw new Error('Repository error');
|
||||
};
|
||||
|
||||
const result = await adapter.recordTeamRatings('race-123');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordTeamRatingsWithDetails', () => {
|
||||
it('should return details for successful recording', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await adapter.recordTeamRatingsWithDetails('race-123');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(0);
|
||||
expect(result.teamsUpdated).toContain('team-123');
|
||||
expect(result.teamsUpdated).toContain('team-456');
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle partial failures', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 2,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 2,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
// Mock repository to fail for team-456
|
||||
const originalSave = mockEventRepo.save.bind(mockEventRepo);
|
||||
mockEventRepo.save = async (event) => {
|
||||
if (event.teamId === 'team-456') {
|
||||
throw new Error('Simulated failure');
|
||||
}
|
||||
return originalSave(event);
|
||||
};
|
||||
|
||||
const result = await adapter.recordTeamRatingsWithDetails('race-123');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.teamsUpdated).toContain('team-123');
|
||||
expect(result.teamsUpdated).not.toContain('team-456');
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0]).toContain('team-456');
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
mockResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await adapter.recordTeamRatingsWithDetails('race-123');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.teamsUpdated).toEqual([]);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle null results', async () => {
|
||||
mockResultsProvider.setResults(null);
|
||||
|
||||
const result = await adapter.recordTeamRatingsWithDetails('race-123');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.teamsUpdated).toEqual([]);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { ITeamRaceResultsProvider } from '../ports/ITeamRaceResultsProvider';
|
||||
import type { ITeamRatingEventRepository } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import type { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import { TeamDrivingRatingEventFactory } from '@core/racing/domain/services/TeamDrivingRatingEventFactory';
|
||||
import { AppendTeamRatingEventsUseCase } from './AppendTeamRatingEventsUseCase';
|
||||
|
||||
/**
|
||||
* Integration Adapter: TeamRatingIntegrationAdapter
|
||||
*
|
||||
* Minimal integration with race flow.
|
||||
* Can be called from CompleteRaceUseCase to record team ratings.
|
||||
*
|
||||
* Usage in CompleteRaceUseCase:
|
||||
* ```typescript
|
||||
* // After race completion
|
||||
* const teamRatingAdapter = new TeamRatingIntegrationAdapter(
|
||||
* teamRaceResultsProvider,
|
||||
* ratingEventRepository,
|
||||
* ratingRepository
|
||||
* );
|
||||
*
|
||||
* await teamRatingAdapter.recordTeamRatings(raceId);
|
||||
* ```
|
||||
*/
|
||||
export class TeamRatingIntegrationAdapter {
|
||||
private appendUseCase: AppendTeamRatingEventsUseCase;
|
||||
|
||||
constructor(
|
||||
private readonly teamRaceResultsProvider: ITeamRaceResultsProvider,
|
||||
ratingEventRepository: ITeamRatingEventRepository,
|
||||
ratingRepository: ITeamRatingRepository,
|
||||
) {
|
||||
this.appendUseCase = new AppendTeamRatingEventsUseCase(
|
||||
ratingEventRepository,
|
||||
ratingRepository
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record team ratings for a completed race.
|
||||
* Returns true if successful, false otherwise.
|
||||
*/
|
||||
async recordTeamRatings(raceId: string): Promise<boolean> {
|
||||
try {
|
||||
// Get team race results
|
||||
const teamRaceResults = await this.teamRaceResultsProvider.getTeamRaceResults(raceId);
|
||||
|
||||
if (!teamRaceResults || teamRaceResults.results.length === 0) {
|
||||
return true; // No team results to process
|
||||
}
|
||||
|
||||
// Create rating events
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(teamRaceResults);
|
||||
|
||||
if (eventsByTeam.size === 0) {
|
||||
return true; // No events generated
|
||||
}
|
||||
|
||||
// Process each team
|
||||
for (const [teamId, events] of eventsByTeam) {
|
||||
try {
|
||||
await this.appendUseCase.execute(events);
|
||||
} catch (error) {
|
||||
console.error(`Failed to record team ratings for team ${teamId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to record team ratings:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record team ratings with detailed output.
|
||||
*/
|
||||
async recordTeamRatingsWithDetails(raceId: string): Promise<{
|
||||
success: boolean;
|
||||
eventsCreated: number;
|
||||
teamsUpdated: string[];
|
||||
errors: string[];
|
||||
}> {
|
||||
const errors: string[] = [];
|
||||
const teamsUpdated: string[] = [];
|
||||
let totalEventsCreated = 0;
|
||||
|
||||
try {
|
||||
const teamRaceResults = await this.teamRaceResultsProvider.getTeamRaceResults(raceId);
|
||||
|
||||
if (!teamRaceResults || teamRaceResults.results.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
eventsCreated: 0,
|
||||
teamsUpdated: [],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(teamRaceResults);
|
||||
|
||||
if (eventsByTeam.size === 0) {
|
||||
return {
|
||||
success: true,
|
||||
eventsCreated: 0,
|
||||
teamsUpdated: [],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
for (const [teamId, events] of eventsByTeam) {
|
||||
try {
|
||||
await this.appendUseCase.execute(events);
|
||||
teamsUpdated.push(teamId);
|
||||
totalEventsCreated += events.length;
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to process events for team ${teamId}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
eventsCreated: totalEventsCreated,
|
||||
teamsUpdated,
|
||||
errors,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to record team ratings: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errors.push(errorMsg);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
eventsCreated: 0,
|
||||
teamsUpdated: [],
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
198
core/racing/domain/entities/TeamRatingEvent.test.ts
Normal file
198
core/racing/domain/entities/TeamRatingEvent.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { TeamRatingEvent } from './TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
describe('TeamRatingEvent', () => {
|
||||
const validProps = {
|
||||
id: TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174000'),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T00:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st in race' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a valid rating event', () => {
|
||||
const event = TeamRatingEvent.create(validProps);
|
||||
|
||||
expect(event.id.value).toBe(validProps.id.value);
|
||||
expect(event.teamId).toBe(validProps.teamId);
|
||||
expect(event.dimension.value).toBe('driving');
|
||||
expect(event.delta.value).toBe(10);
|
||||
expect(event.occurredAt).toEqual(validProps.occurredAt);
|
||||
expect(event.createdAt).toEqual(validProps.createdAt);
|
||||
expect(event.source).toEqual(validProps.source);
|
||||
expect(event.reason).toEqual(validProps.reason);
|
||||
expect(event.visibility).toEqual(validProps.visibility);
|
||||
expect(event.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should create event with optional weight', () => {
|
||||
const props = { ...validProps, weight: 2 };
|
||||
const event = TeamRatingEvent.create(props);
|
||||
|
||||
expect(event.weight).toBe(2);
|
||||
});
|
||||
|
||||
it('should throw for empty teamId', () => {
|
||||
const props = { ...validProps, teamId: '' };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing dimension', () => {
|
||||
const { dimension: _dimension, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing delta', () => {
|
||||
const { delta: _delta, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing source', () => {
|
||||
const { source: _source, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing reason', () => {
|
||||
const { reason: _reason, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing visibility', () => {
|
||||
const { visibility: _visibility, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for invalid weight', () => {
|
||||
const props = { ...validProps, weight: 0 };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for future occurredAt', () => {
|
||||
const futureDate = new Date(Date.now() + 86400000); // Tomorrow
|
||||
const props = { ...validProps, occurredAt: futureDate };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for future createdAt', () => {
|
||||
const futureDate = new Date(Date.now() + 86400000); // Tomorrow
|
||||
const props = { ...validProps, createdAt: futureDate };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for version < 1', () => {
|
||||
const props = { ...validProps, version: 0 };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for adminTrust dimension with race source', () => {
|
||||
const props = {
|
||||
...validProps,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
};
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('should throw for driving dimension with vote source', () => {
|
||||
const props = {
|
||||
...validProps,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
source: { type: 'vote' as const, id: 'vote-456' },
|
||||
};
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('should allow adminTrust with adminAction source', () => {
|
||||
const props = {
|
||||
...validProps,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
source: { type: 'adminAction' as const, id: 'action-456' },
|
||||
};
|
||||
const event = TeamRatingEvent.create(props);
|
||||
expect(event.dimension.value).toBe('adminTrust');
|
||||
});
|
||||
|
||||
it('should allow driving with race source', () => {
|
||||
const props = {
|
||||
...validProps,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
};
|
||||
const event = TeamRatingEvent.create(props);
|
||||
expect(event.dimension.value).toBe('driving');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rehydrate', () => {
|
||||
it('should rehydrate event from stored data', () => {
|
||||
const event = TeamRatingEvent.rehydrate(validProps);
|
||||
|
||||
expect(event.id.value).toBe(validProps.id.value);
|
||||
expect(event.teamId).toBe(validProps.teamId);
|
||||
expect(event.dimension.value).toBe('driving');
|
||||
expect(event.delta.value).toBe(10);
|
||||
});
|
||||
|
||||
it('should rehydrate event with optional weight', () => {
|
||||
const props = { ...validProps, weight: 2 };
|
||||
const event = TeamRatingEvent.rehydrate(props);
|
||||
|
||||
expect(event.weight).toBe(2);
|
||||
});
|
||||
|
||||
it('should return true for same ID', () => {
|
||||
const event1 = TeamRatingEvent.create(validProps);
|
||||
const event2 = TeamRatingEvent.rehydrate(validProps);
|
||||
|
||||
expect(event1.equals(event2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different IDs', () => {
|
||||
const event1 = TeamRatingEvent.create(validProps);
|
||||
const event2 = TeamRatingEvent.create({
|
||||
...validProps,
|
||||
id: TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174001'),
|
||||
});
|
||||
|
||||
expect(event1.equals(event2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toJSON', () => {
|
||||
it('should return plain object representation', () => {
|
||||
const event = TeamRatingEvent.create(validProps);
|
||||
const json = event.toJSON();
|
||||
|
||||
expect(json).toEqual({
|
||||
id: validProps.id.value,
|
||||
teamId: validProps.teamId,
|
||||
dimension: 'driving',
|
||||
delta: 10,
|
||||
weight: undefined,
|
||||
occurredAt: validProps.occurredAt.toISOString(),
|
||||
createdAt: validProps.createdAt.toISOString(),
|
||||
source: validProps.source,
|
||||
reason: validProps.reason,
|
||||
visibility: validProps.visibility,
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include weight when present', () => {
|
||||
const props = { ...validProps, weight: 2 };
|
||||
const event = TeamRatingEvent.create(props);
|
||||
const json = event.toJSON();
|
||||
|
||||
expect(json).toHaveProperty('weight', 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
181
core/racing/domain/entities/TeamRatingEvent.ts
Normal file
181
core/racing/domain/entities/TeamRatingEvent.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { IEntity } from '@core/shared/domain';
|
||||
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface TeamRatingEventSource {
|
||||
type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
|
||||
id?: string; // e.g., raceId, penaltyId, voteId
|
||||
}
|
||||
|
||||
export interface TeamRatingEventReason {
|
||||
code: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TeamRatingEventVisibility {
|
||||
public: boolean;
|
||||
}
|
||||
|
||||
export interface TeamRatingEventProps {
|
||||
id: TeamRatingEventId;
|
||||
teamId: string;
|
||||
dimension: TeamRatingDimensionKey;
|
||||
delta: TeamRatingDelta;
|
||||
weight?: number;
|
||||
occurredAt: Date;
|
||||
createdAt: Date;
|
||||
source: TeamRatingEventSource;
|
||||
reason: TeamRatingEventReason;
|
||||
visibility: TeamRatingEventVisibility;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export class TeamRatingEvent implements IEntity<TeamRatingEventId> {
|
||||
readonly id: TeamRatingEventId;
|
||||
readonly teamId: string;
|
||||
readonly dimension: TeamRatingDimensionKey;
|
||||
readonly delta: TeamRatingDelta;
|
||||
readonly weight: number | undefined;
|
||||
readonly occurredAt: Date;
|
||||
readonly createdAt: Date;
|
||||
readonly source: TeamRatingEventSource;
|
||||
readonly reason: TeamRatingEventReason;
|
||||
readonly visibility: TeamRatingEventVisibility;
|
||||
readonly version: number;
|
||||
|
||||
private constructor(props: TeamRatingEventProps) {
|
||||
this.id = props.id;
|
||||
this.teamId = props.teamId;
|
||||
this.dimension = props.dimension;
|
||||
this.delta = props.delta;
|
||||
this.weight = props.weight;
|
||||
this.occurredAt = props.occurredAt;
|
||||
this.createdAt = props.createdAt;
|
||||
this.source = props.source;
|
||||
this.reason = props.reason;
|
||||
this.visibility = props.visibility;
|
||||
this.version = props.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new TeamRatingEvent.
|
||||
*/
|
||||
static create(props: {
|
||||
id: TeamRatingEventId;
|
||||
teamId: string;
|
||||
dimension: TeamRatingDimensionKey;
|
||||
delta: TeamRatingDelta;
|
||||
weight?: number;
|
||||
occurredAt: Date;
|
||||
createdAt: Date;
|
||||
source: TeamRatingEventSource;
|
||||
reason: TeamRatingEventReason;
|
||||
visibility: TeamRatingEventVisibility;
|
||||
version: number;
|
||||
}): TeamRatingEvent {
|
||||
// Validate required fields
|
||||
if (!props.teamId || props.teamId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team ID is required');
|
||||
}
|
||||
|
||||
if (!props.dimension) {
|
||||
throw new RacingDomainValidationError('Dimension is required');
|
||||
}
|
||||
|
||||
if (!props.delta) {
|
||||
throw new RacingDomainValidationError('Delta is required');
|
||||
}
|
||||
|
||||
if (!props.source) {
|
||||
throw new RacingDomainValidationError('Source is required');
|
||||
}
|
||||
|
||||
if (!props.reason) {
|
||||
throw new RacingDomainValidationError('Reason is required');
|
||||
}
|
||||
|
||||
if (!props.visibility) {
|
||||
throw new RacingDomainValidationError('Visibility is required');
|
||||
}
|
||||
|
||||
if (props.weight !== undefined && (typeof props.weight !== 'number' || props.weight <= 0)) {
|
||||
throw new RacingDomainValidationError('Weight must be a positive number if provided');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (props.occurredAt > now) {
|
||||
throw new RacingDomainValidationError('Occurrence date cannot be in the future');
|
||||
}
|
||||
|
||||
if (props.createdAt > now) {
|
||||
throw new RacingDomainValidationError('Creation date cannot be in the future');
|
||||
}
|
||||
|
||||
if (props.version < 1) {
|
||||
throw new RacingDomainValidationError('Version must be at least 1');
|
||||
}
|
||||
|
||||
// Validate invariants
|
||||
if (props.dimension.value === 'adminTrust' && props.source.type === 'race') {
|
||||
throw new RacingDomainInvariantError(
|
||||
'adminTrust dimension cannot be updated from race events'
|
||||
);
|
||||
}
|
||||
|
||||
if (props.dimension.value === 'driving' && props.source.type === 'vote') {
|
||||
throw new RacingDomainInvariantError(
|
||||
'driving dimension cannot be updated from vote events'
|
||||
);
|
||||
}
|
||||
|
||||
return new TeamRatingEvent(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate event from stored data (assumes data is already validated).
|
||||
*/
|
||||
static rehydrate(props: {
|
||||
id: TeamRatingEventId;
|
||||
teamId: string;
|
||||
dimension: TeamRatingDimensionKey;
|
||||
delta: TeamRatingDelta;
|
||||
weight?: number;
|
||||
occurredAt: Date;
|
||||
createdAt: Date;
|
||||
source: TeamRatingEventSource;
|
||||
reason: TeamRatingEventReason;
|
||||
visibility: TeamRatingEventVisibility;
|
||||
version: number;
|
||||
}): TeamRatingEvent {
|
||||
// Rehydration assumes data is already validated (from persistence)
|
||||
return new TeamRatingEvent(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare with another event.
|
||||
*/
|
||||
equals(other: IEntity<TeamRatingEventId>): boolean {
|
||||
return this.id.equals(other.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return plain object representation for serialization.
|
||||
*/
|
||||
toJSON(): object {
|
||||
return {
|
||||
id: this.id.value,
|
||||
teamId: this.teamId,
|
||||
dimension: this.dimension.value,
|
||||
delta: this.delta.value,
|
||||
weight: this.weight,
|
||||
occurredAt: this.occurredAt.toISOString(),
|
||||
createdAt: this.createdAt.toISOString(),
|
||||
source: this.source,
|
||||
reason: this.reason,
|
||||
visibility: this.visibility,
|
||||
version: this.version,
|
||||
};
|
||||
}
|
||||
}
|
||||
35
core/racing/domain/repositories/IDriverStatsRepository.ts
Normal file
35
core/racing/domain/repositories/IDriverStatsRepository.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Application Port: IDriverStatsRepository
|
||||
*
|
||||
* Repository interface for storing and retrieving computed driver statistics.
|
||||
* This is used for caching computed stats and serving frontend data.
|
||||
*/
|
||||
|
||||
import type { DriverStats } from '../../application/use-cases/IDriverStatsUseCase';
|
||||
|
||||
export interface IDriverStatsRepository {
|
||||
/**
|
||||
* Get stats for a specific driver
|
||||
*/
|
||||
getDriverStats(driverId: string): Promise<DriverStats | null>;
|
||||
|
||||
/**
|
||||
* Get stats for a specific driver (synchronous)
|
||||
*/
|
||||
getDriverStatsSync(driverId: string): DriverStats | null;
|
||||
|
||||
/**
|
||||
* Save stats for a specific driver
|
||||
*/
|
||||
saveDriverStats(driverId: string, stats: DriverStats): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get all driver stats
|
||||
*/
|
||||
getAllStats(): Promise<Map<string, DriverStats>>;
|
||||
|
||||
/**
|
||||
* Clear all stats
|
||||
*/
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
38
core/racing/domain/repositories/IMediaRepository.ts
Normal file
38
core/racing/domain/repositories/IMediaRepository.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Application Port: IMediaRepository
|
||||
*
|
||||
* Repository interface for static media assets (logos, images, icons).
|
||||
* Handles frontend assets like team logos, driver avatars, etc.
|
||||
*/
|
||||
|
||||
export interface IMediaRepository {
|
||||
/**
|
||||
* Get driver avatar URL
|
||||
*/
|
||||
getDriverAvatar(driverId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Get team logo URL
|
||||
*/
|
||||
getTeamLogo(teamId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Get track image URL
|
||||
*/
|
||||
getTrackImage(trackId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Get category icon URL
|
||||
*/
|
||||
getCategoryIcon(categoryId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Get sponsor logo URL
|
||||
*/
|
||||
getSponsorLogo(sponsorId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Clear all media data (for reseeding)
|
||||
*/
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Repository Interface: ITeamRatingEventRepository
|
||||
*
|
||||
* Port for persisting and retrieving team rating events (ledger).
|
||||
* Events are immutable and ordered by occurredAt for deterministic snapshot computation.
|
||||
*/
|
||||
|
||||
import type { TeamRatingEvent } from '../entities/TeamRatingEvent';
|
||||
import type { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
|
||||
export interface FindByTeamIdOptions {
|
||||
/** Only return events after this ID (for pagination/streaming) */
|
||||
afterId?: TeamRatingEventId;
|
||||
/** Maximum number of events to return */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface TeamRatingEventFilter {
|
||||
/** Filter by dimension keys */
|
||||
dimensions?: string[];
|
||||
/** Filter by source types */
|
||||
sourceTypes?: ('race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment')[];
|
||||
/** Filter by date range (inclusive) */
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
/** Filter by reason codes */
|
||||
reasonCodes?: string[];
|
||||
/** Filter by visibility */
|
||||
visibility?: 'public' | 'private';
|
||||
}
|
||||
|
||||
export interface PaginatedQueryOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filter?: TeamRatingEventFilter;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
nextOffset?: number;
|
||||
}
|
||||
|
||||
export interface ITeamRatingEventRepository {
|
||||
/**
|
||||
* Save a rating event to the ledger
|
||||
*/
|
||||
save(event: TeamRatingEvent): Promise<TeamRatingEvent>;
|
||||
|
||||
/**
|
||||
* Find all rating events for a team, ordered by occurredAt (ascending)
|
||||
* Options allow for pagination and streaming
|
||||
*/
|
||||
findByTeamId(teamId: string, options?: FindByTeamIdOptions): Promise<TeamRatingEvent[]>;
|
||||
|
||||
/**
|
||||
* Find multiple events by their IDs
|
||||
*/
|
||||
findByIds(ids: TeamRatingEventId[]): Promise<TeamRatingEvent[]>;
|
||||
|
||||
/**
|
||||
* Get all events for a team (for snapshot recomputation)
|
||||
*/
|
||||
getAllByTeamId(teamId: string): Promise<TeamRatingEvent[]>;
|
||||
|
||||
/**
|
||||
* Find events with pagination and filtering
|
||||
*/
|
||||
findEventsPaginated(teamId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<TeamRatingEvent>>;
|
||||
}
|
||||
20
core/racing/domain/repositories/ITeamRatingRepository.ts
Normal file
20
core/racing/domain/repositories/ITeamRatingRepository.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Repository Interface: ITeamRatingRepository
|
||||
*
|
||||
* Port for persisting and retrieving TeamRating snapshots.
|
||||
* Snapshots are derived from rating events for fast reads.
|
||||
*/
|
||||
|
||||
import type { TeamRatingSnapshot } from '../services/TeamRatingSnapshotCalculator';
|
||||
|
||||
export interface ITeamRatingRepository {
|
||||
/**
|
||||
* Find rating snapshot by team ID
|
||||
*/
|
||||
findByTeamId(teamId: string): Promise<TeamRatingSnapshot | null>;
|
||||
|
||||
/**
|
||||
* Save or update a team rating snapshot
|
||||
*/
|
||||
save(teamRating: TeamRatingSnapshot): Promise<TeamRatingSnapshot>;
|
||||
}
|
||||
44
core/racing/domain/repositories/ITeamStatsRepository.ts
Normal file
44
core/racing/domain/repositories/ITeamStatsRepository.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Application Port: ITeamStatsRepository
|
||||
*
|
||||
* Repository interface for storing and retrieving computed team statistics.
|
||||
* This is used for caching computed stats and serving frontend data.
|
||||
*/
|
||||
|
||||
export interface TeamStats {
|
||||
logoUrl: string;
|
||||
performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||
specialization: 'endurance' | 'sprint' | 'mixed';
|
||||
region: string;
|
||||
languages: string[];
|
||||
totalWins: number;
|
||||
totalRaces: number;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface ITeamStatsRepository {
|
||||
/**
|
||||
* Get stats for a specific team
|
||||
*/
|
||||
getTeamStats(teamId: string): Promise<TeamStats | null>;
|
||||
|
||||
/**
|
||||
* Get stats for a specific team (synchronous)
|
||||
*/
|
||||
getTeamStatsSync(teamId: string): TeamStats | null;
|
||||
|
||||
/**
|
||||
* Save stats for a specific team
|
||||
*/
|
||||
saveTeamStats(teamId: string, stats: TeamStats): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get all team stats
|
||||
*/
|
||||
getAllStats(): Promise<Map<string, TeamStats>>;
|
||||
|
||||
/**
|
||||
* Clear all stats
|
||||
*/
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { IDomainService } from '@core/shared/domain';
|
||||
|
||||
export interface DriverStats {
|
||||
rating: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
totalRaces: number;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface IDriverStatsService extends IDomainService {
|
||||
getDriverStats(driverId: string): DriverStats | null;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { IDomainService } from '@core/shared/domain';
|
||||
|
||||
export interface DriverRanking {
|
||||
driverId: string;
|
||||
rating: number;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface IRankingService extends IDomainService {
|
||||
getAllDriverRankings(): DriverRanking[];
|
||||
}
|
||||
452
core/racing/domain/services/TeamDrivingRatingCalculator.test.ts
Normal file
452
core/racing/domain/services/TeamDrivingRatingCalculator.test.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import { TeamDrivingRatingCalculator, TeamDrivingRaceResult, TeamDrivingQualifyingResult, TeamDrivingOvertakeStats } from './TeamDrivingRatingCalculator';
|
||||
|
||||
describe('TeamDrivingRatingCalculator', () => {
|
||||
describe('calculateFromRaceFinish', () => {
|
||||
it('should create events from race finish data', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].teamId).toBe('team-123');
|
||||
expect(events[0].dimension.value).toBe('driving');
|
||||
});
|
||||
|
||||
it('should create events for DNS status', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dnsEvent = events.find(e => e.reason.code === 'RACE_DNS');
|
||||
expect(dnsEvent).toBeDefined();
|
||||
expect(dnsEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should create events for DNF status', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 2,
|
||||
status: 'dnf',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dnfEvent = events.find(e => e.reason.code === 'RACE_DNF');
|
||||
expect(dnfEvent).toBeDefined();
|
||||
expect(dnfEvent?.delta.value).toBe(-15);
|
||||
});
|
||||
|
||||
it('should create events for DSQ status', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 0,
|
||||
status: 'dsq',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dsqEvent = events.find(e => e.reason.code === 'RACE_DSQ');
|
||||
expect(dsqEvent).toBeDefined();
|
||||
expect(dsqEvent?.delta.value).toBe(-25);
|
||||
});
|
||||
|
||||
it('should create events for AFK status', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 0,
|
||||
status: 'afk',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const afkEvent = events.find(e => e.reason.code === 'RACE_AFK');
|
||||
expect(afkEvent).toBeDefined();
|
||||
expect(afkEvent?.delta.value).toBe(-20);
|
||||
});
|
||||
|
||||
it('should apply incident penalties', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 5,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const incidentEvent = events.find(e => e.reason.code === 'RACE_INCIDENTS');
|
||||
expect(incidentEvent).toBeDefined();
|
||||
expect(incidentEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should apply gain bonus for beating higher-rated teams', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 65, // High strength
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const gainEvent = events.find(e => e.reason.code === 'RACE_GAIN_BONUS');
|
||||
expect(gainEvent).toBeDefined();
|
||||
expect(gainEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(gainEvent?.weight).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should create pace events when pace is provided', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
pace: 80,
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const paceEvent = events.find(e => e.reason.code === 'RACE_PACE');
|
||||
expect(paceEvent).toBeDefined();
|
||||
expect(paceEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(paceEvent?.weight).toBe(0.3);
|
||||
});
|
||||
|
||||
it('should create consistency events when consistency is provided', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
consistency: 85,
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const consistencyEvent = events.find(e => e.reason.code === 'RACE_CONSISTENCY');
|
||||
expect(consistencyEvent).toBeDefined();
|
||||
expect(consistencyEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(consistencyEvent?.weight).toBe(0.3);
|
||||
});
|
||||
|
||||
it('should create teamwork events when teamwork is provided', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
teamwork: 90,
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const teamworkEvent = events.find(e => e.reason.code === 'RACE_TEAMWORK');
|
||||
expect(teamworkEvent).toBeDefined();
|
||||
expect(teamworkEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(teamworkEvent?.weight).toBe(0.4);
|
||||
});
|
||||
|
||||
it('should create sportsmanship events when sportsmanship is provided', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
sportsmanship: 95,
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const sportsmanshipEvent = events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP');
|
||||
expect(sportsmanshipEvent).toBeDefined();
|
||||
expect(sportsmanshipEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(sportsmanshipEvent?.weight).toBe(0.3);
|
||||
});
|
||||
|
||||
it('should handle all optional ratings together', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 65, // High enough for gain bonus
|
||||
raceId: 'race-456',
|
||||
pace: 75,
|
||||
consistency: 80,
|
||||
teamwork: 85,
|
||||
sportsmanship: 90,
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
// Should have multiple events
|
||||
expect(events.length).toBeGreaterThan(5);
|
||||
|
||||
// Check for specific events
|
||||
expect(events.find(e => e.reason.code === 'RACE_PERFORMANCE')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_GAIN_BONUS')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_INCIDENTS')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_PACE')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_CONSISTENCY')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_TEAMWORK')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateFromQualifying', () => {
|
||||
it('should create qualifying events', () => {
|
||||
const result: TeamDrivingQualifyingResult = {
|
||||
teamId: 'team-123',
|
||||
qualifyingPosition: 3,
|
||||
fieldSize: 10,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromQualifying(result);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].teamId).toBe('team-123');
|
||||
expect(events[0].dimension.value).toBe('driving');
|
||||
expect(events[0].reason.code).toBe('RACE_QUALIFYING');
|
||||
expect(events[0].weight).toBe(0.25);
|
||||
});
|
||||
|
||||
it('should create positive delta for good qualifying position', () => {
|
||||
const result: TeamDrivingQualifyingResult = {
|
||||
teamId: 'team-123',
|
||||
qualifyingPosition: 1,
|
||||
fieldSize: 10,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromQualifying(result);
|
||||
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create negative delta for poor qualifying position', () => {
|
||||
const result: TeamDrivingQualifyingResult = {
|
||||
teamId: 'team-123',
|
||||
qualifyingPosition: 10,
|
||||
fieldSize: 10,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromQualifying(result);
|
||||
|
||||
expect(events[0].delta.value).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateFromOvertakeStats', () => {
|
||||
it('should create overtake events', () => {
|
||||
const stats: TeamDrivingOvertakeStats = {
|
||||
teamId: 'team-123',
|
||||
overtakes: 5,
|
||||
successfulDefenses: 3,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const overtakeEvent = events.find(e => e.reason.code === 'RACE_OVERTAKE');
|
||||
expect(overtakeEvent).toBeDefined();
|
||||
expect(overtakeEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(overtakeEvent?.weight).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should create defense events', () => {
|
||||
const stats: TeamDrivingOvertakeStats = {
|
||||
teamId: 'team-123',
|
||||
overtakes: 0,
|
||||
successfulDefenses: 4,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
|
||||
|
||||
const defenseEvent = events.find(e => e.reason.code === 'RACE_DEFENSE');
|
||||
expect(defenseEvent).toBeDefined();
|
||||
expect(defenseEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(defenseEvent?.weight).toBe(0.4);
|
||||
});
|
||||
|
||||
it('should create both overtake and defense events', () => {
|
||||
const stats: TeamDrivingOvertakeStats = {
|
||||
teamId: 'team-123',
|
||||
overtakes: 3,
|
||||
successfulDefenses: 2,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
|
||||
|
||||
expect(events.length).toBe(2);
|
||||
expect(events.find(e => e.reason.code === 'RACE_OVERTAKE')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_DEFENSE')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return empty array for zero stats', () => {
|
||||
const stats: TeamDrivingOvertakeStats = {
|
||||
teamId: 'team-123',
|
||||
overtakes: 0,
|
||||
successfulDefenses: 0,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
|
||||
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle extreme field sizes', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 100,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle many incidents', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 20,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const incidentEvent = events.find(e => e.reason.code === 'RACE_INCIDENTS');
|
||||
expect(incidentEvent).toBeDefined();
|
||||
// Should be capped at 20
|
||||
expect(incidentEvent?.delta.value).toBe(-20);
|
||||
});
|
||||
|
||||
it('should handle low ratings', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
pace: 10,
|
||||
consistency: 15,
|
||||
teamwork: 20,
|
||||
sportsmanship: 25,
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const paceEvent = events.find(e => e.reason.code === 'RACE_PACE');
|
||||
expect(paceEvent?.delta.value).toBeLessThan(0);
|
||||
|
||||
const consistencyEvent = events.find(e => e.reason.code === 'RACE_CONSISTENCY');
|
||||
expect(consistencyEvent?.delta.value).toBeLessThan(0);
|
||||
|
||||
const teamworkEvent = events.find(e => e.reason.code === 'RACE_TEAMWORK');
|
||||
expect(teamworkEvent?.delta.value).toBeLessThan(0);
|
||||
|
||||
const sportsmanshipEvent = events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP');
|
||||
expect(sportsmanshipEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should handle high ratings', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 65,
|
||||
raceId: 'race-456',
|
||||
pace: 95,
|
||||
consistency: 98,
|
||||
teamwork: 92,
|
||||
sportsmanship: 97,
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const paceEvent = events.find(e => e.reason.code === 'RACE_PACE');
|
||||
expect(paceEvent?.delta.value).toBeGreaterThan(0);
|
||||
|
||||
const consistencyEvent = events.find(e => e.reason.code === 'RACE_CONSISTENCY');
|
||||
expect(consistencyEvent?.delta.value).toBeGreaterThan(0);
|
||||
|
||||
const teamworkEvent = events.find(e => e.reason.code === 'RACE_TEAMWORK');
|
||||
expect(teamworkEvent?.delta.value).toBeGreaterThan(0);
|
||||
|
||||
const sportsmanshipEvent = events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP');
|
||||
expect(sportsmanshipEvent?.delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
476
core/racing/domain/services/TeamDrivingRatingCalculator.ts
Normal file
476
core/racing/domain/services/TeamDrivingRatingCalculator.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import { TeamRatingEvent } from '../entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
||||
import { TeamDrivingReasonCode } from '../value-objects/TeamDrivingReasonCode';
|
||||
|
||||
export interface TeamDrivingRaceResult {
|
||||
teamId: string;
|
||||
position: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
|
||||
fieldSize: number;
|
||||
strengthOfField: number; // Average rating of competing teams
|
||||
raceId: string;
|
||||
pace?: number | undefined; // Optional: pace rating (0-100)
|
||||
consistency?: number | undefined; // Optional: consistency rating (0-100)
|
||||
teamwork?: number | undefined; // Optional: teamwork rating (0-100)
|
||||
sportsmanship?: number | undefined; // Optional: sportsmanship rating (0-100)
|
||||
}
|
||||
|
||||
export interface TeamDrivingQualifyingResult {
|
||||
teamId: string;
|
||||
qualifyingPosition: number;
|
||||
fieldSize: number;
|
||||
raceId: string;
|
||||
}
|
||||
|
||||
export interface TeamDrivingOvertakeStats {
|
||||
teamId: string;
|
||||
overtakes: number;
|
||||
successfulDefenses: number;
|
||||
raceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain Service: TeamDrivingRatingCalculator
|
||||
*
|
||||
* Full calculator for team driving rating events.
|
||||
* Mirrors user slice 3 in core/racing/ with comprehensive driving dimension logic.
|
||||
*
|
||||
* Pure domain logic - no persistence concerns.
|
||||
*/
|
||||
export class TeamDrivingRatingCalculator {
|
||||
/**
|
||||
* Calculate rating events from a team's race finish.
|
||||
* Generates comprehensive driving dimension events.
|
||||
*/
|
||||
static calculateFromRaceFinish(result: TeamDrivingRaceResult): TeamRatingEvent[] {
|
||||
const events: TeamRatingEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
if (result.status === 'finished') {
|
||||
// 1. Performance delta based on position and field strength
|
||||
const performanceDelta = this.calculatePerformanceDelta(
|
||||
result.position,
|
||||
result.fieldSize,
|
||||
result.strengthOfField
|
||||
);
|
||||
|
||||
if (performanceDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(performanceDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_PERFORMANCE').value,
|
||||
description: `Finished ${result.position}${this.getOrdinalSuffix(result.position)} in race`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Gain bonus for beating higher-rated teams
|
||||
const gainBonus = this.calculateGainBonus(result.position, result.strengthOfField);
|
||||
if (gainBonus !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(gainBonus),
|
||||
weight: 0.5,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_GAIN_BONUS').value,
|
||||
description: `Bonus for beating higher-rated opponents`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Pace rating (if provided)
|
||||
if (result.pace !== undefined) {
|
||||
const paceDelta = this.calculatePaceDelta(result.pace);
|
||||
if (paceDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(paceDelta),
|
||||
weight: 0.3,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_PACE').value,
|
||||
description: `Pace rating: ${result.pace}/100`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Consistency rating (if provided)
|
||||
if (result.consistency !== undefined) {
|
||||
const consistencyDelta = this.calculateConsistencyDelta(result.consistency);
|
||||
if (consistencyDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(consistencyDelta),
|
||||
weight: 0.3,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_CONSISTENCY').value,
|
||||
description: `Consistency rating: ${result.consistency}/100`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Teamwork rating (if provided)
|
||||
if (result.teamwork !== undefined) {
|
||||
const teamworkDelta = this.calculateTeamworkDelta(result.teamwork);
|
||||
if (teamworkDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(teamworkDelta),
|
||||
weight: 0.4,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_TEAMWORK').value,
|
||||
description: `Teamwork rating: ${result.teamwork}/100`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Sportsmanship rating (if provided)
|
||||
if (result.sportsmanship !== undefined) {
|
||||
const sportsmanshipDelta = this.calculateSportsmanshipDelta(result.sportsmanship);
|
||||
if (sportsmanshipDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(sportsmanshipDelta),
|
||||
weight: 0.3,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_SPORTSMANSHIP').value,
|
||||
description: `Sportsmanship rating: ${result.sportsmanship}/100`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Incident penalty (applies to all statuses)
|
||||
if (result.incidents > 0) {
|
||||
const incidentPenalty = this.calculateIncidentPenalty(result.incidents);
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-incidentPenalty),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_INCIDENTS').value,
|
||||
description: `${result.incidents} incident${result.incidents > 1 ? 's' : ''}`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 8. Status-based penalties
|
||||
if (result.status === 'dnf') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-15),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_DNF').value,
|
||||
description: 'Did not finish',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (result.status === 'dsq') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-25),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_DSQ').value,
|
||||
description: 'Disqualified',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (result.status === 'dns') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-10),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_DNS').value,
|
||||
description: 'Did not start',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (result.status === 'afk') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-20),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_AFK').value,
|
||||
description: 'Away from keyboard',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rating events from qualifying results.
|
||||
*/
|
||||
static calculateFromQualifying(result: TeamDrivingQualifyingResult): TeamRatingEvent[] {
|
||||
const events: TeamRatingEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
const qualifyingDelta = this.calculateQualifyingDelta(result.qualifyingPosition, result.fieldSize);
|
||||
if (qualifyingDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(qualifyingDelta),
|
||||
weight: 0.25,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_QUALIFYING').value,
|
||||
description: `Qualified ${result.qualifyingPosition}${this.getOrdinalSuffix(result.qualifyingPosition)}`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rating events from overtake/defense statistics.
|
||||
*/
|
||||
static calculateFromOvertakeStats(stats: TeamDrivingOvertakeStats): TeamRatingEvent[] {
|
||||
const events: TeamRatingEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
// Overtake bonus
|
||||
if (stats.overtakes > 0) {
|
||||
const overtakeDelta = this.calculateOvertakeDelta(stats.overtakes);
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: stats.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(overtakeDelta),
|
||||
weight: 0.5,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: stats.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_OVERTAKE').value,
|
||||
description: `${stats.overtakes} overtakes`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Defense bonus
|
||||
if (stats.successfulDefenses > 0) {
|
||||
const defenseDelta = this.calculateDefenseDelta(stats.successfulDefenses);
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: stats.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(defenseDelta),
|
||||
weight: 0.4,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: stats.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_DEFENSE').value,
|
||||
description: `${stats.successfulDefenses} successful defenses`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private static calculatePerformanceDelta(
|
||||
position: number,
|
||||
fieldSize: number,
|
||||
strengthOfField: number
|
||||
): number {
|
||||
// Base delta from position (1st = +20, last = -20)
|
||||
const positionFactor = ((fieldSize - position + 1) / fieldSize) * 40 - 20;
|
||||
|
||||
// Adjust for field strength
|
||||
const strengthFactor = (strengthOfField - 50) * 0.1; // +/- 5 for strong/weak fields
|
||||
|
||||
return Math.round((positionFactor + strengthFactor) * 10) / 10;
|
||||
}
|
||||
|
||||
private static calculateGainBonus(position: number, strengthOfField: number): number {
|
||||
// Bonus for beating teams with higher ratings
|
||||
if (strengthOfField > 60 && position <= Math.floor(strengthOfField / 10)) {
|
||||
return 5;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static calculateIncidentPenalty(incidents: number): number {
|
||||
// Exponential penalty for multiple incidents
|
||||
return Math.min(incidents * 2, 20);
|
||||
}
|
||||
|
||||
private static calculatePaceDelta(pace: number): number {
|
||||
// Pace rating 0-100, convert to delta -10 to +10
|
||||
if (pace < 0 || pace > 100) return 0;
|
||||
return Math.round(((pace - 50) * 0.2) * 10) / 10;
|
||||
}
|
||||
|
||||
private static calculateConsistencyDelta(consistency: number): number {
|
||||
// Consistency rating 0-100, convert to delta -8 to +8
|
||||
if (consistency < 0 || consistency > 100) return 0;
|
||||
return Math.round(((consistency - 50) * 0.16) * 10) / 10;
|
||||
}
|
||||
|
||||
private static calculateTeamworkDelta(teamwork: number): number {
|
||||
// Teamwork rating 0-100, convert to delta -10 to +10
|
||||
if (teamwork < 0 || teamwork > 100) return 0;
|
||||
return Math.round(((teamwork - 50) * 0.2) * 10) / 10;
|
||||
}
|
||||
|
||||
private static calculateSportsmanshipDelta(sportsmanship: number): number {
|
||||
// Sportsmanship rating 0-100, convert to delta -8 to +8
|
||||
if (sportsmanship < 0 || sportsmanship > 100) return 0;
|
||||
return Math.round(((sportsmanship - 50) * 0.16) * 10) / 10;
|
||||
}
|
||||
|
||||
private static calculateQualifyingDelta(qualifyingPosition: number, fieldSize: number): number {
|
||||
// Qualifying performance (less weight than race)
|
||||
const positionFactor = ((fieldSize - qualifyingPosition + 1) / fieldSize) * 10 - 5;
|
||||
return Math.round(positionFactor * 10) / 10;
|
||||
}
|
||||
|
||||
private static calculateOvertakeDelta(overtakes: number): number {
|
||||
// Overtake bonus: +2 per overtake, max +10
|
||||
return Math.min(overtakes * 2, 10);
|
||||
}
|
||||
|
||||
private static calculateDefenseDelta(defenses: number): number {
|
||||
// Defense bonus: +1.5 per defense, max +8
|
||||
return Math.min(Math.round(defenses * 1.5 * 10) / 10, 8);
|
||||
}
|
||||
|
||||
private static getOrdinalSuffix(position: number): string {
|
||||
const j = position % 10;
|
||||
const k = position % 100;
|
||||
|
||||
if (j === 1 && k !== 11) return 'st';
|
||||
if (j === 2 && k !== 12) return 'nd';
|
||||
if (j === 3 && k !== 13) return 'rd';
|
||||
return 'th';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import { TeamDrivingRatingEventFactory, TeamDrivingRaceFactsDto, TeamDrivingQualifyingFactsDto, TeamDrivingOvertakeFactsDto } from './TeamDrivingRatingEventFactory';
|
||||
|
||||
describe('TeamDrivingRatingEventFactory', () => {
|
||||
describe('createFromRaceFinish', () => {
|
||||
it('should create events from race finish data', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].teamId).toBe('team-123');
|
||||
expect(events[0].dimension.value).toBe('driving');
|
||||
});
|
||||
|
||||
it('should create events for DNS status', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dnsEvent = events.find(e => e.reason.code === 'RACE_DNS');
|
||||
expect(dnsEvent).toBeDefined();
|
||||
expect(dnsEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should create events for DNF status', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 2,
|
||||
status: 'dnf',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dnfEvent = events.find(e => e.reason.code === 'RACE_DNF');
|
||||
expect(dnfEvent).toBeDefined();
|
||||
expect(dnfEvent?.delta.value).toBe(-15);
|
||||
});
|
||||
|
||||
it('should create events for DSQ status', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 0,
|
||||
status: 'dsq',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dsqEvent = events.find(e => e.reason.code === 'RACE_DSQ');
|
||||
expect(dsqEvent).toBeDefined();
|
||||
expect(dsqEvent?.delta.value).toBe(-25);
|
||||
});
|
||||
|
||||
it('should create events for AFK status', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 0,
|
||||
status: 'afk',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const afkEvent = events.find(e => e.reason.code === 'RACE_AFK');
|
||||
expect(afkEvent).toBeDefined();
|
||||
expect(afkEvent?.delta.value).toBe(-20);
|
||||
});
|
||||
|
||||
it('should apply incident penalties', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 5,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
const incidentEvent = events.find(e => e.reason.code === 'RACE_INCIDENTS');
|
||||
expect(incidentEvent).toBeDefined();
|
||||
expect(incidentEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should apply gain bonus for beating higher-rated teams', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 65, // High strength
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
const gainEvent = events.find(e => e.reason.code === 'RACE_GAIN_BONUS');
|
||||
expect(gainEvent).toBeDefined();
|
||||
expect(gainEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(gainEvent?.weight).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should create pace events when pace is provided', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
pace: 80,
|
||||
});
|
||||
|
||||
const paceEvent = events.find(e => e.reason.code === 'RACE_PACE');
|
||||
expect(paceEvent).toBeDefined();
|
||||
expect(paceEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(paceEvent?.weight).toBe(0.3);
|
||||
});
|
||||
|
||||
it('should create consistency events when consistency is provided', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
consistency: 85,
|
||||
});
|
||||
|
||||
const consistencyEvent = events.find(e => e.reason.code === 'RACE_CONSISTENCY');
|
||||
expect(consistencyEvent).toBeDefined();
|
||||
expect(consistencyEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(consistencyEvent?.weight).toBe(0.3);
|
||||
});
|
||||
|
||||
it('should create teamwork events when teamwork is provided', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
teamwork: 90,
|
||||
});
|
||||
|
||||
const teamworkEvent = events.find(e => e.reason.code === 'RACE_TEAMWORK');
|
||||
expect(teamworkEvent).toBeDefined();
|
||||
expect(teamworkEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(teamworkEvent?.weight).toBe(0.4);
|
||||
});
|
||||
|
||||
it('should create sportsmanship events when sportsmanship is provided', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
sportsmanship: 95,
|
||||
});
|
||||
|
||||
const sportsmanshipEvent = events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP');
|
||||
expect(sportsmanshipEvent).toBeDefined();
|
||||
expect(sportsmanshipEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(sportsmanshipEvent?.weight).toBe(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDrivingEventsFromRace', () => {
|
||||
it('should create events for multiple teams', () => {
|
||||
const raceFacts: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-456',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(2);
|
||||
expect(eventsByTeam.get('team-123')).toBeDefined();
|
||||
expect(eventsByTeam.get('team-456')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle empty results', () => {
|
||||
const raceFacts: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-456',
|
||||
teamId: 'team-123',
|
||||
results: [],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should skip teams with no events', () => {
|
||||
const raceFacts: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-456',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(1);
|
||||
expect(eventsByTeam.get('team-123')?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle optional ratings in results', () => {
|
||||
const raceFacts: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-456',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 65,
|
||||
pace: 85,
|
||||
consistency: 80,
|
||||
teamwork: 90,
|
||||
sportsmanship: 95,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
const events = eventsByTeam.get('team-123')!;
|
||||
|
||||
expect(events.length).toBeGreaterThan(5);
|
||||
expect(events.find(e => e.reason.code === 'RACE_PACE')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_CONSISTENCY')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_TEAMWORK')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromQualifying', () => {
|
||||
it('should create qualifying events', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromQualifying({
|
||||
teamId: 'team-123',
|
||||
qualifyingPosition: 3,
|
||||
fieldSize: 10,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].teamId).toBe('team-123');
|
||||
expect(events[0].dimension.value).toBe('driving');
|
||||
expect(events[0].reason.code).toBe('RACE_QUALIFYING');
|
||||
expect(events[0].weight).toBe(0.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDrivingEventsFromQualifying', () => {
|
||||
it('should create events for multiple teams', () => {
|
||||
const qualifyingFacts: TeamDrivingQualifyingFactsDto = {
|
||||
raceId: 'race-456',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
qualifyingPosition: 1,
|
||||
fieldSize: 10,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
qualifyingPosition: 5,
|
||||
fieldSize: 10,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromQualifying(qualifyingFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(2);
|
||||
expect(eventsByTeam.get('team-123')).toBeDefined();
|
||||
expect(eventsByTeam.get('team-456')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromOvertakeStats', () => {
|
||||
it('should create overtake events', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromOvertakeStats({
|
||||
teamId: 'team-123',
|
||||
overtakes: 5,
|
||||
successfulDefenses: 3,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const overtakeEvent = events.find(e => e.reason.code === 'RACE_OVERTAKE');
|
||||
expect(overtakeEvent).toBeDefined();
|
||||
expect(overtakeEvent?.delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create defense events', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromOvertakeStats({
|
||||
teamId: 'team-123',
|
||||
overtakes: 0,
|
||||
successfulDefenses: 4,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
const defenseEvent = events.find(e => e.reason.code === 'RACE_DEFENSE');
|
||||
expect(defenseEvent).toBeDefined();
|
||||
expect(defenseEvent?.delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDrivingEventsFromOvertakes', () => {
|
||||
it('should create events for multiple teams', () => {
|
||||
const overtakeFacts: TeamDrivingOvertakeFactsDto = {
|
||||
raceId: 'race-456',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
overtakes: 3,
|
||||
successfulDefenses: 2,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
overtakes: 1,
|
||||
successfulDefenses: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromOvertakes(overtakeFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(2);
|
||||
expect(eventsByTeam.get('team-123')).toBeDefined();
|
||||
expect(eventsByTeam.get('team-456')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromPenalty', () => {
|
||||
it('should create driving penalty event', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'minor',
|
||||
severity: 'low',
|
||||
});
|
||||
|
||||
const drivingEvent = events.find(e => e.dimension.value === 'driving');
|
||||
expect(drivingEvent).toBeDefined();
|
||||
expect(drivingEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should create admin trust penalty event', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'minor',
|
||||
severity: 'low',
|
||||
});
|
||||
|
||||
const adminEvent = events.find(e => e.dimension.value === 'adminTrust');
|
||||
expect(adminEvent).toBeDefined();
|
||||
expect(adminEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should apply severity multipliers', () => {
|
||||
const lowEvents = TeamDrivingRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'major',
|
||||
severity: 'low',
|
||||
});
|
||||
|
||||
const highEvents = TeamDrivingRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'major',
|
||||
severity: 'high',
|
||||
});
|
||||
|
||||
const lowDelta = lowEvents.find(e => e.dimension.value === 'driving')?.delta.value || 0;
|
||||
const highDelta = highEvents.find(e => e.dimension.value === 'driving')?.delta.value || 0;
|
||||
|
||||
expect(highDelta).toBeLessThan(lowDelta);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromVote', () => {
|
||||
it('should create positive vote event', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromVote({
|
||||
teamId: 'team-123',
|
||||
outcome: 'positive',
|
||||
voteCount: 10,
|
||||
eligibleVoterCount: 15,
|
||||
percentPositive: 80,
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create negative vote event', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromVote({
|
||||
teamId: 'team-123',
|
||||
outcome: 'negative',
|
||||
voteCount: 10,
|
||||
eligibleVoterCount: 15,
|
||||
percentPositive: 20,
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should weight by vote count', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromVote({
|
||||
teamId: 'team-123',
|
||||
outcome: 'positive',
|
||||
voteCount: 20,
|
||||
eligibleVoterCount: 20,
|
||||
percentPositive: 100,
|
||||
});
|
||||
|
||||
expect(events[0].weight).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromAdminAction', () => {
|
||||
it('should create admin action bonus event', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromAdminAction({
|
||||
teamId: 'team-123',
|
||||
actionType: 'bonus',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create admin action penalty event', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromAdminAction({
|
||||
teamId: 'team-123',
|
||||
actionType: 'penalty',
|
||||
severity: 'high',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should create admin warning response event', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromAdminAction({
|
||||
teamId: 'team-123',
|
||||
actionType: 'warning',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
451
core/racing/domain/services/TeamDrivingRatingEventFactory.ts
Normal file
451
core/racing/domain/services/TeamDrivingRatingEventFactory.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import { TeamRatingEvent } from '../entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
||||
import { TeamDrivingReasonCode } from '../value-objects/TeamDrivingReasonCode';
|
||||
import { TeamDrivingRatingCalculator, TeamDrivingRaceResult, TeamDrivingQualifyingResult, TeamDrivingOvertakeStats } from './TeamDrivingRatingCalculator';
|
||||
|
||||
export interface TeamDrivingRaceFactsDto {
|
||||
raceId: string;
|
||||
teamId: string;
|
||||
results: Array<{
|
||||
teamId: string;
|
||||
position: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
|
||||
fieldSize: number;
|
||||
strengthOfField: number;
|
||||
pace?: number;
|
||||
consistency?: number;
|
||||
teamwork?: number;
|
||||
sportsmanship?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TeamDrivingQualifyingFactsDto {
|
||||
raceId: string;
|
||||
results: Array<{
|
||||
teamId: string;
|
||||
qualifyingPosition: number;
|
||||
fieldSize: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TeamDrivingOvertakeFactsDto {
|
||||
raceId: string;
|
||||
results: Array<{
|
||||
teamId: string;
|
||||
overtakes: number;
|
||||
successfulDefenses: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain Service: TeamDrivingRatingEventFactory
|
||||
*
|
||||
* Factory for creating team driving rating events using the full TeamDrivingRatingCalculator.
|
||||
* Mirrors user slice 3 pattern in core/racing/.
|
||||
*
|
||||
* Pure domain logic - no persistence concerns.
|
||||
*/
|
||||
export class TeamDrivingRatingEventFactory {
|
||||
/**
|
||||
* Create rating events from a team's race finish.
|
||||
* Uses TeamDrivingRatingCalculator for comprehensive calculations.
|
||||
*/
|
||||
static createFromRaceFinish(input: {
|
||||
teamId: string;
|
||||
position: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
|
||||
fieldSize: number;
|
||||
strengthOfField: number;
|
||||
raceId: string;
|
||||
pace?: number;
|
||||
consistency?: number;
|
||||
teamwork?: number;
|
||||
sportsmanship?: number;
|
||||
}): TeamRatingEvent[] {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: input.teamId,
|
||||
position: input.position,
|
||||
incidents: input.incidents,
|
||||
status: input.status,
|
||||
fieldSize: input.fieldSize,
|
||||
strengthOfField: input.strengthOfField,
|
||||
raceId: input.raceId,
|
||||
pace: input.pace as number | undefined,
|
||||
consistency: input.consistency as number | undefined,
|
||||
teamwork: input.teamwork as number | undefined,
|
||||
sportsmanship: input.sportsmanship as number | undefined,
|
||||
};
|
||||
|
||||
return TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from multiple race results.
|
||||
* Returns events grouped by team ID.
|
||||
*/
|
||||
static createDrivingEventsFromRace(raceFacts: TeamDrivingRaceFactsDto): Map<string, TeamRatingEvent[]> {
|
||||
const eventsByTeam = new Map<string, TeamRatingEvent[]>();
|
||||
|
||||
for (const result of raceFacts.results) {
|
||||
const input: {
|
||||
teamId: string;
|
||||
position: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
|
||||
fieldSize: number;
|
||||
strengthOfField: number;
|
||||
raceId: string;
|
||||
pace?: number;
|
||||
consistency?: number;
|
||||
teamwork?: number;
|
||||
sportsmanship?: number;
|
||||
} = {
|
||||
teamId: result.teamId,
|
||||
position: result.position,
|
||||
incidents: result.incidents,
|
||||
status: result.status,
|
||||
fieldSize: raceFacts.results.length,
|
||||
strengthOfField: result.strengthOfField,
|
||||
raceId: raceFacts.raceId,
|
||||
};
|
||||
|
||||
if (result.pace !== undefined) {
|
||||
input.pace = result.pace;
|
||||
}
|
||||
if (result.consistency !== undefined) {
|
||||
input.consistency = result.consistency;
|
||||
}
|
||||
if (result.teamwork !== undefined) {
|
||||
input.teamwork = result.teamwork;
|
||||
}
|
||||
if (result.sportsmanship !== undefined) {
|
||||
input.sportsmanship = result.sportsmanship;
|
||||
}
|
||||
|
||||
const events = this.createFromRaceFinish(input);
|
||||
|
||||
if (events.length > 0) {
|
||||
eventsByTeam.set(result.teamId, events);
|
||||
}
|
||||
}
|
||||
|
||||
return eventsByTeam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from qualifying results.
|
||||
* Uses TeamDrivingRatingCalculator for qualifying calculations.
|
||||
*/
|
||||
static createFromQualifying(input: {
|
||||
teamId: string;
|
||||
qualifyingPosition: number;
|
||||
fieldSize: number;
|
||||
raceId: string;
|
||||
}): TeamRatingEvent[] {
|
||||
const result: TeamDrivingQualifyingResult = {
|
||||
teamId: input.teamId,
|
||||
qualifyingPosition: input.qualifyingPosition,
|
||||
fieldSize: input.fieldSize,
|
||||
raceId: input.raceId,
|
||||
};
|
||||
|
||||
return TeamDrivingRatingCalculator.calculateFromQualifying(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from multiple qualifying results.
|
||||
* Returns events grouped by team ID.
|
||||
*/
|
||||
static createDrivingEventsFromQualifying(qualifyingFacts: TeamDrivingQualifyingFactsDto): Map<string, TeamRatingEvent[]> {
|
||||
const eventsByTeam = new Map<string, TeamRatingEvent[]>();
|
||||
|
||||
for (const result of qualifyingFacts.results) {
|
||||
const events = this.createFromQualifying({
|
||||
teamId: result.teamId,
|
||||
qualifyingPosition: result.qualifyingPosition,
|
||||
fieldSize: result.fieldSize,
|
||||
raceId: qualifyingFacts.raceId,
|
||||
});
|
||||
|
||||
if (events.length > 0) {
|
||||
eventsByTeam.set(result.teamId, events);
|
||||
}
|
||||
}
|
||||
|
||||
return eventsByTeam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from overtake/defense statistics.
|
||||
* Uses TeamDrivingRatingCalculator for overtake calculations.
|
||||
*/
|
||||
static createFromOvertakeStats(input: {
|
||||
teamId: string;
|
||||
overtakes: number;
|
||||
successfulDefenses: number;
|
||||
raceId: string;
|
||||
}): TeamRatingEvent[] {
|
||||
const stats: TeamDrivingOvertakeStats = {
|
||||
teamId: input.teamId,
|
||||
overtakes: input.overtakes,
|
||||
successfulDefenses: input.successfulDefenses,
|
||||
raceId: input.raceId,
|
||||
};
|
||||
|
||||
return TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from multiple overtake stats.
|
||||
* Returns events grouped by team ID.
|
||||
*/
|
||||
static createDrivingEventsFromOvertakes(overtakeFacts: TeamDrivingOvertakeFactsDto): Map<string, TeamRatingEvent[]> {
|
||||
const eventsByTeam = new Map<string, TeamRatingEvent[]>();
|
||||
|
||||
for (const result of overtakeFacts.results) {
|
||||
const events = this.createFromOvertakeStats({
|
||||
teamId: result.teamId,
|
||||
overtakes: result.overtakes,
|
||||
successfulDefenses: result.successfulDefenses,
|
||||
raceId: overtakeFacts.raceId,
|
||||
});
|
||||
|
||||
if (events.length > 0) {
|
||||
eventsByTeam.set(result.teamId, events);
|
||||
}
|
||||
}
|
||||
|
||||
return eventsByTeam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from a penalty.
|
||||
* Generates both driving and adminTrust events.
|
||||
* Uses TeamDrivingReasonCode for validation.
|
||||
*/
|
||||
static createFromPenalty(input: {
|
||||
teamId: string;
|
||||
penaltyType: 'minor' | 'major' | 'critical';
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
incidentCount?: number;
|
||||
}): TeamRatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: TeamRatingEvent[] = [];
|
||||
|
||||
// Driving dimension penalty
|
||||
const drivingDelta = this.calculatePenaltyDelta(input.penaltyType, input.severity, 'driving');
|
||||
if (drivingDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(drivingDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'penalty', id: input.penaltyType },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_INCIDENTS').value,
|
||||
description: `${input.penaltyType} penalty for driving violations`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// AdminTrust dimension penalty
|
||||
const adminDelta = this.calculatePenaltyDelta(input.penaltyType, input.severity, 'adminTrust');
|
||||
if (adminDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(adminDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'penalty', id: input.penaltyType },
|
||||
reason: {
|
||||
code: 'PENALTY_ADMIN',
|
||||
description: `${input.penaltyType} penalty for rule violations`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from a vote outcome.
|
||||
* Generates adminTrust events.
|
||||
*/
|
||||
static createFromVote(input: {
|
||||
teamId: string;
|
||||
outcome: 'positive' | 'negative';
|
||||
voteCount: number;
|
||||
eligibleVoterCount: number;
|
||||
percentPositive: number;
|
||||
}): TeamRatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: TeamRatingEvent[] = [];
|
||||
|
||||
// Calculate delta based on vote outcome
|
||||
const delta = this.calculateVoteDelta(
|
||||
input.outcome,
|
||||
input.eligibleVoterCount,
|
||||
input.voteCount,
|
||||
input.percentPositive
|
||||
);
|
||||
|
||||
if (delta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(delta),
|
||||
weight: input.voteCount, // Weight by number of votes
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'vote', id: 'admin_vote' },
|
||||
reason: {
|
||||
code: input.outcome === 'positive' ? 'VOTE_POSITIVE' : 'VOTE_NEGATIVE',
|
||||
description: `Admin vote outcome: ${input.outcome}`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from an admin action.
|
||||
* Generates adminTrust events.
|
||||
*/
|
||||
static createFromAdminAction(input: {
|
||||
teamId: string;
|
||||
actionType: 'bonus' | 'penalty' | 'warning';
|
||||
severity?: 'low' | 'medium' | 'high';
|
||||
}): TeamRatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: TeamRatingEvent[] = [];
|
||||
|
||||
if (input.actionType === 'bonus') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: 'bonus' },
|
||||
reason: {
|
||||
code: 'ADMIN_BONUS',
|
||||
description: 'Admin bonus for positive contribution',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.actionType === 'penalty') {
|
||||
const delta = input.severity === 'high' ? -15 : input.severity === 'medium' ? -10 : -5;
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(delta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: 'penalty' },
|
||||
reason: {
|
||||
code: 'ADMIN_PENALTY',
|
||||
description: `Admin penalty (${input.severity} severity)`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.actionType === 'warning') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(3),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: 'warning' },
|
||||
reason: {
|
||||
code: 'ADMIN_WARNING_RESPONSE',
|
||||
description: 'Response to admin warning',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private static calculatePenaltyDelta(
|
||||
penaltyType: 'minor' | 'major' | 'critical',
|
||||
severity: 'low' | 'medium' | 'high',
|
||||
dimension: 'driving' | 'adminTrust'
|
||||
): number {
|
||||
const baseValues = {
|
||||
minor: { driving: -5, adminTrust: -3 },
|
||||
major: { driving: -10, adminTrust: -8 },
|
||||
critical: { driving: -20, adminTrust: -15 },
|
||||
};
|
||||
|
||||
const severityMultipliers = {
|
||||
low: 1,
|
||||
medium: 1.5,
|
||||
high: 2,
|
||||
};
|
||||
|
||||
const base = baseValues[penaltyType][dimension];
|
||||
const multiplier = severityMultipliers[severity];
|
||||
|
||||
return Math.round(base * multiplier);
|
||||
}
|
||||
|
||||
private static calculateVoteDelta(
|
||||
outcome: 'positive' | 'negative',
|
||||
eligibleVoterCount: number,
|
||||
voteCount: number,
|
||||
percentPositive: number
|
||||
): number {
|
||||
if (voteCount === 0) return 0;
|
||||
|
||||
const participationRate = voteCount / eligibleVoterCount;
|
||||
const strength = (percentPositive / 100) * 2 - 1; // -1 to +1
|
||||
|
||||
// Base delta of +/- 10, scaled by participation and strength
|
||||
const baseDelta = outcome === 'positive' ? 10 : -10;
|
||||
const scaledDelta = baseDelta * participationRate * (0.5 + Math.abs(strength) * 0.5);
|
||||
|
||||
return Math.round(scaledDelta * 10) / 10;
|
||||
}
|
||||
}
|
||||
312
core/racing/domain/services/TeamRatingEventFactory.test.ts
Normal file
312
core/racing/domain/services/TeamRatingEventFactory.test.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { TeamRatingEventFactory, TeamRaceFactsDto } from './TeamRatingEventFactory';
|
||||
|
||||
describe('TeamRatingEventFactory', () => {
|
||||
describe('createFromRaceFinish', () => {
|
||||
it('should create events from race finish data', () => {
|
||||
const events = TeamRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].teamId).toBe('team-123');
|
||||
expect(events[0].dimension.value).toBe('driving');
|
||||
});
|
||||
|
||||
it('should create events for DNS status', () => {
|
||||
const events = TeamRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dnsEvent = events.find(e => e.reason.code === 'RACE_DNS');
|
||||
expect(dnsEvent).toBeDefined();
|
||||
expect(dnsEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should create events for DNF status', () => {
|
||||
const events = TeamRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 2,
|
||||
status: 'dnf',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dnfEvent = events.find(e => e.reason.code === 'RACE_DNF');
|
||||
expect(dnfEvent).toBeDefined();
|
||||
expect(dnfEvent?.delta.value).toBe(-15);
|
||||
});
|
||||
|
||||
it('should create events for DSQ status', () => {
|
||||
const events = TeamRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 0,
|
||||
status: 'dsq',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dsqEvent = events.find(e => e.reason.code === 'RACE_DSQ');
|
||||
expect(dsqEvent).toBeDefined();
|
||||
expect(dsqEvent?.delta.value).toBe(-25);
|
||||
});
|
||||
|
||||
it('should create events for AFK status', () => {
|
||||
const events = TeamRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 0,
|
||||
status: 'afk',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const afkEvent = events.find(e => e.reason.code === 'RACE_AFK');
|
||||
expect(afkEvent).toBeDefined();
|
||||
expect(afkEvent?.delta.value).toBe(-20);
|
||||
});
|
||||
|
||||
it('should apply incident penalties', () => {
|
||||
const events = TeamRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 5,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
const incidentEvent = events.find(e => e.reason.code === 'RACE_INCIDENTS');
|
||||
expect(incidentEvent).toBeDefined();
|
||||
expect(incidentEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should apply gain bonus for beating higher-rated teams', () => {
|
||||
const events = TeamRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 65, // High strength
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
const gainEvent = events.find(e => e.reason.code === 'RACE_GAIN_BONUS');
|
||||
expect(gainEvent).toBeDefined();
|
||||
expect(gainEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(gainEvent?.weight).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDrivingEventsFromRace', () => {
|
||||
it('should create events for multiple teams', () => {
|
||||
const raceFacts: TeamRaceFactsDto = {
|
||||
raceId: 'race-456',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamRatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(2);
|
||||
expect(eventsByTeam.get('team-123')).toBeDefined();
|
||||
expect(eventsByTeam.get('team-456')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle empty results', () => {
|
||||
const raceFacts: TeamRaceFactsDto = {
|
||||
raceId: 'race-456',
|
||||
teamId: 'team-123',
|
||||
results: [],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamRatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should skip teams with no events', () => {
|
||||
const raceFacts: TeamRaceFactsDto = {
|
||||
raceId: 'race-456',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamRatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(1);
|
||||
expect(eventsByTeam.get('team-123')?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromPenalty', () => {
|
||||
it('should create driving penalty event', () => {
|
||||
const events = TeamRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'minor',
|
||||
severity: 'low',
|
||||
});
|
||||
|
||||
const drivingEvent = events.find(e => e.dimension.value === 'driving');
|
||||
expect(drivingEvent).toBeDefined();
|
||||
expect(drivingEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should create admin trust penalty event', () => {
|
||||
const events = TeamRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'minor',
|
||||
severity: 'low',
|
||||
});
|
||||
|
||||
const adminEvent = events.find(e => e.dimension.value === 'adminTrust');
|
||||
expect(adminEvent).toBeDefined();
|
||||
expect(adminEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should apply severity multipliers', () => {
|
||||
const lowEvents = TeamRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'major',
|
||||
severity: 'low',
|
||||
});
|
||||
|
||||
const highEvents = TeamRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'major',
|
||||
severity: 'high',
|
||||
});
|
||||
|
||||
const lowDelta = lowEvents.find(e => e.dimension.value === 'driving')?.delta.value || 0;
|
||||
const highDelta = highEvents.find(e => e.dimension.value === 'driving')?.delta.value || 0;
|
||||
|
||||
expect(highDelta).toBeLessThan(lowDelta);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromVote', () => {
|
||||
it('should create positive vote event', () => {
|
||||
const events = TeamRatingEventFactory.createFromVote({
|
||||
teamId: 'team-123',
|
||||
outcome: 'positive',
|
||||
voteCount: 10,
|
||||
eligibleVoterCount: 15,
|
||||
percentPositive: 80,
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create negative vote event', () => {
|
||||
const events = TeamRatingEventFactory.createFromVote({
|
||||
teamId: 'team-123',
|
||||
outcome: 'negative',
|
||||
voteCount: 10,
|
||||
eligibleVoterCount: 15,
|
||||
percentPositive: 20,
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should weight by vote count', () => {
|
||||
const events = TeamRatingEventFactory.createFromVote({
|
||||
teamId: 'team-123',
|
||||
outcome: 'positive',
|
||||
voteCount: 20,
|
||||
eligibleVoterCount: 20,
|
||||
percentPositive: 100,
|
||||
});
|
||||
|
||||
expect(events[0].weight).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromAdminAction', () => {
|
||||
it('should create admin action bonus event', () => {
|
||||
const events = TeamRatingEventFactory.createFromAdminAction({
|
||||
teamId: 'team-123',
|
||||
actionType: 'bonus',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create admin action penalty event', () => {
|
||||
const events = TeamRatingEventFactory.createFromAdminAction({
|
||||
teamId: 'team-123',
|
||||
actionType: 'penalty',
|
||||
severity: 'high',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should create admin warning response event', () => {
|
||||
const events = TeamRatingEventFactory.createFromAdminAction({
|
||||
teamId: 'team-123',
|
||||
actionType: 'warning',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
496
core/racing/domain/services/TeamRatingEventFactory.ts
Normal file
496
core/racing/domain/services/TeamRatingEventFactory.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import { TeamRatingEvent } from '../entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
||||
|
||||
export interface TeamRaceFactsDto {
|
||||
raceId: string;
|
||||
teamId: string;
|
||||
results: Array<{
|
||||
teamId: string;
|
||||
position: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
|
||||
fieldSize: number;
|
||||
strengthOfField: number; // Average rating of competing teams
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TeamPenaltyInput {
|
||||
teamId: string;
|
||||
penaltyType: 'minor' | 'major' | 'critical';
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
incidentCount?: number;
|
||||
}
|
||||
|
||||
export interface TeamVoteInput {
|
||||
teamId: string;
|
||||
outcome: 'positive' | 'negative';
|
||||
voteCount: number;
|
||||
eligibleVoterCount: number;
|
||||
percentPositive: number;
|
||||
}
|
||||
|
||||
export interface TeamAdminActionInput {
|
||||
teamId: string;
|
||||
actionType: 'bonus' | 'penalty' | 'warning';
|
||||
severity?: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain Service: TeamRatingEventFactory
|
||||
*
|
||||
* Factory for creating team rating events from various sources.
|
||||
* Mirrors the RatingEventFactory pattern for user ratings.
|
||||
*
|
||||
* Pure domain logic - no persistence concerns.
|
||||
*/
|
||||
export class TeamRatingEventFactory {
|
||||
/**
|
||||
* Create rating events from a team's race finish.
|
||||
* Generates driving dimension events.
|
||||
*/
|
||||
static createFromRaceFinish(input: {
|
||||
teamId: string;
|
||||
position: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
|
||||
fieldSize: number;
|
||||
strengthOfField: number;
|
||||
raceId: string;
|
||||
}): TeamRatingEvent[] {
|
||||
const events: TeamRatingEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
if (input.status === 'finished') {
|
||||
// Performance delta based on position and field strength
|
||||
const performanceDelta = this.calculatePerformanceDelta(
|
||||
input.position,
|
||||
input.fieldSize,
|
||||
input.strengthOfField
|
||||
);
|
||||
|
||||
if (performanceDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(performanceDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'RACE_PERFORMANCE',
|
||||
description: `Finished ${input.position}${this.getOrdinalSuffix(input.position)} in race`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Gain bonus for beating higher-rated teams
|
||||
const gainBonus = this.calculateGainBonus(input.position, input.strengthOfField);
|
||||
if (gainBonus !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(gainBonus),
|
||||
weight: 0.5,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'RACE_GAIN_BONUS',
|
||||
description: `Bonus for beating higher-rated opponents`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Incident penalty
|
||||
if (input.incidents > 0) {
|
||||
const incidentPenalty = this.calculateIncidentPenalty(input.incidents);
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-incidentPenalty),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'RACE_INCIDENTS',
|
||||
description: `${input.incidents} incident${input.incidents > 1 ? 's' : ''}`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Status-based penalties
|
||||
if (input.status === 'dnf') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-15),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'RACE_DNF',
|
||||
description: 'Did not finish',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.status === 'dsq') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-25),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'RACE_DSQ',
|
||||
description: 'Disqualified',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.status === 'dns') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-10),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'RACE_DNS',
|
||||
description: 'Did not start',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.status === 'afk') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-20),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'RACE_AFK',
|
||||
description: 'Away from keyboard',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from multiple race results.
|
||||
* Returns events grouped by team ID.
|
||||
*/
|
||||
static createDrivingEventsFromRace(raceFacts: TeamRaceFactsDto): Map<string, TeamRatingEvent[]> {
|
||||
const eventsByTeam = new Map<string, TeamRatingEvent[]>();
|
||||
|
||||
for (const result of raceFacts.results) {
|
||||
const events = this.createFromRaceFinish({
|
||||
teamId: result.teamId,
|
||||
position: result.position,
|
||||
incidents: result.incidents,
|
||||
status: result.status,
|
||||
fieldSize: raceFacts.results.length,
|
||||
strengthOfField: 50, // Default strength if not provided
|
||||
raceId: raceFacts.raceId,
|
||||
});
|
||||
|
||||
if (events.length > 0) {
|
||||
eventsByTeam.set(result.teamId, events);
|
||||
}
|
||||
}
|
||||
|
||||
return eventsByTeam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from a penalty.
|
||||
* Generates both driving and adminTrust events.
|
||||
*/
|
||||
static createFromPenalty(input: TeamPenaltyInput): TeamRatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: TeamRatingEvent[] = [];
|
||||
|
||||
// Driving dimension penalty
|
||||
const drivingDelta = this.calculatePenaltyDelta(input.penaltyType, input.severity, 'driving');
|
||||
if (drivingDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(drivingDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'penalty', id: input.penaltyType },
|
||||
reason: {
|
||||
code: 'PENALTY_DRIVING',
|
||||
description: `${input.penaltyType} penalty for driving violations`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// AdminTrust dimension penalty
|
||||
const adminDelta = this.calculatePenaltyDelta(input.penaltyType, input.severity, 'adminTrust');
|
||||
if (adminDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(adminDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'penalty', id: input.penaltyType },
|
||||
reason: {
|
||||
code: 'PENALTY_ADMIN',
|
||||
description: `${input.penaltyType} penalty for rule violations`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from a vote outcome.
|
||||
* Generates adminTrust events.
|
||||
*/
|
||||
static createFromVote(input: TeamVoteInput): TeamRatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: TeamRatingEvent[] = [];
|
||||
|
||||
// Calculate delta based on vote outcome
|
||||
const delta = this.calculateVoteDelta(
|
||||
input.outcome,
|
||||
input.eligibleVoterCount,
|
||||
input.voteCount,
|
||||
input.percentPositive
|
||||
);
|
||||
|
||||
if (delta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(delta),
|
||||
weight: input.voteCount, // Weight by number of votes
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'vote', id: 'admin_vote' },
|
||||
reason: {
|
||||
code: input.outcome === 'positive' ? 'VOTE_POSITIVE' : 'VOTE_NEGATIVE',
|
||||
description: `Admin vote outcome: ${input.outcome}`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from an admin action.
|
||||
* Generates adminTrust events.
|
||||
*/
|
||||
static createFromAdminAction(input: TeamAdminActionInput): TeamRatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: TeamRatingEvent[] = [];
|
||||
|
||||
if (input.actionType === 'bonus') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: 'bonus' },
|
||||
reason: {
|
||||
code: 'ADMIN_BONUS',
|
||||
description: 'Admin bonus for positive contribution',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.actionType === 'penalty') {
|
||||
const delta = input.severity === 'high' ? -15 : input.severity === 'medium' ? -10 : -5;
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(delta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: 'penalty' },
|
||||
reason: {
|
||||
code: 'ADMIN_PENALTY',
|
||||
description: `Admin penalty (${input.severity} severity)`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.actionType === 'warning') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(3),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: 'warning' },
|
||||
reason: {
|
||||
code: 'ADMIN_WARNING_RESPONSE',
|
||||
description: 'Response to admin warning',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private static calculatePerformanceDelta(
|
||||
position: number,
|
||||
fieldSize: number,
|
||||
strengthOfField: number
|
||||
): number {
|
||||
// Base delta from position (1st = +20, last = -20)
|
||||
const positionFactor = ((fieldSize - position + 1) / fieldSize) * 40 - 20;
|
||||
|
||||
// Adjust for field strength
|
||||
const strengthFactor = (strengthOfField - 50) * 0.1; // +/- 5 for strong/weak fields
|
||||
|
||||
return Math.round((positionFactor + strengthFactor) * 10) / 10;
|
||||
}
|
||||
|
||||
private static calculateGainBonus(position: number, strengthOfField: number): number {
|
||||
// Bonus for beating teams with higher ratings
|
||||
if (strengthOfField > 60 && position <= Math.floor(strengthOfField / 10)) {
|
||||
return 5;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static calculateIncidentPenalty(incidents: number): number {
|
||||
// Exponential penalty for multiple incidents
|
||||
return Math.min(incidents * 2, 20);
|
||||
}
|
||||
|
||||
private static calculatePenaltyDelta(
|
||||
penaltyType: 'minor' | 'major' | 'critical',
|
||||
severity: 'low' | 'medium' | 'high',
|
||||
dimension: 'driving' | 'adminTrust'
|
||||
): number {
|
||||
const baseValues = {
|
||||
minor: { driving: -5, adminTrust: -3 },
|
||||
major: { driving: -10, adminTrust: -8 },
|
||||
critical: { driving: -20, adminTrust: -15 },
|
||||
};
|
||||
|
||||
const severityMultipliers = {
|
||||
low: 1,
|
||||
medium: 1.5,
|
||||
high: 2,
|
||||
};
|
||||
|
||||
const base = baseValues[penaltyType][dimension];
|
||||
const multiplier = severityMultipliers[severity];
|
||||
|
||||
return Math.round(base * multiplier);
|
||||
}
|
||||
|
||||
private static calculateVoteDelta(
|
||||
outcome: 'positive' | 'negative',
|
||||
eligibleVoterCount: number,
|
||||
voteCount: number,
|
||||
percentPositive: number
|
||||
): number {
|
||||
if (voteCount === 0) return 0;
|
||||
|
||||
const participationRate = voteCount / eligibleVoterCount;
|
||||
const strength = (percentPositive / 100) * 2 - 1; // -1 to +1
|
||||
|
||||
// Base delta of +/- 10, scaled by participation and strength
|
||||
const baseDelta = outcome === 'positive' ? 10 : -10;
|
||||
const scaledDelta = baseDelta * participationRate * (0.5 + Math.abs(strength) * 0.5);
|
||||
|
||||
return Math.round(scaledDelta * 10) / 10;
|
||||
}
|
||||
|
||||
private static getOrdinalSuffix(position: number): string {
|
||||
const j = position % 10;
|
||||
const k = position % 100;
|
||||
|
||||
if (j === 1 && k !== 11) return 'st';
|
||||
if (j === 2 && k !== 12) return 'nd';
|
||||
if (j === 3 && k !== 13) return 'rd';
|
||||
return 'th';
|
||||
}
|
||||
}
|
||||
290
core/racing/domain/services/TeamRatingSnapshotCalculator.test.ts
Normal file
290
core/racing/domain/services/TeamRatingSnapshotCalculator.test.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { TeamRatingSnapshotCalculator } from './TeamRatingSnapshotCalculator';
|
||||
import { TeamRatingEvent } from '../entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
||||
import { TeamRatingValue } from '../value-objects/TeamRatingValue';
|
||||
|
||||
describe('TeamRatingSnapshotCalculator', () => {
|
||||
describe('calculate', () => {
|
||||
it('should return default ratings for empty events', () => {
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', []);
|
||||
|
||||
expect(snapshot.teamId).toBe('team-123');
|
||||
expect(snapshot.driving.value).toBe(50);
|
||||
expect(snapshot.adminTrust.value).toBe(50);
|
||||
expect(snapshot.overall).toBe(50);
|
||||
expect(snapshot.eventCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate single dimension rating', () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
|
||||
|
||||
expect(snapshot.driving.value).toBe(60); // 50 + 10
|
||||
expect(snapshot.adminTrust.value).toBe(50); // Default
|
||||
expect(snapshot.overall).toBeCloseTo(57, 1); // 60 * 0.7 + 50 * 0.3 = 57
|
||||
});
|
||||
|
||||
it('should calculate multiple events with weights', () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
weight: 1,
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-5),
|
||||
weight: 2,
|
||||
occurredAt: new Date('2024-01-01T11:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T11:00:00Z'),
|
||||
source: { type: 'race', id: 'race-2' },
|
||||
reason: { code: 'RACE_PENALTY', description: 'Incident penalty' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
|
||||
|
||||
// Weighted average: (10*1 + (-5)*2) / (1+2) = 0/3 = 0
|
||||
// So driving = 50 + 0 = 50
|
||||
expect(snapshot.driving.value).toBe(50);
|
||||
});
|
||||
|
||||
it('should calculate mixed dimensions', () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(15),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'adminAction', id: 'action-1' },
|
||||
reason: { code: 'ADMIN_BONUS', description: 'Helpful admin work' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
|
||||
|
||||
expect(snapshot.driving.value).toBe(65); // 50 + 15
|
||||
expect(snapshot.adminTrust.value).toBe(55); // 50 + 5
|
||||
expect(snapshot.overall).toBeCloseTo(62, 1); // 65 * 0.7 + 55 * 0.3 = 62
|
||||
});
|
||||
|
||||
it('should clamp values between 0 and 100', () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(60), // Would make it 110
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
|
||||
|
||||
expect(snapshot.driving.value).toBe(100); // Clamped
|
||||
});
|
||||
|
||||
it('should track last updated date', () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(3),
|
||||
occurredAt: new Date('2024-01-02T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-02T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-2' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 2nd' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
|
||||
|
||||
expect(snapshot.lastUpdated).toEqual(new Date('2024-01-02T10:00:00Z'));
|
||||
expect(snapshot.eventCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateDimensionChange', () => {
|
||||
it('should calculate net change for a dimension', () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
weight: 1,
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-5),
|
||||
weight: 2,
|
||||
occurredAt: new Date('2024-01-01T11:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T11:00:00Z'),
|
||||
source: { type: 'race', id: 'race-2' },
|
||||
reason: { code: 'RACE_PENALTY', description: 'Incident penalty' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const change = TeamRatingSnapshotCalculator.calculateDimensionChange(
|
||||
TeamRatingDimensionKey.create('driving'),
|
||||
events
|
||||
);
|
||||
|
||||
// (10*1 + (-5)*2) / (1+2) = 0/3 = 0
|
||||
expect(change).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 for no events', () => {
|
||||
const change = TeamRatingSnapshotCalculator.calculateDimensionChange(
|
||||
TeamRatingDimensionKey.create('driving'),
|
||||
[]
|
||||
);
|
||||
|
||||
expect(change).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateOverWindow', () => {
|
||||
it('should calculate ratings for a time window', () => {
|
||||
const allEvents = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-02T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-02T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-2' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 2nd' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculateOverWindow(
|
||||
'team-123',
|
||||
allEvents,
|
||||
new Date('2024-01-01T00:00:00Z'),
|
||||
new Date('2024-01-01T23:59:59Z')
|
||||
);
|
||||
|
||||
// Only first event in window
|
||||
expect(snapshot.driving.value).toBe(60); // 50 + 10
|
||||
expect(snapshot.eventCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateDelta', () => {
|
||||
it('should calculate differences between snapshots', () => {
|
||||
const before = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(50),
|
||||
adminTrust: TeamRatingValue.create(50),
|
||||
overall: 50,
|
||||
lastUpdated: new Date('2024-01-01'),
|
||||
eventCount: 10,
|
||||
};
|
||||
|
||||
const after = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(65),
|
||||
adminTrust: TeamRatingValue.create(55),
|
||||
overall: 62,
|
||||
lastUpdated: new Date('2024-01-02'),
|
||||
eventCount: 15,
|
||||
};
|
||||
|
||||
const delta = TeamRatingSnapshotCalculator.calculateDelta(before, after);
|
||||
|
||||
expect(delta.driving).toBe(15);
|
||||
expect(delta.adminTrust).toBe(5);
|
||||
expect(delta.overall).toBe(12);
|
||||
});
|
||||
});
|
||||
});
|
||||
162
core/racing/domain/services/TeamRatingSnapshotCalculator.ts
Normal file
162
core/racing/domain/services/TeamRatingSnapshotCalculator.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { TeamRatingEvent } from '../entities/TeamRatingEvent';
|
||||
import { TeamRatingValue } from '../value-objects/TeamRatingValue';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
|
||||
export interface TeamRatingSnapshot {
|
||||
teamId: string;
|
||||
driving: TeamRatingValue;
|
||||
adminTrust: TeamRatingValue;
|
||||
overall: number; // Calculated overall rating
|
||||
lastUpdated: Date;
|
||||
eventCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain Service: TeamRatingSnapshotCalculator
|
||||
*
|
||||
* Calculates team rating snapshots from event ledgers.
|
||||
* Mirrors the user RatingSnapshotCalculator pattern.
|
||||
*
|
||||
* Pure domain logic - no persistence concerns.
|
||||
*/
|
||||
export class TeamRatingSnapshotCalculator {
|
||||
/**
|
||||
* Calculate current team rating snapshot from all events.
|
||||
*
|
||||
* @param teamId - The team ID to calculate for
|
||||
* @param events - All rating events for the team
|
||||
* @returns TeamRatingSnapshot with current ratings
|
||||
*/
|
||||
static calculate(teamId: string, events: TeamRatingEvent[]): TeamRatingSnapshot {
|
||||
// Start with default ratings (50 for each dimension)
|
||||
const defaultRating = 50;
|
||||
|
||||
if (events.length === 0) {
|
||||
return {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(defaultRating),
|
||||
adminTrust: TeamRatingValue.create(defaultRating),
|
||||
overall: defaultRating,
|
||||
lastUpdated: new Date(),
|
||||
eventCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Group events by dimension
|
||||
const eventsByDimension = events.reduce((acc, event) => {
|
||||
const key = event.dimension.value;
|
||||
if (!acc[key]) {
|
||||
acc[key] = [];
|
||||
}
|
||||
acc[key].push(event);
|
||||
return acc;
|
||||
}, {} as Record<string, TeamRatingEvent[]>);
|
||||
|
||||
// Calculate each dimension
|
||||
const dimensionRatings: Record<string, number> = {};
|
||||
|
||||
for (const [dimensionKey, dimensionEvents] of Object.entries(eventsByDimension)) {
|
||||
const totalWeight = dimensionEvents.reduce((sum, event) => {
|
||||
return sum + (event.weight || 1);
|
||||
}, 0);
|
||||
|
||||
const weightedSum = dimensionEvents.reduce((sum, event) => {
|
||||
return sum + (event.delta.value * (event.weight || 1));
|
||||
}, 0);
|
||||
|
||||
// Normalize and add to base rating
|
||||
const normalizedDelta = weightedSum / totalWeight;
|
||||
dimensionRatings[dimensionKey] = Math.max(0, Math.min(100, defaultRating + normalizedDelta));
|
||||
}
|
||||
|
||||
const drivingRating = dimensionRatings['driving'] ?? defaultRating;
|
||||
const adminTrustRating = dimensionRatings['adminTrust'] ?? defaultRating;
|
||||
|
||||
// Calculate overall as weighted average
|
||||
const overall = (drivingRating * 0.7 + adminTrustRating * 0.3);
|
||||
|
||||
// Find latest event date
|
||||
const lastUpdated = events.reduce((latest, event) => {
|
||||
return event.occurredAt > latest ? event.occurredAt : latest;
|
||||
}, new Date(0));
|
||||
|
||||
return {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(drivingRating),
|
||||
adminTrust: TeamRatingValue.create(adminTrustRating),
|
||||
overall: Math.round(overall * 10) / 10, // Round to 1 decimal
|
||||
lastUpdated,
|
||||
eventCount: events.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rating change for a specific dimension from events.
|
||||
*
|
||||
* @param dimension - The dimension to calculate for
|
||||
* @param events - Events to calculate from
|
||||
* @returns Net change value
|
||||
*/
|
||||
static calculateDimensionChange(
|
||||
dimension: TeamRatingDimensionKey,
|
||||
events: TeamRatingEvent[]
|
||||
): number {
|
||||
const filtered = events.filter(e => e.dimension.equals(dimension));
|
||||
|
||||
if (filtered.length === 0) return 0;
|
||||
|
||||
const totalWeight = filtered.reduce((sum, event) => {
|
||||
return sum + (event.weight || 1);
|
||||
}, 0);
|
||||
|
||||
const weightedSum = filtered.reduce((sum, event) => {
|
||||
return sum + (event.delta.value * (event.weight || 1));
|
||||
}, 0);
|
||||
|
||||
return weightedSum / totalWeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rating change over a time window.
|
||||
*
|
||||
* @param teamId - The team ID
|
||||
* @param events - All events
|
||||
* @param from - Start date
|
||||
* @param to - End date
|
||||
* @returns Snapshot of ratings at the end of the window
|
||||
*/
|
||||
static calculateOverWindow(
|
||||
teamId: string,
|
||||
events: TeamRatingEvent[],
|
||||
from: Date,
|
||||
to: Date
|
||||
): TeamRatingSnapshot {
|
||||
const windowEvents = events.filter(e =>
|
||||
e.occurredAt >= from && e.occurredAt <= to
|
||||
);
|
||||
|
||||
return this.calculate(teamId, windowEvents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rating change between two snapshots.
|
||||
*
|
||||
* @param before - Snapshot before changes
|
||||
* @param after - Snapshot after changes
|
||||
* @returns Object with change values
|
||||
*/
|
||||
static calculateDelta(
|
||||
before: TeamRatingSnapshot,
|
||||
after: TeamRatingSnapshot
|
||||
): {
|
||||
driving: number;
|
||||
adminTrust: number;
|
||||
overall: number;
|
||||
} {
|
||||
return {
|
||||
driving: after.driving.value - before.driving.value,
|
||||
adminTrust: after.adminTrust.value - before.adminTrust.value,
|
||||
overall: after.overall - before.overall,
|
||||
};
|
||||
}
|
||||
}
|
||||
214
core/racing/domain/value-objects/TeamDrivingReasonCode.test.ts
Normal file
214
core/racing/domain/value-objects/TeamDrivingReasonCode.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { TeamDrivingReasonCode, TEAM_DRIVING_REASON_CODES } from './TeamDrivingReasonCode';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
describe('TeamDrivingReasonCode', () => {
|
||||
describe('create', () => {
|
||||
it('should create valid reason codes', () => {
|
||||
for (const code of TEAM_DRIVING_REASON_CODES) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.value).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
expect(() => TeamDrivingReasonCode.create('')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamDrivingReasonCode.create('')).toThrow('cannot be empty');
|
||||
});
|
||||
|
||||
it('should throw error for whitespace-only string', () => {
|
||||
expect(() => TeamDrivingReasonCode.create(' ')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw error for leading whitespace', () => {
|
||||
expect(() => TeamDrivingReasonCode.create(' RACE_PERFORMANCE')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamDrivingReasonCode.create(' RACE_PERFORMANCE')).toThrow('leading or trailing whitespace');
|
||||
});
|
||||
|
||||
it('should throw error for trailing whitespace', () => {
|
||||
expect(() => TeamDrivingReasonCode.create('RACE_PERFORMANCE ')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamDrivingReasonCode.create('RACE_PERFORMANCE ')).toThrow('leading or trailing whitespace');
|
||||
});
|
||||
|
||||
it('should throw error for invalid reason code', () => {
|
||||
expect(() => TeamDrivingReasonCode.create('INVALID_CODE')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamDrivingReasonCode.create('INVALID_CODE')).toThrow('Invalid team driving reason code');
|
||||
});
|
||||
|
||||
it('should throw error for null/undefined', () => {
|
||||
expect(() => TeamDrivingReasonCode.create(null as any)).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamDrivingReasonCode.create(undefined as any)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for same value', () => {
|
||||
const code1 = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
|
||||
const code2 = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
|
||||
expect(code1.equals(code2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different values', () => {
|
||||
const code1 = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
|
||||
const code2 = TeamDrivingReasonCode.create('RACE_INCIDENTS');
|
||||
expect(code1.equals(code2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toString', () => {
|
||||
it('should return the string value', () => {
|
||||
const code = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
|
||||
expect(code.toString()).toBe('RACE_PERFORMANCE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPerformance', () => {
|
||||
it('should return true for performance codes', () => {
|
||||
const performanceCodes = [
|
||||
'RACE_PERFORMANCE',
|
||||
'RACE_GAIN_BONUS',
|
||||
'RACE_PACE',
|
||||
'RACE_QUALIFYING',
|
||||
'RACE_CONSISTENCY',
|
||||
];
|
||||
|
||||
for (const code of performanceCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isPerformance()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false for non-performance codes', () => {
|
||||
const nonPerformanceCodes = [
|
||||
'RACE_INCIDENTS',
|
||||
'RACE_DNF',
|
||||
'RACE_DSQ',
|
||||
'RACE_DNS',
|
||||
'RACE_AFK',
|
||||
'RACE_OVERTAKE',
|
||||
'RACE_DEFENSE',
|
||||
'RACE_TEAMWORK',
|
||||
'RACE_SPORTSMANSHIP',
|
||||
];
|
||||
|
||||
for (const code of nonPerformanceCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isPerformance()).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPenalty', () => {
|
||||
it('should return true for penalty codes', () => {
|
||||
const penaltyCodes = [
|
||||
'RACE_INCIDENTS',
|
||||
'RACE_DNF',
|
||||
'RACE_DSQ',
|
||||
'RACE_DNS',
|
||||
'RACE_AFK',
|
||||
];
|
||||
|
||||
for (const code of penaltyCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isPenalty()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false for non-penalty codes', () => {
|
||||
const nonPenaltyCodes = [
|
||||
'RACE_PERFORMANCE',
|
||||
'RACE_GAIN_BONUS',
|
||||
'RACE_PACE',
|
||||
'RACE_QUALIFYING',
|
||||
'RACE_CONSISTENCY',
|
||||
'RACE_OVERTAKE',
|
||||
'RACE_DEFENSE',
|
||||
'RACE_TEAMWORK',
|
||||
'RACE_SPORTSMANSHIP',
|
||||
];
|
||||
|
||||
for (const code of nonPenaltyCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isPenalty()).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPositive', () => {
|
||||
it('should return true for positive codes', () => {
|
||||
const positiveCodes = [
|
||||
'RACE_PERFORMANCE',
|
||||
'RACE_GAIN_BONUS',
|
||||
'RACE_OVERTAKE',
|
||||
'RACE_DEFENSE',
|
||||
'RACE_TEAMWORK',
|
||||
'RACE_SPORTSMANSHIP',
|
||||
];
|
||||
|
||||
for (const code of positiveCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isPositive()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false for non-positive codes', () => {
|
||||
const nonPositiveCodes = [
|
||||
'RACE_INCIDENTS',
|
||||
'RACE_DNF',
|
||||
'RACE_DSQ',
|
||||
'RACE_DNS',
|
||||
'RACE_AFK',
|
||||
'RACE_PACE',
|
||||
'RACE_QUALIFYING',
|
||||
'RACE_CONSISTENCY',
|
||||
];
|
||||
|
||||
for (const code of nonPositiveCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isPositive()).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNegative', () => {
|
||||
it('should return true for negative codes', () => {
|
||||
const negativeCodes = [
|
||||
'RACE_INCIDENTS',
|
||||
'RACE_DNF',
|
||||
'RACE_DSQ',
|
||||
'RACE_DNS',
|
||||
'RACE_AFK',
|
||||
];
|
||||
|
||||
for (const code of negativeCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isNegative()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false for non-negative codes', () => {
|
||||
const nonNegativeCodes = [
|
||||
'RACE_PERFORMANCE',
|
||||
'RACE_GAIN_BONUS',
|
||||
'RACE_PACE',
|
||||
'RACE_QUALIFYING',
|
||||
'RACE_CONSISTENCY',
|
||||
'RACE_OVERTAKE',
|
||||
'RACE_DEFENSE',
|
||||
'RACE_TEAMWORK',
|
||||
'RACE_SPORTSMANSHIP',
|
||||
];
|
||||
|
||||
for (const code of nonNegativeCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isNegative()).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('props', () => {
|
||||
it('should return the correct props object', () => {
|
||||
const code = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
|
||||
expect(code.props).toEqual({ value: 'RACE_PERFORMANCE' });
|
||||
});
|
||||
});
|
||||
});
|
||||
100
core/racing/domain/value-objects/TeamDrivingReasonCode.ts
Normal file
100
core/racing/domain/value-objects/TeamDrivingReasonCode.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface TeamDrivingReasonCodeProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid reason codes for team driving rating events
|
||||
*/
|
||||
export const TEAM_DRIVING_REASON_CODES = [
|
||||
'RACE_PERFORMANCE',
|
||||
'RACE_GAIN_BONUS',
|
||||
'RACE_INCIDENTS',
|
||||
'RACE_DNF',
|
||||
'RACE_DSQ',
|
||||
'RACE_DNS',
|
||||
'RACE_AFK',
|
||||
'RACE_PACE',
|
||||
'RACE_DEFENSE',
|
||||
'RACE_OVERTAKE',
|
||||
'RACE_QUALIFYING',
|
||||
'RACE_CONSISTENCY',
|
||||
'RACE_TEAMWORK',
|
||||
'RACE_SPORTSMANSHIP',
|
||||
] as const;
|
||||
|
||||
export type TeamDrivingReasonCodeValue = (typeof TEAM_DRIVING_REASON_CODES)[number];
|
||||
|
||||
/**
|
||||
* Value object representing a team driving reason code
|
||||
*/
|
||||
export class TeamDrivingReasonCode implements IValueObject<TeamDrivingReasonCodeProps> {
|
||||
readonly value: TeamDrivingReasonCodeValue;
|
||||
|
||||
private constructor(value: TeamDrivingReasonCodeValue) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: string): TeamDrivingReasonCode {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team driving reason code cannot be empty');
|
||||
}
|
||||
|
||||
// Strict validation: no leading/trailing whitespace allowed
|
||||
if (value !== value.trim()) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Team driving reason code cannot have leading or trailing whitespace: "${value}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (!TEAM_DRIVING_REASON_CODES.includes(value as TeamDrivingReasonCodeValue)) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Invalid team driving reason code: ${value}. Valid options: ${TEAM_DRIVING_REASON_CODES.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return new TeamDrivingReasonCode(value as TeamDrivingReasonCodeValue);
|
||||
}
|
||||
|
||||
get props(): TeamDrivingReasonCodeProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<TeamDrivingReasonCodeProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a performance-related reason
|
||||
*/
|
||||
isPerformance(): boolean {
|
||||
return ['RACE_PERFORMANCE', 'RACE_GAIN_BONUS', 'RACE_PACE', 'RACE_QUALIFYING', 'RACE_CONSISTENCY'].includes(this.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a penalty-related reason
|
||||
*/
|
||||
isPenalty(): boolean {
|
||||
return ['RACE_INCIDENTS', 'RACE_DNF', 'RACE_DSQ', 'RACE_DNS', 'RACE_AFK'].includes(this.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a positive reason
|
||||
*/
|
||||
isPositive(): boolean {
|
||||
return ['RACE_PERFORMANCE', 'RACE_GAIN_BONUS', 'RACE_OVERTAKE', 'RACE_DEFENSE', 'RACE_TEAMWORK', 'RACE_SPORTSMANSHIP'].includes(this.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a negative reason
|
||||
*/
|
||||
isNegative(): boolean {
|
||||
return ['RACE_INCIDENTS', 'RACE_DNF', 'RACE_DSQ', 'RACE_DNS', 'RACE_AFK'].includes(this.value);
|
||||
}
|
||||
}
|
||||
185
core/racing/domain/value-objects/TeamRating.ts
Normal file
185
core/racing/domain/value-objects/TeamRating.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
/**
|
||||
* Value Object: TeamRating
|
||||
*
|
||||
* Multi-dimensional rating system for teams covering:
|
||||
* - Driving: racing ability, performance, consistency
|
||||
* - AdminTrust: reliability, leadership, community contribution
|
||||
*/
|
||||
|
||||
export interface TeamRatingDimension {
|
||||
value: number; // Current rating value (0-100 scale)
|
||||
confidence: number; // Confidence level based on sample size (0-1)
|
||||
sampleSize: number; // Number of events contributing to this rating
|
||||
trend: 'rising' | 'stable' | 'falling';
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
export interface TeamRatingProps {
|
||||
teamId: string;
|
||||
driving: TeamRatingDimension;
|
||||
adminTrust: TeamRatingDimension;
|
||||
overall: number;
|
||||
calculatorVersion?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const DEFAULT_DIMENSION: TeamRatingDimension = {
|
||||
value: 50,
|
||||
confidence: 0,
|
||||
sampleSize: 0,
|
||||
trend: 'stable',
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
export class TeamRating implements IValueObject<TeamRatingProps> {
|
||||
readonly props: TeamRatingProps;
|
||||
|
||||
private constructor(props: TeamRatingProps) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
get teamId(): string {
|
||||
return this.props.teamId;
|
||||
}
|
||||
|
||||
get driving(): TeamRatingDimension {
|
||||
return this.props.driving;
|
||||
}
|
||||
|
||||
get adminTrust(): TeamRatingDimension {
|
||||
return this.props.adminTrust;
|
||||
}
|
||||
|
||||
get overall(): number {
|
||||
return this.props.overall;
|
||||
}
|
||||
|
||||
get createdAt(): Date {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
|
||||
get updatedAt(): Date {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
get calculatorVersion(): string | undefined {
|
||||
return this.props.calculatorVersion;
|
||||
}
|
||||
|
||||
static create(teamId: string): TeamRating {
|
||||
if (!teamId || teamId.trim().length === 0) {
|
||||
throw new Error('TeamRating teamId is required');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
return new TeamRating({
|
||||
teamId,
|
||||
driving: { ...DEFAULT_DIMENSION, lastUpdated: now },
|
||||
adminTrust: { ...DEFAULT_DIMENSION, lastUpdated: now },
|
||||
overall: 50,
|
||||
calculatorVersion: '1.0',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
static restore(props: TeamRatingProps): TeamRating {
|
||||
return new TeamRating(props);
|
||||
}
|
||||
|
||||
equals(other: IValueObject<TeamRatingProps>): boolean {
|
||||
return this.props.teamId === other.props.teamId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update driving rating based on race performance
|
||||
*/
|
||||
updateDrivingRating(
|
||||
newValue: number,
|
||||
weight: number = 1
|
||||
): TeamRating {
|
||||
const updated = this.updateDimension(this.driving, newValue, weight);
|
||||
return this.withUpdates({ driving: updated });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update admin trust rating based on league management feedback
|
||||
*/
|
||||
updateAdminTrustRating(
|
||||
newValue: number,
|
||||
weight: number = 1
|
||||
): TeamRating {
|
||||
const updated = this.updateDimension(this.adminTrust, newValue, weight);
|
||||
return this.withUpdates({ adminTrust: updated });
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate weighted overall rating
|
||||
*/
|
||||
calculateOverall(): number {
|
||||
// Weight dimensions by confidence
|
||||
const weights = {
|
||||
driving: 0.7 * this.driving.confidence,
|
||||
adminTrust: 0.3 * this.adminTrust.confidence,
|
||||
};
|
||||
|
||||
const totalWeight = Object.values(weights).reduce((sum, w) => sum + w, 0);
|
||||
|
||||
if (totalWeight === 0) {
|
||||
return 50; // Default when no ratings yet
|
||||
}
|
||||
|
||||
const weightedSum =
|
||||
this.driving.value * weights.driving +
|
||||
this.adminTrust.value * weights.adminTrust;
|
||||
|
||||
return Math.round(weightedSum / totalWeight);
|
||||
}
|
||||
|
||||
private updateDimension(
|
||||
dimension: TeamRatingDimension,
|
||||
newValue: number,
|
||||
weight: number
|
||||
): TeamRatingDimension {
|
||||
const clampedValue = Math.max(0, Math.min(100, newValue));
|
||||
const newSampleSize = dimension.sampleSize + weight;
|
||||
|
||||
// Exponential moving average with decay based on sample size
|
||||
const alpha = Math.min(0.3, 1 / (dimension.sampleSize + 1));
|
||||
const updatedValue = dimension.value * (1 - alpha) + clampedValue * alpha;
|
||||
|
||||
// Calculate confidence (asymptotic to 1)
|
||||
const confidence = 1 - Math.exp(-newSampleSize / 20);
|
||||
|
||||
// Determine trend
|
||||
const valueDiff = updatedValue - dimension.value;
|
||||
let trend: 'rising' | 'stable' | 'falling' = 'stable';
|
||||
if (valueDiff > 2) trend = 'rising';
|
||||
if (valueDiff < -2) trend = 'falling';
|
||||
|
||||
return {
|
||||
value: Math.round(updatedValue * 10) / 10,
|
||||
confidence: Math.round(confidence * 100) / 100,
|
||||
sampleSize: newSampleSize,
|
||||
trend,
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
private withUpdates(updates: Partial<TeamRatingProps>): TeamRating {
|
||||
const newRating = new TeamRating({
|
||||
...this.props,
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
// Recalculate overall
|
||||
return new TeamRating({
|
||||
...newRating.props,
|
||||
overall: newRating.calculateOverall(),
|
||||
});
|
||||
}
|
||||
}
|
||||
96
core/racing/domain/value-objects/TeamRatingDelta.test.ts
Normal file
96
core/racing/domain/value-objects/TeamRatingDelta.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { TeamRatingDelta } from './TeamRatingDelta';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
describe('TeamRatingDelta', () => {
|
||||
describe('create', () => {
|
||||
it('should create valid delta values', () => {
|
||||
expect(TeamRatingDelta.create(0).value).toBe(0);
|
||||
expect(TeamRatingDelta.create(10).value).toBe(10);
|
||||
expect(TeamRatingDelta.create(-10).value).toBe(-10);
|
||||
expect(TeamRatingDelta.create(100).value).toBe(100);
|
||||
expect(TeamRatingDelta.create(-100).value).toBe(-100);
|
||||
expect(TeamRatingDelta.create(50.5).value).toBe(50.5);
|
||||
expect(TeamRatingDelta.create(-50.5).value).toBe(-50.5);
|
||||
});
|
||||
|
||||
it('should throw for values outside range', () => {
|
||||
expect(() => TeamRatingDelta.create(100.1)).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingDelta.create(-100.1)).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingDelta.create(101)).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingDelta.create(-101)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should accept zero', () => {
|
||||
const delta = TeamRatingDelta.create(0);
|
||||
expect(delta.value).toBe(0);
|
||||
});
|
||||
|
||||
it('should throw for non-numeric values', () => {
|
||||
expect(() => TeamRatingDelta.create('50' as unknown as number)).toThrow();
|
||||
expect(() => TeamRatingDelta.create(null as unknown as number)).toThrow();
|
||||
expect(() => TeamRatingDelta.create(undefined as unknown as number)).toThrow();
|
||||
});
|
||||
|
||||
it('should return true for same value', () => {
|
||||
const delta1 = TeamRatingDelta.create(10);
|
||||
const delta2 = TeamRatingDelta.create(10);
|
||||
expect(delta1.equals(delta2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different values', () => {
|
||||
const delta1 = TeamRatingDelta.create(10);
|
||||
const delta2 = TeamRatingDelta.create(-10);
|
||||
expect(delta1.equals(delta2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle decimal comparisons', () => {
|
||||
const delta1 = TeamRatingDelta.create(50.5);
|
||||
const delta2 = TeamRatingDelta.create(50.5);
|
||||
expect(delta1.equals(delta2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose props correctly', () => {
|
||||
const delta = TeamRatingDelta.create(10);
|
||||
expect(delta.props.value).toBe(10);
|
||||
});
|
||||
|
||||
it('should return numeric value', () => {
|
||||
const delta = TeamRatingDelta.create(50.5);
|
||||
expect(delta.toNumber()).toBe(50.5);
|
||||
});
|
||||
|
||||
it('should return string representation', () => {
|
||||
const delta = TeamRatingDelta.create(50.5);
|
||||
expect(delta.toString()).toBe('50.5');
|
||||
});
|
||||
|
||||
it('should return true for positive deltas', () => {
|
||||
expect(TeamRatingDelta.create(1).isPositive()).toBe(true);
|
||||
expect(TeamRatingDelta.create(100).isPositive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for zero and negative deltas', () => {
|
||||
expect(TeamRatingDelta.create(0).isPositive()).toBe(false);
|
||||
expect(TeamRatingDelta.create(-1).isPositive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for negative deltas', () => {
|
||||
expect(TeamRatingDelta.create(-1).isNegative()).toBe(true);
|
||||
expect(TeamRatingDelta.create(-100).isNegative()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for zero and positive deltas', () => {
|
||||
expect(TeamRatingDelta.create(0).isNegative()).toBe(false);
|
||||
expect(TeamRatingDelta.create(1).isNegative()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for zero delta', () => {
|
||||
expect(TeamRatingDelta.create(0).isZero()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-zero deltas', () => {
|
||||
expect(TeamRatingDelta.create(1).isZero()).toBe(false);
|
||||
expect(TeamRatingDelta.create(-1).isZero()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
57
core/racing/domain/value-objects/TeamRatingDelta.ts
Normal file
57
core/racing/domain/value-objects/TeamRatingDelta.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface TeamRatingDeltaProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class TeamRatingDelta implements IValueObject<TeamRatingDeltaProps> {
|
||||
readonly value: number;
|
||||
|
||||
private constructor(value: number) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: number): TeamRatingDelta {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
throw new RacingDomainValidationError('Team rating delta must be a valid number');
|
||||
}
|
||||
|
||||
// Delta can be negative or positive, but within reasonable bounds
|
||||
if (value < -100 || value > 100) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Team rating delta must be between -100 and 100, got: ${value}`
|
||||
);
|
||||
}
|
||||
|
||||
return new TeamRatingDelta(value);
|
||||
}
|
||||
|
||||
get props(): TeamRatingDeltaProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<TeamRatingDeltaProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toNumber(): number {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value.toString();
|
||||
}
|
||||
|
||||
isPositive(): boolean {
|
||||
return this.value > 0;
|
||||
}
|
||||
|
||||
isNegative(): boolean {
|
||||
return this.value < 0;
|
||||
}
|
||||
|
||||
isZero(): boolean {
|
||||
return this.value === 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { TeamRatingDimensionKey } from './TeamRatingDimensionKey';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
describe('TeamRatingDimensionKey', () => {
|
||||
describe('create', () => {
|
||||
it('should create valid dimension keys', () => {
|
||||
expect(TeamRatingDimensionKey.create('driving').value).toBe('driving');
|
||||
expect(TeamRatingDimensionKey.create('adminTrust').value).toBe('adminTrust');
|
||||
});
|
||||
|
||||
it('should throw for invalid dimension key', () => {
|
||||
expect(() => TeamRatingDimensionKey.create('invalid')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingDimensionKey.create('driving ')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingDimensionKey.create('')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for empty string', () => {
|
||||
expect(() => TeamRatingDimensionKey.create('')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for whitespace', () => {
|
||||
expect(() => TeamRatingDimensionKey.create(' ')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should return true for same value', () => {
|
||||
const key1 = TeamRatingDimensionKey.create('driving');
|
||||
const key2 = TeamRatingDimensionKey.create('driving');
|
||||
expect(key1.equals(key2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different values', () => {
|
||||
const key1 = TeamRatingDimensionKey.create('driving');
|
||||
const key2 = TeamRatingDimensionKey.create('adminTrust');
|
||||
expect(key1.equals(key2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should expose props correctly', () => {
|
||||
const key = TeamRatingDimensionKey.create('driving');
|
||||
expect(key.props.value).toBe('driving');
|
||||
});
|
||||
|
||||
it('should return string representation', () => {
|
||||
const key = TeamRatingDimensionKey.create('driving');
|
||||
expect(key.toString()).toBe('driving');
|
||||
});
|
||||
});
|
||||
});
|
||||
49
core/racing/domain/value-objects/TeamRatingDimensionKey.ts
Normal file
49
core/racing/domain/value-objects/TeamRatingDimensionKey.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface TeamRatingDimensionKeyProps {
|
||||
value: 'driving' | 'adminTrust';
|
||||
}
|
||||
|
||||
const VALID_DIMENSIONS = ['driving', 'adminTrust'] as const;
|
||||
|
||||
export class TeamRatingDimensionKey implements IValueObject<TeamRatingDimensionKeyProps> {
|
||||
readonly value: TeamRatingDimensionKeyProps['value'];
|
||||
|
||||
private constructor(value: TeamRatingDimensionKeyProps['value']) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: string): TeamRatingDimensionKey {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team rating dimension key cannot be empty');
|
||||
}
|
||||
|
||||
// Strict validation: no leading/trailing whitespace allowed
|
||||
if (value !== value.trim()) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Team rating dimension key cannot have leading or trailing whitespace: "${value}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (!VALID_DIMENSIONS.includes(value as TeamRatingDimensionKeyProps['value'])) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Invalid team rating dimension key: ${value}. Valid options: ${VALID_DIMENSIONS.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return new TeamRatingDimensionKey(value as TeamRatingDimensionKeyProps['value']);
|
||||
}
|
||||
|
||||
get props(): TeamRatingDimensionKeyProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<TeamRatingDimensionKeyProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
68
core/racing/domain/value-objects/TeamRatingEventId.test.ts
Normal file
68
core/racing/domain/value-objects/TeamRatingEventId.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { TeamRatingEventId } from './TeamRatingEventId';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
describe('TeamRatingEventId', () => {
|
||||
describe('create', () => {
|
||||
it('should create valid UUID', () => {
|
||||
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const id = TeamRatingEventId.create(validUuid);
|
||||
expect(id.value).toBe(validUuid);
|
||||
});
|
||||
|
||||
it('should throw for invalid UUID', () => {
|
||||
expect(() => TeamRatingEventId.create('not-a-uuid')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingEventId.create('123e4567-e89b-12d3-a456')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingEventId.create('')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for empty string', () => {
|
||||
expect(() => TeamRatingEventId.create('')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for whitespace', () => {
|
||||
expect(() => TeamRatingEventId.create(' ')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should handle uppercase UUIDs', () => {
|
||||
const uuid = '123E4567-E89B-12D3-A456-426614174000';
|
||||
const id = TeamRatingEventId.create(uuid);
|
||||
expect(id.value).toBe(uuid);
|
||||
});
|
||||
|
||||
it('should generate a valid UUID', () => {
|
||||
const id = TeamRatingEventId.generate();
|
||||
expect(id.value).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
});
|
||||
|
||||
it('should generate unique IDs', () => {
|
||||
const id1 = TeamRatingEventId.generate();
|
||||
const id2 = TeamRatingEventId.generate();
|
||||
expect(id1.equals(id2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for same UUID', () => {
|
||||
const uuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const id1 = TeamRatingEventId.create(uuid);
|
||||
const id2 = TeamRatingEventId.create(uuid);
|
||||
expect(id1.equals(id2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different UUIDs', () => {
|
||||
const id1 = TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174000');
|
||||
const id2 = TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174001');
|
||||
expect(id1.equals(id2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should expose props correctly', () => {
|
||||
const uuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const id = TeamRatingEventId.create(uuid);
|
||||
expect(id.props.value).toBe(uuid);
|
||||
});
|
||||
|
||||
it('should return string representation', () => {
|
||||
const uuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const id = TeamRatingEventId.create(uuid);
|
||||
expect(id.toString()).toBe(uuid);
|
||||
});
|
||||
});
|
||||
});
|
||||
62
core/racing/domain/value-objects/TeamRatingEventId.ts
Normal file
62
core/racing/domain/value-objects/TeamRatingEventId.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
// Simple UUID v4 generator
|
||||
function uuidv4(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
export interface TeamRatingEventIdProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class TeamRatingEventId implements IValueObject<TeamRatingEventIdProps> {
|
||||
readonly value: string;
|
||||
|
||||
private constructor(value: string) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: string): TeamRatingEventId {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('TeamRatingEventId cannot be empty');
|
||||
}
|
||||
|
||||
// Strict validation: no leading/trailing whitespace allowed
|
||||
if (value !== value.trim()) {
|
||||
throw new RacingDomainValidationError(
|
||||
`TeamRatingEventId cannot have leading or trailing whitespace: "${value}"`
|
||||
);
|
||||
}
|
||||
|
||||
// Basic UUID format validation
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(value)) {
|
||||
throw new RacingDomainValidationError(
|
||||
`TeamRatingEventId must be a valid UUID format, got: "${value}"`
|
||||
);
|
||||
}
|
||||
|
||||
return new TeamRatingEventId(value);
|
||||
}
|
||||
|
||||
static generate(): TeamRatingEventId {
|
||||
return new TeamRatingEventId(uuidv4());
|
||||
}
|
||||
|
||||
get props(): TeamRatingEventIdProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<TeamRatingEventIdProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
67
core/racing/domain/value-objects/TeamRatingValue.test.ts
Normal file
67
core/racing/domain/value-objects/TeamRatingValue.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { TeamRatingValue } from './TeamRatingValue';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
describe('TeamRatingValue', () => {
|
||||
describe('create', () => {
|
||||
it('should create valid rating values', () => {
|
||||
expect(TeamRatingValue.create(0).value).toBe(0);
|
||||
expect(TeamRatingValue.create(50).value).toBe(50);
|
||||
expect(TeamRatingValue.create(100).value).toBe(100);
|
||||
expect(TeamRatingValue.create(75.5).value).toBe(75.5);
|
||||
});
|
||||
|
||||
it('should throw for values below 0', () => {
|
||||
expect(() => TeamRatingValue.create(-1)).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingValue.create(-0.1)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for values above 100', () => {
|
||||
expect(() => TeamRatingValue.create(100.1)).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingValue.create(101)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should accept decimal values', () => {
|
||||
const value = TeamRatingValue.create(75.5);
|
||||
expect(value.value).toBe(75.5);
|
||||
});
|
||||
|
||||
it('should throw for non-numeric values', () => {
|
||||
expect(() => TeamRatingValue.create('50' as unknown as number)).toThrow();
|
||||
expect(() => TeamRatingValue.create(null as unknown as number)).toThrow();
|
||||
expect(() => TeamRatingValue.create(undefined as unknown as number)).toThrow();
|
||||
});
|
||||
|
||||
it('should return true for same value', () => {
|
||||
const val1 = TeamRatingValue.create(50);
|
||||
const val2 = TeamRatingValue.create(50);
|
||||
expect(val1.equals(val2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different values', () => {
|
||||
const val1 = TeamRatingValue.create(50);
|
||||
const val2 = TeamRatingValue.create(60);
|
||||
expect(val1.equals(val2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle decimal comparisons', () => {
|
||||
const val1 = TeamRatingValue.create(75.5);
|
||||
const val2 = TeamRatingValue.create(75.5);
|
||||
expect(val1.equals(val2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose props correctly', () => {
|
||||
const value = TeamRatingValue.create(50);
|
||||
expect(value.props.value).toBe(50);
|
||||
});
|
||||
|
||||
it('should return numeric value', () => {
|
||||
const value = TeamRatingValue.create(75.5);
|
||||
expect(value.toNumber()).toBe(75.5);
|
||||
});
|
||||
|
||||
it('should return string representation', () => {
|
||||
const value = TeamRatingValue.create(75.5);
|
||||
expect(value.toString()).toBe('75.5');
|
||||
});
|
||||
});
|
||||
});
|
||||
44
core/racing/domain/value-objects/TeamRatingValue.ts
Normal file
44
core/racing/domain/value-objects/TeamRatingValue.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface TeamRatingValueProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class TeamRatingValue implements IValueObject<TeamRatingValueProps> {
|
||||
readonly value: number;
|
||||
|
||||
private constructor(value: number) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: number): TeamRatingValue {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
throw new RacingDomainValidationError('Team rating value must be a valid number');
|
||||
}
|
||||
|
||||
if (value < 0 || value > 100) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Team rating value must be between 0 and 100, got: ${value}`
|
||||
);
|
||||
}
|
||||
|
||||
return new TeamRatingValue(value);
|
||||
}
|
||||
|
||||
get props(): TeamRatingValueProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<TeamRatingValueProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toNumber(): number {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value.toString();
|
||||
}
|
||||
}
|
||||
606
plans/seeds-clean-arch.md
Normal file
606
plans/seeds-clean-arch.md
Normal file
@@ -0,0 +1,606 @@
|
||||
# Clean Architecture Violations & Refactoring Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Recent changes introduced severe violations of Clean Architecture principles by creating singleton stores and in-memory services that bypass proper dependency injection and repository patterns. This document outlines all violations and provides a comprehensive refactoring strategy.
|
||||
|
||||
---
|
||||
|
||||
## 1. Violations Identified
|
||||
|
||||
### 1.1 Critical Violations
|
||||
|
||||
#### **Singleton Pattern in Adapters Layer**
|
||||
- **File**: `adapters/racing/services/DriverStatsStore.ts`
|
||||
- **Violation**: Global singleton with `getInstance()` static method
|
||||
- **Impact**: Bypasses dependency injection, creates hidden global state
|
||||
- **Lines**: 7-18
|
||||
|
||||
#### **In-Memory Services Using Singletons**
|
||||
- **File**: `adapters/racing/services/InMemoryRankingService.ts`
|
||||
- **Violation**: Direct singleton access via `DriverStatsStore.getInstance()` (line 14)
|
||||
- **Impact**: Service depends on global state, not injectable dependencies
|
||||
- **Lines**: 14
|
||||
|
||||
#### **In-Memory Driver Stats Service**
|
||||
- **File**: `adapters/racing/services/InMemoryDriverStatsService.ts`
|
||||
- **Violation**: Uses singleton store instead of repository pattern
|
||||
- **Impact**: Business logic depends on infrastructure implementation
|
||||
- **Lines**: 10
|
||||
|
||||
#### **Team Stats Store**
|
||||
- **File**: `adapters/racing/services/TeamStatsStore.ts`
|
||||
- **Violation**: Same singleton pattern as DriverStatsStore
|
||||
- **Impact**: Global state management in adapters layer
|
||||
|
||||
### 1.2 Architecture Violations
|
||||
|
||||
#### **Domain Services in Adapters**
|
||||
- **Location**: `adapters/racing/services/`
|
||||
- **Violation**: Services should be in domain layer, not adapters
|
||||
- **Impact**: Mixes application logic with infrastructure
|
||||
|
||||
#### **Hardcoded Data Sources**
|
||||
- **Issue**: RankingService computes from singleton store, not real data
|
||||
- **Impact**: Rankings don't reflect actual race results/standings
|
||||
|
||||
---
|
||||
|
||||
## 2. Clean Architecture Principles Violated
|
||||
|
||||
### 2.1 Dependency Rule
|
||||
**Principle**: Dependencies must point inward (domain → application → adapters → frameworks)
|
||||
|
||||
**Violation**:
|
||||
- `InMemoryRankingService` (adapters) → `DriverStatsStore` (singleton global)
|
||||
- This creates a dependency on global state, not domain abstractions
|
||||
|
||||
### 2.2 Dependency Injection
|
||||
**Principle**: All dependencies must be injected, never fetched
|
||||
|
||||
**Violation**:
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
const statsStore = DriverStatsStore.getInstance();
|
||||
|
||||
// ✅ CORRECT
|
||||
constructor(private readonly statsRepository: IDriverStatsRepository) {}
|
||||
```
|
||||
|
||||
### 2.3 Repository Pattern
|
||||
**Principle**: Persistence concerns belong in repositories, not services
|
||||
|
||||
**Violation**:
|
||||
- `DriverStatsStore` acts as a repository but is a singleton
|
||||
- Services directly access store instead of using repository interfaces
|
||||
|
||||
### 2.4 Domain Service Purity
|
||||
**Principle**: Domain services contain business logic, no persistence
|
||||
|
||||
**Violation**:
|
||||
- `InMemoryRankingService` is in adapters, not domain
|
||||
- It contains persistence logic (reading from store)
|
||||
|
||||
---
|
||||
|
||||
## 3. Proper Architecture Specification
|
||||
|
||||
### 3.1 Correct Layer Structure
|
||||
|
||||
```
|
||||
core/racing/
|
||||
├── domain/
|
||||
│ ├── services/
|
||||
│ │ ├── IRankingService.ts # Domain interface
|
||||
│ │ └── IDriverStatsService.ts # Domain interface
|
||||
│ └── repositories/
|
||||
│ ├── IResultRepository.ts # Persistence port
|
||||
│ ├── IStandingRepository.ts # Persistence port
|
||||
│ └── IDriverRepository.ts # Persistence port
|
||||
|
||||
adapters/racing/
|
||||
├── persistence/
|
||||
│ ├── inmemory/
|
||||
│ │ ├── InMemoryResultRepository.ts
|
||||
│ │ ├── InMemoryStandingRepository.ts
|
||||
│ │ └── InMemoryDriverRepository.ts
|
||||
│ └── typeorm/
|
||||
│ ├── TypeOrmResultRepository.ts
|
||||
│ ├── TypeOrmStandingRepository.ts
|
||||
│ └── TypeOrmDriverRepository.ts
|
||||
|
||||
apps/api/racing/
|
||||
├── controllers/
|
||||
├── services/
|
||||
└── presenters/
|
||||
```
|
||||
|
||||
### 3.2 Domain Services (Pure Business Logic)
|
||||
|
||||
**Location**: `core/racing/domain/services/`
|
||||
|
||||
**Characteristics**:
|
||||
- No persistence
|
||||
- No singletons
|
||||
- Pure business logic
|
||||
- Injected dependencies via interfaces
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// core/racing/domain/services/RankingService.ts
|
||||
export class RankingService implements IRankingService {
|
||||
constructor(
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async getAllDriverRankings(): Promise<DriverRanking[]> {
|
||||
// Query real data from repositories
|
||||
const standings = await this.standingRepository.findAll();
|
||||
const drivers = await this.driverRepository.findAll();
|
||||
|
||||
// Compute rankings from actual data
|
||||
return this.computeRankings(standings, drivers);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Repository Pattern
|
||||
|
||||
**Location**: `adapters/racing/persistence/`
|
||||
|
||||
**Characteristics**:
|
||||
- Implement domain repository interfaces
|
||||
- Handle persistence details
|
||||
- No singletons
|
||||
- Injected via constructor
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// adapters/racing/persistence/inmemory/InMemoryStandingRepository.ts
|
||||
export class InMemoryStandingRepository implements IStandingRepository {
|
||||
private standings = new Map<string, Standing>();
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Standing[]> {
|
||||
return Array.from(this.standings.values())
|
||||
.filter(s => s.leagueId === leagueId)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
}
|
||||
|
||||
async save(standing: Standing): Promise<Standing> {
|
||||
const key = `${standing.leagueId}-${standing.driverId}`;
|
||||
this.standings.set(key, standing);
|
||||
return standing;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Refactoring Strategy
|
||||
|
||||
### 4.1 Remove Violating Files
|
||||
|
||||
**DELETE**:
|
||||
- `adapters/racing/services/DriverStatsStore.ts`
|
||||
- `adapters/racing/services/TeamStatsStore.ts`
|
||||
- `adapters/racing/services/InMemoryRankingService.ts`
|
||||
- `adapters/racing/services/InMemoryDriverStatsService.ts`
|
||||
|
||||
### 4.2 Create Proper Domain Services
|
||||
|
||||
**CREATE**: `core/racing/domain/services/RankingService.ts`
|
||||
```typescript
|
||||
export class RankingService implements IRankingService {
|
||||
constructor(
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async getAllDriverRankings(): Promise<DriverRanking[]> {
|
||||
this.logger.debug('[RankingService] Computing rankings from standings');
|
||||
|
||||
const standings = await this.standingRepository.findAll();
|
||||
const drivers = await this.driverRepository.findAll();
|
||||
|
||||
// Group standings by driver and compute stats
|
||||
const driverStats = new Map<string, { rating: number; wins: number; races: number }>();
|
||||
|
||||
for (const standing of standings) {
|
||||
const existing = driverStats.get(standing.driverId) || { rating: 0, wins: 0, races: 0 };
|
||||
existing.races++;
|
||||
if (standing.position === 1) existing.wins++;
|
||||
existing.rating += this.calculateRating(standing.position);
|
||||
driverStats.set(standing.driverId, existing);
|
||||
}
|
||||
|
||||
// Convert to rankings
|
||||
const rankings: DriverRanking[] = Array.from(driverStats.entries()).map(([driverId, stats]) => ({
|
||||
driverId,
|
||||
rating: Math.round(stats.rating / stats.races),
|
||||
wins: stats.wins,
|
||||
totalRaces: stats.races,
|
||||
overallRank: null
|
||||
}));
|
||||
|
||||
// Sort by rating and assign ranks
|
||||
rankings.sort((a, b) => b.rating - a.rating);
|
||||
rankings.forEach((r, idx) => r.overallRank = idx + 1);
|
||||
|
||||
return rankings;
|
||||
}
|
||||
|
||||
private calculateRating(position: number): number {
|
||||
// iRacing-style rating calculation
|
||||
const base = 1000;
|
||||
const points = Math.max(0, 25 - position);
|
||||
return base + (points * 50);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CREATE**: `core/racing/domain/services/DriverStatsService.ts`
|
||||
```typescript
|
||||
export class DriverStatsService implements IDriverStatsService {
|
||||
constructor(
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async getDriverStats(driverId: string): Promise<DriverStats | null> {
|
||||
const results = await this.resultRepository.findByDriverId(driverId);
|
||||
const standings = await this.standingRepository.findAll();
|
||||
|
||||
if (results.length === 0) return null;
|
||||
|
||||
const wins = results.filter(r => r.position === 1).length;
|
||||
const podiums = results.filter(r => r.position <= 3).length;
|
||||
const totalRaces = results.length;
|
||||
|
||||
// Calculate rating from standings
|
||||
const driverStanding = standings.find(s => s.driverId === driverId);
|
||||
const rating = driverStanding ? this.calculateRatingFromStanding(driverStanding) : 1000;
|
||||
|
||||
// Find overall rank
|
||||
const sortedStandings = standings.sort((a, b) => b.points - a.points);
|
||||
const rankIndex = sortedStandings.findIndex(s => s.driverId === driverId);
|
||||
const overallRank = rankIndex >= 0 ? rankIndex + 1 : null;
|
||||
|
||||
return {
|
||||
rating,
|
||||
wins,
|
||||
podiums,
|
||||
totalRaces,
|
||||
overallRank
|
||||
};
|
||||
}
|
||||
|
||||
private calculateRatingFromStanding(standing: Standing): number {
|
||||
// Calculate based on position and points
|
||||
return Math.round(1000 + (standing.points * 10));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Create Proper Repositories
|
||||
|
||||
**CREATE**: `adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository.ts`
|
||||
```typescript
|
||||
export class InMemoryDriverStatsRepository implements IDriverStatsRepository {
|
||||
private stats = new Map<string, DriverStats>();
|
||||
|
||||
async getDriverStats(driverId: string): Promise<DriverStats | null> {
|
||||
return this.stats.get(driverId) || null;
|
||||
}
|
||||
|
||||
async saveDriverStats(driverId: string, stats: DriverStats): Promise<void> {
|
||||
this.stats.set(driverId, stats);
|
||||
}
|
||||
|
||||
async getAllStats(): Promise<Map<string, DriverStats>> {
|
||||
return new Map(this.stats);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Update Seed Data Strategy
|
||||
|
||||
**Current Problem**: Seeds populate singleton stores directly
|
||||
|
||||
**New Strategy**: Seed proper repositories, compute stats from results
|
||||
|
||||
**UPDATE**: `adapters/bootstrap/SeedRacingData.ts`
|
||||
```typescript
|
||||
export class SeedRacingData {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly seedDeps: RacingSeedDependencies,
|
||||
private readonly statsRepository: IDriverStatsRepository // NEW
|
||||
) {}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// ... existing seeding logic ...
|
||||
|
||||
// After seeding results and standings, compute and store stats
|
||||
await this.computeAndStoreDriverStats();
|
||||
await this.computeAndStoreTeamStats();
|
||||
}
|
||||
|
||||
private async computeAndStoreDriverStats(): Promise<void> {
|
||||
const drivers = await this.seedDeps.driverRepository.findAll();
|
||||
const standings = await this.seedDeps.standingRepository.findAll();
|
||||
|
||||
for (const driver of drivers) {
|
||||
const driverStandings = standings.filter(s => s.driverId === driver.id);
|
||||
if (driverStandings.length === 0) continue;
|
||||
|
||||
const stats = this.calculateDriverStats(driver, driverStandings);
|
||||
await this.statsRepository.saveDriverStats(driver.id, stats);
|
||||
}
|
||||
|
||||
this.logger.info(`[Bootstrap] Computed stats for ${drivers.length} drivers`);
|
||||
}
|
||||
|
||||
private calculateDriverStats(driver: Driver, standings: Standing[]): DriverStats {
|
||||
const wins = standings.filter(s => s.position === 1).length;
|
||||
const podiums = standings.filter(s => s.position <= 3).length;
|
||||
const totalRaces = standings.length;
|
||||
const avgPosition = standings.reduce((sum, s) => sum + s.position, 0) / totalRaces;
|
||||
|
||||
// Calculate rating based on performance
|
||||
const baseRating = 1000;
|
||||
const performanceBonus = (wins * 100) + (podiums * 50) + Math.max(0, 200 - (avgPosition * 10));
|
||||
const rating = Math.round(baseRating + performanceBonus);
|
||||
|
||||
// Find overall rank
|
||||
const allStandings = await this.seedDeps.standingRepository.findAll();
|
||||
const sorted = allStandings.sort((a, b) => b.points - a.points);
|
||||
const rankIndex = sorted.findIndex(s => s.driverId === driver.id);
|
||||
const overallRank = rankIndex >= 0 ? rankIndex + 1 : null;
|
||||
|
||||
return {
|
||||
rating,
|
||||
safetyRating: 85, // Could be computed from penalties/incidents
|
||||
sportsmanshipRating: 4.5, // Could be computed from protests
|
||||
totalRaces,
|
||||
wins,
|
||||
podiums,
|
||||
dnfs: totalRaces - wins - podiums, // Approximate
|
||||
avgFinish: avgPosition,
|
||||
bestFinish: Math.min(...standings.map(s => s.position)),
|
||||
worstFinish: Math.max(...standings.map(s => s.position)),
|
||||
consistency: Math.round(100 - (avgPosition * 2)), // Simplified
|
||||
experienceLevel: this.determineExperienceLevel(totalRaces),
|
||||
overallRank
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend Data Strategy
|
||||
|
||||
### 5.1 Media Repository Pattern
|
||||
|
||||
**Location**: `adapters/racing/persistence/media/`
|
||||
|
||||
**Purpose**: Handle static assets (logos, images, categories)
|
||||
|
||||
**Structure**:
|
||||
```
|
||||
adapters/racing/persistence/media/
|
||||
├── IMediaRepository.ts
|
||||
├── InMemoryMediaRepository.ts
|
||||
├── FileSystemMediaRepository.ts
|
||||
└── S3MediaRepository.ts
|
||||
```
|
||||
|
||||
**Interface**:
|
||||
```typescript
|
||||
export interface IMediaRepository {
|
||||
getDriverAvatar(driverId: string): Promise<string | null>;
|
||||
getTeamLogo(teamId: string): Promise<string | null>;
|
||||
getTrackImage(trackId: string): Promise<string | null>;
|
||||
getCategoryIcon(categoryId: string): Promise<string | null>;
|
||||
getSponsorLogo(sponsorId: string): Promise<string | null>;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Data Enrichment Strategy
|
||||
|
||||
**Problem**: Frontend needs ratings, wins, categories, logos
|
||||
|
||||
**Solution**:
|
||||
1. **Seed real data** (results, standings, races)
|
||||
2. **Compute stats** from real data
|
||||
3. **Store in repositories** (not singletons)
|
||||
4. **Serve via queries** (CQRS pattern)
|
||||
|
||||
**Flow**:
|
||||
```
|
||||
Seed Results → Compute Standings → Calculate Stats → Store in Repository → Query for Frontend
|
||||
```
|
||||
|
||||
### 5.3 Query Layer for Frontend
|
||||
|
||||
**Location**: `core/racing/application/queries/`
|
||||
|
||||
**Examples**:
|
||||
```typescript
|
||||
// GetDriverProfileQuery.ts
|
||||
export class GetDriverProfileQuery {
|
||||
constructor(
|
||||
private readonly driverStatsRepository: IDriverStatsRepository,
|
||||
private readonly mediaRepository: IMediaRepository,
|
||||
private readonly driverRepository: IDriverRepository
|
||||
) {}
|
||||
|
||||
async execute(driverId: string): Promise<DriverProfileViewModel> {
|
||||
const driver = await this.driverRepository.findById(driverId);
|
||||
const stats = await this.driverStatsRepository.getDriverStats(driverId);
|
||||
const avatar = await this.mediaRepository.getDriverAvatar(driverId);
|
||||
|
||||
return {
|
||||
id: driverId,
|
||||
name: driver.name,
|
||||
avatar,
|
||||
rating: stats.rating,
|
||||
wins: stats.wins,
|
||||
podiums: stats.podiums,
|
||||
totalRaces: stats.totalRaces,
|
||||
rank: stats.overallRank,
|
||||
experienceLevel: stats.experienceLevel
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Steps
|
||||
|
||||
### Phase 1: Remove Violations (Immediate)
|
||||
1. ✅ Delete `DriverStatsStore.ts`
|
||||
2. ✅ Delete `TeamStatsStore.ts`
|
||||
3. ✅ Delete `InMemoryRankingService.ts`
|
||||
4. ✅ Delete `InMemoryDriverStatsService.ts`
|
||||
5. ✅ Remove imports from `SeedRacingData.ts`
|
||||
|
||||
### Phase 2: Create Proper Infrastructure
|
||||
1. ✅ Create `core/racing/domain/services/RankingService.ts`
|
||||
2. ✅ Create `core/racing/domain/services/DriverStatsService.ts`
|
||||
3. ✅ Create `adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository.ts`
|
||||
4. ✅ Create `adapters/racing/persistence/media/InMemoryMediaRepository.ts`
|
||||
|
||||
### Phase 3: Update Seed Logic
|
||||
1. ✅ Modify `SeedRacingData.ts` to compute stats from results
|
||||
2. ✅ Remove singleton store population
|
||||
3. ✅ Add stats repository injection
|
||||
4. ✅ Add media data seeding
|
||||
|
||||
### Phase 4: Update Application Layer
|
||||
1. ✅ Update factories to inject proper services
|
||||
2. ✅ Update controllers to use domain services
|
||||
3. ✅ Update presenters to query repositories
|
||||
|
||||
### Phase 5: Frontend Integration
|
||||
1. ✅ Create query use cases for frontend data
|
||||
2. ✅ Implement media repository for assets
|
||||
3. ✅ Update API endpoints to serve computed data
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing Strategy
|
||||
|
||||
### 7.1 Unit Tests
|
||||
```typescript
|
||||
// RankingService.test.ts
|
||||
describe('RankingService', () => {
|
||||
it('computes rankings from real standings', async () => {
|
||||
const mockStandings = [/* real standings */];
|
||||
const mockResults = [/* real results */];
|
||||
|
||||
const service = new RankingService(
|
||||
mockResultRepo,
|
||||
mockStandingRepo,
|
||||
mockDriverRepo,
|
||||
mockLogger
|
||||
);
|
||||
|
||||
const rankings = await service.getAllDriverRankings();
|
||||
|
||||
expect(rankings).toHaveLength(150);
|
||||
expect(rankings[0].overallRank).toBe(1);
|
||||
expect(rankings[0].rating).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 7.2 Integration Tests
|
||||
```typescript
|
||||
// SeedRacingData.integration.test.ts
|
||||
describe('SeedRacingData', () => {
|
||||
it('seeds data and computes stats correctly', async () => {
|
||||
const seed = new SeedRacingData(logger, deps, statsRepo);
|
||||
await seed.execute();
|
||||
|
||||
const stats = await statsRepo.getDriverStats(driverId);
|
||||
expect(stats.rating).toBeDefined();
|
||||
expect(stats.wins).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Benefits of This Approach
|
||||
|
||||
### 8.1 Architecture Benefits
|
||||
- ✅ **Clean Architecture Compliance**: Proper layer separation
|
||||
- ✅ **Dependency Injection**: All dependencies injected
|
||||
- ✅ **Testability**: Easy to mock repositories
|
||||
- ✅ **Maintainability**: Clear separation of concerns
|
||||
|
||||
### 8.2 Functional Benefits
|
||||
- ✅ **Real Data**: Rankings computed from actual race results
|
||||
- ✅ **Scalability**: Works with any persistence (memory, Postgres, etc.)
|
||||
- ✅ **Flexibility**: Easy to add new data sources
|
||||
- ✅ **Consistency**: Single source of truth for stats
|
||||
|
||||
### 8.3 Development Benefits
|
||||
- ✅ **No Hidden State**: No singletons
|
||||
- ✅ **Explicit Dependencies**: Clear what each service needs
|
||||
- ✅ **Framework Agnostic**: Core doesn't depend on infrastructure
|
||||
- ✅ **Future Proof**: Easy to migrate to different storage
|
||||
|
||||
---
|
||||
|
||||
## 9. Migration Checklist
|
||||
|
||||
- [ ] Remove all singleton stores
|
||||
- [ ] Remove all in-memory services from adapters/racing/services/
|
||||
- [ ] Create proper domain services in core/racing/domain/services/
|
||||
- [ ] Create proper repositories in adapters/racing/persistence/
|
||||
- [ ] Update SeedRacingData to compute stats from real data
|
||||
- [ ] Update all factories to use dependency injection
|
||||
- [ ] Update controllers to use domain services
|
||||
- [ ] Update presenters to use query patterns
|
||||
- [ ] Add media repository for frontend assets
|
||||
- [ ] Create query use cases for frontend data
|
||||
- [ ] Update tests to use proper patterns
|
||||
- [ ] Verify no singleton usage anywhere
|
||||
- [ ] Verify all services are pure domain services
|
||||
- [ ] Verify all persistence is in repositories
|
||||
|
||||
---
|
||||
|
||||
## 10. Summary
|
||||
|
||||
The current implementation violates Clean Architecture by:
|
||||
1. Using singletons for state management
|
||||
2. Placing services in adapters layer
|
||||
3. Hardcoding data sources instead of using repositories
|
||||
4. Mixing persistence logic with business logic
|
||||
|
||||
The solution requires:
|
||||
1. **Removing** all singleton stores and in-memory services
|
||||
2. **Creating** proper domain services that compute from real data
|
||||
3. **Implementing** repository pattern for all persistence
|
||||
4. **Updating** seed logic to compute stats from results/standings
|
||||
5. **Adding** media repository for frontend assets
|
||||
6. **Using** CQRS pattern for queries
|
||||
|
||||
This will result in a clean, maintainable, and scalable architecture that properly follows Clean Architecture principles.
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Last Updated**: 2025-12-29
|
||||
**Status**: Planning Phase
|
||||
**Next Steps**: Implementation
|
||||
Reference in New Issue
Block a user