team rating

This commit is contained in:
2025-12-30 12:25:45 +01:00
parent ccaa39c39c
commit 83371ea839
93 changed files with 10324 additions and 490 deletions

View File

@@ -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();

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View 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();
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
});
});
});

View File

@@ -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;
}
}

View File

@@ -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');
});
});
});

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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();
});
});

View File

@@ -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);
}
}

View File

@@ -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');
});
});

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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',
],
},
{

View File

@@ -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: [

View File

@@ -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';

View File

@@ -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,

View File

@@ -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],
},
];

View File

@@ -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 () => {

View File

@@ -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> {

View File

@@ -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';

View File

@@ -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,

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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[];
}

View 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;
};
}

View 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)
}

View File

@@ -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,

View 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>;
}

View 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();
});
});
});

View 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,
},
};
}
}

View File

@@ -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();
});
});
});

View File

@@ -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;
}
}

View 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';

View File

@@ -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);
});
});
});

View File

@@ -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);
}
}
}

View 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';
}
}

View File

@@ -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,
);

View File

@@ -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,
} : {}),
};
}),
);

View File

@@ -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,

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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,

View 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>;
}

View 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[]>;
}

View 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>;
}

View 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;
}
}
}

View File

@@ -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);
});
});
});

View File

@@ -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');
}
}

View File

@@ -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([]);
});
});
});

View File

@@ -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,
};
}
}
}

View 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');
});
});
});

View 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;
}
}
}

View File

@@ -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();
});
});
});

View 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];
}
}

View File

@@ -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([]);
});
});
});

View File

@@ -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,
};
}
}
}

View 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);
});
});
});

View 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,
};
}
}

View 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>;
}

View 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>;
}

View File

@@ -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>>;
}

View 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>;
}

View 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>;
}

View File

@@ -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;
}

View File

@@ -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[];
}

View 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);
});
});
});

View 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';
}
}

View File

@@ -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);
});
});
});

View 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;
}
}

View 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);
});
});
});

View 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';
}
}

View 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);
});
});
});

View 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,
};
}
}

View 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' });
});
});
});

View 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);
}
}

View 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(),
});
}
}

View 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);
});
});
});

View 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;
}
}

View File

@@ -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');
});
});
});

View 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;
}
}

View 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);
});
});
});

View 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;
}
}

View 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');
});
});
});

View 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
View 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