team rating
This commit is contained in:
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