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