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

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