team rating
This commit is contained in:
198
core/racing/domain/entities/TeamRatingEvent.test.ts
Normal file
198
core/racing/domain/entities/TeamRatingEvent.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { TeamRatingEvent } from './TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
describe('TeamRatingEvent', () => {
|
||||
const validProps = {
|
||||
id: TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174000'),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T00:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st in race' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a valid rating event', () => {
|
||||
const event = TeamRatingEvent.create(validProps);
|
||||
|
||||
expect(event.id.value).toBe(validProps.id.value);
|
||||
expect(event.teamId).toBe(validProps.teamId);
|
||||
expect(event.dimension.value).toBe('driving');
|
||||
expect(event.delta.value).toBe(10);
|
||||
expect(event.occurredAt).toEqual(validProps.occurredAt);
|
||||
expect(event.createdAt).toEqual(validProps.createdAt);
|
||||
expect(event.source).toEqual(validProps.source);
|
||||
expect(event.reason).toEqual(validProps.reason);
|
||||
expect(event.visibility).toEqual(validProps.visibility);
|
||||
expect(event.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should create event with optional weight', () => {
|
||||
const props = { ...validProps, weight: 2 };
|
||||
const event = TeamRatingEvent.create(props);
|
||||
|
||||
expect(event.weight).toBe(2);
|
||||
});
|
||||
|
||||
it('should throw for empty teamId', () => {
|
||||
const props = { ...validProps, teamId: '' };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing dimension', () => {
|
||||
const { dimension: _dimension, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing delta', () => {
|
||||
const { delta: _delta, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing source', () => {
|
||||
const { source: _source, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing reason', () => {
|
||||
const { reason: _reason, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing visibility', () => {
|
||||
const { visibility: _visibility, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for invalid weight', () => {
|
||||
const props = { ...validProps, weight: 0 };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for future occurredAt', () => {
|
||||
const futureDate = new Date(Date.now() + 86400000); // Tomorrow
|
||||
const props = { ...validProps, occurredAt: futureDate };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for future createdAt', () => {
|
||||
const futureDate = new Date(Date.now() + 86400000); // Tomorrow
|
||||
const props = { ...validProps, createdAt: futureDate };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for version < 1', () => {
|
||||
const props = { ...validProps, version: 0 };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for adminTrust dimension with race source', () => {
|
||||
const props = {
|
||||
...validProps,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
};
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('should throw for driving dimension with vote source', () => {
|
||||
const props = {
|
||||
...validProps,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
source: { type: 'vote' as const, id: 'vote-456' },
|
||||
};
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('should allow adminTrust with adminAction source', () => {
|
||||
const props = {
|
||||
...validProps,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
source: { type: 'adminAction' as const, id: 'action-456' },
|
||||
};
|
||||
const event = TeamRatingEvent.create(props);
|
||||
expect(event.dimension.value).toBe('adminTrust');
|
||||
});
|
||||
|
||||
it('should allow driving with race source', () => {
|
||||
const props = {
|
||||
...validProps,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
};
|
||||
const event = TeamRatingEvent.create(props);
|
||||
expect(event.dimension.value).toBe('driving');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rehydrate', () => {
|
||||
it('should rehydrate event from stored data', () => {
|
||||
const event = TeamRatingEvent.rehydrate(validProps);
|
||||
|
||||
expect(event.id.value).toBe(validProps.id.value);
|
||||
expect(event.teamId).toBe(validProps.teamId);
|
||||
expect(event.dimension.value).toBe('driving');
|
||||
expect(event.delta.value).toBe(10);
|
||||
});
|
||||
|
||||
it('should rehydrate event with optional weight', () => {
|
||||
const props = { ...validProps, weight: 2 };
|
||||
const event = TeamRatingEvent.rehydrate(props);
|
||||
|
||||
expect(event.weight).toBe(2);
|
||||
});
|
||||
|
||||
it('should return true for same ID', () => {
|
||||
const event1 = TeamRatingEvent.create(validProps);
|
||||
const event2 = TeamRatingEvent.rehydrate(validProps);
|
||||
|
||||
expect(event1.equals(event2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different IDs', () => {
|
||||
const event1 = TeamRatingEvent.create(validProps);
|
||||
const event2 = TeamRatingEvent.create({
|
||||
...validProps,
|
||||
id: TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174001'),
|
||||
});
|
||||
|
||||
expect(event1.equals(event2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toJSON', () => {
|
||||
it('should return plain object representation', () => {
|
||||
const event = TeamRatingEvent.create(validProps);
|
||||
const json = event.toJSON();
|
||||
|
||||
expect(json).toEqual({
|
||||
id: validProps.id.value,
|
||||
teamId: validProps.teamId,
|
||||
dimension: 'driving',
|
||||
delta: 10,
|
||||
weight: undefined,
|
||||
occurredAt: validProps.occurredAt.toISOString(),
|
||||
createdAt: validProps.createdAt.toISOString(),
|
||||
source: validProps.source,
|
||||
reason: validProps.reason,
|
||||
visibility: validProps.visibility,
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include weight when present', () => {
|
||||
const props = { ...validProps, weight: 2 };
|
||||
const event = TeamRatingEvent.create(props);
|
||||
const json = event.toJSON();
|
||||
|
||||
expect(json).toHaveProperty('weight', 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
181
core/racing/domain/entities/TeamRatingEvent.ts
Normal file
181
core/racing/domain/entities/TeamRatingEvent.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { IEntity } from '@core/shared/domain';
|
||||
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface TeamRatingEventSource {
|
||||
type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
|
||||
id?: string; // e.g., raceId, penaltyId, voteId
|
||||
}
|
||||
|
||||
export interface TeamRatingEventReason {
|
||||
code: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TeamRatingEventVisibility {
|
||||
public: boolean;
|
||||
}
|
||||
|
||||
export interface TeamRatingEventProps {
|
||||
id: TeamRatingEventId;
|
||||
teamId: string;
|
||||
dimension: TeamRatingDimensionKey;
|
||||
delta: TeamRatingDelta;
|
||||
weight?: number;
|
||||
occurredAt: Date;
|
||||
createdAt: Date;
|
||||
source: TeamRatingEventSource;
|
||||
reason: TeamRatingEventReason;
|
||||
visibility: TeamRatingEventVisibility;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export class TeamRatingEvent implements IEntity<TeamRatingEventId> {
|
||||
readonly id: TeamRatingEventId;
|
||||
readonly teamId: string;
|
||||
readonly dimension: TeamRatingDimensionKey;
|
||||
readonly delta: TeamRatingDelta;
|
||||
readonly weight: number | undefined;
|
||||
readonly occurredAt: Date;
|
||||
readonly createdAt: Date;
|
||||
readonly source: TeamRatingEventSource;
|
||||
readonly reason: TeamRatingEventReason;
|
||||
readonly visibility: TeamRatingEventVisibility;
|
||||
readonly version: number;
|
||||
|
||||
private constructor(props: TeamRatingEventProps) {
|
||||
this.id = props.id;
|
||||
this.teamId = props.teamId;
|
||||
this.dimension = props.dimension;
|
||||
this.delta = props.delta;
|
||||
this.weight = props.weight;
|
||||
this.occurredAt = props.occurredAt;
|
||||
this.createdAt = props.createdAt;
|
||||
this.source = props.source;
|
||||
this.reason = props.reason;
|
||||
this.visibility = props.visibility;
|
||||
this.version = props.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new TeamRatingEvent.
|
||||
*/
|
||||
static create(props: {
|
||||
id: TeamRatingEventId;
|
||||
teamId: string;
|
||||
dimension: TeamRatingDimensionKey;
|
||||
delta: TeamRatingDelta;
|
||||
weight?: number;
|
||||
occurredAt: Date;
|
||||
createdAt: Date;
|
||||
source: TeamRatingEventSource;
|
||||
reason: TeamRatingEventReason;
|
||||
visibility: TeamRatingEventVisibility;
|
||||
version: number;
|
||||
}): TeamRatingEvent {
|
||||
// Validate required fields
|
||||
if (!props.teamId || props.teamId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team ID is required');
|
||||
}
|
||||
|
||||
if (!props.dimension) {
|
||||
throw new RacingDomainValidationError('Dimension is required');
|
||||
}
|
||||
|
||||
if (!props.delta) {
|
||||
throw new RacingDomainValidationError('Delta is required');
|
||||
}
|
||||
|
||||
if (!props.source) {
|
||||
throw new RacingDomainValidationError('Source is required');
|
||||
}
|
||||
|
||||
if (!props.reason) {
|
||||
throw new RacingDomainValidationError('Reason is required');
|
||||
}
|
||||
|
||||
if (!props.visibility) {
|
||||
throw new RacingDomainValidationError('Visibility is required');
|
||||
}
|
||||
|
||||
if (props.weight !== undefined && (typeof props.weight !== 'number' || props.weight <= 0)) {
|
||||
throw new RacingDomainValidationError('Weight must be a positive number if provided');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (props.occurredAt > now) {
|
||||
throw new RacingDomainValidationError('Occurrence date cannot be in the future');
|
||||
}
|
||||
|
||||
if (props.createdAt > now) {
|
||||
throw new RacingDomainValidationError('Creation date cannot be in the future');
|
||||
}
|
||||
|
||||
if (props.version < 1) {
|
||||
throw new RacingDomainValidationError('Version must be at least 1');
|
||||
}
|
||||
|
||||
// Validate invariants
|
||||
if (props.dimension.value === 'adminTrust' && props.source.type === 'race') {
|
||||
throw new RacingDomainInvariantError(
|
||||
'adminTrust dimension cannot be updated from race events'
|
||||
);
|
||||
}
|
||||
|
||||
if (props.dimension.value === 'driving' && props.source.type === 'vote') {
|
||||
throw new RacingDomainInvariantError(
|
||||
'driving dimension cannot be updated from vote events'
|
||||
);
|
||||
}
|
||||
|
||||
return new TeamRatingEvent(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate event from stored data (assumes data is already validated).
|
||||
*/
|
||||
static rehydrate(props: {
|
||||
id: TeamRatingEventId;
|
||||
teamId: string;
|
||||
dimension: TeamRatingDimensionKey;
|
||||
delta: TeamRatingDelta;
|
||||
weight?: number;
|
||||
occurredAt: Date;
|
||||
createdAt: Date;
|
||||
source: TeamRatingEventSource;
|
||||
reason: TeamRatingEventReason;
|
||||
visibility: TeamRatingEventVisibility;
|
||||
version: number;
|
||||
}): TeamRatingEvent {
|
||||
// Rehydration assumes data is already validated (from persistence)
|
||||
return new TeamRatingEvent(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare with another event.
|
||||
*/
|
||||
equals(other: IEntity<TeamRatingEventId>): boolean {
|
||||
return this.id.equals(other.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return plain object representation for serialization.
|
||||
*/
|
||||
toJSON(): object {
|
||||
return {
|
||||
id: this.id.value,
|
||||
teamId: this.teamId,
|
||||
dimension: this.dimension.value,
|
||||
delta: this.delta.value,
|
||||
weight: this.weight,
|
||||
occurredAt: this.occurredAt.toISOString(),
|
||||
createdAt: this.createdAt.toISOString(),
|
||||
source: this.source,
|
||||
reason: this.reason,
|
||||
visibility: this.visibility,
|
||||
version: this.version,
|
||||
};
|
||||
}
|
||||
}
|
||||
35
core/racing/domain/repositories/IDriverStatsRepository.ts
Normal file
35
core/racing/domain/repositories/IDriverStatsRepository.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Application Port: IDriverStatsRepository
|
||||
*
|
||||
* Repository interface for storing and retrieving computed driver statistics.
|
||||
* This is used for caching computed stats and serving frontend data.
|
||||
*/
|
||||
|
||||
import type { DriverStats } from '../../application/use-cases/IDriverStatsUseCase';
|
||||
|
||||
export interface IDriverStatsRepository {
|
||||
/**
|
||||
* Get stats for a specific driver
|
||||
*/
|
||||
getDriverStats(driverId: string): Promise<DriverStats | null>;
|
||||
|
||||
/**
|
||||
* Get stats for a specific driver (synchronous)
|
||||
*/
|
||||
getDriverStatsSync(driverId: string): DriverStats | null;
|
||||
|
||||
/**
|
||||
* Save stats for a specific driver
|
||||
*/
|
||||
saveDriverStats(driverId: string, stats: DriverStats): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get all driver stats
|
||||
*/
|
||||
getAllStats(): Promise<Map<string, DriverStats>>;
|
||||
|
||||
/**
|
||||
* Clear all stats
|
||||
*/
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
38
core/racing/domain/repositories/IMediaRepository.ts
Normal file
38
core/racing/domain/repositories/IMediaRepository.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Application Port: IMediaRepository
|
||||
*
|
||||
* Repository interface for static media assets (logos, images, icons).
|
||||
* Handles frontend assets like team logos, driver avatars, etc.
|
||||
*/
|
||||
|
||||
export interface IMediaRepository {
|
||||
/**
|
||||
* Get driver avatar URL
|
||||
*/
|
||||
getDriverAvatar(driverId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Get team logo URL
|
||||
*/
|
||||
getTeamLogo(teamId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Get track image URL
|
||||
*/
|
||||
getTrackImage(trackId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Get category icon URL
|
||||
*/
|
||||
getCategoryIcon(categoryId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Get sponsor logo URL
|
||||
*/
|
||||
getSponsorLogo(sponsorId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Clear all media data (for reseeding)
|
||||
*/
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Repository Interface: ITeamRatingEventRepository
|
||||
*
|
||||
* Port for persisting and retrieving team rating events (ledger).
|
||||
* Events are immutable and ordered by occurredAt for deterministic snapshot computation.
|
||||
*/
|
||||
|
||||
import type { TeamRatingEvent } from '../entities/TeamRatingEvent';
|
||||
import type { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
|
||||
export interface FindByTeamIdOptions {
|
||||
/** Only return events after this ID (for pagination/streaming) */
|
||||
afterId?: TeamRatingEventId;
|
||||
/** Maximum number of events to return */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface TeamRatingEventFilter {
|
||||
/** Filter by dimension keys */
|
||||
dimensions?: string[];
|
||||
/** Filter by source types */
|
||||
sourceTypes?: ('race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment')[];
|
||||
/** Filter by date range (inclusive) */
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
/** Filter by reason codes */
|
||||
reasonCodes?: string[];
|
||||
/** Filter by visibility */
|
||||
visibility?: 'public' | 'private';
|
||||
}
|
||||
|
||||
export interface PaginatedQueryOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filter?: TeamRatingEventFilter;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
nextOffset?: number;
|
||||
}
|
||||
|
||||
export interface ITeamRatingEventRepository {
|
||||
/**
|
||||
* Save a rating event to the ledger
|
||||
*/
|
||||
save(event: TeamRatingEvent): Promise<TeamRatingEvent>;
|
||||
|
||||
/**
|
||||
* Find all rating events for a team, ordered by occurredAt (ascending)
|
||||
* Options allow for pagination and streaming
|
||||
*/
|
||||
findByTeamId(teamId: string, options?: FindByTeamIdOptions): Promise<TeamRatingEvent[]>;
|
||||
|
||||
/**
|
||||
* Find multiple events by their IDs
|
||||
*/
|
||||
findByIds(ids: TeamRatingEventId[]): Promise<TeamRatingEvent[]>;
|
||||
|
||||
/**
|
||||
* Get all events for a team (for snapshot recomputation)
|
||||
*/
|
||||
getAllByTeamId(teamId: string): Promise<TeamRatingEvent[]>;
|
||||
|
||||
/**
|
||||
* Find events with pagination and filtering
|
||||
*/
|
||||
findEventsPaginated(teamId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<TeamRatingEvent>>;
|
||||
}
|
||||
20
core/racing/domain/repositories/ITeamRatingRepository.ts
Normal file
20
core/racing/domain/repositories/ITeamRatingRepository.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Repository Interface: ITeamRatingRepository
|
||||
*
|
||||
* Port for persisting and retrieving TeamRating snapshots.
|
||||
* Snapshots are derived from rating events for fast reads.
|
||||
*/
|
||||
|
||||
import type { TeamRatingSnapshot } from '../services/TeamRatingSnapshotCalculator';
|
||||
|
||||
export interface ITeamRatingRepository {
|
||||
/**
|
||||
* Find rating snapshot by team ID
|
||||
*/
|
||||
findByTeamId(teamId: string): Promise<TeamRatingSnapshot | null>;
|
||||
|
||||
/**
|
||||
* Save or update a team rating snapshot
|
||||
*/
|
||||
save(teamRating: TeamRatingSnapshot): Promise<TeamRatingSnapshot>;
|
||||
}
|
||||
44
core/racing/domain/repositories/ITeamStatsRepository.ts
Normal file
44
core/racing/domain/repositories/ITeamStatsRepository.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Application Port: ITeamStatsRepository
|
||||
*
|
||||
* Repository interface for storing and retrieving computed team statistics.
|
||||
* This is used for caching computed stats and serving frontend data.
|
||||
*/
|
||||
|
||||
export interface TeamStats {
|
||||
logoUrl: string;
|
||||
performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||
specialization: 'endurance' | 'sprint' | 'mixed';
|
||||
region: string;
|
||||
languages: string[];
|
||||
totalWins: number;
|
||||
totalRaces: number;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface ITeamStatsRepository {
|
||||
/**
|
||||
* Get stats for a specific team
|
||||
*/
|
||||
getTeamStats(teamId: string): Promise<TeamStats | null>;
|
||||
|
||||
/**
|
||||
* Get stats for a specific team (synchronous)
|
||||
*/
|
||||
getTeamStatsSync(teamId: string): TeamStats | null;
|
||||
|
||||
/**
|
||||
* Save stats for a specific team
|
||||
*/
|
||||
saveTeamStats(teamId: string, stats: TeamStats): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get all team stats
|
||||
*/
|
||||
getAllStats(): Promise<Map<string, TeamStats>>;
|
||||
|
||||
/**
|
||||
* Clear all stats
|
||||
*/
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { IDomainService } from '@core/shared/domain';
|
||||
|
||||
export interface DriverStats {
|
||||
rating: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
totalRaces: number;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface IDriverStatsService extends IDomainService {
|
||||
getDriverStats(driverId: string): DriverStats | null;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { IDomainService } from '@core/shared/domain';
|
||||
|
||||
export interface DriverRanking {
|
||||
driverId: string;
|
||||
rating: number;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface IRankingService extends IDomainService {
|
||||
getAllDriverRankings(): DriverRanking[];
|
||||
}
|
||||
452
core/racing/domain/services/TeamDrivingRatingCalculator.test.ts
Normal file
452
core/racing/domain/services/TeamDrivingRatingCalculator.test.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import { TeamDrivingRatingCalculator, TeamDrivingRaceResult, TeamDrivingQualifyingResult, TeamDrivingOvertakeStats } from './TeamDrivingRatingCalculator';
|
||||
|
||||
describe('TeamDrivingRatingCalculator', () => {
|
||||
describe('calculateFromRaceFinish', () => {
|
||||
it('should create events from race finish data', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].teamId).toBe('team-123');
|
||||
expect(events[0].dimension.value).toBe('driving');
|
||||
});
|
||||
|
||||
it('should create events for DNS status', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dnsEvent = events.find(e => e.reason.code === 'RACE_DNS');
|
||||
expect(dnsEvent).toBeDefined();
|
||||
expect(dnsEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should create events for DNF status', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 2,
|
||||
status: 'dnf',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dnfEvent = events.find(e => e.reason.code === 'RACE_DNF');
|
||||
expect(dnfEvent).toBeDefined();
|
||||
expect(dnfEvent?.delta.value).toBe(-15);
|
||||
});
|
||||
|
||||
it('should create events for DSQ status', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 0,
|
||||
status: 'dsq',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dsqEvent = events.find(e => e.reason.code === 'RACE_DSQ');
|
||||
expect(dsqEvent).toBeDefined();
|
||||
expect(dsqEvent?.delta.value).toBe(-25);
|
||||
});
|
||||
|
||||
it('should create events for AFK status', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 0,
|
||||
status: 'afk',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const afkEvent = events.find(e => e.reason.code === 'RACE_AFK');
|
||||
expect(afkEvent).toBeDefined();
|
||||
expect(afkEvent?.delta.value).toBe(-20);
|
||||
});
|
||||
|
||||
it('should apply incident penalties', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 5,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const incidentEvent = events.find(e => e.reason.code === 'RACE_INCIDENTS');
|
||||
expect(incidentEvent).toBeDefined();
|
||||
expect(incidentEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should apply gain bonus for beating higher-rated teams', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 65, // High strength
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const gainEvent = events.find(e => e.reason.code === 'RACE_GAIN_BONUS');
|
||||
expect(gainEvent).toBeDefined();
|
||||
expect(gainEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(gainEvent?.weight).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should create pace events when pace is provided', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
pace: 80,
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const paceEvent = events.find(e => e.reason.code === 'RACE_PACE');
|
||||
expect(paceEvent).toBeDefined();
|
||||
expect(paceEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(paceEvent?.weight).toBe(0.3);
|
||||
});
|
||||
|
||||
it('should create consistency events when consistency is provided', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
consistency: 85,
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const consistencyEvent = events.find(e => e.reason.code === 'RACE_CONSISTENCY');
|
||||
expect(consistencyEvent).toBeDefined();
|
||||
expect(consistencyEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(consistencyEvent?.weight).toBe(0.3);
|
||||
});
|
||||
|
||||
it('should create teamwork events when teamwork is provided', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
teamwork: 90,
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const teamworkEvent = events.find(e => e.reason.code === 'RACE_TEAMWORK');
|
||||
expect(teamworkEvent).toBeDefined();
|
||||
expect(teamworkEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(teamworkEvent?.weight).toBe(0.4);
|
||||
});
|
||||
|
||||
it('should create sportsmanship events when sportsmanship is provided', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
sportsmanship: 95,
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const sportsmanshipEvent = events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP');
|
||||
expect(sportsmanshipEvent).toBeDefined();
|
||||
expect(sportsmanshipEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(sportsmanshipEvent?.weight).toBe(0.3);
|
||||
});
|
||||
|
||||
it('should handle all optional ratings together', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 65, // High enough for gain bonus
|
||||
raceId: 'race-456',
|
||||
pace: 75,
|
||||
consistency: 80,
|
||||
teamwork: 85,
|
||||
sportsmanship: 90,
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
// Should have multiple events
|
||||
expect(events.length).toBeGreaterThan(5);
|
||||
|
||||
// Check for specific events
|
||||
expect(events.find(e => e.reason.code === 'RACE_PERFORMANCE')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_GAIN_BONUS')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_INCIDENTS')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_PACE')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_CONSISTENCY')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_TEAMWORK')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateFromQualifying', () => {
|
||||
it('should create qualifying events', () => {
|
||||
const result: TeamDrivingQualifyingResult = {
|
||||
teamId: 'team-123',
|
||||
qualifyingPosition: 3,
|
||||
fieldSize: 10,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromQualifying(result);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].teamId).toBe('team-123');
|
||||
expect(events[0].dimension.value).toBe('driving');
|
||||
expect(events[0].reason.code).toBe('RACE_QUALIFYING');
|
||||
expect(events[0].weight).toBe(0.25);
|
||||
});
|
||||
|
||||
it('should create positive delta for good qualifying position', () => {
|
||||
const result: TeamDrivingQualifyingResult = {
|
||||
teamId: 'team-123',
|
||||
qualifyingPosition: 1,
|
||||
fieldSize: 10,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromQualifying(result);
|
||||
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create negative delta for poor qualifying position', () => {
|
||||
const result: TeamDrivingQualifyingResult = {
|
||||
teamId: 'team-123',
|
||||
qualifyingPosition: 10,
|
||||
fieldSize: 10,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromQualifying(result);
|
||||
|
||||
expect(events[0].delta.value).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateFromOvertakeStats', () => {
|
||||
it('should create overtake events', () => {
|
||||
const stats: TeamDrivingOvertakeStats = {
|
||||
teamId: 'team-123',
|
||||
overtakes: 5,
|
||||
successfulDefenses: 3,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const overtakeEvent = events.find(e => e.reason.code === 'RACE_OVERTAKE');
|
||||
expect(overtakeEvent).toBeDefined();
|
||||
expect(overtakeEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(overtakeEvent?.weight).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should create defense events', () => {
|
||||
const stats: TeamDrivingOvertakeStats = {
|
||||
teamId: 'team-123',
|
||||
overtakes: 0,
|
||||
successfulDefenses: 4,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
|
||||
|
||||
const defenseEvent = events.find(e => e.reason.code === 'RACE_DEFENSE');
|
||||
expect(defenseEvent).toBeDefined();
|
||||
expect(defenseEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(defenseEvent?.weight).toBe(0.4);
|
||||
});
|
||||
|
||||
it('should create both overtake and defense events', () => {
|
||||
const stats: TeamDrivingOvertakeStats = {
|
||||
teamId: 'team-123',
|
||||
overtakes: 3,
|
||||
successfulDefenses: 2,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
|
||||
|
||||
expect(events.length).toBe(2);
|
||||
expect(events.find(e => e.reason.code === 'RACE_OVERTAKE')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_DEFENSE')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return empty array for zero stats', () => {
|
||||
const stats: TeamDrivingOvertakeStats = {
|
||||
teamId: 'team-123',
|
||||
overtakes: 0,
|
||||
successfulDefenses: 0,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
|
||||
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle extreme field sizes', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 100,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle many incidents', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 20,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const incidentEvent = events.find(e => e.reason.code === 'RACE_INCIDENTS');
|
||||
expect(incidentEvent).toBeDefined();
|
||||
// Should be capped at 20
|
||||
expect(incidentEvent?.delta.value).toBe(-20);
|
||||
});
|
||||
|
||||
it('should handle low ratings', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
pace: 10,
|
||||
consistency: 15,
|
||||
teamwork: 20,
|
||||
sportsmanship: 25,
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const paceEvent = events.find(e => e.reason.code === 'RACE_PACE');
|
||||
expect(paceEvent?.delta.value).toBeLessThan(0);
|
||||
|
||||
const consistencyEvent = events.find(e => e.reason.code === 'RACE_CONSISTENCY');
|
||||
expect(consistencyEvent?.delta.value).toBeLessThan(0);
|
||||
|
||||
const teamworkEvent = events.find(e => e.reason.code === 'RACE_TEAMWORK');
|
||||
expect(teamworkEvent?.delta.value).toBeLessThan(0);
|
||||
|
||||
const sportsmanshipEvent = events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP');
|
||||
expect(sportsmanshipEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should handle high ratings', () => {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 65,
|
||||
raceId: 'race-456',
|
||||
pace: 95,
|
||||
consistency: 98,
|
||||
teamwork: 92,
|
||||
sportsmanship: 97,
|
||||
};
|
||||
|
||||
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
|
||||
const paceEvent = events.find(e => e.reason.code === 'RACE_PACE');
|
||||
expect(paceEvent?.delta.value).toBeGreaterThan(0);
|
||||
|
||||
const consistencyEvent = events.find(e => e.reason.code === 'RACE_CONSISTENCY');
|
||||
expect(consistencyEvent?.delta.value).toBeGreaterThan(0);
|
||||
|
||||
const teamworkEvent = events.find(e => e.reason.code === 'RACE_TEAMWORK');
|
||||
expect(teamworkEvent?.delta.value).toBeGreaterThan(0);
|
||||
|
||||
const sportsmanshipEvent = events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP');
|
||||
expect(sportsmanshipEvent?.delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
476
core/racing/domain/services/TeamDrivingRatingCalculator.ts
Normal file
476
core/racing/domain/services/TeamDrivingRatingCalculator.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import { TeamRatingEvent } from '../entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
||||
import { TeamDrivingReasonCode } from '../value-objects/TeamDrivingReasonCode';
|
||||
|
||||
export interface TeamDrivingRaceResult {
|
||||
teamId: string;
|
||||
position: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
|
||||
fieldSize: number;
|
||||
strengthOfField: number; // Average rating of competing teams
|
||||
raceId: string;
|
||||
pace?: number | undefined; // Optional: pace rating (0-100)
|
||||
consistency?: number | undefined; // Optional: consistency rating (0-100)
|
||||
teamwork?: number | undefined; // Optional: teamwork rating (0-100)
|
||||
sportsmanship?: number | undefined; // Optional: sportsmanship rating (0-100)
|
||||
}
|
||||
|
||||
export interface TeamDrivingQualifyingResult {
|
||||
teamId: string;
|
||||
qualifyingPosition: number;
|
||||
fieldSize: number;
|
||||
raceId: string;
|
||||
}
|
||||
|
||||
export interface TeamDrivingOvertakeStats {
|
||||
teamId: string;
|
||||
overtakes: number;
|
||||
successfulDefenses: number;
|
||||
raceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain Service: TeamDrivingRatingCalculator
|
||||
*
|
||||
* Full calculator for team driving rating events.
|
||||
* Mirrors user slice 3 in core/racing/ with comprehensive driving dimension logic.
|
||||
*
|
||||
* Pure domain logic - no persistence concerns.
|
||||
*/
|
||||
export class TeamDrivingRatingCalculator {
|
||||
/**
|
||||
* Calculate rating events from a team's race finish.
|
||||
* Generates comprehensive driving dimension events.
|
||||
*/
|
||||
static calculateFromRaceFinish(result: TeamDrivingRaceResult): TeamRatingEvent[] {
|
||||
const events: TeamRatingEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
if (result.status === 'finished') {
|
||||
// 1. Performance delta based on position and field strength
|
||||
const performanceDelta = this.calculatePerformanceDelta(
|
||||
result.position,
|
||||
result.fieldSize,
|
||||
result.strengthOfField
|
||||
);
|
||||
|
||||
if (performanceDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(performanceDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_PERFORMANCE').value,
|
||||
description: `Finished ${result.position}${this.getOrdinalSuffix(result.position)} in race`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Gain bonus for beating higher-rated teams
|
||||
const gainBonus = this.calculateGainBonus(result.position, result.strengthOfField);
|
||||
if (gainBonus !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(gainBonus),
|
||||
weight: 0.5,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_GAIN_BONUS').value,
|
||||
description: `Bonus for beating higher-rated opponents`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Pace rating (if provided)
|
||||
if (result.pace !== undefined) {
|
||||
const paceDelta = this.calculatePaceDelta(result.pace);
|
||||
if (paceDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(paceDelta),
|
||||
weight: 0.3,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_PACE').value,
|
||||
description: `Pace rating: ${result.pace}/100`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Consistency rating (if provided)
|
||||
if (result.consistency !== undefined) {
|
||||
const consistencyDelta = this.calculateConsistencyDelta(result.consistency);
|
||||
if (consistencyDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(consistencyDelta),
|
||||
weight: 0.3,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_CONSISTENCY').value,
|
||||
description: `Consistency rating: ${result.consistency}/100`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Teamwork rating (if provided)
|
||||
if (result.teamwork !== undefined) {
|
||||
const teamworkDelta = this.calculateTeamworkDelta(result.teamwork);
|
||||
if (teamworkDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(teamworkDelta),
|
||||
weight: 0.4,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_TEAMWORK').value,
|
||||
description: `Teamwork rating: ${result.teamwork}/100`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Sportsmanship rating (if provided)
|
||||
if (result.sportsmanship !== undefined) {
|
||||
const sportsmanshipDelta = this.calculateSportsmanshipDelta(result.sportsmanship);
|
||||
if (sportsmanshipDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(sportsmanshipDelta),
|
||||
weight: 0.3,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_SPORTSMANSHIP').value,
|
||||
description: `Sportsmanship rating: ${result.sportsmanship}/100`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Incident penalty (applies to all statuses)
|
||||
if (result.incidents > 0) {
|
||||
const incidentPenalty = this.calculateIncidentPenalty(result.incidents);
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-incidentPenalty),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_INCIDENTS').value,
|
||||
description: `${result.incidents} incident${result.incidents > 1 ? 's' : ''}`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 8. Status-based penalties
|
||||
if (result.status === 'dnf') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-15),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_DNF').value,
|
||||
description: 'Did not finish',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (result.status === 'dsq') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-25),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_DSQ').value,
|
||||
description: 'Disqualified',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (result.status === 'dns') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-10),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_DNS').value,
|
||||
description: 'Did not start',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (result.status === 'afk') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-20),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_AFK').value,
|
||||
description: 'Away from keyboard',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rating events from qualifying results.
|
||||
*/
|
||||
static calculateFromQualifying(result: TeamDrivingQualifyingResult): TeamRatingEvent[] {
|
||||
const events: TeamRatingEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
const qualifyingDelta = this.calculateQualifyingDelta(result.qualifyingPosition, result.fieldSize);
|
||||
if (qualifyingDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: result.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(qualifyingDelta),
|
||||
weight: 0.25,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: result.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_QUALIFYING').value,
|
||||
description: `Qualified ${result.qualifyingPosition}${this.getOrdinalSuffix(result.qualifyingPosition)}`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rating events from overtake/defense statistics.
|
||||
*/
|
||||
static calculateFromOvertakeStats(stats: TeamDrivingOvertakeStats): TeamRatingEvent[] {
|
||||
const events: TeamRatingEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
// Overtake bonus
|
||||
if (stats.overtakes > 0) {
|
||||
const overtakeDelta = this.calculateOvertakeDelta(stats.overtakes);
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: stats.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(overtakeDelta),
|
||||
weight: 0.5,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: stats.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_OVERTAKE').value,
|
||||
description: `${stats.overtakes} overtakes`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Defense bonus
|
||||
if (stats.successfulDefenses > 0) {
|
||||
const defenseDelta = this.calculateDefenseDelta(stats.successfulDefenses);
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: stats.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(defenseDelta),
|
||||
weight: 0.4,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: stats.raceId },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_DEFENSE').value,
|
||||
description: `${stats.successfulDefenses} successful defenses`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private static calculatePerformanceDelta(
|
||||
position: number,
|
||||
fieldSize: number,
|
||||
strengthOfField: number
|
||||
): number {
|
||||
// Base delta from position (1st = +20, last = -20)
|
||||
const positionFactor = ((fieldSize - position + 1) / fieldSize) * 40 - 20;
|
||||
|
||||
// Adjust for field strength
|
||||
const strengthFactor = (strengthOfField - 50) * 0.1; // +/- 5 for strong/weak fields
|
||||
|
||||
return Math.round((positionFactor + strengthFactor) * 10) / 10;
|
||||
}
|
||||
|
||||
private static calculateGainBonus(position: number, strengthOfField: number): number {
|
||||
// Bonus for beating teams with higher ratings
|
||||
if (strengthOfField > 60 && position <= Math.floor(strengthOfField / 10)) {
|
||||
return 5;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static calculateIncidentPenalty(incidents: number): number {
|
||||
// Exponential penalty for multiple incidents
|
||||
return Math.min(incidents * 2, 20);
|
||||
}
|
||||
|
||||
private static calculatePaceDelta(pace: number): number {
|
||||
// Pace rating 0-100, convert to delta -10 to +10
|
||||
if (pace < 0 || pace > 100) return 0;
|
||||
return Math.round(((pace - 50) * 0.2) * 10) / 10;
|
||||
}
|
||||
|
||||
private static calculateConsistencyDelta(consistency: number): number {
|
||||
// Consistency rating 0-100, convert to delta -8 to +8
|
||||
if (consistency < 0 || consistency > 100) return 0;
|
||||
return Math.round(((consistency - 50) * 0.16) * 10) / 10;
|
||||
}
|
||||
|
||||
private static calculateTeamworkDelta(teamwork: number): number {
|
||||
// Teamwork rating 0-100, convert to delta -10 to +10
|
||||
if (teamwork < 0 || teamwork > 100) return 0;
|
||||
return Math.round(((teamwork - 50) * 0.2) * 10) / 10;
|
||||
}
|
||||
|
||||
private static calculateSportsmanshipDelta(sportsmanship: number): number {
|
||||
// Sportsmanship rating 0-100, convert to delta -8 to +8
|
||||
if (sportsmanship < 0 || sportsmanship > 100) return 0;
|
||||
return Math.round(((sportsmanship - 50) * 0.16) * 10) / 10;
|
||||
}
|
||||
|
||||
private static calculateQualifyingDelta(qualifyingPosition: number, fieldSize: number): number {
|
||||
// Qualifying performance (less weight than race)
|
||||
const positionFactor = ((fieldSize - qualifyingPosition + 1) / fieldSize) * 10 - 5;
|
||||
return Math.round(positionFactor * 10) / 10;
|
||||
}
|
||||
|
||||
private static calculateOvertakeDelta(overtakes: number): number {
|
||||
// Overtake bonus: +2 per overtake, max +10
|
||||
return Math.min(overtakes * 2, 10);
|
||||
}
|
||||
|
||||
private static calculateDefenseDelta(defenses: number): number {
|
||||
// Defense bonus: +1.5 per defense, max +8
|
||||
return Math.min(Math.round(defenses * 1.5 * 10) / 10, 8);
|
||||
}
|
||||
|
||||
private static getOrdinalSuffix(position: number): string {
|
||||
const j = position % 10;
|
||||
const k = position % 100;
|
||||
|
||||
if (j === 1 && k !== 11) return 'st';
|
||||
if (j === 2 && k !== 12) return 'nd';
|
||||
if (j === 3 && k !== 13) return 'rd';
|
||||
return 'th';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import { TeamDrivingRatingEventFactory, TeamDrivingRaceFactsDto, TeamDrivingQualifyingFactsDto, TeamDrivingOvertakeFactsDto } from './TeamDrivingRatingEventFactory';
|
||||
|
||||
describe('TeamDrivingRatingEventFactory', () => {
|
||||
describe('createFromRaceFinish', () => {
|
||||
it('should create events from race finish data', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].teamId).toBe('team-123');
|
||||
expect(events[0].dimension.value).toBe('driving');
|
||||
});
|
||||
|
||||
it('should create events for DNS status', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dnsEvent = events.find(e => e.reason.code === 'RACE_DNS');
|
||||
expect(dnsEvent).toBeDefined();
|
||||
expect(dnsEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should create events for DNF status', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 2,
|
||||
status: 'dnf',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dnfEvent = events.find(e => e.reason.code === 'RACE_DNF');
|
||||
expect(dnfEvent).toBeDefined();
|
||||
expect(dnfEvent?.delta.value).toBe(-15);
|
||||
});
|
||||
|
||||
it('should create events for DSQ status', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 0,
|
||||
status: 'dsq',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dsqEvent = events.find(e => e.reason.code === 'RACE_DSQ');
|
||||
expect(dsqEvent).toBeDefined();
|
||||
expect(dsqEvent?.delta.value).toBe(-25);
|
||||
});
|
||||
|
||||
it('should create events for AFK status', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 0,
|
||||
status: 'afk',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const afkEvent = events.find(e => e.reason.code === 'RACE_AFK');
|
||||
expect(afkEvent).toBeDefined();
|
||||
expect(afkEvent?.delta.value).toBe(-20);
|
||||
});
|
||||
|
||||
it('should apply incident penalties', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 5,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
const incidentEvent = events.find(e => e.reason.code === 'RACE_INCIDENTS');
|
||||
expect(incidentEvent).toBeDefined();
|
||||
expect(incidentEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should apply gain bonus for beating higher-rated teams', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 65, // High strength
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
const gainEvent = events.find(e => e.reason.code === 'RACE_GAIN_BONUS');
|
||||
expect(gainEvent).toBeDefined();
|
||||
expect(gainEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(gainEvent?.weight).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should create pace events when pace is provided', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
pace: 80,
|
||||
});
|
||||
|
||||
const paceEvent = events.find(e => e.reason.code === 'RACE_PACE');
|
||||
expect(paceEvent).toBeDefined();
|
||||
expect(paceEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(paceEvent?.weight).toBe(0.3);
|
||||
});
|
||||
|
||||
it('should create consistency events when consistency is provided', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
consistency: 85,
|
||||
});
|
||||
|
||||
const consistencyEvent = events.find(e => e.reason.code === 'RACE_CONSISTENCY');
|
||||
expect(consistencyEvent).toBeDefined();
|
||||
expect(consistencyEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(consistencyEvent?.weight).toBe(0.3);
|
||||
});
|
||||
|
||||
it('should create teamwork events when teamwork is provided', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
teamwork: 90,
|
||||
});
|
||||
|
||||
const teamworkEvent = events.find(e => e.reason.code === 'RACE_TEAMWORK');
|
||||
expect(teamworkEvent).toBeDefined();
|
||||
expect(teamworkEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(teamworkEvent?.weight).toBe(0.4);
|
||||
});
|
||||
|
||||
it('should create sportsmanship events when sportsmanship is provided', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
sportsmanship: 95,
|
||||
});
|
||||
|
||||
const sportsmanshipEvent = events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP');
|
||||
expect(sportsmanshipEvent).toBeDefined();
|
||||
expect(sportsmanshipEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(sportsmanshipEvent?.weight).toBe(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDrivingEventsFromRace', () => {
|
||||
it('should create events for multiple teams', () => {
|
||||
const raceFacts: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-456',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(2);
|
||||
expect(eventsByTeam.get('team-123')).toBeDefined();
|
||||
expect(eventsByTeam.get('team-456')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle empty results', () => {
|
||||
const raceFacts: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-456',
|
||||
teamId: 'team-123',
|
||||
results: [],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should skip teams with no events', () => {
|
||||
const raceFacts: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-456',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(1);
|
||||
expect(eventsByTeam.get('team-123')?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle optional ratings in results', () => {
|
||||
const raceFacts: TeamDrivingRaceFactsDto = {
|
||||
raceId: 'race-456',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 65,
|
||||
pace: 85,
|
||||
consistency: 80,
|
||||
teamwork: 90,
|
||||
sportsmanship: 95,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
const events = eventsByTeam.get('team-123')!;
|
||||
|
||||
expect(events.length).toBeGreaterThan(5);
|
||||
expect(events.find(e => e.reason.code === 'RACE_PACE')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_CONSISTENCY')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_TEAMWORK')).toBeDefined();
|
||||
expect(events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromQualifying', () => {
|
||||
it('should create qualifying events', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromQualifying({
|
||||
teamId: 'team-123',
|
||||
qualifyingPosition: 3,
|
||||
fieldSize: 10,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].teamId).toBe('team-123');
|
||||
expect(events[0].dimension.value).toBe('driving');
|
||||
expect(events[0].reason.code).toBe('RACE_QUALIFYING');
|
||||
expect(events[0].weight).toBe(0.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDrivingEventsFromQualifying', () => {
|
||||
it('should create events for multiple teams', () => {
|
||||
const qualifyingFacts: TeamDrivingQualifyingFactsDto = {
|
||||
raceId: 'race-456',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
qualifyingPosition: 1,
|
||||
fieldSize: 10,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
qualifyingPosition: 5,
|
||||
fieldSize: 10,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromQualifying(qualifyingFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(2);
|
||||
expect(eventsByTeam.get('team-123')).toBeDefined();
|
||||
expect(eventsByTeam.get('team-456')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromOvertakeStats', () => {
|
||||
it('should create overtake events', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromOvertakeStats({
|
||||
teamId: 'team-123',
|
||||
overtakes: 5,
|
||||
successfulDefenses: 3,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const overtakeEvent = events.find(e => e.reason.code === 'RACE_OVERTAKE');
|
||||
expect(overtakeEvent).toBeDefined();
|
||||
expect(overtakeEvent?.delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create defense events', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromOvertakeStats({
|
||||
teamId: 'team-123',
|
||||
overtakes: 0,
|
||||
successfulDefenses: 4,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
const defenseEvent = events.find(e => e.reason.code === 'RACE_DEFENSE');
|
||||
expect(defenseEvent).toBeDefined();
|
||||
expect(defenseEvent?.delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDrivingEventsFromOvertakes', () => {
|
||||
it('should create events for multiple teams', () => {
|
||||
const overtakeFacts: TeamDrivingOvertakeFactsDto = {
|
||||
raceId: 'race-456',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
overtakes: 3,
|
||||
successfulDefenses: 2,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
overtakes: 1,
|
||||
successfulDefenses: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromOvertakes(overtakeFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(2);
|
||||
expect(eventsByTeam.get('team-123')).toBeDefined();
|
||||
expect(eventsByTeam.get('team-456')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromPenalty', () => {
|
||||
it('should create driving penalty event', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'minor',
|
||||
severity: 'low',
|
||||
});
|
||||
|
||||
const drivingEvent = events.find(e => e.dimension.value === 'driving');
|
||||
expect(drivingEvent).toBeDefined();
|
||||
expect(drivingEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should create admin trust penalty event', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'minor',
|
||||
severity: 'low',
|
||||
});
|
||||
|
||||
const adminEvent = events.find(e => e.dimension.value === 'adminTrust');
|
||||
expect(adminEvent).toBeDefined();
|
||||
expect(adminEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should apply severity multipliers', () => {
|
||||
const lowEvents = TeamDrivingRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'major',
|
||||
severity: 'low',
|
||||
});
|
||||
|
||||
const highEvents = TeamDrivingRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'major',
|
||||
severity: 'high',
|
||||
});
|
||||
|
||||
const lowDelta = lowEvents.find(e => e.dimension.value === 'driving')?.delta.value || 0;
|
||||
const highDelta = highEvents.find(e => e.dimension.value === 'driving')?.delta.value || 0;
|
||||
|
||||
expect(highDelta).toBeLessThan(lowDelta);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromVote', () => {
|
||||
it('should create positive vote event', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromVote({
|
||||
teamId: 'team-123',
|
||||
outcome: 'positive',
|
||||
voteCount: 10,
|
||||
eligibleVoterCount: 15,
|
||||
percentPositive: 80,
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create negative vote event', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromVote({
|
||||
teamId: 'team-123',
|
||||
outcome: 'negative',
|
||||
voteCount: 10,
|
||||
eligibleVoterCount: 15,
|
||||
percentPositive: 20,
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should weight by vote count', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromVote({
|
||||
teamId: 'team-123',
|
||||
outcome: 'positive',
|
||||
voteCount: 20,
|
||||
eligibleVoterCount: 20,
|
||||
percentPositive: 100,
|
||||
});
|
||||
|
||||
expect(events[0].weight).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromAdminAction', () => {
|
||||
it('should create admin action bonus event', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromAdminAction({
|
||||
teamId: 'team-123',
|
||||
actionType: 'bonus',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create admin action penalty event', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromAdminAction({
|
||||
teamId: 'team-123',
|
||||
actionType: 'penalty',
|
||||
severity: 'high',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should create admin warning response event', () => {
|
||||
const events = TeamDrivingRatingEventFactory.createFromAdminAction({
|
||||
teamId: 'team-123',
|
||||
actionType: 'warning',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
451
core/racing/domain/services/TeamDrivingRatingEventFactory.ts
Normal file
451
core/racing/domain/services/TeamDrivingRatingEventFactory.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import { TeamRatingEvent } from '../entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
||||
import { TeamDrivingReasonCode } from '../value-objects/TeamDrivingReasonCode';
|
||||
import { TeamDrivingRatingCalculator, TeamDrivingRaceResult, TeamDrivingQualifyingResult, TeamDrivingOvertakeStats } from './TeamDrivingRatingCalculator';
|
||||
|
||||
export interface TeamDrivingRaceFactsDto {
|
||||
raceId: string;
|
||||
teamId: string;
|
||||
results: Array<{
|
||||
teamId: string;
|
||||
position: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
|
||||
fieldSize: number;
|
||||
strengthOfField: number;
|
||||
pace?: number;
|
||||
consistency?: number;
|
||||
teamwork?: number;
|
||||
sportsmanship?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TeamDrivingQualifyingFactsDto {
|
||||
raceId: string;
|
||||
results: Array<{
|
||||
teamId: string;
|
||||
qualifyingPosition: number;
|
||||
fieldSize: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TeamDrivingOvertakeFactsDto {
|
||||
raceId: string;
|
||||
results: Array<{
|
||||
teamId: string;
|
||||
overtakes: number;
|
||||
successfulDefenses: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain Service: TeamDrivingRatingEventFactory
|
||||
*
|
||||
* Factory for creating team driving rating events using the full TeamDrivingRatingCalculator.
|
||||
* Mirrors user slice 3 pattern in core/racing/.
|
||||
*
|
||||
* Pure domain logic - no persistence concerns.
|
||||
*/
|
||||
export class TeamDrivingRatingEventFactory {
|
||||
/**
|
||||
* Create rating events from a team's race finish.
|
||||
* Uses TeamDrivingRatingCalculator for comprehensive calculations.
|
||||
*/
|
||||
static createFromRaceFinish(input: {
|
||||
teamId: string;
|
||||
position: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
|
||||
fieldSize: number;
|
||||
strengthOfField: number;
|
||||
raceId: string;
|
||||
pace?: number;
|
||||
consistency?: number;
|
||||
teamwork?: number;
|
||||
sportsmanship?: number;
|
||||
}): TeamRatingEvent[] {
|
||||
const result: TeamDrivingRaceResult = {
|
||||
teamId: input.teamId,
|
||||
position: input.position,
|
||||
incidents: input.incidents,
|
||||
status: input.status,
|
||||
fieldSize: input.fieldSize,
|
||||
strengthOfField: input.strengthOfField,
|
||||
raceId: input.raceId,
|
||||
pace: input.pace as number | undefined,
|
||||
consistency: input.consistency as number | undefined,
|
||||
teamwork: input.teamwork as number | undefined,
|
||||
sportsmanship: input.sportsmanship as number | undefined,
|
||||
};
|
||||
|
||||
return TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from multiple race results.
|
||||
* Returns events grouped by team ID.
|
||||
*/
|
||||
static createDrivingEventsFromRace(raceFacts: TeamDrivingRaceFactsDto): Map<string, TeamRatingEvent[]> {
|
||||
const eventsByTeam = new Map<string, TeamRatingEvent[]>();
|
||||
|
||||
for (const result of raceFacts.results) {
|
||||
const input: {
|
||||
teamId: string;
|
||||
position: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
|
||||
fieldSize: number;
|
||||
strengthOfField: number;
|
||||
raceId: string;
|
||||
pace?: number;
|
||||
consistency?: number;
|
||||
teamwork?: number;
|
||||
sportsmanship?: number;
|
||||
} = {
|
||||
teamId: result.teamId,
|
||||
position: result.position,
|
||||
incidents: result.incidents,
|
||||
status: result.status,
|
||||
fieldSize: raceFacts.results.length,
|
||||
strengthOfField: result.strengthOfField,
|
||||
raceId: raceFacts.raceId,
|
||||
};
|
||||
|
||||
if (result.pace !== undefined) {
|
||||
input.pace = result.pace;
|
||||
}
|
||||
if (result.consistency !== undefined) {
|
||||
input.consistency = result.consistency;
|
||||
}
|
||||
if (result.teamwork !== undefined) {
|
||||
input.teamwork = result.teamwork;
|
||||
}
|
||||
if (result.sportsmanship !== undefined) {
|
||||
input.sportsmanship = result.sportsmanship;
|
||||
}
|
||||
|
||||
const events = this.createFromRaceFinish(input);
|
||||
|
||||
if (events.length > 0) {
|
||||
eventsByTeam.set(result.teamId, events);
|
||||
}
|
||||
}
|
||||
|
||||
return eventsByTeam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from qualifying results.
|
||||
* Uses TeamDrivingRatingCalculator for qualifying calculations.
|
||||
*/
|
||||
static createFromQualifying(input: {
|
||||
teamId: string;
|
||||
qualifyingPosition: number;
|
||||
fieldSize: number;
|
||||
raceId: string;
|
||||
}): TeamRatingEvent[] {
|
||||
const result: TeamDrivingQualifyingResult = {
|
||||
teamId: input.teamId,
|
||||
qualifyingPosition: input.qualifyingPosition,
|
||||
fieldSize: input.fieldSize,
|
||||
raceId: input.raceId,
|
||||
};
|
||||
|
||||
return TeamDrivingRatingCalculator.calculateFromQualifying(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from multiple qualifying results.
|
||||
* Returns events grouped by team ID.
|
||||
*/
|
||||
static createDrivingEventsFromQualifying(qualifyingFacts: TeamDrivingQualifyingFactsDto): Map<string, TeamRatingEvent[]> {
|
||||
const eventsByTeam = new Map<string, TeamRatingEvent[]>();
|
||||
|
||||
for (const result of qualifyingFacts.results) {
|
||||
const events = this.createFromQualifying({
|
||||
teamId: result.teamId,
|
||||
qualifyingPosition: result.qualifyingPosition,
|
||||
fieldSize: result.fieldSize,
|
||||
raceId: qualifyingFacts.raceId,
|
||||
});
|
||||
|
||||
if (events.length > 0) {
|
||||
eventsByTeam.set(result.teamId, events);
|
||||
}
|
||||
}
|
||||
|
||||
return eventsByTeam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from overtake/defense statistics.
|
||||
* Uses TeamDrivingRatingCalculator for overtake calculations.
|
||||
*/
|
||||
static createFromOvertakeStats(input: {
|
||||
teamId: string;
|
||||
overtakes: number;
|
||||
successfulDefenses: number;
|
||||
raceId: string;
|
||||
}): TeamRatingEvent[] {
|
||||
const stats: TeamDrivingOvertakeStats = {
|
||||
teamId: input.teamId,
|
||||
overtakes: input.overtakes,
|
||||
successfulDefenses: input.successfulDefenses,
|
||||
raceId: input.raceId,
|
||||
};
|
||||
|
||||
return TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from multiple overtake stats.
|
||||
* Returns events grouped by team ID.
|
||||
*/
|
||||
static createDrivingEventsFromOvertakes(overtakeFacts: TeamDrivingOvertakeFactsDto): Map<string, TeamRatingEvent[]> {
|
||||
const eventsByTeam = new Map<string, TeamRatingEvent[]>();
|
||||
|
||||
for (const result of overtakeFacts.results) {
|
||||
const events = this.createFromOvertakeStats({
|
||||
teamId: result.teamId,
|
||||
overtakes: result.overtakes,
|
||||
successfulDefenses: result.successfulDefenses,
|
||||
raceId: overtakeFacts.raceId,
|
||||
});
|
||||
|
||||
if (events.length > 0) {
|
||||
eventsByTeam.set(result.teamId, events);
|
||||
}
|
||||
}
|
||||
|
||||
return eventsByTeam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from a penalty.
|
||||
* Generates both driving and adminTrust events.
|
||||
* Uses TeamDrivingReasonCode for validation.
|
||||
*/
|
||||
static createFromPenalty(input: {
|
||||
teamId: string;
|
||||
penaltyType: 'minor' | 'major' | 'critical';
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
incidentCount?: number;
|
||||
}): TeamRatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: TeamRatingEvent[] = [];
|
||||
|
||||
// Driving dimension penalty
|
||||
const drivingDelta = this.calculatePenaltyDelta(input.penaltyType, input.severity, 'driving');
|
||||
if (drivingDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(drivingDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'penalty', id: input.penaltyType },
|
||||
reason: {
|
||||
code: TeamDrivingReasonCode.create('RACE_INCIDENTS').value,
|
||||
description: `${input.penaltyType} penalty for driving violations`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// AdminTrust dimension penalty
|
||||
const adminDelta = this.calculatePenaltyDelta(input.penaltyType, input.severity, 'adminTrust');
|
||||
if (adminDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(adminDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'penalty', id: input.penaltyType },
|
||||
reason: {
|
||||
code: 'PENALTY_ADMIN',
|
||||
description: `${input.penaltyType} penalty for rule violations`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from a vote outcome.
|
||||
* Generates adminTrust events.
|
||||
*/
|
||||
static createFromVote(input: {
|
||||
teamId: string;
|
||||
outcome: 'positive' | 'negative';
|
||||
voteCount: number;
|
||||
eligibleVoterCount: number;
|
||||
percentPositive: number;
|
||||
}): TeamRatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: TeamRatingEvent[] = [];
|
||||
|
||||
// Calculate delta based on vote outcome
|
||||
const delta = this.calculateVoteDelta(
|
||||
input.outcome,
|
||||
input.eligibleVoterCount,
|
||||
input.voteCount,
|
||||
input.percentPositive
|
||||
);
|
||||
|
||||
if (delta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(delta),
|
||||
weight: input.voteCount, // Weight by number of votes
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'vote', id: 'admin_vote' },
|
||||
reason: {
|
||||
code: input.outcome === 'positive' ? 'VOTE_POSITIVE' : 'VOTE_NEGATIVE',
|
||||
description: `Admin vote outcome: ${input.outcome}`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from an admin action.
|
||||
* Generates adminTrust events.
|
||||
*/
|
||||
static createFromAdminAction(input: {
|
||||
teamId: string;
|
||||
actionType: 'bonus' | 'penalty' | 'warning';
|
||||
severity?: 'low' | 'medium' | 'high';
|
||||
}): TeamRatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: TeamRatingEvent[] = [];
|
||||
|
||||
if (input.actionType === 'bonus') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: 'bonus' },
|
||||
reason: {
|
||||
code: 'ADMIN_BONUS',
|
||||
description: 'Admin bonus for positive contribution',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.actionType === 'penalty') {
|
||||
const delta = input.severity === 'high' ? -15 : input.severity === 'medium' ? -10 : -5;
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(delta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: 'penalty' },
|
||||
reason: {
|
||||
code: 'ADMIN_PENALTY',
|
||||
description: `Admin penalty (${input.severity} severity)`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.actionType === 'warning') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(3),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: 'warning' },
|
||||
reason: {
|
||||
code: 'ADMIN_WARNING_RESPONSE',
|
||||
description: 'Response to admin warning',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private static calculatePenaltyDelta(
|
||||
penaltyType: 'minor' | 'major' | 'critical',
|
||||
severity: 'low' | 'medium' | 'high',
|
||||
dimension: 'driving' | 'adminTrust'
|
||||
): number {
|
||||
const baseValues = {
|
||||
minor: { driving: -5, adminTrust: -3 },
|
||||
major: { driving: -10, adminTrust: -8 },
|
||||
critical: { driving: -20, adminTrust: -15 },
|
||||
};
|
||||
|
||||
const severityMultipliers = {
|
||||
low: 1,
|
||||
medium: 1.5,
|
||||
high: 2,
|
||||
};
|
||||
|
||||
const base = baseValues[penaltyType][dimension];
|
||||
const multiplier = severityMultipliers[severity];
|
||||
|
||||
return Math.round(base * multiplier);
|
||||
}
|
||||
|
||||
private static calculateVoteDelta(
|
||||
outcome: 'positive' | 'negative',
|
||||
eligibleVoterCount: number,
|
||||
voteCount: number,
|
||||
percentPositive: number
|
||||
): number {
|
||||
if (voteCount === 0) return 0;
|
||||
|
||||
const participationRate = voteCount / eligibleVoterCount;
|
||||
const strength = (percentPositive / 100) * 2 - 1; // -1 to +1
|
||||
|
||||
// Base delta of +/- 10, scaled by participation and strength
|
||||
const baseDelta = outcome === 'positive' ? 10 : -10;
|
||||
const scaledDelta = baseDelta * participationRate * (0.5 + Math.abs(strength) * 0.5);
|
||||
|
||||
return Math.round(scaledDelta * 10) / 10;
|
||||
}
|
||||
}
|
||||
312
core/racing/domain/services/TeamRatingEventFactory.test.ts
Normal file
312
core/racing/domain/services/TeamRatingEventFactory.test.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { TeamRatingEventFactory, TeamRaceFactsDto } from './TeamRatingEventFactory';
|
||||
|
||||
describe('TeamRatingEventFactory', () => {
|
||||
describe('createFromRaceFinish', () => {
|
||||
it('should create events from race finish data', () => {
|
||||
const events = TeamRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].teamId).toBe('team-123');
|
||||
expect(events[0].dimension.value).toBe('driving');
|
||||
});
|
||||
|
||||
it('should create events for DNS status', () => {
|
||||
const events = TeamRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dnsEvent = events.find(e => e.reason.code === 'RACE_DNS');
|
||||
expect(dnsEvent).toBeDefined();
|
||||
expect(dnsEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should create events for DNF status', () => {
|
||||
const events = TeamRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 2,
|
||||
status: 'dnf',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dnfEvent = events.find(e => e.reason.code === 'RACE_DNF');
|
||||
expect(dnfEvent).toBeDefined();
|
||||
expect(dnfEvent?.delta.value).toBe(-15);
|
||||
});
|
||||
|
||||
it('should create events for DSQ status', () => {
|
||||
const events = TeamRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 0,
|
||||
status: 'dsq',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dsqEvent = events.find(e => e.reason.code === 'RACE_DSQ');
|
||||
expect(dsqEvent).toBeDefined();
|
||||
expect(dsqEvent?.delta.value).toBe(-25);
|
||||
});
|
||||
|
||||
it('should create events for AFK status', () => {
|
||||
const events = TeamRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 5,
|
||||
incidents: 0,
|
||||
status: 'afk',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const afkEvent = events.find(e => e.reason.code === 'RACE_AFK');
|
||||
expect(afkEvent).toBeDefined();
|
||||
expect(afkEvent?.delta.value).toBe(-20);
|
||||
});
|
||||
|
||||
it('should apply incident penalties', () => {
|
||||
const events = TeamRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 3,
|
||||
incidents: 5,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 55,
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
const incidentEvent = events.find(e => e.reason.code === 'RACE_INCIDENTS');
|
||||
expect(incidentEvent).toBeDefined();
|
||||
expect(incidentEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should apply gain bonus for beating higher-rated teams', () => {
|
||||
const events = TeamRatingEventFactory.createFromRaceFinish({
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 10,
|
||||
strengthOfField: 65, // High strength
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
const gainEvent = events.find(e => e.reason.code === 'RACE_GAIN_BONUS');
|
||||
expect(gainEvent).toBeDefined();
|
||||
expect(gainEvent?.delta.value).toBeGreaterThan(0);
|
||||
expect(gainEvent?.weight).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDrivingEventsFromRace', () => {
|
||||
it('should create events for multiple teams', () => {
|
||||
const raceFacts: TeamRaceFactsDto = {
|
||||
raceId: 'race-456',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
{
|
||||
teamId: 'team-456',
|
||||
position: 2,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
fieldSize: 3,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamRatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(2);
|
||||
expect(eventsByTeam.get('team-123')).toBeDefined();
|
||||
expect(eventsByTeam.get('team-456')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle empty results', () => {
|
||||
const raceFacts: TeamRaceFactsDto = {
|
||||
raceId: 'race-456',
|
||||
teamId: 'team-123',
|
||||
results: [],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamRatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should skip teams with no events', () => {
|
||||
const raceFacts: TeamRaceFactsDto = {
|
||||
raceId: 'race-456',
|
||||
teamId: 'team-123',
|
||||
results: [
|
||||
{
|
||||
teamId: 'team-123',
|
||||
position: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
fieldSize: 1,
|
||||
strengthOfField: 55,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByTeam = TeamRatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByTeam.size).toBe(1);
|
||||
expect(eventsByTeam.get('team-123')?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromPenalty', () => {
|
||||
it('should create driving penalty event', () => {
|
||||
const events = TeamRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'minor',
|
||||
severity: 'low',
|
||||
});
|
||||
|
||||
const drivingEvent = events.find(e => e.dimension.value === 'driving');
|
||||
expect(drivingEvent).toBeDefined();
|
||||
expect(drivingEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should create admin trust penalty event', () => {
|
||||
const events = TeamRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'minor',
|
||||
severity: 'low',
|
||||
});
|
||||
|
||||
const adminEvent = events.find(e => e.dimension.value === 'adminTrust');
|
||||
expect(adminEvent).toBeDefined();
|
||||
expect(adminEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should apply severity multipliers', () => {
|
||||
const lowEvents = TeamRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'major',
|
||||
severity: 'low',
|
||||
});
|
||||
|
||||
const highEvents = TeamRatingEventFactory.createFromPenalty({
|
||||
teamId: 'team-123',
|
||||
penaltyType: 'major',
|
||||
severity: 'high',
|
||||
});
|
||||
|
||||
const lowDelta = lowEvents.find(e => e.dimension.value === 'driving')?.delta.value || 0;
|
||||
const highDelta = highEvents.find(e => e.dimension.value === 'driving')?.delta.value || 0;
|
||||
|
||||
expect(highDelta).toBeLessThan(lowDelta);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromVote', () => {
|
||||
it('should create positive vote event', () => {
|
||||
const events = TeamRatingEventFactory.createFromVote({
|
||||
teamId: 'team-123',
|
||||
outcome: 'positive',
|
||||
voteCount: 10,
|
||||
eligibleVoterCount: 15,
|
||||
percentPositive: 80,
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create negative vote event', () => {
|
||||
const events = TeamRatingEventFactory.createFromVote({
|
||||
teamId: 'team-123',
|
||||
outcome: 'negative',
|
||||
voteCount: 10,
|
||||
eligibleVoterCount: 15,
|
||||
percentPositive: 20,
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should weight by vote count', () => {
|
||||
const events = TeamRatingEventFactory.createFromVote({
|
||||
teamId: 'team-123',
|
||||
outcome: 'positive',
|
||||
voteCount: 20,
|
||||
eligibleVoterCount: 20,
|
||||
percentPositive: 100,
|
||||
});
|
||||
|
||||
expect(events[0].weight).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromAdminAction', () => {
|
||||
it('should create admin action bonus event', () => {
|
||||
const events = TeamRatingEventFactory.createFromAdminAction({
|
||||
teamId: 'team-123',
|
||||
actionType: 'bonus',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create admin action penalty event', () => {
|
||||
const events = TeamRatingEventFactory.createFromAdminAction({
|
||||
teamId: 'team-123',
|
||||
actionType: 'penalty',
|
||||
severity: 'high',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should create admin warning response event', () => {
|
||||
const events = TeamRatingEventFactory.createFromAdminAction({
|
||||
teamId: 'team-123',
|
||||
actionType: 'warning',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
496
core/racing/domain/services/TeamRatingEventFactory.ts
Normal file
496
core/racing/domain/services/TeamRatingEventFactory.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import { TeamRatingEvent } from '../entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
||||
|
||||
export interface TeamRaceFactsDto {
|
||||
raceId: string;
|
||||
teamId: string;
|
||||
results: Array<{
|
||||
teamId: string;
|
||||
position: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
|
||||
fieldSize: number;
|
||||
strengthOfField: number; // Average rating of competing teams
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TeamPenaltyInput {
|
||||
teamId: string;
|
||||
penaltyType: 'minor' | 'major' | 'critical';
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
incidentCount?: number;
|
||||
}
|
||||
|
||||
export interface TeamVoteInput {
|
||||
teamId: string;
|
||||
outcome: 'positive' | 'negative';
|
||||
voteCount: number;
|
||||
eligibleVoterCount: number;
|
||||
percentPositive: number;
|
||||
}
|
||||
|
||||
export interface TeamAdminActionInput {
|
||||
teamId: string;
|
||||
actionType: 'bonus' | 'penalty' | 'warning';
|
||||
severity?: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain Service: TeamRatingEventFactory
|
||||
*
|
||||
* Factory for creating team rating events from various sources.
|
||||
* Mirrors the RatingEventFactory pattern for user ratings.
|
||||
*
|
||||
* Pure domain logic - no persistence concerns.
|
||||
*/
|
||||
export class TeamRatingEventFactory {
|
||||
/**
|
||||
* Create rating events from a team's race finish.
|
||||
* Generates driving dimension events.
|
||||
*/
|
||||
static createFromRaceFinish(input: {
|
||||
teamId: string;
|
||||
position: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
|
||||
fieldSize: number;
|
||||
strengthOfField: number;
|
||||
raceId: string;
|
||||
}): TeamRatingEvent[] {
|
||||
const events: TeamRatingEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
if (input.status === 'finished') {
|
||||
// Performance delta based on position and field strength
|
||||
const performanceDelta = this.calculatePerformanceDelta(
|
||||
input.position,
|
||||
input.fieldSize,
|
||||
input.strengthOfField
|
||||
);
|
||||
|
||||
if (performanceDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(performanceDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'RACE_PERFORMANCE',
|
||||
description: `Finished ${input.position}${this.getOrdinalSuffix(input.position)} in race`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Gain bonus for beating higher-rated teams
|
||||
const gainBonus = this.calculateGainBonus(input.position, input.strengthOfField);
|
||||
if (gainBonus !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(gainBonus),
|
||||
weight: 0.5,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'RACE_GAIN_BONUS',
|
||||
description: `Bonus for beating higher-rated opponents`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Incident penalty
|
||||
if (input.incidents > 0) {
|
||||
const incidentPenalty = this.calculateIncidentPenalty(input.incidents);
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-incidentPenalty),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'RACE_INCIDENTS',
|
||||
description: `${input.incidents} incident${input.incidents > 1 ? 's' : ''}`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Status-based penalties
|
||||
if (input.status === 'dnf') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-15),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'RACE_DNF',
|
||||
description: 'Did not finish',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.status === 'dsq') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-25),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'RACE_DSQ',
|
||||
description: 'Disqualified',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.status === 'dns') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-10),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'RACE_DNS',
|
||||
description: 'Did not start',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.status === 'afk') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-20),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'RACE_AFK',
|
||||
description: 'Away from keyboard',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from multiple race results.
|
||||
* Returns events grouped by team ID.
|
||||
*/
|
||||
static createDrivingEventsFromRace(raceFacts: TeamRaceFactsDto): Map<string, TeamRatingEvent[]> {
|
||||
const eventsByTeam = new Map<string, TeamRatingEvent[]>();
|
||||
|
||||
for (const result of raceFacts.results) {
|
||||
const events = this.createFromRaceFinish({
|
||||
teamId: result.teamId,
|
||||
position: result.position,
|
||||
incidents: result.incidents,
|
||||
status: result.status,
|
||||
fieldSize: raceFacts.results.length,
|
||||
strengthOfField: 50, // Default strength if not provided
|
||||
raceId: raceFacts.raceId,
|
||||
});
|
||||
|
||||
if (events.length > 0) {
|
||||
eventsByTeam.set(result.teamId, events);
|
||||
}
|
||||
}
|
||||
|
||||
return eventsByTeam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from a penalty.
|
||||
* Generates both driving and adminTrust events.
|
||||
*/
|
||||
static createFromPenalty(input: TeamPenaltyInput): TeamRatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: TeamRatingEvent[] = [];
|
||||
|
||||
// Driving dimension penalty
|
||||
const drivingDelta = this.calculatePenaltyDelta(input.penaltyType, input.severity, 'driving');
|
||||
if (drivingDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(drivingDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'penalty', id: input.penaltyType },
|
||||
reason: {
|
||||
code: 'PENALTY_DRIVING',
|
||||
description: `${input.penaltyType} penalty for driving violations`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// AdminTrust dimension penalty
|
||||
const adminDelta = this.calculatePenaltyDelta(input.penaltyType, input.severity, 'adminTrust');
|
||||
if (adminDelta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(adminDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'penalty', id: input.penaltyType },
|
||||
reason: {
|
||||
code: 'PENALTY_ADMIN',
|
||||
description: `${input.penaltyType} penalty for rule violations`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from a vote outcome.
|
||||
* Generates adminTrust events.
|
||||
*/
|
||||
static createFromVote(input: TeamVoteInput): TeamRatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: TeamRatingEvent[] = [];
|
||||
|
||||
// Calculate delta based on vote outcome
|
||||
const delta = this.calculateVoteDelta(
|
||||
input.outcome,
|
||||
input.eligibleVoterCount,
|
||||
input.voteCount,
|
||||
input.percentPositive
|
||||
);
|
||||
|
||||
if (delta !== 0) {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(delta),
|
||||
weight: input.voteCount, // Weight by number of votes
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'vote', id: 'admin_vote' },
|
||||
reason: {
|
||||
code: input.outcome === 'positive' ? 'VOTE_POSITIVE' : 'VOTE_NEGATIVE',
|
||||
description: `Admin vote outcome: ${input.outcome}`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from an admin action.
|
||||
* Generates adminTrust events.
|
||||
*/
|
||||
static createFromAdminAction(input: TeamAdminActionInput): TeamRatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: TeamRatingEvent[] = [];
|
||||
|
||||
if (input.actionType === 'bonus') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: 'bonus' },
|
||||
reason: {
|
||||
code: 'ADMIN_BONUS',
|
||||
description: 'Admin bonus for positive contribution',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.actionType === 'penalty') {
|
||||
const delta = input.severity === 'high' ? -15 : input.severity === 'medium' ? -10 : -5;
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(delta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: 'penalty' },
|
||||
reason: {
|
||||
code: 'ADMIN_PENALTY',
|
||||
description: `Admin penalty (${input.severity} severity)`,
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.actionType === 'warning') {
|
||||
events.push(
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: input.teamId,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(3),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: 'warning' },
|
||||
reason: {
|
||||
code: 'ADMIN_WARNING_RESPONSE',
|
||||
description: 'Response to admin warning',
|
||||
},
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private static calculatePerformanceDelta(
|
||||
position: number,
|
||||
fieldSize: number,
|
||||
strengthOfField: number
|
||||
): number {
|
||||
// Base delta from position (1st = +20, last = -20)
|
||||
const positionFactor = ((fieldSize - position + 1) / fieldSize) * 40 - 20;
|
||||
|
||||
// Adjust for field strength
|
||||
const strengthFactor = (strengthOfField - 50) * 0.1; // +/- 5 for strong/weak fields
|
||||
|
||||
return Math.round((positionFactor + strengthFactor) * 10) / 10;
|
||||
}
|
||||
|
||||
private static calculateGainBonus(position: number, strengthOfField: number): number {
|
||||
// Bonus for beating teams with higher ratings
|
||||
if (strengthOfField > 60 && position <= Math.floor(strengthOfField / 10)) {
|
||||
return 5;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static calculateIncidentPenalty(incidents: number): number {
|
||||
// Exponential penalty for multiple incidents
|
||||
return Math.min(incidents * 2, 20);
|
||||
}
|
||||
|
||||
private static calculatePenaltyDelta(
|
||||
penaltyType: 'minor' | 'major' | 'critical',
|
||||
severity: 'low' | 'medium' | 'high',
|
||||
dimension: 'driving' | 'adminTrust'
|
||||
): number {
|
||||
const baseValues = {
|
||||
minor: { driving: -5, adminTrust: -3 },
|
||||
major: { driving: -10, adminTrust: -8 },
|
||||
critical: { driving: -20, adminTrust: -15 },
|
||||
};
|
||||
|
||||
const severityMultipliers = {
|
||||
low: 1,
|
||||
medium: 1.5,
|
||||
high: 2,
|
||||
};
|
||||
|
||||
const base = baseValues[penaltyType][dimension];
|
||||
const multiplier = severityMultipliers[severity];
|
||||
|
||||
return Math.round(base * multiplier);
|
||||
}
|
||||
|
||||
private static calculateVoteDelta(
|
||||
outcome: 'positive' | 'negative',
|
||||
eligibleVoterCount: number,
|
||||
voteCount: number,
|
||||
percentPositive: number
|
||||
): number {
|
||||
if (voteCount === 0) return 0;
|
||||
|
||||
const participationRate = voteCount / eligibleVoterCount;
|
||||
const strength = (percentPositive / 100) * 2 - 1; // -1 to +1
|
||||
|
||||
// Base delta of +/- 10, scaled by participation and strength
|
||||
const baseDelta = outcome === 'positive' ? 10 : -10;
|
||||
const scaledDelta = baseDelta * participationRate * (0.5 + Math.abs(strength) * 0.5);
|
||||
|
||||
return Math.round(scaledDelta * 10) / 10;
|
||||
}
|
||||
|
||||
private static getOrdinalSuffix(position: number): string {
|
||||
const j = position % 10;
|
||||
const k = position % 100;
|
||||
|
||||
if (j === 1 && k !== 11) return 'st';
|
||||
if (j === 2 && k !== 12) return 'nd';
|
||||
if (j === 3 && k !== 13) return 'rd';
|
||||
return 'th';
|
||||
}
|
||||
}
|
||||
290
core/racing/domain/services/TeamRatingSnapshotCalculator.test.ts
Normal file
290
core/racing/domain/services/TeamRatingSnapshotCalculator.test.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { TeamRatingSnapshotCalculator } from './TeamRatingSnapshotCalculator';
|
||||
import { TeamRatingEvent } from '../entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
||||
import { TeamRatingValue } from '../value-objects/TeamRatingValue';
|
||||
|
||||
describe('TeamRatingSnapshotCalculator', () => {
|
||||
describe('calculate', () => {
|
||||
it('should return default ratings for empty events', () => {
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', []);
|
||||
|
||||
expect(snapshot.teamId).toBe('team-123');
|
||||
expect(snapshot.driving.value).toBe(50);
|
||||
expect(snapshot.adminTrust.value).toBe(50);
|
||||
expect(snapshot.overall).toBe(50);
|
||||
expect(snapshot.eventCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate single dimension rating', () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
|
||||
|
||||
expect(snapshot.driving.value).toBe(60); // 50 + 10
|
||||
expect(snapshot.adminTrust.value).toBe(50); // Default
|
||||
expect(snapshot.overall).toBeCloseTo(57, 1); // 60 * 0.7 + 50 * 0.3 = 57
|
||||
});
|
||||
|
||||
it('should calculate multiple events with weights', () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
weight: 1,
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-5),
|
||||
weight: 2,
|
||||
occurredAt: new Date('2024-01-01T11:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T11:00:00Z'),
|
||||
source: { type: 'race', id: 'race-2' },
|
||||
reason: { code: 'RACE_PENALTY', description: 'Incident penalty' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
|
||||
|
||||
// Weighted average: (10*1 + (-5)*2) / (1+2) = 0/3 = 0
|
||||
// So driving = 50 + 0 = 50
|
||||
expect(snapshot.driving.value).toBe(50);
|
||||
});
|
||||
|
||||
it('should calculate mixed dimensions', () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(15),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'adminAction', id: 'action-1' },
|
||||
reason: { code: 'ADMIN_BONUS', description: 'Helpful admin work' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
|
||||
|
||||
expect(snapshot.driving.value).toBe(65); // 50 + 15
|
||||
expect(snapshot.adminTrust.value).toBe(55); // 50 + 5
|
||||
expect(snapshot.overall).toBeCloseTo(62, 1); // 65 * 0.7 + 55 * 0.3 = 62
|
||||
});
|
||||
|
||||
it('should clamp values between 0 and 100', () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(60), // Would make it 110
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
|
||||
|
||||
expect(snapshot.driving.value).toBe(100); // Clamped
|
||||
});
|
||||
|
||||
it('should track last updated date', () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(3),
|
||||
occurredAt: new Date('2024-01-02T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-02T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-2' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 2nd' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
|
||||
|
||||
expect(snapshot.lastUpdated).toEqual(new Date('2024-01-02T10:00:00Z'));
|
||||
expect(snapshot.eventCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateDimensionChange', () => {
|
||||
it('should calculate net change for a dimension', () => {
|
||||
const events = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
weight: 1,
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(-5),
|
||||
weight: 2,
|
||||
occurredAt: new Date('2024-01-01T11:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T11:00:00Z'),
|
||||
source: { type: 'race', id: 'race-2' },
|
||||
reason: { code: 'RACE_PENALTY', description: 'Incident penalty' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const change = TeamRatingSnapshotCalculator.calculateDimensionChange(
|
||||
TeamRatingDimensionKey.create('driving'),
|
||||
events
|
||||
);
|
||||
|
||||
// (10*1 + (-5)*2) / (1+2) = 0/3 = 0
|
||||
expect(change).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 for no events', () => {
|
||||
const change = TeamRatingSnapshotCalculator.calculateDimensionChange(
|
||||
TeamRatingDimensionKey.create('driving'),
|
||||
[]
|
||||
);
|
||||
|
||||
expect(change).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateOverWindow', () => {
|
||||
it('should calculate ratings for a time window', () => {
|
||||
const allEvents = [
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
TeamRatingEvent.create({
|
||||
id: TeamRatingEventId.generate(),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-02T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-02T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-2' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 2nd' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const snapshot = TeamRatingSnapshotCalculator.calculateOverWindow(
|
||||
'team-123',
|
||||
allEvents,
|
||||
new Date('2024-01-01T00:00:00Z'),
|
||||
new Date('2024-01-01T23:59:59Z')
|
||||
);
|
||||
|
||||
// Only first event in window
|
||||
expect(snapshot.driving.value).toBe(60); // 50 + 10
|
||||
expect(snapshot.eventCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateDelta', () => {
|
||||
it('should calculate differences between snapshots', () => {
|
||||
const before = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(50),
|
||||
adminTrust: TeamRatingValue.create(50),
|
||||
overall: 50,
|
||||
lastUpdated: new Date('2024-01-01'),
|
||||
eventCount: 10,
|
||||
};
|
||||
|
||||
const after = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(65),
|
||||
adminTrust: TeamRatingValue.create(55),
|
||||
overall: 62,
|
||||
lastUpdated: new Date('2024-01-02'),
|
||||
eventCount: 15,
|
||||
};
|
||||
|
||||
const delta = TeamRatingSnapshotCalculator.calculateDelta(before, after);
|
||||
|
||||
expect(delta.driving).toBe(15);
|
||||
expect(delta.adminTrust).toBe(5);
|
||||
expect(delta.overall).toBe(12);
|
||||
});
|
||||
});
|
||||
});
|
||||
162
core/racing/domain/services/TeamRatingSnapshotCalculator.ts
Normal file
162
core/racing/domain/services/TeamRatingSnapshotCalculator.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { TeamRatingEvent } from '../entities/TeamRatingEvent';
|
||||
import { TeamRatingValue } from '../value-objects/TeamRatingValue';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
|
||||
export interface TeamRatingSnapshot {
|
||||
teamId: string;
|
||||
driving: TeamRatingValue;
|
||||
adminTrust: TeamRatingValue;
|
||||
overall: number; // Calculated overall rating
|
||||
lastUpdated: Date;
|
||||
eventCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain Service: TeamRatingSnapshotCalculator
|
||||
*
|
||||
* Calculates team rating snapshots from event ledgers.
|
||||
* Mirrors the user RatingSnapshotCalculator pattern.
|
||||
*
|
||||
* Pure domain logic - no persistence concerns.
|
||||
*/
|
||||
export class TeamRatingSnapshotCalculator {
|
||||
/**
|
||||
* Calculate current team rating snapshot from all events.
|
||||
*
|
||||
* @param teamId - The team ID to calculate for
|
||||
* @param events - All rating events for the team
|
||||
* @returns TeamRatingSnapshot with current ratings
|
||||
*/
|
||||
static calculate(teamId: string, events: TeamRatingEvent[]): TeamRatingSnapshot {
|
||||
// Start with default ratings (50 for each dimension)
|
||||
const defaultRating = 50;
|
||||
|
||||
if (events.length === 0) {
|
||||
return {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(defaultRating),
|
||||
adminTrust: TeamRatingValue.create(defaultRating),
|
||||
overall: defaultRating,
|
||||
lastUpdated: new Date(),
|
||||
eventCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Group events by dimension
|
||||
const eventsByDimension = events.reduce((acc, event) => {
|
||||
const key = event.dimension.value;
|
||||
if (!acc[key]) {
|
||||
acc[key] = [];
|
||||
}
|
||||
acc[key].push(event);
|
||||
return acc;
|
||||
}, {} as Record<string, TeamRatingEvent[]>);
|
||||
|
||||
// Calculate each dimension
|
||||
const dimensionRatings: Record<string, number> = {};
|
||||
|
||||
for (const [dimensionKey, dimensionEvents] of Object.entries(eventsByDimension)) {
|
||||
const totalWeight = dimensionEvents.reduce((sum, event) => {
|
||||
return sum + (event.weight || 1);
|
||||
}, 0);
|
||||
|
||||
const weightedSum = dimensionEvents.reduce((sum, event) => {
|
||||
return sum + (event.delta.value * (event.weight || 1));
|
||||
}, 0);
|
||||
|
||||
// Normalize and add to base rating
|
||||
const normalizedDelta = weightedSum / totalWeight;
|
||||
dimensionRatings[dimensionKey] = Math.max(0, Math.min(100, defaultRating + normalizedDelta));
|
||||
}
|
||||
|
||||
const drivingRating = dimensionRatings['driving'] ?? defaultRating;
|
||||
const adminTrustRating = dimensionRatings['adminTrust'] ?? defaultRating;
|
||||
|
||||
// Calculate overall as weighted average
|
||||
const overall = (drivingRating * 0.7 + adminTrustRating * 0.3);
|
||||
|
||||
// Find latest event date
|
||||
const lastUpdated = events.reduce((latest, event) => {
|
||||
return event.occurredAt > latest ? event.occurredAt : latest;
|
||||
}, new Date(0));
|
||||
|
||||
return {
|
||||
teamId,
|
||||
driving: TeamRatingValue.create(drivingRating),
|
||||
adminTrust: TeamRatingValue.create(adminTrustRating),
|
||||
overall: Math.round(overall * 10) / 10, // Round to 1 decimal
|
||||
lastUpdated,
|
||||
eventCount: events.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rating change for a specific dimension from events.
|
||||
*
|
||||
* @param dimension - The dimension to calculate for
|
||||
* @param events - Events to calculate from
|
||||
* @returns Net change value
|
||||
*/
|
||||
static calculateDimensionChange(
|
||||
dimension: TeamRatingDimensionKey,
|
||||
events: TeamRatingEvent[]
|
||||
): number {
|
||||
const filtered = events.filter(e => e.dimension.equals(dimension));
|
||||
|
||||
if (filtered.length === 0) return 0;
|
||||
|
||||
const totalWeight = filtered.reduce((sum, event) => {
|
||||
return sum + (event.weight || 1);
|
||||
}, 0);
|
||||
|
||||
const weightedSum = filtered.reduce((sum, event) => {
|
||||
return sum + (event.delta.value * (event.weight || 1));
|
||||
}, 0);
|
||||
|
||||
return weightedSum / totalWeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rating change over a time window.
|
||||
*
|
||||
* @param teamId - The team ID
|
||||
* @param events - All events
|
||||
* @param from - Start date
|
||||
* @param to - End date
|
||||
* @returns Snapshot of ratings at the end of the window
|
||||
*/
|
||||
static calculateOverWindow(
|
||||
teamId: string,
|
||||
events: TeamRatingEvent[],
|
||||
from: Date,
|
||||
to: Date
|
||||
): TeamRatingSnapshot {
|
||||
const windowEvents = events.filter(e =>
|
||||
e.occurredAt >= from && e.occurredAt <= to
|
||||
);
|
||||
|
||||
return this.calculate(teamId, windowEvents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rating change between two snapshots.
|
||||
*
|
||||
* @param before - Snapshot before changes
|
||||
* @param after - Snapshot after changes
|
||||
* @returns Object with change values
|
||||
*/
|
||||
static calculateDelta(
|
||||
before: TeamRatingSnapshot,
|
||||
after: TeamRatingSnapshot
|
||||
): {
|
||||
driving: number;
|
||||
adminTrust: number;
|
||||
overall: number;
|
||||
} {
|
||||
return {
|
||||
driving: after.driving.value - before.driving.value,
|
||||
adminTrust: after.adminTrust.value - before.adminTrust.value,
|
||||
overall: after.overall - before.overall,
|
||||
};
|
||||
}
|
||||
}
|
||||
214
core/racing/domain/value-objects/TeamDrivingReasonCode.test.ts
Normal file
214
core/racing/domain/value-objects/TeamDrivingReasonCode.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { TeamDrivingReasonCode, TEAM_DRIVING_REASON_CODES } from './TeamDrivingReasonCode';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
describe('TeamDrivingReasonCode', () => {
|
||||
describe('create', () => {
|
||||
it('should create valid reason codes', () => {
|
||||
for (const code of TEAM_DRIVING_REASON_CODES) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.value).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
expect(() => TeamDrivingReasonCode.create('')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamDrivingReasonCode.create('')).toThrow('cannot be empty');
|
||||
});
|
||||
|
||||
it('should throw error for whitespace-only string', () => {
|
||||
expect(() => TeamDrivingReasonCode.create(' ')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw error for leading whitespace', () => {
|
||||
expect(() => TeamDrivingReasonCode.create(' RACE_PERFORMANCE')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamDrivingReasonCode.create(' RACE_PERFORMANCE')).toThrow('leading or trailing whitespace');
|
||||
});
|
||||
|
||||
it('should throw error for trailing whitespace', () => {
|
||||
expect(() => TeamDrivingReasonCode.create('RACE_PERFORMANCE ')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamDrivingReasonCode.create('RACE_PERFORMANCE ')).toThrow('leading or trailing whitespace');
|
||||
});
|
||||
|
||||
it('should throw error for invalid reason code', () => {
|
||||
expect(() => TeamDrivingReasonCode.create('INVALID_CODE')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamDrivingReasonCode.create('INVALID_CODE')).toThrow('Invalid team driving reason code');
|
||||
});
|
||||
|
||||
it('should throw error for null/undefined', () => {
|
||||
expect(() => TeamDrivingReasonCode.create(null as any)).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamDrivingReasonCode.create(undefined as any)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for same value', () => {
|
||||
const code1 = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
|
||||
const code2 = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
|
||||
expect(code1.equals(code2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different values', () => {
|
||||
const code1 = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
|
||||
const code2 = TeamDrivingReasonCode.create('RACE_INCIDENTS');
|
||||
expect(code1.equals(code2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toString', () => {
|
||||
it('should return the string value', () => {
|
||||
const code = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
|
||||
expect(code.toString()).toBe('RACE_PERFORMANCE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPerformance', () => {
|
||||
it('should return true for performance codes', () => {
|
||||
const performanceCodes = [
|
||||
'RACE_PERFORMANCE',
|
||||
'RACE_GAIN_BONUS',
|
||||
'RACE_PACE',
|
||||
'RACE_QUALIFYING',
|
||||
'RACE_CONSISTENCY',
|
||||
];
|
||||
|
||||
for (const code of performanceCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isPerformance()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false for non-performance codes', () => {
|
||||
const nonPerformanceCodes = [
|
||||
'RACE_INCIDENTS',
|
||||
'RACE_DNF',
|
||||
'RACE_DSQ',
|
||||
'RACE_DNS',
|
||||
'RACE_AFK',
|
||||
'RACE_OVERTAKE',
|
||||
'RACE_DEFENSE',
|
||||
'RACE_TEAMWORK',
|
||||
'RACE_SPORTSMANSHIP',
|
||||
];
|
||||
|
||||
for (const code of nonPerformanceCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isPerformance()).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPenalty', () => {
|
||||
it('should return true for penalty codes', () => {
|
||||
const penaltyCodes = [
|
||||
'RACE_INCIDENTS',
|
||||
'RACE_DNF',
|
||||
'RACE_DSQ',
|
||||
'RACE_DNS',
|
||||
'RACE_AFK',
|
||||
];
|
||||
|
||||
for (const code of penaltyCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isPenalty()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false for non-penalty codes', () => {
|
||||
const nonPenaltyCodes = [
|
||||
'RACE_PERFORMANCE',
|
||||
'RACE_GAIN_BONUS',
|
||||
'RACE_PACE',
|
||||
'RACE_QUALIFYING',
|
||||
'RACE_CONSISTENCY',
|
||||
'RACE_OVERTAKE',
|
||||
'RACE_DEFENSE',
|
||||
'RACE_TEAMWORK',
|
||||
'RACE_SPORTSMANSHIP',
|
||||
];
|
||||
|
||||
for (const code of nonPenaltyCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isPenalty()).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPositive', () => {
|
||||
it('should return true for positive codes', () => {
|
||||
const positiveCodes = [
|
||||
'RACE_PERFORMANCE',
|
||||
'RACE_GAIN_BONUS',
|
||||
'RACE_OVERTAKE',
|
||||
'RACE_DEFENSE',
|
||||
'RACE_TEAMWORK',
|
||||
'RACE_SPORTSMANSHIP',
|
||||
];
|
||||
|
||||
for (const code of positiveCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isPositive()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false for non-positive codes', () => {
|
||||
const nonPositiveCodes = [
|
||||
'RACE_INCIDENTS',
|
||||
'RACE_DNF',
|
||||
'RACE_DSQ',
|
||||
'RACE_DNS',
|
||||
'RACE_AFK',
|
||||
'RACE_PACE',
|
||||
'RACE_QUALIFYING',
|
||||
'RACE_CONSISTENCY',
|
||||
];
|
||||
|
||||
for (const code of nonPositiveCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isPositive()).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNegative', () => {
|
||||
it('should return true for negative codes', () => {
|
||||
const negativeCodes = [
|
||||
'RACE_INCIDENTS',
|
||||
'RACE_DNF',
|
||||
'RACE_DSQ',
|
||||
'RACE_DNS',
|
||||
'RACE_AFK',
|
||||
];
|
||||
|
||||
for (const code of negativeCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isNegative()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false for non-negative codes', () => {
|
||||
const nonNegativeCodes = [
|
||||
'RACE_PERFORMANCE',
|
||||
'RACE_GAIN_BONUS',
|
||||
'RACE_PACE',
|
||||
'RACE_QUALIFYING',
|
||||
'RACE_CONSISTENCY',
|
||||
'RACE_OVERTAKE',
|
||||
'RACE_DEFENSE',
|
||||
'RACE_TEAMWORK',
|
||||
'RACE_SPORTSMANSHIP',
|
||||
];
|
||||
|
||||
for (const code of nonNegativeCodes) {
|
||||
const reasonCode = TeamDrivingReasonCode.create(code);
|
||||
expect(reasonCode.isNegative()).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('props', () => {
|
||||
it('should return the correct props object', () => {
|
||||
const code = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
|
||||
expect(code.props).toEqual({ value: 'RACE_PERFORMANCE' });
|
||||
});
|
||||
});
|
||||
});
|
||||
100
core/racing/domain/value-objects/TeamDrivingReasonCode.ts
Normal file
100
core/racing/domain/value-objects/TeamDrivingReasonCode.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface TeamDrivingReasonCodeProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid reason codes for team driving rating events
|
||||
*/
|
||||
export const TEAM_DRIVING_REASON_CODES = [
|
||||
'RACE_PERFORMANCE',
|
||||
'RACE_GAIN_BONUS',
|
||||
'RACE_INCIDENTS',
|
||||
'RACE_DNF',
|
||||
'RACE_DSQ',
|
||||
'RACE_DNS',
|
||||
'RACE_AFK',
|
||||
'RACE_PACE',
|
||||
'RACE_DEFENSE',
|
||||
'RACE_OVERTAKE',
|
||||
'RACE_QUALIFYING',
|
||||
'RACE_CONSISTENCY',
|
||||
'RACE_TEAMWORK',
|
||||
'RACE_SPORTSMANSHIP',
|
||||
] as const;
|
||||
|
||||
export type TeamDrivingReasonCodeValue = (typeof TEAM_DRIVING_REASON_CODES)[number];
|
||||
|
||||
/**
|
||||
* Value object representing a team driving reason code
|
||||
*/
|
||||
export class TeamDrivingReasonCode implements IValueObject<TeamDrivingReasonCodeProps> {
|
||||
readonly value: TeamDrivingReasonCodeValue;
|
||||
|
||||
private constructor(value: TeamDrivingReasonCodeValue) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: string): TeamDrivingReasonCode {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team driving reason code cannot be empty');
|
||||
}
|
||||
|
||||
// Strict validation: no leading/trailing whitespace allowed
|
||||
if (value !== value.trim()) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Team driving reason code cannot have leading or trailing whitespace: "${value}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (!TEAM_DRIVING_REASON_CODES.includes(value as TeamDrivingReasonCodeValue)) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Invalid team driving reason code: ${value}. Valid options: ${TEAM_DRIVING_REASON_CODES.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return new TeamDrivingReasonCode(value as TeamDrivingReasonCodeValue);
|
||||
}
|
||||
|
||||
get props(): TeamDrivingReasonCodeProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<TeamDrivingReasonCodeProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a performance-related reason
|
||||
*/
|
||||
isPerformance(): boolean {
|
||||
return ['RACE_PERFORMANCE', 'RACE_GAIN_BONUS', 'RACE_PACE', 'RACE_QUALIFYING', 'RACE_CONSISTENCY'].includes(this.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a penalty-related reason
|
||||
*/
|
||||
isPenalty(): boolean {
|
||||
return ['RACE_INCIDENTS', 'RACE_DNF', 'RACE_DSQ', 'RACE_DNS', 'RACE_AFK'].includes(this.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a positive reason
|
||||
*/
|
||||
isPositive(): boolean {
|
||||
return ['RACE_PERFORMANCE', 'RACE_GAIN_BONUS', 'RACE_OVERTAKE', 'RACE_DEFENSE', 'RACE_TEAMWORK', 'RACE_SPORTSMANSHIP'].includes(this.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a negative reason
|
||||
*/
|
||||
isNegative(): boolean {
|
||||
return ['RACE_INCIDENTS', 'RACE_DNF', 'RACE_DSQ', 'RACE_DNS', 'RACE_AFK'].includes(this.value);
|
||||
}
|
||||
}
|
||||
185
core/racing/domain/value-objects/TeamRating.ts
Normal file
185
core/racing/domain/value-objects/TeamRating.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
/**
|
||||
* Value Object: TeamRating
|
||||
*
|
||||
* Multi-dimensional rating system for teams covering:
|
||||
* - Driving: racing ability, performance, consistency
|
||||
* - AdminTrust: reliability, leadership, community contribution
|
||||
*/
|
||||
|
||||
export interface TeamRatingDimension {
|
||||
value: number; // Current rating value (0-100 scale)
|
||||
confidence: number; // Confidence level based on sample size (0-1)
|
||||
sampleSize: number; // Number of events contributing to this rating
|
||||
trend: 'rising' | 'stable' | 'falling';
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
export interface TeamRatingProps {
|
||||
teamId: string;
|
||||
driving: TeamRatingDimension;
|
||||
adminTrust: TeamRatingDimension;
|
||||
overall: number;
|
||||
calculatorVersion?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const DEFAULT_DIMENSION: TeamRatingDimension = {
|
||||
value: 50,
|
||||
confidence: 0,
|
||||
sampleSize: 0,
|
||||
trend: 'stable',
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
export class TeamRating implements IValueObject<TeamRatingProps> {
|
||||
readonly props: TeamRatingProps;
|
||||
|
||||
private constructor(props: TeamRatingProps) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
get teamId(): string {
|
||||
return this.props.teamId;
|
||||
}
|
||||
|
||||
get driving(): TeamRatingDimension {
|
||||
return this.props.driving;
|
||||
}
|
||||
|
||||
get adminTrust(): TeamRatingDimension {
|
||||
return this.props.adminTrust;
|
||||
}
|
||||
|
||||
get overall(): number {
|
||||
return this.props.overall;
|
||||
}
|
||||
|
||||
get createdAt(): Date {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
|
||||
get updatedAt(): Date {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
get calculatorVersion(): string | undefined {
|
||||
return this.props.calculatorVersion;
|
||||
}
|
||||
|
||||
static create(teamId: string): TeamRating {
|
||||
if (!teamId || teamId.trim().length === 0) {
|
||||
throw new Error('TeamRating teamId is required');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
return new TeamRating({
|
||||
teamId,
|
||||
driving: { ...DEFAULT_DIMENSION, lastUpdated: now },
|
||||
adminTrust: { ...DEFAULT_DIMENSION, lastUpdated: now },
|
||||
overall: 50,
|
||||
calculatorVersion: '1.0',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
static restore(props: TeamRatingProps): TeamRating {
|
||||
return new TeamRating(props);
|
||||
}
|
||||
|
||||
equals(other: IValueObject<TeamRatingProps>): boolean {
|
||||
return this.props.teamId === other.props.teamId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update driving rating based on race performance
|
||||
*/
|
||||
updateDrivingRating(
|
||||
newValue: number,
|
||||
weight: number = 1
|
||||
): TeamRating {
|
||||
const updated = this.updateDimension(this.driving, newValue, weight);
|
||||
return this.withUpdates({ driving: updated });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update admin trust rating based on league management feedback
|
||||
*/
|
||||
updateAdminTrustRating(
|
||||
newValue: number,
|
||||
weight: number = 1
|
||||
): TeamRating {
|
||||
const updated = this.updateDimension(this.adminTrust, newValue, weight);
|
||||
return this.withUpdates({ adminTrust: updated });
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate weighted overall rating
|
||||
*/
|
||||
calculateOverall(): number {
|
||||
// Weight dimensions by confidence
|
||||
const weights = {
|
||||
driving: 0.7 * this.driving.confidence,
|
||||
adminTrust: 0.3 * this.adminTrust.confidence,
|
||||
};
|
||||
|
||||
const totalWeight = Object.values(weights).reduce((sum, w) => sum + w, 0);
|
||||
|
||||
if (totalWeight === 0) {
|
||||
return 50; // Default when no ratings yet
|
||||
}
|
||||
|
||||
const weightedSum =
|
||||
this.driving.value * weights.driving +
|
||||
this.adminTrust.value * weights.adminTrust;
|
||||
|
||||
return Math.round(weightedSum / totalWeight);
|
||||
}
|
||||
|
||||
private updateDimension(
|
||||
dimension: TeamRatingDimension,
|
||||
newValue: number,
|
||||
weight: number
|
||||
): TeamRatingDimension {
|
||||
const clampedValue = Math.max(0, Math.min(100, newValue));
|
||||
const newSampleSize = dimension.sampleSize + weight;
|
||||
|
||||
// Exponential moving average with decay based on sample size
|
||||
const alpha = Math.min(0.3, 1 / (dimension.sampleSize + 1));
|
||||
const updatedValue = dimension.value * (1 - alpha) + clampedValue * alpha;
|
||||
|
||||
// Calculate confidence (asymptotic to 1)
|
||||
const confidence = 1 - Math.exp(-newSampleSize / 20);
|
||||
|
||||
// Determine trend
|
||||
const valueDiff = updatedValue - dimension.value;
|
||||
let trend: 'rising' | 'stable' | 'falling' = 'stable';
|
||||
if (valueDiff > 2) trend = 'rising';
|
||||
if (valueDiff < -2) trend = 'falling';
|
||||
|
||||
return {
|
||||
value: Math.round(updatedValue * 10) / 10,
|
||||
confidence: Math.round(confidence * 100) / 100,
|
||||
sampleSize: newSampleSize,
|
||||
trend,
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
private withUpdates(updates: Partial<TeamRatingProps>): TeamRating {
|
||||
const newRating = new TeamRating({
|
||||
...this.props,
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
// Recalculate overall
|
||||
return new TeamRating({
|
||||
...newRating.props,
|
||||
overall: newRating.calculateOverall(),
|
||||
});
|
||||
}
|
||||
}
|
||||
96
core/racing/domain/value-objects/TeamRatingDelta.test.ts
Normal file
96
core/racing/domain/value-objects/TeamRatingDelta.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { TeamRatingDelta } from './TeamRatingDelta';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
describe('TeamRatingDelta', () => {
|
||||
describe('create', () => {
|
||||
it('should create valid delta values', () => {
|
||||
expect(TeamRatingDelta.create(0).value).toBe(0);
|
||||
expect(TeamRatingDelta.create(10).value).toBe(10);
|
||||
expect(TeamRatingDelta.create(-10).value).toBe(-10);
|
||||
expect(TeamRatingDelta.create(100).value).toBe(100);
|
||||
expect(TeamRatingDelta.create(-100).value).toBe(-100);
|
||||
expect(TeamRatingDelta.create(50.5).value).toBe(50.5);
|
||||
expect(TeamRatingDelta.create(-50.5).value).toBe(-50.5);
|
||||
});
|
||||
|
||||
it('should throw for values outside range', () => {
|
||||
expect(() => TeamRatingDelta.create(100.1)).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingDelta.create(-100.1)).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingDelta.create(101)).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingDelta.create(-101)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should accept zero', () => {
|
||||
const delta = TeamRatingDelta.create(0);
|
||||
expect(delta.value).toBe(0);
|
||||
});
|
||||
|
||||
it('should throw for non-numeric values', () => {
|
||||
expect(() => TeamRatingDelta.create('50' as unknown as number)).toThrow();
|
||||
expect(() => TeamRatingDelta.create(null as unknown as number)).toThrow();
|
||||
expect(() => TeamRatingDelta.create(undefined as unknown as number)).toThrow();
|
||||
});
|
||||
|
||||
it('should return true for same value', () => {
|
||||
const delta1 = TeamRatingDelta.create(10);
|
||||
const delta2 = TeamRatingDelta.create(10);
|
||||
expect(delta1.equals(delta2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different values', () => {
|
||||
const delta1 = TeamRatingDelta.create(10);
|
||||
const delta2 = TeamRatingDelta.create(-10);
|
||||
expect(delta1.equals(delta2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle decimal comparisons', () => {
|
||||
const delta1 = TeamRatingDelta.create(50.5);
|
||||
const delta2 = TeamRatingDelta.create(50.5);
|
||||
expect(delta1.equals(delta2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose props correctly', () => {
|
||||
const delta = TeamRatingDelta.create(10);
|
||||
expect(delta.props.value).toBe(10);
|
||||
});
|
||||
|
||||
it('should return numeric value', () => {
|
||||
const delta = TeamRatingDelta.create(50.5);
|
||||
expect(delta.toNumber()).toBe(50.5);
|
||||
});
|
||||
|
||||
it('should return string representation', () => {
|
||||
const delta = TeamRatingDelta.create(50.5);
|
||||
expect(delta.toString()).toBe('50.5');
|
||||
});
|
||||
|
||||
it('should return true for positive deltas', () => {
|
||||
expect(TeamRatingDelta.create(1).isPositive()).toBe(true);
|
||||
expect(TeamRatingDelta.create(100).isPositive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for zero and negative deltas', () => {
|
||||
expect(TeamRatingDelta.create(0).isPositive()).toBe(false);
|
||||
expect(TeamRatingDelta.create(-1).isPositive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for negative deltas', () => {
|
||||
expect(TeamRatingDelta.create(-1).isNegative()).toBe(true);
|
||||
expect(TeamRatingDelta.create(-100).isNegative()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for zero and positive deltas', () => {
|
||||
expect(TeamRatingDelta.create(0).isNegative()).toBe(false);
|
||||
expect(TeamRatingDelta.create(1).isNegative()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for zero delta', () => {
|
||||
expect(TeamRatingDelta.create(0).isZero()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-zero deltas', () => {
|
||||
expect(TeamRatingDelta.create(1).isZero()).toBe(false);
|
||||
expect(TeamRatingDelta.create(-1).isZero()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
57
core/racing/domain/value-objects/TeamRatingDelta.ts
Normal file
57
core/racing/domain/value-objects/TeamRatingDelta.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface TeamRatingDeltaProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class TeamRatingDelta implements IValueObject<TeamRatingDeltaProps> {
|
||||
readonly value: number;
|
||||
|
||||
private constructor(value: number) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: number): TeamRatingDelta {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
throw new RacingDomainValidationError('Team rating delta must be a valid number');
|
||||
}
|
||||
|
||||
// Delta can be negative or positive, but within reasonable bounds
|
||||
if (value < -100 || value > 100) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Team rating delta must be between -100 and 100, got: ${value}`
|
||||
);
|
||||
}
|
||||
|
||||
return new TeamRatingDelta(value);
|
||||
}
|
||||
|
||||
get props(): TeamRatingDeltaProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<TeamRatingDeltaProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toNumber(): number {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value.toString();
|
||||
}
|
||||
|
||||
isPositive(): boolean {
|
||||
return this.value > 0;
|
||||
}
|
||||
|
||||
isNegative(): boolean {
|
||||
return this.value < 0;
|
||||
}
|
||||
|
||||
isZero(): boolean {
|
||||
return this.value === 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { TeamRatingDimensionKey } from './TeamRatingDimensionKey';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
describe('TeamRatingDimensionKey', () => {
|
||||
describe('create', () => {
|
||||
it('should create valid dimension keys', () => {
|
||||
expect(TeamRatingDimensionKey.create('driving').value).toBe('driving');
|
||||
expect(TeamRatingDimensionKey.create('adminTrust').value).toBe('adminTrust');
|
||||
});
|
||||
|
||||
it('should throw for invalid dimension key', () => {
|
||||
expect(() => TeamRatingDimensionKey.create('invalid')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingDimensionKey.create('driving ')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingDimensionKey.create('')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for empty string', () => {
|
||||
expect(() => TeamRatingDimensionKey.create('')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for whitespace', () => {
|
||||
expect(() => TeamRatingDimensionKey.create(' ')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should return true for same value', () => {
|
||||
const key1 = TeamRatingDimensionKey.create('driving');
|
||||
const key2 = TeamRatingDimensionKey.create('driving');
|
||||
expect(key1.equals(key2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different values', () => {
|
||||
const key1 = TeamRatingDimensionKey.create('driving');
|
||||
const key2 = TeamRatingDimensionKey.create('adminTrust');
|
||||
expect(key1.equals(key2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should expose props correctly', () => {
|
||||
const key = TeamRatingDimensionKey.create('driving');
|
||||
expect(key.props.value).toBe('driving');
|
||||
});
|
||||
|
||||
it('should return string representation', () => {
|
||||
const key = TeamRatingDimensionKey.create('driving');
|
||||
expect(key.toString()).toBe('driving');
|
||||
});
|
||||
});
|
||||
});
|
||||
49
core/racing/domain/value-objects/TeamRatingDimensionKey.ts
Normal file
49
core/racing/domain/value-objects/TeamRatingDimensionKey.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface TeamRatingDimensionKeyProps {
|
||||
value: 'driving' | 'adminTrust';
|
||||
}
|
||||
|
||||
const VALID_DIMENSIONS = ['driving', 'adminTrust'] as const;
|
||||
|
||||
export class TeamRatingDimensionKey implements IValueObject<TeamRatingDimensionKeyProps> {
|
||||
readonly value: TeamRatingDimensionKeyProps['value'];
|
||||
|
||||
private constructor(value: TeamRatingDimensionKeyProps['value']) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: string): TeamRatingDimensionKey {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team rating dimension key cannot be empty');
|
||||
}
|
||||
|
||||
// Strict validation: no leading/trailing whitespace allowed
|
||||
if (value !== value.trim()) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Team rating dimension key cannot have leading or trailing whitespace: "${value}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (!VALID_DIMENSIONS.includes(value as TeamRatingDimensionKeyProps['value'])) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Invalid team rating dimension key: ${value}. Valid options: ${VALID_DIMENSIONS.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return new TeamRatingDimensionKey(value as TeamRatingDimensionKeyProps['value']);
|
||||
}
|
||||
|
||||
get props(): TeamRatingDimensionKeyProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<TeamRatingDimensionKeyProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
68
core/racing/domain/value-objects/TeamRatingEventId.test.ts
Normal file
68
core/racing/domain/value-objects/TeamRatingEventId.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { TeamRatingEventId } from './TeamRatingEventId';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
describe('TeamRatingEventId', () => {
|
||||
describe('create', () => {
|
||||
it('should create valid UUID', () => {
|
||||
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const id = TeamRatingEventId.create(validUuid);
|
||||
expect(id.value).toBe(validUuid);
|
||||
});
|
||||
|
||||
it('should throw for invalid UUID', () => {
|
||||
expect(() => TeamRatingEventId.create('not-a-uuid')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingEventId.create('123e4567-e89b-12d3-a456')).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingEventId.create('')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for empty string', () => {
|
||||
expect(() => TeamRatingEventId.create('')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for whitespace', () => {
|
||||
expect(() => TeamRatingEventId.create(' ')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should handle uppercase UUIDs', () => {
|
||||
const uuid = '123E4567-E89B-12D3-A456-426614174000';
|
||||
const id = TeamRatingEventId.create(uuid);
|
||||
expect(id.value).toBe(uuid);
|
||||
});
|
||||
|
||||
it('should generate a valid UUID', () => {
|
||||
const id = TeamRatingEventId.generate();
|
||||
expect(id.value).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
});
|
||||
|
||||
it('should generate unique IDs', () => {
|
||||
const id1 = TeamRatingEventId.generate();
|
||||
const id2 = TeamRatingEventId.generate();
|
||||
expect(id1.equals(id2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for same UUID', () => {
|
||||
const uuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const id1 = TeamRatingEventId.create(uuid);
|
||||
const id2 = TeamRatingEventId.create(uuid);
|
||||
expect(id1.equals(id2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different UUIDs', () => {
|
||||
const id1 = TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174000');
|
||||
const id2 = TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174001');
|
||||
expect(id1.equals(id2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should expose props correctly', () => {
|
||||
const uuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const id = TeamRatingEventId.create(uuid);
|
||||
expect(id.props.value).toBe(uuid);
|
||||
});
|
||||
|
||||
it('should return string representation', () => {
|
||||
const uuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const id = TeamRatingEventId.create(uuid);
|
||||
expect(id.toString()).toBe(uuid);
|
||||
});
|
||||
});
|
||||
});
|
||||
62
core/racing/domain/value-objects/TeamRatingEventId.ts
Normal file
62
core/racing/domain/value-objects/TeamRatingEventId.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
// Simple UUID v4 generator
|
||||
function uuidv4(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
export interface TeamRatingEventIdProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class TeamRatingEventId implements IValueObject<TeamRatingEventIdProps> {
|
||||
readonly value: string;
|
||||
|
||||
private constructor(value: string) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: string): TeamRatingEventId {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('TeamRatingEventId cannot be empty');
|
||||
}
|
||||
|
||||
// Strict validation: no leading/trailing whitespace allowed
|
||||
if (value !== value.trim()) {
|
||||
throw new RacingDomainValidationError(
|
||||
`TeamRatingEventId cannot have leading or trailing whitespace: "${value}"`
|
||||
);
|
||||
}
|
||||
|
||||
// Basic UUID format validation
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(value)) {
|
||||
throw new RacingDomainValidationError(
|
||||
`TeamRatingEventId must be a valid UUID format, got: "${value}"`
|
||||
);
|
||||
}
|
||||
|
||||
return new TeamRatingEventId(value);
|
||||
}
|
||||
|
||||
static generate(): TeamRatingEventId {
|
||||
return new TeamRatingEventId(uuidv4());
|
||||
}
|
||||
|
||||
get props(): TeamRatingEventIdProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<TeamRatingEventIdProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
67
core/racing/domain/value-objects/TeamRatingValue.test.ts
Normal file
67
core/racing/domain/value-objects/TeamRatingValue.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { TeamRatingValue } from './TeamRatingValue';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
describe('TeamRatingValue', () => {
|
||||
describe('create', () => {
|
||||
it('should create valid rating values', () => {
|
||||
expect(TeamRatingValue.create(0).value).toBe(0);
|
||||
expect(TeamRatingValue.create(50).value).toBe(50);
|
||||
expect(TeamRatingValue.create(100).value).toBe(100);
|
||||
expect(TeamRatingValue.create(75.5).value).toBe(75.5);
|
||||
});
|
||||
|
||||
it('should throw for values below 0', () => {
|
||||
expect(() => TeamRatingValue.create(-1)).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingValue.create(-0.1)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for values above 100', () => {
|
||||
expect(() => TeamRatingValue.create(100.1)).toThrow(RacingDomainValidationError);
|
||||
expect(() => TeamRatingValue.create(101)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should accept decimal values', () => {
|
||||
const value = TeamRatingValue.create(75.5);
|
||||
expect(value.value).toBe(75.5);
|
||||
});
|
||||
|
||||
it('should throw for non-numeric values', () => {
|
||||
expect(() => TeamRatingValue.create('50' as unknown as number)).toThrow();
|
||||
expect(() => TeamRatingValue.create(null as unknown as number)).toThrow();
|
||||
expect(() => TeamRatingValue.create(undefined as unknown as number)).toThrow();
|
||||
});
|
||||
|
||||
it('should return true for same value', () => {
|
||||
const val1 = TeamRatingValue.create(50);
|
||||
const val2 = TeamRatingValue.create(50);
|
||||
expect(val1.equals(val2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different values', () => {
|
||||
const val1 = TeamRatingValue.create(50);
|
||||
const val2 = TeamRatingValue.create(60);
|
||||
expect(val1.equals(val2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle decimal comparisons', () => {
|
||||
const val1 = TeamRatingValue.create(75.5);
|
||||
const val2 = TeamRatingValue.create(75.5);
|
||||
expect(val1.equals(val2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose props correctly', () => {
|
||||
const value = TeamRatingValue.create(50);
|
||||
expect(value.props.value).toBe(50);
|
||||
});
|
||||
|
||||
it('should return numeric value', () => {
|
||||
const value = TeamRatingValue.create(75.5);
|
||||
expect(value.toNumber()).toBe(75.5);
|
||||
});
|
||||
|
||||
it('should return string representation', () => {
|
||||
const value = TeamRatingValue.create(75.5);
|
||||
expect(value.toString()).toBe('75.5');
|
||||
});
|
||||
});
|
||||
});
|
||||
44
core/racing/domain/value-objects/TeamRatingValue.ts
Normal file
44
core/racing/domain/value-objects/TeamRatingValue.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface TeamRatingValueProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class TeamRatingValue implements IValueObject<TeamRatingValueProps> {
|
||||
readonly value: number;
|
||||
|
||||
private constructor(value: number) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: number): TeamRatingValue {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
throw new RacingDomainValidationError('Team rating value must be a valid number');
|
||||
}
|
||||
|
||||
if (value < 0 || value > 100) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Team rating value must be between 0 and 100, got: ${value}`
|
||||
);
|
||||
}
|
||||
|
||||
return new TeamRatingValue(value);
|
||||
}
|
||||
|
||||
get props(): TeamRatingValueProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<TeamRatingValueProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toNumber(): number {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value.toString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user