team rating
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* DTO: RecordTeamRaceRatingEventsDto
|
||||
*
|
||||
* Input for RecordTeamRaceRatingEventsUseCase
|
||||
*/
|
||||
|
||||
export interface RecordTeamRaceRatingEventsInput {
|
||||
raceId: string;
|
||||
}
|
||||
|
||||
export interface RecordTeamRaceRatingEventsOutput {
|
||||
success: boolean;
|
||||
raceId: string;
|
||||
eventsCreated: number;
|
||||
teamsUpdated: string[];
|
||||
errors: string[];
|
||||
}
|
||||
49
core/racing/application/dtos/TeamLedgerEntryDto.ts
Normal file
49
core/racing/application/dtos/TeamLedgerEntryDto.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* DTO: TeamLedgerEntryDto
|
||||
*
|
||||
* Simplified team rating event for ledger display/query.
|
||||
* Pragmatic read model - direct repo DTOs, no domain logic.
|
||||
*/
|
||||
|
||||
export interface TeamLedgerEntryDto {
|
||||
id: string;
|
||||
teamId: string;
|
||||
dimension: string; // 'driving', 'adminTrust'
|
||||
delta: number; // positive or negative change
|
||||
weight?: number;
|
||||
occurredAt: string; // ISO date string
|
||||
createdAt: string; // ISO date string
|
||||
|
||||
source: {
|
||||
type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
|
||||
id?: string;
|
||||
};
|
||||
|
||||
reason: {
|
||||
code: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
visibility: {
|
||||
public: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TeamLedgerFilter {
|
||||
dimensions?: string[]; // Filter by dimension keys
|
||||
sourceTypes?: ('race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment')[];
|
||||
from?: string; // ISO date string
|
||||
to?: string; // ISO date string
|
||||
reasonCodes?: string[];
|
||||
}
|
||||
|
||||
export interface PaginatedTeamLedgerResult {
|
||||
entries: TeamLedgerEntryDto[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
nextOffset?: number | null;
|
||||
};
|
||||
}
|
||||
30
core/racing/application/dtos/TeamRatingSummaryDto.ts
Normal file
30
core/racing/application/dtos/TeamRatingSummaryDto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* DTO: TeamRatingSummaryDto
|
||||
*
|
||||
* Comprehensive team rating summary with platform ratings.
|
||||
* Pragmatic read model - direct repo DTOs, no domain logic.
|
||||
*/
|
||||
|
||||
export interface TeamRatingDimension {
|
||||
value: number;
|
||||
confidence: number;
|
||||
sampleSize: number;
|
||||
trend: 'rising' | 'stable' | 'falling';
|
||||
lastUpdated: string; // ISO date string
|
||||
}
|
||||
|
||||
export interface TeamRatingSummaryDto {
|
||||
teamId: string;
|
||||
|
||||
// Platform ratings (from internal calculations)
|
||||
platform: {
|
||||
driving: TeamRatingDimension;
|
||||
adminTrust: TeamRatingDimension;
|
||||
overall: number;
|
||||
};
|
||||
|
||||
// Timestamps
|
||||
createdAt: string; // ISO date string
|
||||
updatedAt: string; // ISO date string
|
||||
lastRatingEventAt?: string; // ISO date string (optional)
|
||||
}
|
||||
@@ -47,6 +47,13 @@ export * from './use-cases/GetPendingSponsorshipRequestsUseCase';
|
||||
export * from './use-cases/GetEntitySponsorshipPricingUseCase';
|
||||
export * from './ports/LeagueScoringPresetProvider';
|
||||
|
||||
// Team Rating Queries
|
||||
export * from './queries/index';
|
||||
|
||||
// Team Rating DTOs
|
||||
export type { TeamRatingSummaryDto, TeamRatingDimension } from './dtos/TeamRatingSummaryDto';
|
||||
export type { TeamLedgerEntryDto, TeamLedgerFilter, PaginatedTeamLedgerResult } from './dtos/TeamLedgerEntryDto';
|
||||
|
||||
// Re-export domain types for legacy callers (type-only)
|
||||
export type {
|
||||
LeagueMembership,
|
||||
|
||||
15
core/racing/application/ports/ITeamRaceResultsProvider.ts
Normal file
15
core/racing/application/ports/ITeamRaceResultsProvider.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { TeamDrivingRaceFactsDto } from '../../domain/services/TeamDrivingRatingEventFactory';
|
||||
|
||||
/**
|
||||
* Port: ITeamRaceResultsProvider
|
||||
*
|
||||
* Provides race results for teams from the racing context.
|
||||
* This is a port that adapts the racing domain data to the rating system.
|
||||
*/
|
||||
export interface ITeamRaceResultsProvider {
|
||||
/**
|
||||
* Get race results for teams
|
||||
* Returns team race facts needed for rating calculations
|
||||
*/
|
||||
getTeamRaceResults(raceId: string): Promise<TeamDrivingRaceFactsDto | null>;
|
||||
}
|
||||
376
core/racing/application/queries/GetTeamRatingLedgerQuery.test.ts
Normal file
376
core/racing/application/queries/GetTeamRatingLedgerQuery.test.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Tests for GetTeamRatingLedgerQuery
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||
import { GetTeamRatingLedgerQuery, GetTeamRatingLedgerQueryHandler } from './GetTeamRatingLedgerQuery';
|
||||
import { TeamRatingEvent } from '../../domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../../domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../../domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../../domain/value-objects/TeamRatingDelta';
|
||||
|
||||
describe('GetTeamRatingLedgerQuery', () => {
|
||||
let mockRatingEventRepo: any;
|
||||
let handler: GetTeamRatingLedgerQueryHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRatingEventRepo = {
|
||||
findEventsPaginated: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetTeamRatingLedgerQueryHandler(mockRatingEventRepo);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should return paginated ledger entries', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
// Mock paginated result
|
||||
const event1 = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: { code: 'RACE_FINISH' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const event2 = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(-5),
|
||||
occurredAt: new Date('2024-01-02T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-02T10:00:00Z'),
|
||||
source: { type: 'penalty', id: 'penalty-456' },
|
||||
reason: { code: 'LATE_JOIN' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
weight: 2,
|
||||
});
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [event1, event2],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
nextOffset: undefined,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.entries.length).toBe(2);
|
||||
|
||||
const entry1 = result.entries[0];
|
||||
expect(entry1).toBeDefined();
|
||||
if (entry1) {
|
||||
expect(entry1.teamId).toBe(teamId);
|
||||
expect(entry1.dimension).toBe('driving');
|
||||
expect(entry1.delta).toBe(10);
|
||||
expect(entry1.source.type).toBe('race');
|
||||
expect(entry1.source.id).toBe('race-123');
|
||||
expect(entry1.reason.code).toBe('RACE_FINISH');
|
||||
expect(entry1.visibility.public).toBe(true);
|
||||
}
|
||||
|
||||
const entry2 = result.entries[1];
|
||||
expect(entry2).toBeDefined();
|
||||
if (entry2) {
|
||||
expect(entry2.dimension).toBe('adminTrust');
|
||||
expect(entry2.delta).toBe(-5);
|
||||
expect(entry2.weight).toBe(2);
|
||||
expect(entry2.source.type).toBe('penalty');
|
||||
expect(entry2.source.id).toBe('penalty-456');
|
||||
}
|
||||
|
||||
expect(result.pagination.total).toBe(2);
|
||||
expect(result.pagination.limit).toBe(20);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply default pagination values', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId };
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockRatingEventRepo.findEventsPaginated).toHaveBeenCalledWith(
|
||||
teamId,
|
||||
expect.objectContaining({
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply custom pagination values', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
hasMore: true,
|
||||
nextOffset: 30,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId, limit: 10, offset: 20 };
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockRatingEventRepo.findEventsPaginated).toHaveBeenCalledWith(
|
||||
teamId,
|
||||
expect.objectContaining({
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply filters when provided', async () => {
|
||||
const teamId = 'team-123';
|
||||
const filter = {
|
||||
dimensions: ['driving'],
|
||||
sourceTypes: ['race', 'penalty'] as ('race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment')[],
|
||||
from: '2024-01-01T00:00:00Z',
|
||||
to: '2024-01-31T23:59:59Z',
|
||||
reasonCodes: ['RACE_FINISH', 'LATE_JOIN'],
|
||||
};
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId, filter };
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockRatingEventRepo.findEventsPaginated).toHaveBeenCalledWith(
|
||||
teamId,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
dimensions: ['driving'],
|
||||
sourceTypes: ['race', 'penalty'],
|
||||
from: new Date('2024-01-01T00:00:00Z'),
|
||||
to: new Date('2024-01-31T23:59:59Z'),
|
||||
reasonCodes: ['RACE_FINISH', 'LATE_JOIN'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle events with optional weight', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
const eventWithWeight = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(15),
|
||||
weight: 1.5,
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-789' },
|
||||
reason: { code: 'PERFORMANCE_BONUS' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const eventWithoutWeight = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-02T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-02T10:00:00Z'),
|
||||
source: { type: 'vote', id: 'vote-123' },
|
||||
reason: { code: 'POSITIVE_VOTE' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [eventWithWeight, eventWithoutWeight],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
const entry1 = result.entries[0];
|
||||
const entry2 = result.entries[1];
|
||||
expect(entry1).toBeDefined();
|
||||
expect(entry2).toBeDefined();
|
||||
|
||||
if (entry1) {
|
||||
expect(entry1.weight).toBe(1.5);
|
||||
}
|
||||
if (entry2) {
|
||||
expect(entry2.weight).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle events with optional source.id', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
const eventWithId = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: { code: 'RACE_FINISH' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const eventWithoutId = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-02T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-02T10:00:00Z'),
|
||||
source: { type: 'manualAdjustment' },
|
||||
reason: { code: 'ADMIN_ADJUSTMENT' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [eventWithId, eventWithoutId],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
const entry1 = result.entries[0];
|
||||
const entry2 = result.entries[1];
|
||||
expect(entry1).toBeDefined();
|
||||
expect(entry2).toBeDefined();
|
||||
|
||||
if (entry1) {
|
||||
expect(entry1.source.id).toBe('race-123');
|
||||
}
|
||||
if (entry2) {
|
||||
expect(entry2.source.id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle events with optional reason.description', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
const eventWithDescription = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st in class' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const eventWithoutDescription = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-02T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-02T10:00:00Z'),
|
||||
source: { type: 'vote', id: 'vote-123' },
|
||||
reason: { code: 'POSITIVE_VOTE' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [eventWithDescription, eventWithoutDescription],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
const entry1 = result.entries[0];
|
||||
const entry2 = result.entries[1];
|
||||
expect(entry1).toBeDefined();
|
||||
expect(entry2).toBeDefined();
|
||||
|
||||
if (entry1) {
|
||||
expect(entry1.reason.description).toBe('Finished 1st in class');
|
||||
}
|
||||
if (entry2) {
|
||||
expect(entry2.reason.description).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return nextOffset when hasMore is true', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
total: 50,
|
||||
limit: 20,
|
||||
offset: 20,
|
||||
hasMore: true,
|
||||
nextOffset: 40,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.pagination.nextOffset).toBe(40);
|
||||
});
|
||||
|
||||
it('should return null nextOffset when hasMore is false', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
total: 15,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const query: GetTeamRatingLedgerQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
expect(result.pagination.nextOffset).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
106
core/racing/application/queries/GetTeamRatingLedgerQuery.ts
Normal file
106
core/racing/application/queries/GetTeamRatingLedgerQuery.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Query: GetTeamRatingLedgerQuery
|
||||
*
|
||||
* Paginated/filtered query for team rating events (ledger).
|
||||
* Mirrors user slice 6 pattern but for teams.
|
||||
*/
|
||||
|
||||
import { TeamLedgerEntryDto, TeamLedgerFilter, PaginatedTeamLedgerResult } from '../dtos/TeamLedgerEntryDto';
|
||||
import { ITeamRatingEventRepository, PaginatedQueryOptions, TeamRatingEventFilter } from '../../domain/repositories/ITeamRatingEventRepository';
|
||||
|
||||
export interface GetTeamRatingLedgerQuery {
|
||||
teamId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filter?: TeamLedgerFilter;
|
||||
}
|
||||
|
||||
export class GetTeamRatingLedgerQueryHandler {
|
||||
constructor(
|
||||
private readonly ratingEventRepo: ITeamRatingEventRepository
|
||||
) {}
|
||||
|
||||
async execute(query: GetTeamRatingLedgerQuery): Promise<PaginatedTeamLedgerResult> {
|
||||
const { teamId, limit = 20, offset = 0, filter } = query;
|
||||
|
||||
// Build repo options
|
||||
const repoOptions: PaginatedQueryOptions = {
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
|
||||
// Add filter if provided
|
||||
if (filter) {
|
||||
const ratingEventFilter: TeamRatingEventFilter = {};
|
||||
|
||||
if (filter.dimensions) {
|
||||
ratingEventFilter.dimensions = filter.dimensions;
|
||||
}
|
||||
if (filter.sourceTypes) {
|
||||
ratingEventFilter.sourceTypes = filter.sourceTypes;
|
||||
}
|
||||
if (filter.from) {
|
||||
ratingEventFilter.from = new Date(filter.from);
|
||||
}
|
||||
if (filter.to) {
|
||||
ratingEventFilter.to = new Date(filter.to);
|
||||
}
|
||||
if (filter.reasonCodes) {
|
||||
ratingEventFilter.reasonCodes = filter.reasonCodes;
|
||||
}
|
||||
|
||||
repoOptions.filter = ratingEventFilter;
|
||||
}
|
||||
|
||||
// Query repository
|
||||
const result = await this.ratingEventRepo.findEventsPaginated(teamId, repoOptions);
|
||||
|
||||
// Convert domain entities to DTOs
|
||||
const entries: TeamLedgerEntryDto[] = result.items.map(event => {
|
||||
const source: { type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment'; id?: string } = {
|
||||
type: event.source.type,
|
||||
};
|
||||
if (event.source.id !== undefined) {
|
||||
source.id = event.source.id;
|
||||
}
|
||||
|
||||
const reason: { code: string; description?: string } = {
|
||||
code: event.reason.code,
|
||||
};
|
||||
if (event.reason.description !== undefined) {
|
||||
reason.description = event.reason.description;
|
||||
}
|
||||
|
||||
const dto: TeamLedgerEntryDto = {
|
||||
id: event.id.value,
|
||||
teamId: event.teamId,
|
||||
dimension: event.dimension.value,
|
||||
delta: event.delta.value,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
createdAt: event.createdAt.toISOString(),
|
||||
source,
|
||||
reason,
|
||||
visibility: {
|
||||
public: event.visibility.public,
|
||||
},
|
||||
};
|
||||
if (event.weight !== undefined) {
|
||||
dto.weight = event.weight;
|
||||
}
|
||||
return dto;
|
||||
});
|
||||
|
||||
const nextOffset = result.nextOffset !== undefined ? result.nextOffset : null;
|
||||
|
||||
return {
|
||||
entries,
|
||||
pagination: {
|
||||
total: result.total,
|
||||
limit: result.limit,
|
||||
offset: result.offset,
|
||||
hasMore: result.hasMore,
|
||||
nextOffset,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Tests for GetTeamRatingsSummaryQuery
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||
import { GetTeamRatingsSummaryQuery, GetTeamRatingsSummaryQueryHandler } from './GetTeamRatingsSummaryQuery';
|
||||
import { TeamRatingSnapshot } from '../../domain/services/TeamRatingSnapshotCalculator';
|
||||
import { TeamRatingValue } from '../../domain/value-objects/TeamRatingValue';
|
||||
import { TeamRatingEvent } from '../../domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../../domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../../domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../../domain/value-objects/TeamRatingDelta';
|
||||
|
||||
describe('GetTeamRatingsSummaryQuery', () => {
|
||||
let mockTeamRatingRepo: any;
|
||||
let mockRatingEventRepo: any;
|
||||
let handler: GetTeamRatingsSummaryQueryHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
mockTeamRatingRepo = {
|
||||
findByTeamId: vi.fn(),
|
||||
};
|
||||
mockRatingEventRepo = {
|
||||
getAllByTeamId: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetTeamRatingsSummaryQueryHandler(
|
||||
mockTeamRatingRepo,
|
||||
mockRatingEventRepo
|
||||
);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should return summary with platform ratings', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
// Mock team rating snapshot
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(75),
|
||||
adminTrust: TeamRatingValue.create(60),
|
||||
overall: 70.5,
|
||||
lastUpdated: new Date('2024-01-01T10:00:00Z'),
|
||||
eventCount: 5,
|
||||
};
|
||||
mockTeamRatingRepo.findByTeamId.mockResolvedValue(snapshot);
|
||||
|
||||
// Mock rating events
|
||||
const event = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: { code: 'RACE_FINISH' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
mockRatingEventRepo.getAllByTeamId.mockResolvedValue([event]);
|
||||
|
||||
const query: GetTeamRatingsSummaryQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.teamId).toBe(teamId);
|
||||
expect(result.platform.driving.value).toBe(75);
|
||||
expect(result.platform.adminTrust.value).toBe(60);
|
||||
expect(result.platform.overall).toBe(70.5);
|
||||
expect(result.lastRatingEventAt).toBe('2024-01-01T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should handle missing team rating gracefully', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
mockTeamRatingRepo.findByTeamId.mockResolvedValue(null);
|
||||
mockRatingEventRepo.getAllByTeamId.mockResolvedValue([]);
|
||||
|
||||
const query: GetTeamRatingsSummaryQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.teamId).toBe(teamId);
|
||||
expect(result.platform.driving.value).toBe(0);
|
||||
expect(result.platform.adminTrust.value).toBe(0);
|
||||
expect(result.platform.overall).toBe(0);
|
||||
expect(result.lastRatingEventAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle multiple events and find latest', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(80),
|
||||
adminTrust: TeamRatingValue.create(70),
|
||||
overall: 77,
|
||||
lastUpdated: new Date('2024-01-02T10:00:00Z'),
|
||||
eventCount: 10,
|
||||
};
|
||||
mockTeamRatingRepo.findByTeamId.mockResolvedValue(snapshot);
|
||||
|
||||
// Multiple events with different timestamps
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-01T08:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T08:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-02T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-02T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-2' },
|
||||
reason: { code: 'RACE_FINISH' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
mockRatingEventRepo.getAllByTeamId.mockResolvedValue(events);
|
||||
|
||||
const query: GetTeamRatingsSummaryQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.lastRatingEventAt).toBe('2024-01-02T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should calculate confidence and sampleSize from event count', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(65),
|
||||
adminTrust: TeamRatingValue.create(55),
|
||||
overall: 62,
|
||||
lastUpdated: new Date('2024-01-01T10:00:00Z'),
|
||||
eventCount: 8,
|
||||
};
|
||||
mockTeamRatingRepo.findByTeamId.mockResolvedValue(snapshot);
|
||||
mockRatingEventRepo.getAllByTeamId.mockResolvedValue([]);
|
||||
|
||||
const query: GetTeamRatingsSummaryQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
// Confidence should be min(1, eventCount/10) = 0.8
|
||||
expect(result.platform.driving.confidence).toBe(0.8);
|
||||
expect(result.platform.driving.sampleSize).toBe(8);
|
||||
expect(result.platform.adminTrust.confidence).toBe(0.8);
|
||||
expect(result.platform.adminTrust.sampleSize).toBe(8);
|
||||
});
|
||||
|
||||
it('should handle empty events array', async () => {
|
||||
const teamId = 'team-123';
|
||||
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(50),
|
||||
adminTrust: TeamRatingValue.create(50),
|
||||
overall: 50,
|
||||
lastUpdated: new Date('2024-01-01T10:00:00Z'),
|
||||
eventCount: 0,
|
||||
};
|
||||
mockTeamRatingRepo.findByTeamId.mockResolvedValue(snapshot);
|
||||
mockRatingEventRepo.getAllByTeamId.mockResolvedValue([]);
|
||||
|
||||
const query: GetTeamRatingsSummaryQuery = { teamId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.platform.driving.confidence).toBe(0);
|
||||
expect(result.platform.driving.sampleSize).toBe(0);
|
||||
expect(result.platform.driving.trend).toBe('stable');
|
||||
expect(result.lastRatingEventAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Query: GetTeamRatingsSummaryQuery
|
||||
*
|
||||
* Fast read query for team rating summary.
|
||||
* Mirrors user slice 6 pattern but for teams.
|
||||
*/
|
||||
|
||||
import { TeamRatingSummaryDto } from '../dtos/TeamRatingSummaryDto';
|
||||
import { ITeamRatingRepository } from '../../domain/repositories/ITeamRatingRepository';
|
||||
import { ITeamRatingEventRepository } from '../../domain/repositories/ITeamRatingEventRepository';
|
||||
|
||||
export interface GetTeamRatingsSummaryQuery {
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
export class GetTeamRatingsSummaryQueryHandler {
|
||||
constructor(
|
||||
private readonly teamRatingRepo: ITeamRatingRepository,
|
||||
private readonly ratingEventRepo: ITeamRatingEventRepository
|
||||
) {}
|
||||
|
||||
async execute(query: GetTeamRatingsSummaryQuery): Promise<TeamRatingSummaryDto> {
|
||||
const { teamId } = query;
|
||||
|
||||
// Fetch platform rating snapshot
|
||||
const teamRating = await this.teamRatingRepo.findByTeamId(teamId);
|
||||
|
||||
// Get last event timestamp if available
|
||||
let lastRatingEventAt: string | undefined;
|
||||
if (teamRating) {
|
||||
// Get all events to find the most recent one
|
||||
const events = await this.ratingEventRepo.getAllByTeamId(teamId);
|
||||
if (events.length > 0) {
|
||||
const lastEvent = events[events.length - 1];
|
||||
if (lastEvent) {
|
||||
lastRatingEventAt = lastEvent.occurredAt.toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build platform rating dimensions
|
||||
// For team ratings, we don't have confidence/sampleSize/trend per dimension
|
||||
// We'll derive these from event count and recent activity
|
||||
const eventCount = teamRating?.eventCount || 0;
|
||||
const lastUpdated = teamRating?.lastUpdated || new Date(0);
|
||||
|
||||
const platform = {
|
||||
driving: {
|
||||
value: teamRating?.driving.value || 0,
|
||||
confidence: Math.min(1, eventCount / 10), // Simple confidence based on event count
|
||||
sampleSize: eventCount,
|
||||
trend: 'stable' as const, // Could be calculated from recent events
|
||||
lastUpdated: lastUpdated.toISOString(),
|
||||
},
|
||||
adminTrust: {
|
||||
value: teamRating?.adminTrust.value || 0,
|
||||
confidence: Math.min(1, eventCount / 10),
|
||||
sampleSize: eventCount,
|
||||
trend: 'stable' as const,
|
||||
lastUpdated: lastUpdated.toISOString(),
|
||||
},
|
||||
overall: teamRating?.overall || 0,
|
||||
};
|
||||
|
||||
// Get timestamps
|
||||
const createdAt = lastUpdated.toISOString();
|
||||
const updatedAt = lastUpdated.toISOString();
|
||||
|
||||
const result: TeamRatingSummaryDto = {
|
||||
teamId,
|
||||
platform,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
|
||||
if (lastRatingEventAt) {
|
||||
result.lastRatingEventAt = lastRatingEventAt;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
5
core/racing/application/queries/index.ts
Normal file
5
core/racing/application/queries/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Team Rating Queries
|
||||
export type { GetTeamRatingsSummaryQuery } from './GetTeamRatingsSummaryQuery';
|
||||
export { GetTeamRatingsSummaryQueryHandler } from './GetTeamRatingsSummaryQuery';
|
||||
export type { GetTeamRatingLedgerQuery } from './GetTeamRatingLedgerQuery';
|
||||
export { GetTeamRatingLedgerQueryHandler } from './GetTeamRatingLedgerQuery';
|
||||
@@ -0,0 +1,196 @@
|
||||
import { AppendTeamRatingEventsUseCase } from './AppendTeamRatingEventsUseCase';
|
||||
import { ITeamRatingEventRepository } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
|
||||
// Mock repositories
|
||||
class MockTeamRatingEventRepository implements ITeamRatingEventRepository {
|
||||
private events: TeamRatingEvent[] = [];
|
||||
|
||||
async save(event: TeamRatingEvent): Promise<TeamRatingEvent> {
|
||||
this.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findByIds(ids: TeamRatingEventId[]): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => ids.some(id => id.equals(e.id)));
|
||||
}
|
||||
|
||||
async getAllByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findEventsPaginated(teamId: string): Promise<any> {
|
||||
const events = await this.getAllByTeamId(teamId);
|
||||
return {
|
||||
items: events,
|
||||
total: events.length,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.events = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockTeamRatingRepository implements ITeamRatingRepository {
|
||||
private snapshots: Map<string, any> = new Map();
|
||||
|
||||
async findByTeamId(teamId: string): Promise<any | null> {
|
||||
return this.snapshots.get(teamId) || null;
|
||||
}
|
||||
|
||||
async save(snapshot: any): Promise<any> {
|
||||
this.snapshots.set(snapshot.teamId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.snapshots.clear();
|
||||
}
|
||||
}
|
||||
|
||||
describe('AppendTeamRatingEventsUseCase', () => {
|
||||
let useCase: AppendTeamRatingEventsUseCase;
|
||||
let mockEventRepo: MockTeamRatingEventRepository;
|
||||
let mockRatingRepo: MockTeamRatingRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEventRepo = new MockTeamRatingEventRepository();
|
||||
mockRatingRepo = new MockTeamRatingRepository();
|
||||
useCase = new AppendTeamRatingEventsUseCase(mockEventRepo, mockRatingRepo);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockEventRepo.clear();
|
||||
mockRatingRepo.clear();
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should do nothing when no events provided', async () => {
|
||||
await useCase.execute([]);
|
||||
|
||||
const events = await mockEventRepo.getAllByTeamId('team-123');
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should save single event and update snapshot', async () => {
|
||||
const event = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await useCase.execute([event]);
|
||||
|
||||
// Check event was saved
|
||||
const savedEvents = await mockEventRepo.getAllByTeamId('team-123');
|
||||
expect(savedEvents.length).toBe(1);
|
||||
expect(savedEvents[0]!.id.equals(event.id)).toBe(true);
|
||||
|
||||
// Check snapshot was updated
|
||||
const snapshot = await mockRatingRepo.findByTeamId('team-123');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot.teamId).toBe('team-123');
|
||||
expect(snapshot.driving.value).toBe(60); // 50 + 10
|
||||
});
|
||||
|
||||
it('should save multiple events for same team and update snapshot', async () => {
|
||||
const event1 = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const event2 = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-01T11:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T11:00:00Z'),
|
||||
source: { type: 'race', id: 'race-457' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 2nd' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await useCase.execute([event1, event2]);
|
||||
|
||||
// Check both events were saved
|
||||
const savedEvents = await mockEventRepo.getAllByTeamId('team-123');
|
||||
expect(savedEvents.length).toBe(2);
|
||||
|
||||
// Check snapshot was updated with weighted average
|
||||
const snapshot = await mockRatingRepo.findByTeamId('team-123');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot.teamId).toBe('team-123');
|
||||
// Should be 50 + weighted average of (10, 5) = 50 + 7.5 = 57.5
|
||||
expect(snapshot.driving.value).toBe(57.5);
|
||||
});
|
||||
|
||||
it('should handle multiple teams in one batch', async () => {
|
||||
const event1 = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const event2 = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-456',
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'adminAction', id: 'action-789' },
|
||||
reason: { code: 'POSITIVE_ADMIN_ACTION', description: 'Helped organize event' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await useCase.execute([event1, event2]);
|
||||
|
||||
// Check both team snapshots were updated
|
||||
const snapshot1 = await mockRatingRepo.findByTeamId('team-123');
|
||||
const snapshot2 = await mockRatingRepo.findByTeamId('team-456');
|
||||
|
||||
expect(snapshot1).toBeDefined();
|
||||
expect(snapshot1.driving.value).toBe(60);
|
||||
|
||||
expect(snapshot2).toBeDefined();
|
||||
expect(snapshot2.adminTrust.value).toBe(55);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ITeamRatingEventRepository } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import type { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingSnapshotCalculator } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
|
||||
/**
|
||||
* Use Case: AppendTeamRatingEventsUseCase
|
||||
*
|
||||
* Appends new rating events to the ledger and updates the team rating snapshot.
|
||||
* Mirrors the AppendRatingEventsUseCase pattern for users.
|
||||
*/
|
||||
export class AppendTeamRatingEventsUseCase {
|
||||
constructor(
|
||||
private readonly ratingEventRepository: ITeamRatingEventRepository,
|
||||
private readonly ratingRepository: ITeamRatingRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the use case
|
||||
*
|
||||
* @param events - Array of rating events to append
|
||||
* @returns The updated team rating snapshot
|
||||
*/
|
||||
async execute(events: TeamRatingEvent[]): Promise<void> {
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get unique team IDs from events
|
||||
const teamIds = [...new Set(events.map(e => e.teamId))];
|
||||
|
||||
// Save all events
|
||||
for (const event of events) {
|
||||
await this.ratingEventRepository.save(event);
|
||||
}
|
||||
|
||||
// Update snapshots for each affected team
|
||||
for (const teamId of teamIds) {
|
||||
// Get all events for this team
|
||||
const allEvents = await this.ratingEventRepository.getAllByTeamId(teamId);
|
||||
|
||||
// Calculate new snapshot
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate(teamId, allEvents);
|
||||
|
||||
// Save snapshot
|
||||
await this.ratingRepository.save(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
113
core/racing/application/use-cases/DriverStatsUseCase.ts
Normal file
113
core/racing/application/use-cases/DriverStatsUseCase.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Application Use Case: DriverStatsUseCase
|
||||
*
|
||||
* Computes detailed driver statistics from race results and standings.
|
||||
* Orchestrates repositories to provide stats data to presentation layer.
|
||||
*/
|
||||
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||
import type { IDriverStatsUseCase, DriverStats } from './IDriverStatsUseCase';
|
||||
|
||||
export class DriverStatsUseCase implements IDriverStatsUseCase {
|
||||
constructor(
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.logger.info('[DriverStatsUseCase] Initialized with real data repositories');
|
||||
}
|
||||
|
||||
async getDriverStats(driverId: string): Promise<DriverStats | null> {
|
||||
this.logger.debug(`[DriverStatsUseCase] Computing stats for driver: ${driverId}`);
|
||||
|
||||
try {
|
||||
// Get all results for this driver
|
||||
const results = await this.resultRepository.findByDriverId(driverId);
|
||||
|
||||
if (results.length === 0) {
|
||||
this.logger.warn(`[DriverStatsUseCase] No results found for driver: ${driverId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get standings for context
|
||||
const standings = await this.standingRepository.findAll();
|
||||
const driverStanding = standings.find(s => s.driverId.toString() === driverId);
|
||||
|
||||
// Calculate basic stats from results
|
||||
const wins = results.filter(r => r.position.toNumber() === 1).length;
|
||||
const podiums = results.filter(r => r.position.toNumber() <= 3).length;
|
||||
const dnfs = results.filter(r => r.position.toNumber() > 20).length;
|
||||
const totalRaces = results.length;
|
||||
|
||||
const positions = results.map(r => r.position.toNumber());
|
||||
const avgFinish = positions.reduce((sum, pos) => sum + pos, 0) / totalRaces;
|
||||
const bestFinish = Math.min(...positions);
|
||||
const worstFinish = Math.max(...positions);
|
||||
|
||||
// Calculate rating based on performance
|
||||
let rating = 1000;
|
||||
if (driverStanding) {
|
||||
// Use standing-based rating
|
||||
const pointsBonus = driverStanding.points.toNumber() * 2;
|
||||
const positionBonus = Math.max(0, 50 - (driverStanding.position.toNumber() * 2));
|
||||
const winBonus = driverStanding.wins * 100;
|
||||
rating = Math.round(1000 + pointsBonus + positionBonus + winBonus);
|
||||
} else {
|
||||
// Calculate from results if no standing
|
||||
const performanceBonus = ((totalRaces - wins) * 5) + ((totalRaces - podiums) * 2);
|
||||
rating = Math.round(1000 + (wins * 100) + (podiums * 50) - performanceBonus);
|
||||
}
|
||||
|
||||
// Calculate consistency (inverse of position variance)
|
||||
const avgPosition = avgFinish;
|
||||
const variance = positions.reduce((sum, pos) => sum + Math.pow(pos - avgPosition, 2), 0) / totalRaces;
|
||||
const consistency = Math.round(Math.max(0, 100 - (variance * 2)));
|
||||
|
||||
// Safety rating (simplified - based on incidents)
|
||||
const totalIncidents = results.reduce((sum, r) => sum + r.incidents.toNumber(), 0);
|
||||
const safetyRating = Math.round(Math.max(0, 100 - (totalIncidents / totalRaces)));
|
||||
|
||||
// Sportsmanship rating (placeholder - could be based on penalties/protests)
|
||||
const sportsmanshipRating = 4.5;
|
||||
|
||||
// Experience level
|
||||
const experienceLevel = this.determineExperienceLevel(totalRaces);
|
||||
|
||||
// Overall rank
|
||||
const overallRank = driverStanding ? driverStanding.position.toNumber() : null;
|
||||
|
||||
const stats: DriverStats = {
|
||||
rating,
|
||||
safetyRating,
|
||||
sportsmanshipRating,
|
||||
totalRaces,
|
||||
wins,
|
||||
podiums,
|
||||
dnfs,
|
||||
avgFinish: Math.round(avgFinish * 10) / 10,
|
||||
bestFinish,
|
||||
worstFinish,
|
||||
consistency,
|
||||
experienceLevel,
|
||||
overallRank
|
||||
};
|
||||
|
||||
this.logger.debug(`[DriverStatsUseCase] Computed stats for driver ${driverId}: rating=${stats.rating}, wins=${stats.wins}`);
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
this.logger.error(`[DriverStatsUseCase] Error computing stats for driver ${driverId}:`, error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private determineExperienceLevel(totalRaces: number): string {
|
||||
if (totalRaces >= 100) return 'Veteran';
|
||||
if (totalRaces >= 50) return 'Experienced';
|
||||
if (totalRaces >= 20) return 'Intermediate';
|
||||
if (totalRaces >= 10) return 'Rookie';
|
||||
return 'Beginner';
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import { GetAllTeamsUseCase, type GetAllTeamsInput, type GetAllTeamsResult } from './GetAllTeamsUseCase';
|
||||
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import type { ITeamStatsRepository } from '../../domain/repositories/ITeamStatsRepository';
|
||||
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
@@ -30,6 +32,23 @@ describe('GetAllTeamsUseCase', () => {
|
||||
removeJoinRequest: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTeamStatsRepo: ITeamStatsRepository = {
|
||||
getTeamStats: vi.fn(),
|
||||
getTeamStatsSync: vi.fn(),
|
||||
saveTeamStats: vi.fn(),
|
||||
getAllStats: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
const mockMediaRepo: IMediaRepository = {
|
||||
getDriverAvatar: vi.fn(),
|
||||
getTeamLogo: vi.fn(),
|
||||
getTrackImage: vi.fn(),
|
||||
getCategoryIcon: vi.fn(),
|
||||
getSponsorLogo: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLogger: Logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
@@ -50,6 +69,8 @@ describe('GetAllTeamsUseCase', () => {
|
||||
const useCase = new GetAllTeamsUseCase(
|
||||
mockTeamRepo,
|
||||
mockTeamMembershipRepo,
|
||||
mockTeamStatsRepo,
|
||||
mockMediaRepo,
|
||||
mockLogger,
|
||||
output,
|
||||
);
|
||||
@@ -115,6 +136,8 @@ describe('GetAllTeamsUseCase', () => {
|
||||
const useCase = new GetAllTeamsUseCase(
|
||||
mockTeamRepo,
|
||||
mockTeamMembershipRepo,
|
||||
mockTeamStatsRepo,
|
||||
mockMediaRepo,
|
||||
mockLogger,
|
||||
output,
|
||||
);
|
||||
@@ -139,6 +162,8 @@ describe('GetAllTeamsUseCase', () => {
|
||||
const useCase = new GetAllTeamsUseCase(
|
||||
mockTeamRepo,
|
||||
mockTeamMembershipRepo,
|
||||
mockTeamStatsRepo,
|
||||
mockMediaRepo,
|
||||
mockLogger,
|
||||
output,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import type { ITeamStatsRepository } from '../../domain/repositories/ITeamStatsRepository';
|
||||
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
@@ -18,6 +20,14 @@ export interface TeamSummary {
|
||||
leagues: string[];
|
||||
createdAt: Date;
|
||||
memberCount: number;
|
||||
totalWins?: number;
|
||||
totalRaces?: number;
|
||||
performanceLevel?: string;
|
||||
specialization?: string;
|
||||
region?: string;
|
||||
languages?: string[];
|
||||
logoUrl?: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
export interface GetAllTeamsResult {
|
||||
@@ -32,6 +42,8 @@ export class GetAllTeamsUseCase {
|
||||
constructor(
|
||||
private readonly teamRepository: ITeamRepository,
|
||||
private readonly teamMembershipRepository: ITeamMembershipRepository,
|
||||
private readonly teamStatsRepository: ITeamStatsRepository,
|
||||
private readonly mediaRepository: IMediaRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<GetAllTeamsResult>,
|
||||
) {}
|
||||
@@ -48,6 +60,9 @@ export class GetAllTeamsUseCase {
|
||||
const enrichedTeams: TeamSummary[] = await Promise.all(
|
||||
teams.map(async (team) => {
|
||||
const memberCount = await this.teamMembershipRepository.countByTeamId(team.id);
|
||||
const stats = await this.teamStatsRepository.getTeamStats(team.id);
|
||||
const logoUrl = await this.mediaRepository.getTeamLogo(team.id);
|
||||
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name.props,
|
||||
@@ -57,6 +72,17 @@ export class GetAllTeamsUseCase {
|
||||
leagues: team.leagues.map(l => l.toString()),
|
||||
createdAt: team.createdAt.toDate(),
|
||||
memberCount,
|
||||
// Add stats fields
|
||||
...(stats ? {
|
||||
totalWins: stats.totalWins,
|
||||
totalRaces: stats.totalRaces,
|
||||
performanceLevel: stats.performanceLevel,
|
||||
specialization: stats.specialization,
|
||||
region: stats.region,
|
||||
languages: stats.languages,
|
||||
logoUrl: logoUrl || stats.logoUrl,
|
||||
rating: stats.rating,
|
||||
} : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
type GetDriversLeaderboardInput,
|
||||
} from './GetDriversLeaderboardUseCase';
|
||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||
import type { IRankingService } from '../../domain/services/IRankingService';
|
||||
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
|
||||
import type { IRankingUseCase } from './IRankingUseCase';
|
||||
import type { IDriverStatsUseCase } from './IDriverStatsUseCase';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import type { GetDriversLeaderboardResult } from './GetDriversLeaderboardUseCase';
|
||||
@@ -24,12 +24,12 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
};
|
||||
|
||||
const mockRankingGetAllDriverRankings = vi.fn();
|
||||
const mockRankingService: IRankingService = {
|
||||
const mockRankingUseCase: IRankingUseCase = {
|
||||
getAllDriverRankings: mockRankingGetAllDriverRankings,
|
||||
};
|
||||
|
||||
const mockDriverStatsGetDriverStats = vi.fn();
|
||||
const mockDriverStatsService: IDriverStatsService = {
|
||||
const mockDriverStatsUseCase: IDriverStatsUseCase = {
|
||||
getDriverStats: mockDriverStatsGetDriverStats,
|
||||
};
|
||||
|
||||
@@ -48,8 +48,8 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
it('should return drivers leaderboard data', async () => {
|
||||
const useCase = new GetDriversLeaderboardUseCase(
|
||||
mockDriverRepo,
|
||||
mockRankingService,
|
||||
mockDriverStatsService,
|
||||
mockRankingUseCase,
|
||||
mockDriverStatsUseCase,
|
||||
mockGetDriverAvatar,
|
||||
mockLogger,
|
||||
mockOutput,
|
||||
@@ -117,8 +117,8 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
it('should return empty result when no drivers', async () => {
|
||||
const useCase = new GetDriversLeaderboardUseCase(
|
||||
mockDriverRepo,
|
||||
mockRankingService,
|
||||
mockDriverStatsService,
|
||||
mockRankingUseCase,
|
||||
mockDriverStatsUseCase,
|
||||
mockGetDriverAvatar,
|
||||
mockLogger,
|
||||
mockOutput,
|
||||
@@ -144,8 +144,8 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
it('should handle drivers without stats', async () => {
|
||||
const useCase = new GetDriversLeaderboardUseCase(
|
||||
mockDriverRepo,
|
||||
mockRankingService,
|
||||
mockDriverStatsService,
|
||||
mockRankingUseCase,
|
||||
mockDriverStatsUseCase,
|
||||
mockGetDriverAvatar,
|
||||
mockLogger,
|
||||
mockOutput,
|
||||
@@ -188,8 +188,8 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
it('should return error when repository throws', async () => {
|
||||
const useCase = new GetDriversLeaderboardUseCase(
|
||||
mockDriverRepo,
|
||||
mockRankingService,
|
||||
mockDriverStatsService,
|
||||
mockRankingUseCase,
|
||||
mockDriverStatsUseCase,
|
||||
mockGetDriverAvatar,
|
||||
mockLogger,
|
||||
mockOutput,
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorC
|
||||
import type { Driver } from '../../domain/entities/Driver';
|
||||
import type { Team } from '../../domain/entities/Team';
|
||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
|
||||
import type { IRankingService } from '../../domain/services/IRankingService';
|
||||
import type { IDriverStatsUseCase } from './IDriverStatsUseCase';
|
||||
import type { IRankingUseCase } from './IRankingUseCase';
|
||||
import { SkillLevelService, type SkillLevel } from '../../domain/services/SkillLevelService';
|
||||
|
||||
export type GetDriversLeaderboardInput = {
|
||||
@@ -45,8 +45,8 @@ export type GetDriversLeaderboardErrorCode =
|
||||
export class GetDriversLeaderboardUseCase implements UseCase<GetDriversLeaderboardInput, void, GetDriversLeaderboardErrorCode> {
|
||||
constructor(
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly rankingService: IRankingService,
|
||||
private readonly driverStatsService: IDriverStatsService,
|
||||
private readonly rankingUseCase: IRankingUseCase,
|
||||
private readonly driverStatsUseCase: IDriverStatsUseCase,
|
||||
private readonly getDriverAvatar: (driverId: string) => Promise<string | undefined>,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<GetDriversLeaderboardResult>,
|
||||
@@ -64,7 +64,7 @@ export class GetDriversLeaderboardUseCase implements UseCase<GetDriversLeaderboa
|
||||
|
||||
try {
|
||||
const drivers = await this.driverRepository.findAll();
|
||||
const rankings = this.rankingService.getAllDriverRankings();
|
||||
const rankings = await this.rankingUseCase.getAllDriverRankings();
|
||||
|
||||
const avatarUrls: Record<string, string | undefined> = {};
|
||||
|
||||
@@ -72,9 +72,21 @@ export class GetDriversLeaderboardUseCase implements UseCase<GetDriversLeaderboa
|
||||
avatarUrls[driver.id] = await this.getDriverAvatar(driver.id);
|
||||
}
|
||||
|
||||
// Get stats for all drivers
|
||||
const statsPromises = drivers.map(driver =>
|
||||
this.driverStatsUseCase.getDriverStats(driver.id)
|
||||
);
|
||||
const statsResults = await Promise.all(statsPromises);
|
||||
const statsMap = new Map<string, any>();
|
||||
drivers.forEach((driver, idx) => {
|
||||
if (statsResults[idx]) {
|
||||
statsMap.set(driver.id, statsResults[idx]);
|
||||
}
|
||||
});
|
||||
|
||||
const items: DriverLeaderboardItem[] = drivers.map((driver) => {
|
||||
const ranking = rankings.find((r) => r.driverId === driver.id);
|
||||
const stats = this.driverStatsService.getDriverStats(driver.id);
|
||||
const stats = statsMap.get(driver.id);
|
||||
const rating = ranking?.rating ?? 0;
|
||||
const racesCompleted = stats?.totalRaces ?? 0;
|
||||
const skillLevel: SkillLevel = SkillLevelService.getSkillLevel(rating);
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import {
|
||||
GetProfileOverviewUseCase,
|
||||
type GetProfileOverviewInput,
|
||||
type GetProfileOverviewResult,
|
||||
type GetProfileOverviewErrorCode,
|
||||
} from './GetProfileOverviewUseCase';
|
||||
import { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||
import { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||
import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
|
||||
import { Driver } from '../../domain/entities/Driver';
|
||||
import { Team } from '../../domain/entities/Team';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
describe('GetProfileOverviewUseCase', () => {
|
||||
let useCase: GetProfileOverviewUseCase;
|
||||
@@ -28,8 +24,12 @@ describe('GetProfileOverviewUseCase', () => {
|
||||
let socialRepository: {
|
||||
getFriends: Mock;
|
||||
};
|
||||
let getDriverStats: Mock;
|
||||
let getAllDriverRankings: Mock;
|
||||
let driverStatsUseCase: {
|
||||
getDriverStats: Mock;
|
||||
};
|
||||
let rankingUseCase: {
|
||||
getAllDriverRankings: Mock;
|
||||
};
|
||||
let driverExtendedProfileProvider: {
|
||||
getExtendedProfile: Mock;
|
||||
};
|
||||
@@ -39,20 +39,31 @@ describe('GetProfileOverviewUseCase', () => {
|
||||
driverRepository = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
|
||||
teamRepository = {
|
||||
findAll: vi.fn(),
|
||||
};
|
||||
|
||||
teamMembershipRepository = {
|
||||
getMembership: vi.fn(),
|
||||
};
|
||||
|
||||
socialRepository = {
|
||||
getFriends: vi.fn(),
|
||||
};
|
||||
getDriverStats = vi.fn();
|
||||
getAllDriverRankings = vi.fn();
|
||||
|
||||
driverStatsUseCase = {
|
||||
getDriverStats: vi.fn(),
|
||||
};
|
||||
|
||||
rankingUseCase = {
|
||||
getAllDriverRankings: vi.fn(),
|
||||
};
|
||||
|
||||
driverExtendedProfileProvider = {
|
||||
getExtendedProfile: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<GetProfileOverviewResult> & { present: Mock };
|
||||
@@ -63,8 +74,8 @@ describe('GetProfileOverviewUseCase', () => {
|
||||
teamMembershipRepository as unknown as ITeamMembershipRepository,
|
||||
socialRepository as unknown as ISocialGraphRepository,
|
||||
driverExtendedProfileProvider,
|
||||
getDriverStats,
|
||||
getAllDriverRankings,
|
||||
driverStatsUseCase as unknown as any,
|
||||
rankingUseCase as unknown as any,
|
||||
output,
|
||||
);
|
||||
});
|
||||
@@ -73,85 +84,40 @@ describe('GetProfileOverviewUseCase', () => {
|
||||
const driverId = 'driver-1';
|
||||
const driver = Driver.create({
|
||||
id: driverId,
|
||||
iracingId: '123',
|
||||
iracingId: '12345',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
joinedAt: new Date('2023-01-01'),
|
||||
});
|
||||
const teams = [
|
||||
Team.create({
|
||||
id: 'team-1',
|
||||
name: 'Test Team',
|
||||
tag: 'TT',
|
||||
description: 'Test',
|
||||
ownerId: 'owner-1',
|
||||
leagues: [],
|
||||
}),
|
||||
];
|
||||
const friends = [
|
||||
Driver.create({ id: 'friend-1', iracingId: '456', name: 'Friend', country: 'US' }),
|
||||
];
|
||||
const statsAdapter = {
|
||||
rating: 1500,
|
||||
wins: 5,
|
||||
podiums: 2,
|
||||
dnfs: 1,
|
||||
totalRaces: 10,
|
||||
avgFinish: 3.5,
|
||||
bestFinish: 1,
|
||||
worstFinish: 10,
|
||||
overallRank: 10,
|
||||
consistency: 90,
|
||||
percentile: 75,
|
||||
};
|
||||
const rankings = [{ driverId, rating: 1500, overallRank: 10 }];
|
||||
|
||||
driverRepository.findById.mockResolvedValue(driver);
|
||||
teamRepository.findAll.mockResolvedValue(teams);
|
||||
teamMembershipRepository.getMembership.mockResolvedValue(null);
|
||||
socialRepository.getFriends.mockResolvedValue(friends);
|
||||
getDriverStats.mockReturnValue(statsAdapter);
|
||||
getAllDriverRankings.mockReturnValue(rankings);
|
||||
driverExtendedProfileProvider.getExtendedProfile.mockReturnValue(null);
|
||||
driverStatsUseCase.getDriverStats.mockResolvedValue({
|
||||
rating: 1500,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
dnfs: 2,
|
||||
totalRaces: 20,
|
||||
avgFinish: 8.5,
|
||||
bestFinish: 1,
|
||||
worstFinish: 15,
|
||||
overallRank: 50,
|
||||
consistency: 85,
|
||||
});
|
||||
rankingUseCase.getAllDriverRankings.mockResolvedValue([
|
||||
{ driverId: 'driver-1', rating: 1500, wins: 5, totalRaces: 20, overallRank: 50 },
|
||||
{ driverId: 'driver-2', rating: 1400, wins: 3, totalRaces: 18, overallRank: 75 },
|
||||
]);
|
||||
teamRepository.findAll.mockResolvedValue([]);
|
||||
socialRepository.getFriends.mockResolvedValue([]);
|
||||
driverExtendedProfileProvider.getExtendedProfile.mockReturnValue({
|
||||
bio: 'Test bio',
|
||||
location: 'Test location',
|
||||
favoriteTrack: 'Test track',
|
||||
});
|
||||
|
||||
const result = await useCase.execute({ driverId } as GetProfileOverviewInput);
|
||||
const result = await useCase.execute({ driverId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBeUndefined();
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
const presented = (output.present as unknown as Mock).mock.calls[0]?.[0] as GetProfileOverviewResult;
|
||||
expect(presented.driverInfo.driver.id).toBe(driverId);
|
||||
expect(presented.extendedProfile).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error for non-existing driver', async () => {
|
||||
const driverId = 'driver-1';
|
||||
driverRepository.findById.mockResolvedValue(null);
|
||||
|
||||
const result = await useCase.execute({ driverId } as GetProfileOverviewInput);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr() as ApplicationErrorCode<
|
||||
GetProfileOverviewErrorCode,
|
||||
{ message: string }
|
||||
>;
|
||||
expect(error.code).toBe('DRIVER_NOT_FOUND');
|
||||
expect(error.details.message).toBe('Driver not found');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error on repository failure', async () => {
|
||||
const driverId = 'driver-1';
|
||||
driverRepository.findById.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const result = await useCase.execute({ driverId } as GetProfileOverviewInput);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr() as ApplicationErrorCode<
|
||||
GetProfileOverviewErrorCode,
|
||||
{ message: string }
|
||||
>;
|
||||
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||
expect(error.details.message).toBe('DB error');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(output.present).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'
|
||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
|
||||
import type { DriverExtendedProfileProvider } from '../ports/DriverExtendedProfileProvider';
|
||||
import type { IDriverStatsUseCase, DriverStats } from './IDriverStatsUseCase';
|
||||
import type { IRankingUseCase, DriverRanking } from './IRankingUseCase';
|
||||
import type { Driver } from '../../domain/entities/Driver';
|
||||
import type { Team } from '../../domain/entities/Team';
|
||||
import type { TeamMembership } from '../../domain/types/TeamMembership';
|
||||
@@ -10,26 +12,6 @@ import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
interface ProfileDriverStatsAdapter {
|
||||
rating: number | null;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
dnfs: number;
|
||||
totalRaces: number;
|
||||
avgFinish: number | null;
|
||||
bestFinish: number | null;
|
||||
worstFinish: number | null;
|
||||
overallRank: number | null;
|
||||
consistency: number | null;
|
||||
percentile: number | null;
|
||||
}
|
||||
|
||||
interface DriverRankingEntry {
|
||||
driverId: string;
|
||||
rating: number;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export type GetProfileOverviewInput = {
|
||||
driverId: string;
|
||||
};
|
||||
@@ -98,8 +80,8 @@ export class GetProfileOverviewUseCase {
|
||||
private readonly teamMembershipRepository: ITeamMembershipRepository,
|
||||
private readonly socialRepository: ISocialGraphRepository,
|
||||
private readonly driverExtendedProfileProvider: DriverExtendedProfileProvider,
|
||||
private readonly getDriverStats: (driverId: string) => ProfileDriverStatsAdapter | null,
|
||||
private readonly getAllDriverRankings: () => DriverRankingEntry[],
|
||||
private readonly driverStatsUseCase: IDriverStatsUseCase,
|
||||
private readonly rankingUseCase: IRankingUseCase,
|
||||
private readonly output: UseCaseOutputPort<GetProfileOverviewResult>,
|
||||
) {}
|
||||
|
||||
@@ -120,15 +102,15 @@ export class GetProfileOverviewUseCase {
|
||||
});
|
||||
}
|
||||
|
||||
const [statsAdapter, teams, friends] = await Promise.all([
|
||||
Promise.resolve(this.getDriverStats(driverId)),
|
||||
const [driverStats, teams, friends] = await Promise.all([
|
||||
this.driverStatsUseCase.getDriverStats(driverId),
|
||||
this.teamRepository.findAll(),
|
||||
this.socialRepository.getFriends(driverId),
|
||||
]);
|
||||
|
||||
const driverInfo = this.buildDriverInfo(driver, statsAdapter);
|
||||
const stats = this.buildStats(statsAdapter);
|
||||
const finishDistribution = this.buildFinishDistribution(statsAdapter);
|
||||
const driverInfo = await this.buildDriverInfo(driver, driverStats);
|
||||
const stats = this.buildStats(driverStats);
|
||||
const finishDistribution = this.buildFinishDistribution(driverStats);
|
||||
const teamMemberships = await this.buildTeamMemberships(driver.id, teams);
|
||||
const socialSummary = this.buildSocialSummary(friends);
|
||||
const extendedProfile =
|
||||
@@ -159,11 +141,11 @@ export class GetProfileOverviewUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
private buildDriverInfo(
|
||||
private async buildDriverInfo(
|
||||
driver: Driver,
|
||||
stats: ProfileDriverStatsAdapter | null,
|
||||
): ProfileOverviewDriverInfo {
|
||||
const rankings = this.getAllDriverRankings();
|
||||
stats: DriverStats | null,
|
||||
): Promise<ProfileOverviewDriverInfo> {
|
||||
const rankings = await this.rankingUseCase.getAllDriverRankings();
|
||||
const fallbackRank = this.computeFallbackRank(driver.id, rankings);
|
||||
const totalDrivers = rankings.length;
|
||||
|
||||
@@ -178,7 +160,7 @@ export class GetProfileOverviewUseCase {
|
||||
|
||||
private computeFallbackRank(
|
||||
driverId: string,
|
||||
rankings: DriverRankingEntry[],
|
||||
rankings: DriverRanking[],
|
||||
): number | null {
|
||||
const index = rankings.findIndex(entry => entry.driverId === driverId);
|
||||
if (index === -1) {
|
||||
@@ -188,7 +170,7 @@ export class GetProfileOverviewUseCase {
|
||||
}
|
||||
|
||||
private buildStats(
|
||||
stats: ProfileDriverStatsAdapter | null,
|
||||
stats: DriverStats | null,
|
||||
): ProfileOverviewStats | null {
|
||||
if (!stats) {
|
||||
return null;
|
||||
@@ -213,7 +195,7 @@ export class GetProfileOverviewUseCase {
|
||||
finishRate,
|
||||
winRate,
|
||||
podiumRate,
|
||||
percentile: stats.percentile,
|
||||
percentile: null, // Not available in new DriverStats
|
||||
rating: stats.rating,
|
||||
consistency: stats.consistency,
|
||||
overallRank: stats.overallRank,
|
||||
@@ -221,7 +203,7 @@ export class GetProfileOverviewUseCase {
|
||||
}
|
||||
|
||||
private buildFinishDistribution(
|
||||
stats: ProfileDriverStatsAdapter | null,
|
||||
stats: DriverStats | null,
|
||||
): ProfileOverviewFinishDistribution | null {
|
||||
if (!stats || stats.totalRaces <= 0) {
|
||||
return null;
|
||||
|
||||
@@ -45,9 +45,11 @@ export class GetRacesPageDataUseCase {
|
||||
allLeagues.map(league => [league.id.toString(), league.name.toString()]),
|
||||
);
|
||||
|
||||
const filteredRaces = allRaces
|
||||
.filter(race => race.leagueId === input.leagueId)
|
||||
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
|
||||
const filteredRaces = input.leagueId
|
||||
? allRaces.filter(race => race.leagueId === input.leagueId)
|
||||
: allRaces;
|
||||
|
||||
filteredRaces.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
|
||||
|
||||
const races: GetRacesPageRaceItem[] = filteredRaces.map(race => ({
|
||||
race,
|
||||
|
||||
26
core/racing/application/use-cases/IDriverStatsUseCase.ts
Normal file
26
core/racing/application/use-cases/IDriverStatsUseCase.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Application Use Case Interface: IDriverStatsUseCase
|
||||
*
|
||||
* Use case for computing detailed driver statistics from race results and standings.
|
||||
* This is an application layer concern that orchestrates domain data.
|
||||
*/
|
||||
|
||||
export interface DriverStats {
|
||||
rating: number;
|
||||
safetyRating: number;
|
||||
sportsmanshipRating: number;
|
||||
totalRaces: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
dnfs: number;
|
||||
avgFinish: number;
|
||||
bestFinish: number;
|
||||
worstFinish: number;
|
||||
consistency: number;
|
||||
experienceLevel: string;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface IDriverStatsUseCase {
|
||||
getDriverStats(driverId: string): Promise<DriverStats | null>;
|
||||
}
|
||||
18
core/racing/application/use-cases/IRankingUseCase.ts
Normal file
18
core/racing/application/use-cases/IRankingUseCase.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Application Use Case Interface: IRankingUseCase
|
||||
*
|
||||
* Use case for computing driver rankings from standings and results.
|
||||
* This is an application layer concern that orchestrates domain data.
|
||||
*/
|
||||
|
||||
export interface DriverRanking {
|
||||
driverId: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
totalRaces: number;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface IRankingUseCase {
|
||||
getAllDriverRankings(): Promise<DriverRanking[]>;
|
||||
}
|
||||
22
core/racing/application/use-cases/ITeamRankingUseCase.ts
Normal file
22
core/racing/application/use-cases/ITeamRankingUseCase.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Application Use Case Interface: ITeamRankingUseCase
|
||||
*
|
||||
* Use case for computing team rankings from rating snapshots.
|
||||
* This is an application layer concern that orchestrates domain data.
|
||||
*/
|
||||
|
||||
export interface TeamRanking {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
drivingRating: number;
|
||||
adminTrustRating: number;
|
||||
overallRating: number;
|
||||
eventCount: number;
|
||||
lastUpdated: Date;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface ITeamRankingUseCase {
|
||||
getAllTeamRankings(): Promise<TeamRanking[]>;
|
||||
getTeamRanking(teamId: string): Promise<TeamRanking | null>;
|
||||
}
|
||||
91
core/racing/application/use-cases/RankingUseCase.ts
Normal file
91
core/racing/application/use-cases/RankingUseCase.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Application Use Case: RankingUseCase
|
||||
*
|
||||
* Computes driver rankings from real standings and results data.
|
||||
* Orchestrates repositories to provide ranking data to presentation layer.
|
||||
*/
|
||||
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||
import type { IRankingUseCase, DriverRanking } from './IRankingUseCase';
|
||||
|
||||
export class RankingUseCase implements IRankingUseCase {
|
||||
constructor(
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.logger.info('[RankingUseCase] Initialized with real data repositories');
|
||||
}
|
||||
|
||||
async getAllDriverRankings(): Promise<DriverRanking[]> {
|
||||
this.logger.debug('[RankingUseCase] Computing rankings from standings');
|
||||
|
||||
try {
|
||||
// Get all standings from all leagues
|
||||
const standings = await this.standingRepository.findAll();
|
||||
|
||||
if (standings.length === 0) {
|
||||
this.logger.warn('[RankingUseCase] No standings found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get all drivers for name resolution
|
||||
const drivers = await this.driverRepository.findAll();
|
||||
const driverMap = new Map(drivers.map(d => [d.id, d]));
|
||||
|
||||
// Group standings by driver and aggregate stats
|
||||
const driverStats = new Map<string, {
|
||||
rating: number;
|
||||
wins: number;
|
||||
races: number;
|
||||
driverName?: string;
|
||||
}>();
|
||||
|
||||
for (const standing of standings) {
|
||||
const driverId = standing.driverId.toString();
|
||||
const existing = driverStats.get(driverId) || { rating: 0, wins: 0, races: 0 };
|
||||
|
||||
existing.races += standing.racesCompleted;
|
||||
existing.wins += standing.wins;
|
||||
|
||||
// Calculate rating from points and position
|
||||
const baseRating = 1000;
|
||||
const pointsBonus = standing.points.toNumber() * 2;
|
||||
const positionBonus = Math.max(0, 50 - (standing.position.toNumber() * 2));
|
||||
const winBonus = standing.wins * 100;
|
||||
|
||||
existing.rating = Math.round(baseRating + pointsBonus + positionBonus + winBonus);
|
||||
|
||||
// Add driver name if available
|
||||
const driver = driverMap.get(driverId);
|
||||
if (driver) {
|
||||
existing.driverName = driver.name.toString();
|
||||
}
|
||||
|
||||
driverStats.set(driverId, existing);
|
||||
}
|
||||
|
||||
// Convert to rankings
|
||||
const rankings: DriverRanking[] = Array.from(driverStats.entries()).map(([driverId, stats]) => ({
|
||||
driverId,
|
||||
rating: stats.rating,
|
||||
wins: stats.wins,
|
||||
totalRaces: stats.races,
|
||||
overallRank: null
|
||||
}));
|
||||
|
||||
// Sort by rating descending and assign ranks
|
||||
rankings.sort((a, b) => b.rating - a.rating);
|
||||
rankings.forEach((r, idx) => r.overallRank = idx + 1);
|
||||
|
||||
this.logger.info(`[RankingUseCase] Computed rankings for ${rankings.length} drivers`);
|
||||
|
||||
return rankings;
|
||||
} catch (error) {
|
||||
this.logger.error('[RankingUseCase] Error computing rankings:', error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { RecomputeTeamRatingSnapshotUseCase } from './RecomputeTeamRatingSnapshotUseCase';
|
||||
import { ITeamRatingEventRepository } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
|
||||
// Mock repositories
|
||||
class MockTeamRatingEventRepository implements ITeamRatingEventRepository {
|
||||
private events: TeamRatingEvent[] = [];
|
||||
|
||||
async save(event: TeamRatingEvent): Promise<TeamRatingEvent> {
|
||||
this.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findByIds(ids: TeamRatingEventId[]): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => ids.some(id => id.equals(e.id)));
|
||||
}
|
||||
|
||||
async getAllByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findEventsPaginated(teamId: string): Promise<any> {
|
||||
const events = await this.getAllByTeamId(teamId);
|
||||
return {
|
||||
items: events,
|
||||
total: events.length,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
setEvents(events: TeamRatingEvent[]) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.events = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockTeamRatingRepository implements ITeamRatingRepository {
|
||||
private snapshots: Map<string, any> = new Map();
|
||||
|
||||
async findByTeamId(teamId: string): Promise<any | null> {
|
||||
return this.snapshots.get(teamId) || null;
|
||||
}
|
||||
|
||||
async save(snapshot: any): Promise<any> {
|
||||
this.snapshots.set(snapshot.teamId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
getSnapshot(teamId: string) {
|
||||
return this.snapshots.get(teamId);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.snapshots.clear();
|
||||
}
|
||||
}
|
||||
|
||||
describe('RecomputeTeamRatingSnapshotUseCase', () => {
|
||||
let useCase: RecomputeTeamRatingSnapshotUseCase;
|
||||
let mockEventRepo: MockTeamRatingEventRepository;
|
||||
let mockRatingRepo: MockTeamRatingRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEventRepo = new MockTeamRatingEventRepository();
|
||||
mockRatingRepo = new MockTeamRatingRepository();
|
||||
useCase = new RecomputeTeamRatingSnapshotUseCase(mockEventRepo, mockRatingRepo);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockEventRepo.clear();
|
||||
mockRatingRepo.clear();
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should create snapshot with default values when no events exist', async () => {
|
||||
await useCase.execute('team-123');
|
||||
|
||||
const snapshot = mockRatingRepo.getSnapshot('team-123');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot.teamId).toBe('team-123');
|
||||
expect(snapshot.driving.value).toBe(50);
|
||||
expect(snapshot.adminTrust.value).toBe(50);
|
||||
expect(snapshot.overall).toBe(50);
|
||||
expect(snapshot.eventCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should recompute snapshot from single event', async () => {
|
||||
const event = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
mockEventRepo.setEvents([event]);
|
||||
|
||||
await useCase.execute('team-123');
|
||||
|
||||
const snapshot = mockRatingRepo.getSnapshot('team-123');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot.teamId).toBe('team-123');
|
||||
expect(snapshot.driving.value).toBe(60); // 50 + 10
|
||||
expect(snapshot.adminTrust.value).toBe(50); // Default
|
||||
expect(snapshot.overall).toBe(57); // 60 * 0.7 + 50 * 0.3 = 57
|
||||
expect(snapshot.eventCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should recompute snapshot from multiple events', async () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
weight: 1,
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
weight: 2,
|
||||
occurredAt: new Date('2024-01-01T11:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T11:00:00Z'),
|
||||
source: { type: 'race', id: 'race-457' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 2nd' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(3),
|
||||
weight: 1,
|
||||
occurredAt: new Date('2024-01-01T12:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T12:00:00Z'),
|
||||
source: { type: 'adminAction', id: 'action-789' },
|
||||
reason: { code: 'POSITIVE_ADMIN_ACTION', description: 'Helped organize event' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
mockEventRepo.setEvents(events);
|
||||
|
||||
await useCase.execute('team-123');
|
||||
|
||||
const snapshot = mockRatingRepo.getSnapshot('team-123');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot.teamId).toBe('team-123');
|
||||
|
||||
// Driving: weighted average of (10*1 + 5*2) / (1+2) = 20/3 = 6.67, so 50 + 6.67 = 56.67
|
||||
expect(snapshot.driving.value).toBeCloseTo(56.67, 1);
|
||||
|
||||
// AdminTrust: 50 + 3 = 53
|
||||
expect(snapshot.adminTrust.value).toBe(53);
|
||||
|
||||
// Overall: 56.67 * 0.7 + 53 * 0.3 = 39.67 + 15.9 = 55.57 ≈ 55.6
|
||||
expect(snapshot.overall).toBeCloseTo(55.6, 1);
|
||||
|
||||
expect(snapshot.eventCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle events with different dimensions correctly', async () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-456',
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(15),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'adminAction', id: 'action-123' },
|
||||
reason: { code: 'EXCELLENT_ADMIN', description: 'Great leadership' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
mockEventRepo.setEvents(events);
|
||||
|
||||
await useCase.execute('team-456');
|
||||
|
||||
const snapshot = mockRatingRepo.getSnapshot('team-456');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot.adminTrust.value).toBe(65); // 50 + 15
|
||||
expect(snapshot.driving.value).toBe(50); // Default
|
||||
expect(snapshot.overall).toBe(54.5); // 50 * 0.7 + 65 * 0.3 = 54.5
|
||||
});
|
||||
|
||||
it('should overwrite existing snapshot with recomputed values', async () => {
|
||||
// First, create an initial snapshot
|
||||
const initialEvent = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
mockEventRepo.setEvents([initialEvent]);
|
||||
await useCase.execute('team-123');
|
||||
|
||||
let snapshot = mockRatingRepo.getSnapshot('team-123');
|
||||
expect(snapshot.driving.value).toBe(55);
|
||||
|
||||
// Now add more events and recompute
|
||||
const additionalEvent = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T11:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T11:00:00Z'),
|
||||
source: { type: 'race', id: 'race-457' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 2nd' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
mockEventRepo.setEvents([initialEvent, additionalEvent]);
|
||||
await useCase.execute('team-123');
|
||||
|
||||
snapshot = mockRatingRepo.getSnapshot('team-123');
|
||||
expect(snapshot.driving.value).toBe(57.5); // Weighted average: (5 + 10) / 2 = 7.5, so 50 + 7.5 = 57.5
|
||||
expect(snapshot.eventCount).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ITeamRatingEventRepository } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import type { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import { TeamRatingSnapshotCalculator } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
|
||||
/**
|
||||
* Use Case: RecomputeTeamRatingSnapshotUseCase
|
||||
*
|
||||
* Recalculates a team's rating snapshot from all events in the ledger.
|
||||
* Used for data migration, correction of calculation logic, or audit purposes.
|
||||
* Mirrors the RecomputeUserRatingSnapshotUseCase pattern.
|
||||
*/
|
||||
export class RecomputeTeamRatingSnapshotUseCase {
|
||||
constructor(
|
||||
private readonly ratingEventRepository: ITeamRatingEventRepository,
|
||||
private readonly ratingRepository: ITeamRatingRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the use case for a specific team
|
||||
*
|
||||
* @param teamId - The team ID to recompute
|
||||
* @returns The recomputed snapshot
|
||||
*/
|
||||
async execute(teamId: string): Promise<void> {
|
||||
// Get all events for the team
|
||||
const events = await this.ratingEventRepository.getAllByTeamId(teamId);
|
||||
|
||||
// Calculate snapshot from all events
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate(teamId, events);
|
||||
|
||||
// Save the recomputed snapshot
|
||||
await this.ratingRepository.save(snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the use case for all teams
|
||||
*/
|
||||
async executeForAllTeams(): Promise<void> {
|
||||
// Get all unique team IDs from events
|
||||
// This would typically query for all distinct teamIds in the events table
|
||||
// For now, we'll use a simpler approach - recompute for teams that have snapshots
|
||||
// In a real implementation, you might have a separate method to get all team IDs
|
||||
|
||||
// Note: This is a simplified implementation
|
||||
// In production, you'd want to batch this and handle errors per team
|
||||
throw new Error('executeForAllTeams not implemented - needs team ID discovery');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { RecordTeamRaceRatingEventsUseCase } from './RecordTeamRaceRatingEventsUseCase';
|
||||
import { ITeamRaceResultsProvider } from '../ports/ITeamRaceResultsProvider';
|
||||
import { ITeamRatingEventRepository } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import { AppendTeamRatingEventsUseCase } from './AppendTeamRatingEventsUseCase';
|
||||
import { TeamDrivingRaceFactsDto } from '@core/racing/domain/services/TeamDrivingRatingEventFactory';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
|
||||
// Mock repositories
|
||||
class MockTeamRaceResultsProvider implements ITeamRaceResultsProvider {
|
||||
private results: TeamDrivingRaceFactsDto | null = null;
|
||||
|
||||
async getTeamRaceResults(raceId: string): Promise<TeamDrivingRaceFactsDto | null> {
|
||||
return this.results;
|
||||
}
|
||||
|
||||
setResults(results: TeamDrivingRaceFactsDto | null) {
|
||||
this.results = results;
|
||||
}
|
||||
}
|
||||
|
||||
class MockTeamRatingEventRepository implements ITeamRatingEventRepository {
|
||||
private events: TeamRatingEvent[] = [];
|
||||
|
||||
async save(event: TeamRatingEvent): Promise<TeamRatingEvent> {
|
||||
this.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findByIds(ids: TeamRatingEventId[]): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => ids.some(id => id.equals(e.id)));
|
||||
}
|
||||
|
||||
async getAllByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findEventsPaginated(teamId: string): Promise<any> {
|
||||
const events = await this.getAllByTeamId(teamId);
|
||||
return {
|
||||
items: events,
|
||||
total: events.length,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.events = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockTeamRatingRepository implements ITeamRatingRepository {
|
||||
private snapshots: Map<string, any> = new Map();
|
||||
|
||||
async findByTeamId(teamId: string): Promise<any | null> {
|
||||
return this.snapshots.get(teamId) || null;
|
||||
}
|
||||
|
||||
async save(snapshot: any): Promise<any> {
|
||||
this.snapshots.set(snapshot.teamId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.snapshots.clear();
|
||||
}
|
||||
}
|
||||
|
||||
describe('RecordTeamRaceRatingEventsUseCase', () => {
|
||||
let useCase: RecordTeamRaceRatingEventsUseCase;
|
||||
let mockResultsProvider: MockTeamRaceResultsProvider;
|
||||
let mockEventRepo: MockTeamRatingEventRepository;
|
||||
let mockRatingRepo: MockTeamRatingRepository;
|
||||
let appendUseCase: AppendTeamRatingEventsUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResultsProvider = new MockTeamRaceResultsProvider();
|
||||
mockEventRepo = new MockTeamRatingEventRepository();
|
||||
mockRatingRepo = new MockTeamRatingRepository();
|
||||
appendUseCase = new AppendTeamRatingEventsUseCase(mockEventRepo, mockRatingRepo);
|
||||
useCase = new RecordTeamRaceRatingEventsUseCase(
|
||||
mockResultsProvider,
|
||||
mockEventRepo,
|
||||
mockRatingRepo,
|
||||
appendUseCase
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockEventRepo.clear();
|
||||
mockRatingRepo.clear();
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should return error when race results not found', async () => {
|
||||
mockResultsProvider.setResults(null);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Team race results not found');
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.teamsUpdated).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return success with no events when results are empty', async () => {
|
||||
mockResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.teamsUpdated).toEqual([]);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should create events for single team and update snapshot', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(0);
|
||||
expect(result.teamsUpdated).toContain('team-123');
|
||||
expect(result.errors).toEqual([]);
|
||||
|
||||
// Verify events were saved
|
||||
const savedEvents = await mockEventRepo.getAllByTeamId('team-123');
|
||||
expect(savedEvents.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify snapshot was updated
|
||||
const snapshot = await mockRatingRepo.findByTeamId('team-123');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot.teamId).toBe('team-123');
|
||||
});
|
||||
|
||||
it('should create events for multiple teams', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-789',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'dnf',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(0);
|
||||
expect(result.teamsUpdated).toContain('team-123');
|
||||
expect(result.teamsUpdated).toContain('team-456');
|
||||
expect(result.teamsUpdated).toContain('team-789');
|
||||
expect(result.errors).toEqual([]);
|
||||
|
||||
// Verify all team snapshots were updated
|
||||
const snapshot1 = await mockRatingRepo.findByTeamId('team-123');
|
||||
const snapshot2 = await mockRatingRepo.findByTeamId('team-456');
|
||||
const snapshot3 = await mockRatingRepo.findByTeamId('team-789');
|
||||
|
||||
expect(snapshot1).toBeDefined();
|
||||
expect(snapshot2).toBeDefined();
|
||||
expect(snapshot3).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle optional ratings in results', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 65,
|
||||
pace: 85,
|
||||
consistency: 80,
|
||||
teamwork: 90,
|
||||
sportsmanship: 95,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(5); // Should have many events
|
||||
expect(result.teamsUpdated).toContain('team-123');
|
||||
|
||||
// Verify events include optional rating events
|
||||
const savedEvents = await mockEventRepo.getAllByTeamId('team-123');
|
||||
const paceEvent = savedEvents.find(e => e.reason.code === 'RACE_PACE');
|
||||
const consistencyEvent = savedEvents.find(e => e.reason.code === 'RACE_CONSISTENCY');
|
||||
const teamworkEvent = savedEvents.find(e => e.reason.code === 'RACE_TEAMWORK');
|
||||
const sportsmanshipEvent = savedEvents.find(e => e.reason.code === 'RACE_SPORTSMANSHIP');
|
||||
|
||||
expect(paceEvent).toBeDefined();
|
||||
expect(consistencyEvent).toBeDefined();
|
||||
expect(teamworkEvent).toBeDefined();
|
||||
expect(sportsmanshipEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle partial failures gracefully', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 2,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 2,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
// Mock the append use case to fail for team-456
|
||||
const originalExecute = appendUseCase.execute.bind(appendUseCase);
|
||||
appendUseCase.execute = async (events) => {
|
||||
if (events.length > 0 && events[0] && events[0].teamId === 'team-456') {
|
||||
throw new Error('Simulated failure for team-456');
|
||||
}
|
||||
return originalExecute(events);
|
||||
};
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.teamsUpdated).toContain('team-123');
|
||||
expect(result.teamsUpdated).not.toContain('team-456');
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0]).toContain('team-456');
|
||||
});
|
||||
|
||||
it('should handle repository errors', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
// Mock repository to throw error
|
||||
const originalSave = mockEventRepo.save.bind(mockEventRepo);
|
||||
mockEventRepo.save = async () => {
|
||||
throw new Error('Repository error');
|
||||
};
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0]).toContain('Repository error');
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.teamsUpdated).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty results array', async () => {
|
||||
mockResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.teamsUpdated).toEqual([]);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle race with minimal events generated', async () => {
|
||||
// Race where teams have some impact (DNS creates penalty event)
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(0); // DNS creates penalty event
|
||||
expect(result.teamsUpdated).toContain('team-123');
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ITeamRaceResultsProvider } from '../ports/ITeamRaceResultsProvider';
|
||||
import { TeamDrivingRatingEventFactory } from '@core/racing/domain/services/TeamDrivingRatingEventFactory';
|
||||
import { AppendTeamRatingEventsUseCase } from './AppendTeamRatingEventsUseCase';
|
||||
import { RecordTeamRaceRatingEventsInput, RecordTeamRaceRatingEventsOutput } from '../dtos/RecordTeamRaceRatingEventsDto';
|
||||
|
||||
/**
|
||||
* Use Case: RecordTeamRaceRatingEventsUseCase
|
||||
*
|
||||
* Records rating events for a completed team race.
|
||||
* Mirrors user slice 3 pattern in core/racing/.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Load team race results from racing context
|
||||
* 2. Factory creates team rating events
|
||||
* 3. Append to ledger via AppendTeamRatingEventsUseCase
|
||||
* 4. Recompute snapshots
|
||||
*/
|
||||
export class RecordTeamRaceRatingEventsUseCase {
|
||||
constructor(
|
||||
private readonly teamRaceResultsProvider: ITeamRaceResultsProvider,
|
||||
private readonly appendTeamRatingEventsUseCase: AppendTeamRatingEventsUseCase,
|
||||
) {}
|
||||
|
||||
async execute(input: RecordTeamRaceRatingEventsInput): Promise<RecordTeamRaceRatingEventsOutput> {
|
||||
const errors: string[] = [];
|
||||
const teamsUpdated: string[] = [];
|
||||
let totalEventsCreated = 0;
|
||||
|
||||
try {
|
||||
// 1. Load team race results
|
||||
const teamRaceResults = await this.teamRaceResultsProvider.getTeamRaceResults(input.raceId);
|
||||
|
||||
if (!teamRaceResults) {
|
||||
return {
|
||||
success: false,
|
||||
raceId: input.raceId,
|
||||
eventsCreated: 0,
|
||||
teamsUpdated: [],
|
||||
errors: ['Team race results not found'],
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Create rating events using factory
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(teamRaceResults);
|
||||
|
||||
if (eventsByTeam.size === 0) {
|
||||
return {
|
||||
success: true,
|
||||
raceId: input.raceId,
|
||||
eventsCreated: 0,
|
||||
teamsUpdated: [],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Process each team's events
|
||||
for (const [teamId, events] of eventsByTeam) {
|
||||
try {
|
||||
// Use AppendTeamRatingEventsUseCase to handle ledger and snapshot
|
||||
await this.appendTeamRatingEventsUseCase.execute(events);
|
||||
|
||||
teamsUpdated.push(teamId);
|
||||
totalEventsCreated += events.length;
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to process events for team ${teamId}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
raceId: input.raceId,
|
||||
eventsCreated: totalEventsCreated,
|
||||
teamsUpdated,
|
||||
errors,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to record team race rating events: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errors.push(errorMsg);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
raceId: input.raceId,
|
||||
eventsCreated: 0,
|
||||
teamsUpdated: [],
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
418
core/racing/application/use-cases/TeamRankingUseCase.test.ts
Normal file
418
core/racing/application/use-cases/TeamRankingUseCase.test.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { TeamRankingUseCase } from './TeamRankingUseCase';
|
||||
import type { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { TeamRatingSnapshot } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
import { TeamRatingValue } from '@core/racing/domain/value-objects/TeamRatingValue';
|
||||
import { Team } from '@core/racing/domain/entities/Team';
|
||||
|
||||
// Mock repositories
|
||||
class MockTeamRatingRepository implements ITeamRatingRepository {
|
||||
private snapshots: Map<string, TeamRatingSnapshot> = new Map();
|
||||
|
||||
async findByTeamId(teamId: string): Promise<TeamRatingSnapshot | null> {
|
||||
return this.snapshots.get(teamId) || null;
|
||||
}
|
||||
|
||||
async save(snapshot: TeamRatingSnapshot): Promise<TeamRatingSnapshot> {
|
||||
this.snapshots.set(snapshot.teamId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.snapshots.clear();
|
||||
}
|
||||
|
||||
setSnapshot(teamId: string, snapshot: TeamRatingSnapshot) {
|
||||
this.snapshots.set(teamId, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
class MockTeamRepository implements ITeamRepository {
|
||||
private teams: Map<string, Team> = new Map();
|
||||
|
||||
async findById(id: string): Promise<Team | null> {
|
||||
return this.teams.get(id) || null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Team[]> {
|
||||
return Array.from(this.teams.values());
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Team[]> {
|
||||
return Array.from(this.teams.values()).filter(t =>
|
||||
t.leagues.some(l => l.toString() === leagueId)
|
||||
);
|
||||
}
|
||||
|
||||
async create(team: Team): Promise<Team> {
|
||||
this.teams.set(team.id, team);
|
||||
return team;
|
||||
}
|
||||
|
||||
async update(team: Team): Promise<Team> {
|
||||
this.teams.set(team.id, team);
|
||||
return team;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.teams.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.teams.has(id);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.teams.clear();
|
||||
}
|
||||
|
||||
setTeam(team: Team) {
|
||||
this.teams.set(team.id, team);
|
||||
}
|
||||
}
|
||||
|
||||
// Mock logger
|
||||
const mockLogger: Logger = {
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
};
|
||||
|
||||
describe('TeamRankingUseCase', () => {
|
||||
let useCase: TeamRankingUseCase;
|
||||
let mockRatingRepo: MockTeamRatingRepository;
|
||||
let mockTeamRepo: MockTeamRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRatingRepo = new MockTeamRatingRepository();
|
||||
mockTeamRepo = new MockTeamRepository();
|
||||
useCase = new TeamRankingUseCase(mockRatingRepo, mockTeamRepo, mockLogger);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockRatingRepo.clear();
|
||||
mockTeamRepo.clear();
|
||||
});
|
||||
|
||||
describe('getAllTeamRankings', () => {
|
||||
it('should return empty array when no teams exist', async () => {
|
||||
const result = await useCase.getAllTeamRankings();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when no rating snapshots exist', async () => {
|
||||
const team = Team.create({
|
||||
id: 'team-123',
|
||||
name: 'Test Team',
|
||||
tag: 'TT',
|
||||
description: 'A test team',
|
||||
ownerId: 'driver-123',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team);
|
||||
|
||||
const result = await useCase.getAllTeamRankings();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return single team ranking', async () => {
|
||||
const teamId = 'team-123';
|
||||
const team = Team.create({
|
||||
id: teamId,
|
||||
name: 'Test Team',
|
||||
tag: 'TT',
|
||||
description: 'A test team',
|
||||
ownerId: 'driver-123',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team);
|
||||
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(75),
|
||||
adminTrust: TeamRatingValue.create(80),
|
||||
overall: 76.5,
|
||||
lastUpdated: new Date('2024-01-01'),
|
||||
eventCount: 5,
|
||||
};
|
||||
mockRatingRepo.setSnapshot(teamId, snapshot);
|
||||
|
||||
const result = await useCase.getAllTeamRankings();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
teamId,
|
||||
teamName: 'Test Team',
|
||||
drivingRating: 75,
|
||||
adminTrustRating: 80,
|
||||
overallRating: 76.5,
|
||||
eventCount: 5,
|
||||
lastUpdated: new Date('2024-01-01'),
|
||||
overallRank: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return multiple teams sorted by overall rating', async () => {
|
||||
// Team 1
|
||||
const team1 = Team.create({
|
||||
id: 'team-1',
|
||||
name: 'Team Alpha',
|
||||
tag: 'TA',
|
||||
description: 'Alpha team',
|
||||
ownerId: 'driver-1',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team1);
|
||||
mockRatingRepo.setSnapshot('team-1', {
|
||||
teamId: 'team-1',
|
||||
driving: TeamRatingValue.create(80),
|
||||
adminTrust: TeamRatingValue.create(70),
|
||||
overall: 77,
|
||||
lastUpdated: new Date('2024-01-01'),
|
||||
eventCount: 10,
|
||||
});
|
||||
|
||||
// Team 2
|
||||
const team2 = Team.create({
|
||||
id: 'team-2',
|
||||
name: 'Team Beta',
|
||||
tag: 'TB',
|
||||
description: 'Beta team',
|
||||
ownerId: 'driver-2',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team2);
|
||||
mockRatingRepo.setSnapshot('team-2', {
|
||||
teamId: 'team-2',
|
||||
driving: TeamRatingValue.create(90),
|
||||
adminTrust: TeamRatingValue.create(85),
|
||||
overall: 88,
|
||||
lastUpdated: new Date('2024-01-02'),
|
||||
eventCount: 15,
|
||||
});
|
||||
|
||||
// Team 3
|
||||
const team3 = Team.create({
|
||||
id: 'team-3',
|
||||
name: 'Team Gamma',
|
||||
tag: 'TG',
|
||||
description: 'Gamma team',
|
||||
ownerId: 'driver-3',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team3);
|
||||
mockRatingRepo.setSnapshot('team-3', {
|
||||
teamId: 'team-3',
|
||||
driving: TeamRatingValue.create(60),
|
||||
adminTrust: TeamRatingValue.create(65),
|
||||
overall: 61.5,
|
||||
lastUpdated: new Date('2024-01-03'),
|
||||
eventCount: 3,
|
||||
});
|
||||
|
||||
const result = await useCase.getAllTeamRankings();
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
// Should be sorted by overall rating descending
|
||||
expect(result[0]).toBeDefined();
|
||||
expect(result[0]!.teamId).toBe('team-2');
|
||||
expect(result[0]!.overallRank).toBe(1);
|
||||
expect(result[0]!.overallRating).toBe(88);
|
||||
|
||||
expect(result[1]).toBeDefined();
|
||||
expect(result[1]!.teamId).toBe('team-1');
|
||||
expect(result[1]!.overallRank).toBe(2);
|
||||
expect(result[1]!.overallRating).toBe(77);
|
||||
|
||||
expect(result[2]).toBeDefined();
|
||||
expect(result[2]!.teamId).toBe('team-3');
|
||||
expect(result[2]!.overallRank).toBe(3);
|
||||
expect(result[2]!.overallRating).toBe(61.5);
|
||||
});
|
||||
|
||||
it('should handle teams without snapshots gracefully', async () => {
|
||||
// Team with snapshot
|
||||
const team1 = Team.create({
|
||||
id: 'team-1',
|
||||
name: 'Team With Rating',
|
||||
tag: 'TWR',
|
||||
description: 'Has rating',
|
||||
ownerId: 'driver-1',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team1);
|
||||
mockRatingRepo.setSnapshot('team-1', {
|
||||
teamId: 'team-1',
|
||||
driving: TeamRatingValue.create(70),
|
||||
adminTrust: TeamRatingValue.create(70),
|
||||
overall: 70,
|
||||
lastUpdated: new Date('2024-01-01'),
|
||||
eventCount: 5,
|
||||
});
|
||||
|
||||
// Team without snapshot
|
||||
const team2 = Team.create({
|
||||
id: 'team-2',
|
||||
name: 'Team Without Rating',
|
||||
tag: 'TWR',
|
||||
description: 'No rating',
|
||||
ownerId: 'driver-2',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team2);
|
||||
|
||||
const result = await useCase.getAllTeamRankings();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeDefined();
|
||||
expect(result[0]!.teamId).toBe('team-1');
|
||||
expect(result[0]!.overallRank).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamRanking', () => {
|
||||
it('should return null when team does not exist', async () => {
|
||||
const result = await useCase.getTeamRanking('non-existent-team');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when team exists but has no snapshot', async () => {
|
||||
const team = Team.create({
|
||||
id: 'team-123',
|
||||
name: 'Test Team',
|
||||
tag: 'TT',
|
||||
description: 'A test team',
|
||||
ownerId: 'driver-123',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team);
|
||||
|
||||
const result = await useCase.getTeamRanking('team-123');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return team ranking with correct rank', async () => {
|
||||
// Setup multiple teams
|
||||
const teams = [
|
||||
{ id: 'team-1', name: 'Team A', rating: 85 },
|
||||
{ id: 'team-2', name: 'Team B', rating: 90 },
|
||||
{ id: 'team-3', name: 'Team C', rating: 75 },
|
||||
];
|
||||
|
||||
for (const t of teams) {
|
||||
const team = Team.create({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tag: t.name.substring(0, 2).toUpperCase(),
|
||||
description: `${t.name} description`,
|
||||
ownerId: `driver-${t.id}`,
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team);
|
||||
mockRatingRepo.setSnapshot(t.id, {
|
||||
teamId: t.id,
|
||||
driving: TeamRatingValue.create(t.rating),
|
||||
adminTrust: TeamRatingValue.create(t.rating),
|
||||
overall: t.rating,
|
||||
lastUpdated: new Date('2024-01-01'),
|
||||
eventCount: 5,
|
||||
});
|
||||
}
|
||||
|
||||
// Get ranking for team-2 (should be rank 1 with rating 90)
|
||||
const result = await useCase.getTeamRanking('team-2');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.teamId).toBe('team-2');
|
||||
expect(result?.teamName).toBe('Team B');
|
||||
expect(result?.overallRating).toBe(90);
|
||||
expect(result?.overallRank).toBe(1);
|
||||
});
|
||||
|
||||
it('should calculate correct rank for middle team', async () => {
|
||||
// Setup teams
|
||||
const teams = [
|
||||
{ id: 'team-1', name: 'Team A', rating: 90 },
|
||||
{ id: 'team-2', name: 'Team B', rating: 80 },
|
||||
{ id: 'team-3', name: 'Team C', rating: 70 },
|
||||
];
|
||||
|
||||
for (const t of teams) {
|
||||
const team = Team.create({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tag: t.name.substring(0, 2).toUpperCase(),
|
||||
description: `${t.name} description`,
|
||||
ownerId: `driver-${t.id}`,
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team);
|
||||
mockRatingRepo.setSnapshot(t.id, {
|
||||
teamId: t.id,
|
||||
driving: TeamRatingValue.create(t.rating),
|
||||
adminTrust: TeamRatingValue.create(t.rating),
|
||||
overall: t.rating,
|
||||
lastUpdated: new Date('2024-01-01'),
|
||||
eventCount: 5,
|
||||
});
|
||||
}
|
||||
|
||||
// Get ranking for team-2 (should be rank 2)
|
||||
const result = await useCase.getTeamRanking('team-2');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.overallRank).toBe(2);
|
||||
});
|
||||
|
||||
it('should return complete team ranking data', async () => {
|
||||
const teamId = 'team-123';
|
||||
const team = Team.create({
|
||||
id: teamId,
|
||||
name: 'Complete Team',
|
||||
tag: 'CT',
|
||||
description: 'Complete team description',
|
||||
ownerId: 'driver-123',
|
||||
leagues: [],
|
||||
});
|
||||
mockTeamRepo.setTeam(team);
|
||||
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(82),
|
||||
adminTrust: TeamRatingValue.create(78),
|
||||
overall: 80.8,
|
||||
lastUpdated: new Date('2024-01-15T10:30:00Z'),
|
||||
eventCount: 25,
|
||||
};
|
||||
mockRatingRepo.setSnapshot(teamId, snapshot);
|
||||
|
||||
const result = await useCase.getTeamRanking(teamId);
|
||||
|
||||
expect(result).toEqual({
|
||||
teamId,
|
||||
teamName: 'Complete Team',
|
||||
drivingRating: 82,
|
||||
adminTrustRating: 78,
|
||||
overallRating: 80.8,
|
||||
eventCount: 25,
|
||||
lastUpdated: new Date('2024-01-15T10:30:00Z'),
|
||||
overallRank: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle repository errors gracefully', async () => {
|
||||
// Mock repository to throw error
|
||||
const originalFindAll = mockTeamRepo.findAll.bind(mockTeamRepo);
|
||||
mockTeamRepo.findAll = async () => {
|
||||
throw new Error('Repository connection failed');
|
||||
};
|
||||
|
||||
await expect(useCase.getAllTeamRankings()).rejects.toThrow('Repository connection failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
139
core/racing/application/use-cases/TeamRankingUseCase.ts
Normal file
139
core/racing/application/use-cases/TeamRankingUseCase.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Application Use Case: TeamRankingUseCase
|
||||
*
|
||||
* Computes team rankings from rating snapshots (ledger-based).
|
||||
* Orchestrates repositories to provide team ranking data to presentation layer.
|
||||
* Evolved from direct standings to use team rating events and snapshots.
|
||||
*/
|
||||
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { ITeamRatingRepository } from '../../domain/repositories/ITeamRatingRepository';
|
||||
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||
import type { ITeamRankingUseCase, TeamRanking } from './ITeamRankingUseCase';
|
||||
|
||||
export class TeamRankingUseCase implements ITeamRankingUseCase {
|
||||
constructor(
|
||||
private readonly teamRatingRepository: ITeamRatingRepository,
|
||||
private readonly teamRepository: ITeamRepository,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.logger.info('[TeamRankingUseCase] Initialized with ledger-based team rating repositories');
|
||||
}
|
||||
|
||||
async getAllTeamRankings(): Promise<TeamRanking[]> {
|
||||
this.logger.debug('[TeamRankingUseCase] Computing rankings from team rating snapshots');
|
||||
|
||||
try {
|
||||
// Get all teams for name resolution
|
||||
const teams = await this.teamRepository.findAll();
|
||||
const teamMap = new Map(teams.map(t => [t.id, t]));
|
||||
|
||||
// Get all team IDs
|
||||
const teamIds = Array.from(teamMap.keys());
|
||||
|
||||
if (teamIds.length === 0) {
|
||||
this.logger.warn('[TeamRankingUseCase] No teams found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get rating snapshots for all teams
|
||||
const rankingPromises = teamIds.map(async (teamId) => {
|
||||
const snapshot = await this.teamRatingRepository.findByTeamId(teamId);
|
||||
const team = teamMap.get(teamId);
|
||||
|
||||
if (!snapshot || !team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
teamId,
|
||||
teamName: team.name.toString(),
|
||||
drivingRating: snapshot.driving.value,
|
||||
adminTrustRating: snapshot.adminTrust.value,
|
||||
overallRating: snapshot.overall,
|
||||
eventCount: snapshot.eventCount,
|
||||
lastUpdated: snapshot.lastUpdated,
|
||||
overallRank: null, // Will be assigned after sorting
|
||||
} as TeamRanking;
|
||||
});
|
||||
|
||||
const rankings = (await Promise.all(rankingPromises)).filter(
|
||||
(r): r is TeamRanking => r !== null
|
||||
);
|
||||
|
||||
if (rankings.length === 0) {
|
||||
this.logger.warn('[TeamRankingUseCase] No team rating snapshots found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Sort by overall rating descending and assign ranks
|
||||
rankings.sort((a, b) => b.overallRating - a.overallRating);
|
||||
rankings.forEach((r, idx) => r.overallRank = idx + 1);
|
||||
|
||||
this.logger.info(`[TeamRankingUseCase] Computed rankings for ${rankings.length} teams`);
|
||||
|
||||
return rankings;
|
||||
} catch (error) {
|
||||
this.logger.error('[TeamRankingUseCase] Error computing rankings:', error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getTeamRanking(teamId: string): Promise<TeamRanking | null> {
|
||||
this.logger.debug(`[TeamRankingUseCase] Getting ranking for team ${teamId}`);
|
||||
|
||||
try {
|
||||
const snapshot = await this.teamRatingRepository.findByTeamId(teamId);
|
||||
|
||||
if (!snapshot) {
|
||||
this.logger.warn(`[TeamRankingUseCase] No rating snapshot found for team ${teamId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const team = await this.teamRepository.findById(teamId);
|
||||
|
||||
if (!team) {
|
||||
this.logger.warn(`[TeamRankingUseCase] Team ${teamId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all teams to calculate rank
|
||||
const allTeams = await this.teamRepository.findAll();
|
||||
const allRankings: TeamRanking[] = [];
|
||||
|
||||
for (const t of allTeams) {
|
||||
const s = await this.teamRatingRepository.findByTeamId(t.id);
|
||||
if (s) {
|
||||
allRankings.push({
|
||||
teamId: t.id,
|
||||
teamName: t.name.toString(),
|
||||
drivingRating: s.driving.value,
|
||||
adminTrustRating: s.adminTrust.value,
|
||||
overallRating: s.overall,
|
||||
eventCount: s.eventCount,
|
||||
lastUpdated: s.lastUpdated,
|
||||
overallRank: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort and assign rank
|
||||
allRankings.sort((a, b) => b.overallRating - a.overallRating);
|
||||
const rank = allRankings.findIndex(r => r.teamId === teamId) + 1;
|
||||
|
||||
return {
|
||||
teamId,
|
||||
teamName: team.name.toString(),
|
||||
drivingRating: snapshot.driving.value,
|
||||
adminTrustRating: snapshot.adminTrust.value,
|
||||
overallRating: snapshot.overall,
|
||||
eventCount: snapshot.eventCount,
|
||||
lastUpdated: snapshot.lastUpdated,
|
||||
overallRank: rank,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`[TeamRankingUseCase] Error getting team ranking:`, error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { TeamRatingFactoryUseCase } from './TeamRatingFactoryUseCase';
|
||||
import type { ITeamRaceResultsProvider } from '../ports/ITeamRaceResultsProvider';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { TeamDrivingRaceFactsDto } from '@core/racing/domain/services/TeamDrivingRatingEventFactory';
|
||||
|
||||
// Mock provider
|
||||
class MockTeamRaceResultsProvider implements ITeamRaceResultsProvider {
|
||||
private results: TeamDrivingRaceFactsDto | null = null;
|
||||
|
||||
async getTeamRaceResults(raceId: string): Promise<TeamDrivingRaceFactsDto | null> {
|
||||
return this.results;
|
||||
}
|
||||
|
||||
setResults(results: TeamDrivingRaceFactsDto | null) {
|
||||
this.results = results;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock logger
|
||||
const mockLogger: Logger = {
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
};
|
||||
|
||||
describe('TeamRatingFactoryUseCase', () => {
|
||||
let useCase: TeamRatingFactoryUseCase;
|
||||
let mockResultsProvider: MockTeamRaceResultsProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResultsProvider = new MockTeamRaceResultsProvider();
|
||||
useCase = new TeamRatingFactoryUseCase(mockResultsProvider, mockLogger);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should return error when race results not found', async () => {
|
||||
mockResultsProvider.setResults(null);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Team race results not found');
|
||||
expect(result.events).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return success with no events when results are empty', async () => {
|
||||
mockResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should create events for single team', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.events.length).toBeGreaterThan(0);
|
||||
expect(result.errors).toEqual([]);
|
||||
|
||||
// Verify events have correct structure
|
||||
const event = result.events[0];
|
||||
expect(event.teamId).toBe('team-123');
|
||||
expect(event.source.type).toBe('race');
|
||||
expect(event.source.id).toBe('race-123');
|
||||
});
|
||||
|
||||
it('should create events for multiple teams', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.events.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have events for both teams
|
||||
const team123Events = result.events.filter(e => e.teamId === 'team-123');
|
||||
const team456Events = result.events.filter(e => e.teamId === 'team-456');
|
||||
|
||||
expect(team123Events.length).toBeGreaterThan(0);
|
||||
expect(team456Events.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle optional ratings in results', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 65,
|
||||
pace: 85,
|
||||
consistency: 80,
|
||||
teamwork: 90,
|
||||
sportsmanship: 95,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.events.length).toBeGreaterThan(5); // Should have many events
|
||||
|
||||
// Verify events include optional rating events
|
||||
const reasonCodes = result.events.map(e => e.reason.code);
|
||||
expect(reasonCodes).toContain('RACE_PACE');
|
||||
expect(reasonCodes).toContain('RACE_CONSISTENCY');
|
||||
expect(reasonCodes).toContain('RACE_TEAMWORK');
|
||||
expect(reasonCodes).toContain('RACE_SPORTSMANSHIP');
|
||||
});
|
||||
|
||||
it('should handle repository errors', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
// Mock provider to throw error
|
||||
mockResultsProvider.getTeamRaceResults = async () => {
|
||||
throw new Error('Provider error');
|
||||
};
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0]).toContain('Provider error');
|
||||
expect(result.events).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle race with minimal events generated', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.events.length).toBeGreaterThan(0); // DNS creates penalty event
|
||||
});
|
||||
});
|
||||
|
||||
describe('createManualEvents', () => {
|
||||
it('should create manual events with source ID', () => {
|
||||
const events = useCase.createManualEvents(
|
||||
'team-123',
|
||||
'driving',
|
||||
5,
|
||||
'MANUAL_ADJUSTMENT',
|
||||
'manualAdjustment',
|
||||
'adjustment-123'
|
||||
);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].teamId).toBe('team-123');
|
||||
expect(events[0].dimension.value).toBe('driving');
|
||||
expect(events[0].delta.value).toBe(5);
|
||||
expect(events[0].reason.code).toBe('MANUAL_ADJUSTMENT');
|
||||
expect(events[0].source.type).toBe('manualAdjustment');
|
||||
expect(events[0].source.id).toBe('adjustment-123');
|
||||
});
|
||||
|
||||
it('should create manual events without source ID', () => {
|
||||
const events = useCase.createManualEvents(
|
||||
'team-456',
|
||||
'adminTrust',
|
||||
-3,
|
||||
'PENALTY',
|
||||
'penalty'
|
||||
);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].teamId).toBe('team-456');
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBe(-3);
|
||||
expect(events[0].reason.code).toBe('PENALTY');
|
||||
expect(events[0].source.type).toBe('penalty');
|
||||
expect(events[0].source.id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
121
core/racing/application/use-cases/TeamRatingFactoryUseCase.ts
Normal file
121
core/racing/application/use-cases/TeamRatingFactoryUseCase.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Application Use Case: TeamRatingFactoryUseCase
|
||||
*
|
||||
* Factory use case for creating team rating events from race results.
|
||||
* This replaces direct team rating calculations with event-based approach.
|
||||
* Mirrors the user rating factory pattern.
|
||||
*/
|
||||
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { ITeamRaceResultsProvider } from '../ports/ITeamRaceResultsProvider';
|
||||
import { TeamDrivingRatingEventFactory } from '@core/racing/domain/services/TeamDrivingRatingEventFactory';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
|
||||
export interface TeamRatingFactoryInput {
|
||||
raceId: string;
|
||||
}
|
||||
|
||||
export interface TeamRatingFactoryOutput {
|
||||
success: boolean;
|
||||
raceId: string;
|
||||
events: TeamRatingEvent[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class TeamRatingFactoryUseCase {
|
||||
constructor(
|
||||
private readonly teamRaceResultsProvider: ITeamRaceResultsProvider,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.logger.info('[TeamRatingFactoryUseCase] Initialized');
|
||||
}
|
||||
|
||||
async execute(input: TeamRatingFactoryInput): Promise<TeamRatingFactoryOutput> {
|
||||
this.logger.debug(`[TeamRatingFactoryUseCase] Creating rating events for race ${input.raceId}`);
|
||||
|
||||
try {
|
||||
// Load team race results
|
||||
const teamRaceResults = await this.teamRaceResultsProvider.getTeamRaceResults(input.raceId);
|
||||
|
||||
if (!teamRaceResults) {
|
||||
return {
|
||||
success: false,
|
||||
raceId: input.raceId,
|
||||
events: [],
|
||||
errors: ['Team race results not found'],
|
||||
};
|
||||
}
|
||||
|
||||
// Use factory to create events
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(teamRaceResults);
|
||||
|
||||
// Flatten events from all teams
|
||||
const allEvents: TeamRatingEvent[] = [];
|
||||
for (const [, events] of eventsByTeam) {
|
||||
allEvents.push(...events);
|
||||
}
|
||||
|
||||
if (allEvents.length === 0) {
|
||||
this.logger.info(`[TeamRatingFactoryUseCase] No events generated for race ${input.raceId}`);
|
||||
return {
|
||||
success: true,
|
||||
raceId: input.raceId,
|
||||
events: [],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.info(`[TeamRatingFactoryUseCase] Generated ${allEvents.length} events for race ${input.raceId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
raceId: input.raceId,
|
||||
events: allEvents,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to create rating events: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
this.logger.error('[TeamRatingFactoryUseCase] Error:', error instanceof Error ? error : new Error(String(error)));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
raceId: input.raceId,
|
||||
events: [],
|
||||
errors: [errorMsg],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create team rating events manually (for testing or manual adjustments)
|
||||
*/
|
||||
createManualEvents(
|
||||
teamId: string,
|
||||
dimension: string,
|
||||
delta: number,
|
||||
reason: string,
|
||||
sourceType: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment',
|
||||
sourceId?: string
|
||||
): TeamRatingEvent[] {
|
||||
const source = sourceId ? { type: sourceType, id: sourceId } : { type: sourceType };
|
||||
|
||||
const event = TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId,
|
||||
dimension: TeamRatingDimensionKey.create(dimension),
|
||||
delta: TeamRatingDelta.create(delta),
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: source,
|
||||
reason: { code: reason },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
return [event];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { TeamRatingIntegrationAdapter } from './TeamRatingIntegrationAdapter';
|
||||
import { ITeamRaceResultsProvider } from '../ports/ITeamRaceResultsProvider';
|
||||
import { ITeamRatingEventRepository } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import { TeamDrivingRaceFactsDto } from '@core/racing/domain/services/TeamDrivingRatingEventFactory';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
|
||||
// Mock repositories
|
||||
class MockTeamRaceResultsProvider implements ITeamRaceResultsProvider {
|
||||
private results: TeamDrivingRaceFactsDto | null = null;
|
||||
|
||||
async getTeamRaceResults(raceId: string): Promise<TeamDrivingRaceFactsDto | null> {
|
||||
return this.results;
|
||||
}
|
||||
|
||||
setResults(results: TeamDrivingRaceFactsDto | null) {
|
||||
this.results = results;
|
||||
}
|
||||
}
|
||||
|
||||
class MockTeamRatingEventRepository implements ITeamRatingEventRepository {
|
||||
private events: TeamRatingEvent[] = [];
|
||||
|
||||
async save(event: TeamRatingEvent): Promise<TeamRatingEvent> {
|
||||
this.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findByIds(ids: TeamRatingEventId[]): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => ids.some(id => id.equals(e.id)));
|
||||
}
|
||||
|
||||
async getAllByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
return this.events.filter(e => e.teamId === teamId);
|
||||
}
|
||||
|
||||
async findEventsPaginated(teamId: string): Promise<any> {
|
||||
const events = await this.getAllByTeamId(teamId);
|
||||
return {
|
||||
items: events,
|
||||
total: events.length,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.events = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockTeamRatingRepository implements ITeamRatingRepository {
|
||||
private snapshots: Map<string, any> = new Map();
|
||||
|
||||
async findByTeamId(teamId: string): Promise<any | null> {
|
||||
return this.snapshots.get(teamId) || null;
|
||||
}
|
||||
|
||||
async save(snapshot: any): Promise<any> {
|
||||
this.snapshots.set(snapshot.teamId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.snapshots.clear();
|
||||
}
|
||||
}
|
||||
|
||||
describe('TeamRatingIntegrationAdapter', () => {
|
||||
let adapter: TeamRatingIntegrationAdapter;
|
||||
let mockResultsProvider: MockTeamRaceResultsProvider;
|
||||
let mockEventRepo: MockTeamRatingEventRepository;
|
||||
let mockRatingRepo: MockTeamRatingRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResultsProvider = new MockTeamRaceResultsProvider();
|
||||
mockEventRepo = new MockTeamRatingEventRepository();
|
||||
mockRatingRepo = new MockTeamRatingRepository();
|
||||
adapter = new TeamRatingIntegrationAdapter(
|
||||
mockResultsProvider,
|
||||
mockEventRepo,
|
||||
mockRatingRepo
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockEventRepo.clear();
|
||||
mockRatingRepo.clear();
|
||||
});
|
||||
|
||||
describe('recordTeamRatings', () => {
|
||||
it('should return true when no results found', async () => {
|
||||
mockResultsProvider.setResults(null);
|
||||
|
||||
const result = await adapter.recordTeamRatings('race-123');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when results are empty', async () => {
|
||||
mockResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await adapter.recordTeamRatings('race-123');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when no events generated', async () => {
|
||||
mockResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 50,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await adapter.recordTeamRatings('race-123');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should record team ratings successfully', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await adapter.recordTeamRatings('race-123');
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify events were saved
|
||||
const events1 = await mockEventRepo.getAllByTeamId('team-123');
|
||||
const events2 = await mockEventRepo.getAllByTeamId('team-456');
|
||||
|
||||
expect(events1.length).toBeGreaterThan(0);
|
||||
expect(events2.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify snapshots were updated
|
||||
const snapshot1 = await mockRatingRepo.findByTeamId('team-123');
|
||||
const snapshot2 = await mockRatingRepo.findByTeamId('team-456');
|
||||
|
||||
expect(snapshot1).toBeDefined();
|
||||
expect(snapshot2).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return false on error', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
// Mock repository to throw error
|
||||
const originalSave = mockEventRepo.save.bind(mockEventRepo);
|
||||
mockEventRepo.save = async () => {
|
||||
throw new Error('Repository error');
|
||||
};
|
||||
|
||||
const result = await adapter.recordTeamRatings('race-123');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordTeamRatingsWithDetails', () => {
|
||||
it('should return details for successful recording', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
const result = await adapter.recordTeamRatingsWithDetails('race-123');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(0);
|
||||
expect(result.teamsUpdated).toContain('team-123');
|
||||
expect(result.teamsUpdated).toContain('team-456');
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle partial failures', async () => {
|
||||
const raceResults: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 2,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 2,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockResultsProvider.setResults(raceResults);
|
||||
|
||||
// Mock repository to fail for team-456
|
||||
const originalSave = mockEventRepo.save.bind(mockEventRepo);
|
||||
mockEventRepo.save = async (event) => {
|
||||
if (event.teamId === 'team-456') {
|
||||
throw new Error('Simulated failure');
|
||||
}
|
||||
return originalSave(event);
|
||||
};
|
||||
|
||||
const result = await adapter.recordTeamRatingsWithDetails('race-123');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.teamsUpdated).toContain('team-123');
|
||||
expect(result.teamsUpdated).not.toContain('team-456');
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0]).toContain('team-456');
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
mockResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
teamId: 'team-123',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await adapter.recordTeamRatingsWithDetails('race-123');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.teamsUpdated).toEqual([]);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle null results', async () => {
|
||||
mockResultsProvider.setResults(null);
|
||||
|
||||
const result = await adapter.recordTeamRatingsWithDetails('race-123');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.teamsUpdated).toEqual([]);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { ITeamRaceResultsProvider } from '../ports/ITeamRaceResultsProvider';
|
||||
import type { ITeamRatingEventRepository } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import type { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import { TeamDrivingRatingEventFactory } from '@core/racing/domain/services/TeamDrivingRatingEventFactory';
|
||||
import { AppendTeamRatingEventsUseCase } from './AppendTeamRatingEventsUseCase';
|
||||
|
||||
/**
|
||||
* Integration Adapter: TeamRatingIntegrationAdapter
|
||||
*
|
||||
* Minimal integration with race flow.
|
||||
* Can be called from CompleteRaceUseCase to record team ratings.
|
||||
*
|
||||
* Usage in CompleteRaceUseCase:
|
||||
* ```typescript
|
||||
* // After race completion
|
||||
* const teamRatingAdapter = new TeamRatingIntegrationAdapter(
|
||||
* teamRaceResultsProvider,
|
||||
* ratingEventRepository,
|
||||
* ratingRepository
|
||||
* );
|
||||
*
|
||||
* await teamRatingAdapter.recordTeamRatings(raceId);
|
||||
* ```
|
||||
*/
|
||||
export class TeamRatingIntegrationAdapter {
|
||||
private appendUseCase: AppendTeamRatingEventsUseCase;
|
||||
|
||||
constructor(
|
||||
private readonly teamRaceResultsProvider: ITeamRaceResultsProvider,
|
||||
ratingEventRepository: ITeamRatingEventRepository,
|
||||
ratingRepository: ITeamRatingRepository,
|
||||
) {
|
||||
this.appendUseCase = new AppendTeamRatingEventsUseCase(
|
||||
ratingEventRepository,
|
||||
ratingRepository
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record team ratings for a completed race.
|
||||
* Returns true if successful, false otherwise.
|
||||
*/
|
||||
async recordTeamRatings(raceId: string): Promise<boolean> {
|
||||
try {
|
||||
// Get team race results
|
||||
const teamRaceResults = await this.teamRaceResultsProvider.getTeamRaceResults(raceId);
|
||||
|
||||
if (!teamRaceResults || teamRaceResults.results.length === 0) {
|
||||
return true; // No team results to process
|
||||
}
|
||||
|
||||
// Create rating events
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(teamRaceResults);
|
||||
|
||||
if (eventsByTeam.size === 0) {
|
||||
return true; // No events generated
|
||||
}
|
||||
|
||||
// Process each team
|
||||
for (const [teamId, events] of eventsByTeam) {
|
||||
try {
|
||||
await this.appendUseCase.execute(events);
|
||||
} catch (error) {
|
||||
console.error(`Failed to record team ratings for team ${teamId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to record team ratings:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record team ratings with detailed output.
|
||||
*/
|
||||
async recordTeamRatingsWithDetails(raceId: string): Promise<{
|
||||
success: boolean;
|
||||
eventsCreated: number;
|
||||
teamsUpdated: string[];
|
||||
errors: string[];
|
||||
}> {
|
||||
const errors: string[] = [];
|
||||
const teamsUpdated: string[] = [];
|
||||
let totalEventsCreated = 0;
|
||||
|
||||
try {
|
||||
const teamRaceResults = await this.teamRaceResultsProvider.getTeamRaceResults(raceId);
|
||||
|
||||
if (!teamRaceResults || teamRaceResults.results.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
eventsCreated: 0,
|
||||
teamsUpdated: [],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(teamRaceResults);
|
||||
|
||||
if (eventsByTeam.size === 0) {
|
||||
return {
|
||||
success: true,
|
||||
eventsCreated: 0,
|
||||
teamsUpdated: [],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
for (const [teamId, events] of eventsByTeam) {
|
||||
try {
|
||||
await this.appendUseCase.execute(events);
|
||||
teamsUpdated.push(teamId);
|
||||
totalEventsCreated += events.length;
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to process events for team ${teamId}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
eventsCreated: totalEventsCreated,
|
||||
teamsUpdated,
|
||||
errors,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to record team ratings: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errors.push(errorMsg);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
eventsCreated: 0,
|
||||
teamsUpdated: [],
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user