team rating

This commit is contained in:
2025-12-30 12:25:45 +01:00
parent ccaa39c39c
commit 83371ea839
93 changed files with 10324 additions and 490 deletions

View File

@@ -0,0 +1,214 @@
import { TeamDrivingReasonCode, TEAM_DRIVING_REASON_CODES } from './TeamDrivingReasonCode';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
describe('TeamDrivingReasonCode', () => {
describe('create', () => {
it('should create valid reason codes', () => {
for (const code of TEAM_DRIVING_REASON_CODES) {
const reasonCode = TeamDrivingReasonCode.create(code);
expect(reasonCode.value).toBe(code);
}
});
it('should throw error for empty string', () => {
expect(() => TeamDrivingReasonCode.create('')).toThrow(RacingDomainValidationError);
expect(() => TeamDrivingReasonCode.create('')).toThrow('cannot be empty');
});
it('should throw error for whitespace-only string', () => {
expect(() => TeamDrivingReasonCode.create(' ')).toThrow(RacingDomainValidationError);
});
it('should throw error for leading whitespace', () => {
expect(() => TeamDrivingReasonCode.create(' RACE_PERFORMANCE')).toThrow(RacingDomainValidationError);
expect(() => TeamDrivingReasonCode.create(' RACE_PERFORMANCE')).toThrow('leading or trailing whitespace');
});
it('should throw error for trailing whitespace', () => {
expect(() => TeamDrivingReasonCode.create('RACE_PERFORMANCE ')).toThrow(RacingDomainValidationError);
expect(() => TeamDrivingReasonCode.create('RACE_PERFORMANCE ')).toThrow('leading or trailing whitespace');
});
it('should throw error for invalid reason code', () => {
expect(() => TeamDrivingReasonCode.create('INVALID_CODE')).toThrow(RacingDomainValidationError);
expect(() => TeamDrivingReasonCode.create('INVALID_CODE')).toThrow('Invalid team driving reason code');
});
it('should throw error for null/undefined', () => {
expect(() => TeamDrivingReasonCode.create(null as any)).toThrow(RacingDomainValidationError);
expect(() => TeamDrivingReasonCode.create(undefined as any)).toThrow(RacingDomainValidationError);
});
});
describe('equals', () => {
it('should return true for same value', () => {
const code1 = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
const code2 = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
expect(code1.equals(code2)).toBe(true);
});
it('should return false for different values', () => {
const code1 = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
const code2 = TeamDrivingReasonCode.create('RACE_INCIDENTS');
expect(code1.equals(code2)).toBe(false);
});
});
describe('toString', () => {
it('should return the string value', () => {
const code = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
expect(code.toString()).toBe('RACE_PERFORMANCE');
});
});
describe('isPerformance', () => {
it('should return true for performance codes', () => {
const performanceCodes = [
'RACE_PERFORMANCE',
'RACE_GAIN_BONUS',
'RACE_PACE',
'RACE_QUALIFYING',
'RACE_CONSISTENCY',
];
for (const code of performanceCodes) {
const reasonCode = TeamDrivingReasonCode.create(code);
expect(reasonCode.isPerformance()).toBe(true);
}
});
it('should return false for non-performance codes', () => {
const nonPerformanceCodes = [
'RACE_INCIDENTS',
'RACE_DNF',
'RACE_DSQ',
'RACE_DNS',
'RACE_AFK',
'RACE_OVERTAKE',
'RACE_DEFENSE',
'RACE_TEAMWORK',
'RACE_SPORTSMANSHIP',
];
for (const code of nonPerformanceCodes) {
const reasonCode = TeamDrivingReasonCode.create(code);
expect(reasonCode.isPerformance()).toBe(false);
}
});
});
describe('isPenalty', () => {
it('should return true for penalty codes', () => {
const penaltyCodes = [
'RACE_INCIDENTS',
'RACE_DNF',
'RACE_DSQ',
'RACE_DNS',
'RACE_AFK',
];
for (const code of penaltyCodes) {
const reasonCode = TeamDrivingReasonCode.create(code);
expect(reasonCode.isPenalty()).toBe(true);
}
});
it('should return false for non-penalty codes', () => {
const nonPenaltyCodes = [
'RACE_PERFORMANCE',
'RACE_GAIN_BONUS',
'RACE_PACE',
'RACE_QUALIFYING',
'RACE_CONSISTENCY',
'RACE_OVERTAKE',
'RACE_DEFENSE',
'RACE_TEAMWORK',
'RACE_SPORTSMANSHIP',
];
for (const code of nonPenaltyCodes) {
const reasonCode = TeamDrivingReasonCode.create(code);
expect(reasonCode.isPenalty()).toBe(false);
}
});
});
describe('isPositive', () => {
it('should return true for positive codes', () => {
const positiveCodes = [
'RACE_PERFORMANCE',
'RACE_GAIN_BONUS',
'RACE_OVERTAKE',
'RACE_DEFENSE',
'RACE_TEAMWORK',
'RACE_SPORTSMANSHIP',
];
for (const code of positiveCodes) {
const reasonCode = TeamDrivingReasonCode.create(code);
expect(reasonCode.isPositive()).toBe(true);
}
});
it('should return false for non-positive codes', () => {
const nonPositiveCodes = [
'RACE_INCIDENTS',
'RACE_DNF',
'RACE_DSQ',
'RACE_DNS',
'RACE_AFK',
'RACE_PACE',
'RACE_QUALIFYING',
'RACE_CONSISTENCY',
];
for (const code of nonPositiveCodes) {
const reasonCode = TeamDrivingReasonCode.create(code);
expect(reasonCode.isPositive()).toBe(false);
}
});
});
describe('isNegative', () => {
it('should return true for negative codes', () => {
const negativeCodes = [
'RACE_INCIDENTS',
'RACE_DNF',
'RACE_DSQ',
'RACE_DNS',
'RACE_AFK',
];
for (const code of negativeCodes) {
const reasonCode = TeamDrivingReasonCode.create(code);
expect(reasonCode.isNegative()).toBe(true);
}
});
it('should return false for non-negative codes', () => {
const nonNegativeCodes = [
'RACE_PERFORMANCE',
'RACE_GAIN_BONUS',
'RACE_PACE',
'RACE_QUALIFYING',
'RACE_CONSISTENCY',
'RACE_OVERTAKE',
'RACE_DEFENSE',
'RACE_TEAMWORK',
'RACE_SPORTSMANSHIP',
];
for (const code of nonNegativeCodes) {
const reasonCode = TeamDrivingReasonCode.create(code);
expect(reasonCode.isNegative()).toBe(false);
}
});
});
describe('props', () => {
it('should return the correct props object', () => {
const code = TeamDrivingReasonCode.create('RACE_PERFORMANCE');
expect(code.props).toEqual({ value: 'RACE_PERFORMANCE' });
});
});
});

View File

@@ -0,0 +1,100 @@
import type { IValueObject } from '@core/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export interface TeamDrivingReasonCodeProps {
value: string;
}
/**
* Valid reason codes for team driving rating events
*/
export const TEAM_DRIVING_REASON_CODES = [
'RACE_PERFORMANCE',
'RACE_GAIN_BONUS',
'RACE_INCIDENTS',
'RACE_DNF',
'RACE_DSQ',
'RACE_DNS',
'RACE_AFK',
'RACE_PACE',
'RACE_DEFENSE',
'RACE_OVERTAKE',
'RACE_QUALIFYING',
'RACE_CONSISTENCY',
'RACE_TEAMWORK',
'RACE_SPORTSMANSHIP',
] as const;
export type TeamDrivingReasonCodeValue = (typeof TEAM_DRIVING_REASON_CODES)[number];
/**
* Value object representing a team driving reason code
*/
export class TeamDrivingReasonCode implements IValueObject<TeamDrivingReasonCodeProps> {
readonly value: TeamDrivingReasonCodeValue;
private constructor(value: TeamDrivingReasonCodeValue) {
this.value = value;
}
static create(value: string): TeamDrivingReasonCode {
if (!value || value.trim().length === 0) {
throw new RacingDomainValidationError('Team driving reason code cannot be empty');
}
// Strict validation: no leading/trailing whitespace allowed
if (value !== value.trim()) {
throw new RacingDomainValidationError(
`Team driving reason code cannot have leading or trailing whitespace: "${value}"`
);
}
if (!TEAM_DRIVING_REASON_CODES.includes(value as TeamDrivingReasonCodeValue)) {
throw new RacingDomainValidationError(
`Invalid team driving reason code: ${value}. Valid options: ${TEAM_DRIVING_REASON_CODES.join(', ')}`
);
}
return new TeamDrivingReasonCode(value as TeamDrivingReasonCodeValue);
}
get props(): TeamDrivingReasonCodeProps {
return { value: this.value };
}
equals(other: IValueObject<TeamDrivingReasonCodeProps>): boolean {
return this.value === other.props.value;
}
toString(): string {
return this.value;
}
/**
* Check if this is a performance-related reason
*/
isPerformance(): boolean {
return ['RACE_PERFORMANCE', 'RACE_GAIN_BONUS', 'RACE_PACE', 'RACE_QUALIFYING', 'RACE_CONSISTENCY'].includes(this.value);
}
/**
* Check if this is a penalty-related reason
*/
isPenalty(): boolean {
return ['RACE_INCIDENTS', 'RACE_DNF', 'RACE_DSQ', 'RACE_DNS', 'RACE_AFK'].includes(this.value);
}
/**
* Check if this is a positive reason
*/
isPositive(): boolean {
return ['RACE_PERFORMANCE', 'RACE_GAIN_BONUS', 'RACE_OVERTAKE', 'RACE_DEFENSE', 'RACE_TEAMWORK', 'RACE_SPORTSMANSHIP'].includes(this.value);
}
/**
* Check if this is a negative reason
*/
isNegative(): boolean {
return ['RACE_INCIDENTS', 'RACE_DNF', 'RACE_DSQ', 'RACE_DNS', 'RACE_AFK'].includes(this.value);
}
}

View File

@@ -0,0 +1,185 @@
import type { IValueObject } from '@core/shared/domain';
/**
* Value Object: TeamRating
*
* Multi-dimensional rating system for teams covering:
* - Driving: racing ability, performance, consistency
* - AdminTrust: reliability, leadership, community contribution
*/
export interface TeamRatingDimension {
value: number; // Current rating value (0-100 scale)
confidence: number; // Confidence level based on sample size (0-1)
sampleSize: number; // Number of events contributing to this rating
trend: 'rising' | 'stable' | 'falling';
lastUpdated: Date;
}
export interface TeamRatingProps {
teamId: string;
driving: TeamRatingDimension;
adminTrust: TeamRatingDimension;
overall: number;
calculatorVersion?: string;
createdAt: Date;
updatedAt: Date;
}
const DEFAULT_DIMENSION: TeamRatingDimension = {
value: 50,
confidence: 0,
sampleSize: 0,
trend: 'stable',
lastUpdated: new Date(),
};
export class TeamRating implements IValueObject<TeamRatingProps> {
readonly props: TeamRatingProps;
private constructor(props: TeamRatingProps) {
this.props = props;
}
get teamId(): string {
return this.props.teamId;
}
get driving(): TeamRatingDimension {
return this.props.driving;
}
get adminTrust(): TeamRatingDimension {
return this.props.adminTrust;
}
get overall(): number {
return this.props.overall;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
get calculatorVersion(): string | undefined {
return this.props.calculatorVersion;
}
static create(teamId: string): TeamRating {
if (!teamId || teamId.trim().length === 0) {
throw new Error('TeamRating teamId is required');
}
const now = new Date();
return new TeamRating({
teamId,
driving: { ...DEFAULT_DIMENSION, lastUpdated: now },
adminTrust: { ...DEFAULT_DIMENSION, lastUpdated: now },
overall: 50,
calculatorVersion: '1.0',
createdAt: now,
updatedAt: now,
});
}
static restore(props: TeamRatingProps): TeamRating {
return new TeamRating(props);
}
equals(other: IValueObject<TeamRatingProps>): boolean {
return this.props.teamId === other.props.teamId;
}
/**
* Update driving rating based on race performance
*/
updateDrivingRating(
newValue: number,
weight: number = 1
): TeamRating {
const updated = this.updateDimension(this.driving, newValue, weight);
return this.withUpdates({ driving: updated });
}
/**
* Update admin trust rating based on league management feedback
*/
updateAdminTrustRating(
newValue: number,
weight: number = 1
): TeamRating {
const updated = this.updateDimension(this.adminTrust, newValue, weight);
return this.withUpdates({ adminTrust: updated });
}
/**
* Calculate weighted overall rating
*/
calculateOverall(): number {
// Weight dimensions by confidence
const weights = {
driving: 0.7 * this.driving.confidence,
adminTrust: 0.3 * this.adminTrust.confidence,
};
const totalWeight = Object.values(weights).reduce((sum, w) => sum + w, 0);
if (totalWeight === 0) {
return 50; // Default when no ratings yet
}
const weightedSum =
this.driving.value * weights.driving +
this.adminTrust.value * weights.adminTrust;
return Math.round(weightedSum / totalWeight);
}
private updateDimension(
dimension: TeamRatingDimension,
newValue: number,
weight: number
): TeamRatingDimension {
const clampedValue = Math.max(0, Math.min(100, newValue));
const newSampleSize = dimension.sampleSize + weight;
// Exponential moving average with decay based on sample size
const alpha = Math.min(0.3, 1 / (dimension.sampleSize + 1));
const updatedValue = dimension.value * (1 - alpha) + clampedValue * alpha;
// Calculate confidence (asymptotic to 1)
const confidence = 1 - Math.exp(-newSampleSize / 20);
// Determine trend
const valueDiff = updatedValue - dimension.value;
let trend: 'rising' | 'stable' | 'falling' = 'stable';
if (valueDiff > 2) trend = 'rising';
if (valueDiff < -2) trend = 'falling';
return {
value: Math.round(updatedValue * 10) / 10,
confidence: Math.round(confidence * 100) / 100,
sampleSize: newSampleSize,
trend,
lastUpdated: new Date(),
};
}
private withUpdates(updates: Partial<TeamRatingProps>): TeamRating {
const newRating = new TeamRating({
...this.props,
...updates,
updatedAt: new Date(),
});
// Recalculate overall
return new TeamRating({
...newRating.props,
overall: newRating.calculateOverall(),
});
}
}

View File

@@ -0,0 +1,96 @@
import { TeamRatingDelta } from './TeamRatingDelta';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
describe('TeamRatingDelta', () => {
describe('create', () => {
it('should create valid delta values', () => {
expect(TeamRatingDelta.create(0).value).toBe(0);
expect(TeamRatingDelta.create(10).value).toBe(10);
expect(TeamRatingDelta.create(-10).value).toBe(-10);
expect(TeamRatingDelta.create(100).value).toBe(100);
expect(TeamRatingDelta.create(-100).value).toBe(-100);
expect(TeamRatingDelta.create(50.5).value).toBe(50.5);
expect(TeamRatingDelta.create(-50.5).value).toBe(-50.5);
});
it('should throw for values outside range', () => {
expect(() => TeamRatingDelta.create(100.1)).toThrow(RacingDomainValidationError);
expect(() => TeamRatingDelta.create(-100.1)).toThrow(RacingDomainValidationError);
expect(() => TeamRatingDelta.create(101)).toThrow(RacingDomainValidationError);
expect(() => TeamRatingDelta.create(-101)).toThrow(RacingDomainValidationError);
});
it('should accept zero', () => {
const delta = TeamRatingDelta.create(0);
expect(delta.value).toBe(0);
});
it('should throw for non-numeric values', () => {
expect(() => TeamRatingDelta.create('50' as unknown as number)).toThrow();
expect(() => TeamRatingDelta.create(null as unknown as number)).toThrow();
expect(() => TeamRatingDelta.create(undefined as unknown as number)).toThrow();
});
it('should return true for same value', () => {
const delta1 = TeamRatingDelta.create(10);
const delta2 = TeamRatingDelta.create(10);
expect(delta1.equals(delta2)).toBe(true);
});
it('should return false for different values', () => {
const delta1 = TeamRatingDelta.create(10);
const delta2 = TeamRatingDelta.create(-10);
expect(delta1.equals(delta2)).toBe(false);
});
it('should handle decimal comparisons', () => {
const delta1 = TeamRatingDelta.create(50.5);
const delta2 = TeamRatingDelta.create(50.5);
expect(delta1.equals(delta2)).toBe(true);
});
it('should expose props correctly', () => {
const delta = TeamRatingDelta.create(10);
expect(delta.props.value).toBe(10);
});
it('should return numeric value', () => {
const delta = TeamRatingDelta.create(50.5);
expect(delta.toNumber()).toBe(50.5);
});
it('should return string representation', () => {
const delta = TeamRatingDelta.create(50.5);
expect(delta.toString()).toBe('50.5');
});
it('should return true for positive deltas', () => {
expect(TeamRatingDelta.create(1).isPositive()).toBe(true);
expect(TeamRatingDelta.create(100).isPositive()).toBe(true);
});
it('should return false for zero and negative deltas', () => {
expect(TeamRatingDelta.create(0).isPositive()).toBe(false);
expect(TeamRatingDelta.create(-1).isPositive()).toBe(false);
});
it('should return true for negative deltas', () => {
expect(TeamRatingDelta.create(-1).isNegative()).toBe(true);
expect(TeamRatingDelta.create(-100).isNegative()).toBe(true);
});
it('should return false for zero and positive deltas', () => {
expect(TeamRatingDelta.create(0).isNegative()).toBe(false);
expect(TeamRatingDelta.create(1).isNegative()).toBe(false);
});
it('should return true for zero delta', () => {
expect(TeamRatingDelta.create(0).isZero()).toBe(true);
});
it('should return false for non-zero deltas', () => {
expect(TeamRatingDelta.create(1).isZero()).toBe(false);
expect(TeamRatingDelta.create(-1).isZero()).toBe(false);
});
});
});

View File

@@ -0,0 +1,57 @@
import type { IValueObject } from '@core/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export interface TeamRatingDeltaProps {
value: number;
}
export class TeamRatingDelta implements IValueObject<TeamRatingDeltaProps> {
readonly value: number;
private constructor(value: number) {
this.value = value;
}
static create(value: number): TeamRatingDelta {
if (typeof value !== 'number' || isNaN(value)) {
throw new RacingDomainValidationError('Team rating delta must be a valid number');
}
// Delta can be negative or positive, but within reasonable bounds
if (value < -100 || value > 100) {
throw new RacingDomainValidationError(
`Team rating delta must be between -100 and 100, got: ${value}`
);
}
return new TeamRatingDelta(value);
}
get props(): TeamRatingDeltaProps {
return { value: this.value };
}
equals(other: IValueObject<TeamRatingDeltaProps>): boolean {
return this.value === other.props.value;
}
toNumber(): number {
return this.value;
}
toString(): string {
return this.value.toString();
}
isPositive(): boolean {
return this.value > 0;
}
isNegative(): boolean {
return this.value < 0;
}
isZero(): boolean {
return this.value === 0;
}
}

View File

@@ -0,0 +1,47 @@
import { TeamRatingDimensionKey } from './TeamRatingDimensionKey';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
describe('TeamRatingDimensionKey', () => {
describe('create', () => {
it('should create valid dimension keys', () => {
expect(TeamRatingDimensionKey.create('driving').value).toBe('driving');
expect(TeamRatingDimensionKey.create('adminTrust').value).toBe('adminTrust');
});
it('should throw for invalid dimension key', () => {
expect(() => TeamRatingDimensionKey.create('invalid')).toThrow(RacingDomainValidationError);
expect(() => TeamRatingDimensionKey.create('driving ')).toThrow(RacingDomainValidationError);
expect(() => TeamRatingDimensionKey.create('')).toThrow(RacingDomainValidationError);
});
it('should throw for empty string', () => {
expect(() => TeamRatingDimensionKey.create('')).toThrow(RacingDomainValidationError);
});
it('should throw for whitespace', () => {
expect(() => TeamRatingDimensionKey.create(' ')).toThrow(RacingDomainValidationError);
});
it('should return true for same value', () => {
const key1 = TeamRatingDimensionKey.create('driving');
const key2 = TeamRatingDimensionKey.create('driving');
expect(key1.equals(key2)).toBe(true);
});
it('should return false for different values', () => {
const key1 = TeamRatingDimensionKey.create('driving');
const key2 = TeamRatingDimensionKey.create('adminTrust');
expect(key1.equals(key2)).toBe(false);
});
it('should expose props correctly', () => {
const key = TeamRatingDimensionKey.create('driving');
expect(key.props.value).toBe('driving');
});
it('should return string representation', () => {
const key = TeamRatingDimensionKey.create('driving');
expect(key.toString()).toBe('driving');
});
});
});

View File

@@ -0,0 +1,49 @@
import type { IValueObject } from '@core/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export interface TeamRatingDimensionKeyProps {
value: 'driving' | 'adminTrust';
}
const VALID_DIMENSIONS = ['driving', 'adminTrust'] as const;
export class TeamRatingDimensionKey implements IValueObject<TeamRatingDimensionKeyProps> {
readonly value: TeamRatingDimensionKeyProps['value'];
private constructor(value: TeamRatingDimensionKeyProps['value']) {
this.value = value;
}
static create(value: string): TeamRatingDimensionKey {
if (!value || value.trim().length === 0) {
throw new RacingDomainValidationError('Team rating dimension key cannot be empty');
}
// Strict validation: no leading/trailing whitespace allowed
if (value !== value.trim()) {
throw new RacingDomainValidationError(
`Team rating dimension key cannot have leading or trailing whitespace: "${value}"`
);
}
if (!VALID_DIMENSIONS.includes(value as TeamRatingDimensionKeyProps['value'])) {
throw new RacingDomainValidationError(
`Invalid team rating dimension key: ${value}. Valid options: ${VALID_DIMENSIONS.join(', ')}`
);
}
return new TeamRatingDimensionKey(value as TeamRatingDimensionKeyProps['value']);
}
get props(): TeamRatingDimensionKeyProps {
return { value: this.value };
}
equals(other: IValueObject<TeamRatingDimensionKeyProps>): boolean {
return this.value === other.props.value;
}
toString(): string {
return this.value;
}
}

View File

@@ -0,0 +1,68 @@
import { TeamRatingEventId } from './TeamRatingEventId';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
describe('TeamRatingEventId', () => {
describe('create', () => {
it('should create valid UUID', () => {
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
const id = TeamRatingEventId.create(validUuid);
expect(id.value).toBe(validUuid);
});
it('should throw for invalid UUID', () => {
expect(() => TeamRatingEventId.create('not-a-uuid')).toThrow(RacingDomainValidationError);
expect(() => TeamRatingEventId.create('123e4567-e89b-12d3-a456')).toThrow(RacingDomainValidationError);
expect(() => TeamRatingEventId.create('')).toThrow(RacingDomainValidationError);
});
it('should throw for empty string', () => {
expect(() => TeamRatingEventId.create('')).toThrow(RacingDomainValidationError);
});
it('should throw for whitespace', () => {
expect(() => TeamRatingEventId.create(' ')).toThrow(RacingDomainValidationError);
});
it('should handle uppercase UUIDs', () => {
const uuid = '123E4567-E89B-12D3-A456-426614174000';
const id = TeamRatingEventId.create(uuid);
expect(id.value).toBe(uuid);
});
it('should generate a valid UUID', () => {
const id = TeamRatingEventId.generate();
expect(id.value).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
});
it('should generate unique IDs', () => {
const id1 = TeamRatingEventId.generate();
const id2 = TeamRatingEventId.generate();
expect(id1.equals(id2)).toBe(false);
});
it('should return true for same UUID', () => {
const uuid = '123e4567-e89b-12d3-a456-426614174000';
const id1 = TeamRatingEventId.create(uuid);
const id2 = TeamRatingEventId.create(uuid);
expect(id1.equals(id2)).toBe(true);
});
it('should return false for different UUIDs', () => {
const id1 = TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174000');
const id2 = TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174001');
expect(id1.equals(id2)).toBe(false);
});
it('should expose props correctly', () => {
const uuid = '123e4567-e89b-12d3-a456-426614174000';
const id = TeamRatingEventId.create(uuid);
expect(id.props.value).toBe(uuid);
});
it('should return string representation', () => {
const uuid = '123e4567-e89b-12d3-a456-426614174000';
const id = TeamRatingEventId.create(uuid);
expect(id.toString()).toBe(uuid);
});
});
});

View File

@@ -0,0 +1,62 @@
import type { IValueObject } from '@core/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
// Simple UUID v4 generator
function uuidv4(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
export interface TeamRatingEventIdProps {
value: string;
}
export class TeamRatingEventId implements IValueObject<TeamRatingEventIdProps> {
readonly value: string;
private constructor(value: string) {
this.value = value;
}
static create(value: string): TeamRatingEventId {
if (!value || value.trim().length === 0) {
throw new RacingDomainValidationError('TeamRatingEventId cannot be empty');
}
// Strict validation: no leading/trailing whitespace allowed
if (value !== value.trim()) {
throw new RacingDomainValidationError(
`TeamRatingEventId cannot have leading or trailing whitespace: "${value}"`
);
}
// Basic UUID format validation
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(value)) {
throw new RacingDomainValidationError(
`TeamRatingEventId must be a valid UUID format, got: "${value}"`
);
}
return new TeamRatingEventId(value);
}
static generate(): TeamRatingEventId {
return new TeamRatingEventId(uuidv4());
}
get props(): TeamRatingEventIdProps {
return { value: this.value };
}
equals(other: IValueObject<TeamRatingEventIdProps>): boolean {
return this.value === other.props.value;
}
toString(): string {
return this.value;
}
}

View File

@@ -0,0 +1,67 @@
import { TeamRatingValue } from './TeamRatingValue';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
describe('TeamRatingValue', () => {
describe('create', () => {
it('should create valid rating values', () => {
expect(TeamRatingValue.create(0).value).toBe(0);
expect(TeamRatingValue.create(50).value).toBe(50);
expect(TeamRatingValue.create(100).value).toBe(100);
expect(TeamRatingValue.create(75.5).value).toBe(75.5);
});
it('should throw for values below 0', () => {
expect(() => TeamRatingValue.create(-1)).toThrow(RacingDomainValidationError);
expect(() => TeamRatingValue.create(-0.1)).toThrow(RacingDomainValidationError);
});
it('should throw for values above 100', () => {
expect(() => TeamRatingValue.create(100.1)).toThrow(RacingDomainValidationError);
expect(() => TeamRatingValue.create(101)).toThrow(RacingDomainValidationError);
});
it('should accept decimal values', () => {
const value = TeamRatingValue.create(75.5);
expect(value.value).toBe(75.5);
});
it('should throw for non-numeric values', () => {
expect(() => TeamRatingValue.create('50' as unknown as number)).toThrow();
expect(() => TeamRatingValue.create(null as unknown as number)).toThrow();
expect(() => TeamRatingValue.create(undefined as unknown as number)).toThrow();
});
it('should return true for same value', () => {
const val1 = TeamRatingValue.create(50);
const val2 = TeamRatingValue.create(50);
expect(val1.equals(val2)).toBe(true);
});
it('should return false for different values', () => {
const val1 = TeamRatingValue.create(50);
const val2 = TeamRatingValue.create(60);
expect(val1.equals(val2)).toBe(false);
});
it('should handle decimal comparisons', () => {
const val1 = TeamRatingValue.create(75.5);
const val2 = TeamRatingValue.create(75.5);
expect(val1.equals(val2)).toBe(true);
});
it('should expose props correctly', () => {
const value = TeamRatingValue.create(50);
expect(value.props.value).toBe(50);
});
it('should return numeric value', () => {
const value = TeamRatingValue.create(75.5);
expect(value.toNumber()).toBe(75.5);
});
it('should return string representation', () => {
const value = TeamRatingValue.create(75.5);
expect(value.toString()).toBe('75.5');
});
});
});

View File

@@ -0,0 +1,44 @@
import type { IValueObject } from '@core/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export interface TeamRatingValueProps {
value: number;
}
export class TeamRatingValue implements IValueObject<TeamRatingValueProps> {
readonly value: number;
private constructor(value: number) {
this.value = value;
}
static create(value: number): TeamRatingValue {
if (typeof value !== 'number' || isNaN(value)) {
throw new RacingDomainValidationError('Team rating value must be a valid number');
}
if (value < 0 || value > 100) {
throw new RacingDomainValidationError(
`Team rating value must be between 0 and 100, got: ${value}`
);
}
return new TeamRatingValue(value);
}
get props(): TeamRatingValueProps {
return { value: this.value };
}
equals(other: IValueObject<TeamRatingValueProps>): boolean {
return this.value === other.props.value;
}
toNumber(): number {
return this.value;
}
toString(): string {
return this.value.toString();
}
}