diff --git a/plans/test_gap_analysis.md b/plans/test_gap_analysis.md new file mode 100644 index 000000000..f7aef212e --- /dev/null +++ b/plans/test_gap_analysis.md @@ -0,0 +1,52 @@ +# Test Coverage Analysis & Gap Report + +## 1. Executive Summary +We have compared the existing E2E and Integration tests against the core concepts defined in [`docs/concept/`](docs/concept) and the testing principles in [`docs/TESTING_LAYERS.md`](docs/TESTING_LAYERS.md). + +While the functional coverage is high, there are critical gaps in **Integration Testing** specifically regarding external boundaries (iRacing API) and specific infrastructure-heavy business logic (Rating Engine). + +## 2. Concept vs. Test Mapping + +| Concept Area | E2E Coverage | Integration Coverage | Status | +|--------------|--------------|----------------------|--------| +| **League Management** | [`leagues/`](tests/e2e/leagues) | [`leagues/`](tests/integration/leagues) | ✅ Covered | +| **Season/Schedule** | [`leagues/league-schedule.spec.ts`](tests/e2e/leagues/league-schedule.spec.ts) | [`leagues/schedule/`](tests/integration/leagues/schedule) | ✅ Covered | +| **Results Import** | [`races/race-results.spec.ts`](tests/e2e/races/race-results.spec.ts) | [`races/results/`](tests/integration/races/results) | ⚠️ Missing iRacing API Integration | +| **Complaints/Penalties** | [`leagues/league-stewarding.spec.ts`](tests/e2e/leagues/league-stewarding.spec.ts) | [`races/stewarding/`](tests/integration/races/stewarding) | ✅ Covered | +| **Team Competition** | [`teams/`](tests/e2e/teams) | [`teams/`](tests/integration/teams) | ✅ Covered | +| **Driver Profile/Stats** | [`drivers/`](tests/e2e/drivers) | [`drivers/profile/`](tests/integration/drivers/profile) | ✅ Covered | +| **Rating System** | None | None | ❌ Missing | +| **Social/Messaging** | None | None | ❌ Missing | + +## 3. Identified Gaps in Integration Tests + +According to [`docs/TESTING_LAYERS.md`](docs/TESTING_LAYERS.md), integration tests should protect **environmental correctness** (DB, external APIs, Auth). + +### 🚨 Critical Gaps (Infrastructure/Boundaries) +1. **iRacing API Integration**: + - *Concept*: [`docs/concept/ADMINS.md`](docs/concept/ADMINS.md:83) (Automatic Results Import). + - *Gap*: We have tests for *displaying* results, but no integration tests verifying the actual handshake and parsing logic with the iRacing API boundary. +2. **Rating Engine Persistence**: + - *Concept*: [`docs/concept/RATING.md`](docs/concept/RATING.md) (GridPilot Rating). + - *Gap*: The rating system involves complex calculations that must be persisted correctly. We lack integration tests for the `RatingService` interacting with the DB. +3. **Auth/Identity Provider**: + - *Concept*: [`docs/concept/CONCEPT.md`](docs/concept/CONCEPT.md:172) (Safety, Security & Trust). + - *Gap*: No integration tests for the Auth boundary (e.g., JWT validation, session persistence). + +### 🛠 Functional Gaps (Business Logic Integration) +1. **Social/Messaging**: + - *Concept*: [`docs/concept/SOCIAL.md`](docs/concept/SOCIAL.md) (Messaging, Notifications). + - *Gap*: No integration tests for message persistence or notification delivery (queues). +2. **Constructors-Style Scoring**: + - *Concept*: [`docs/concept/RACING.md`](docs/concept/RACING.md:47) (Constructors-Style Points). + - *Gap*: While we have `StandingsCalculation.test.ts`, we need specific integration tests for complex multi-driver team scoring scenarios against the DB. + +## 4. Proposed Action Plan + +1. **Implement iRacing API Contract/Integration Tests**: Verify the parsing of iRacing result payloads. +2. **Add Rating Persistence Tests**: Ensure `GridPilot Rating` updates correctly in the DB after race results are processed. +3. **Add Social/Notification Integration**: Test the persistence of messages and the triggering of notifications. +4. **Auth Integration**: Verify the system-level Auth flow as per the "Trust" requirement. + +--- +*Uncle Bob's Note: Remember, the closer a test is to the code, the more of them you should have. But for the system to be robust, the boundaries must be ironclad.* diff --git a/tests/e2e/rating/README.md b/tests/e2e/rating/README.md new file mode 100644 index 000000000..e15479ed7 --- /dev/null +++ b/tests/e2e/rating/README.md @@ -0,0 +1,127 @@ +# Rating BDD E2E Tests + +This directory contains BDD (Behavior-Driven Development) E2E tests for the GridPilot Rating system. + +## Overview + +The GridPilot Rating system is a competition rating designed specifically for league racing. Unlike iRating (which is for matchmaking), GridPilot Rating measures: +- **Results Strength**: How well you finish relative to field strength +- **Consistency**: Stability of finishing positions over a season +- **Clean Driving**: Incidents per race, weighted by severity +- **Racecraft**: Positions gained/lost vs. incident involvement +- **Reliability**: Attendance, DNS/DNF record +- **Team Contribution**: Points earned for your team; lineup efficiency + +## Test Files + +### [`rating-profile.spec.ts`](rating-profile.spec.ts) +Tests the driver profile rating display, including: +- Current GridPilot Rating value +- Rating breakdown by component (results, consistency, clean driving, etc.) +- Rating trend over time (seasons) +- Rating comparison with peers +- Rating impact on team contribution + +**Key Scenarios:** +- Driver sees their current GridPilot Rating on profile +- Driver sees rating breakdown by component +- Driver sees rating trend over multiple seasons +- Driver sees how rating compares to league peers +- Driver sees rating impact on team contribution +- Driver sees rating explanation/tooltip +- Driver sees rating update after race completion + +### [`rating-calculation.spec.ts`](rating-calculation.spec.ts) +Tests the rating calculation logic and updates: +- Rating calculation after race completion +- Rating update based on finishing position +- Rating update based on field strength +- Rating update based on incidents +- Rating update based on consistency +- Rating update based on team contribution +- Rating update based on season performance + +**Key Scenarios:** +- Rating increases after strong finish against strong field +- Rating decreases after poor finish or incidents +- Rating reflects consistency over multiple races +- Rating accounts for team contribution +- Rating updates immediately after results are processed +- Rating calculation is transparent and understandable + +### [`rating-leaderboard.spec.ts`](rating-leaderboard.spec.ts) +Tests the rating-based leaderboards: +- Global driver rankings by GridPilot Rating +- League-specific driver rankings +- Team rankings based on driver ratings +- Rating-based filtering and sorting +- Rating-based search functionality + +**Key Scenarios:** +- User sees drivers ranked by GridPilot Rating +- User can filter drivers by rating range +- User can search for drivers by rating +- User can sort drivers by different rating components +- User sees team rankings based on driver ratings +- User sees rating-based leaderboards with accurate data + +## Test Structure + +Each test file follows this pattern: + +```typescript +import { test, expect } from '@playwright/test'; + +test.describe('GridPilot Rating System', () => { + test.beforeEach(async ({ page }) => { + // TODO: Implement authentication setup + }); + + test('Driver sees their GridPilot Rating on profile', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver views their rating + // Given I am a registered driver "John Doe" + // And I have completed several races + // And I am on my profile page + // Then I should see my GridPilot Rating + // And I should see the rating breakdown + }); +}); +``` + +## Test Philosophy + +These tests follow the BDD E2E testing concept: + +- **Focus on outcomes, not visual implementation**: Tests validate what the user sees and can verify, not how it's rendered +- **Use Gherkin syntax**: Tests are written in Given/When/Then format +- **Validate final user outcomes**: Tests serve as acceptance criteria for the rating functionality +- **Use Playwright**: Tests are implemented using Playwright for browser automation + +## TODO Implementation + +All tests are currently placeholders with TODO comments. The actual test implementation should: + +1. Set up authentication (login as a test driver) +2. Navigate to the appropriate page +3. Verify the expected outcomes using Playwright assertions +4. Handle loading states, error states, and edge cases +5. Use test data that matches the expected behavior + +## Test Data + +Tests should use realistic test data that matches the expected behavior: +- Driver: "John Doe" or similar test driver with varying performance +- Races: Completed races with different results (wins, podiums, DNFs) +- Fields: Races with varying field strength (strong vs. weak fields) +- Incidents: Races with different incident counts +- Teams: Teams with multiple drivers contributing to team score + +## Future Enhancements + +- Add test data factories/fixtures for consistent test data +- Add helper functions for common actions (login, navigation, etc.) +- Add visual regression tests for rating display +- Add performance tests for rating calculation +- Add accessibility tests for rating pages +- Add cross-browser compatibility testing diff --git a/tests/e2e/rating/rating-calculation.spec.ts b/tests/e2e/rating/rating-calculation.spec.ts new file mode 100644 index 000000000..e361dbcb9 --- /dev/null +++ b/tests/e2e/rating/rating-calculation.spec.ts @@ -0,0 +1,129 @@ +import { test, expect } from '@playwright/test'; + +test.describe('GridPilot Rating - Calculation Logic', () => { + test.beforeEach(async ({ page }) => { + // TODO: Implement authentication setup + // - Login as test driver + // - Ensure test data exists + }); + + test('Rating increases after strong finish against strong field', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver finishes well against strong competition + // Given I am a driver with baseline rating + // And I complete a race against strong field + // And I finish in top positions + // When I view my rating after race + // Then my rating should increase + // And I should see the increase amount + }); + + test('Rating decreases after poor finish or incidents', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver has poor race with incidents + // Given I am a driver with baseline rating + // And I complete a race with poor finish + // And I have multiple incidents + // When I view my rating after race + // Then my rating should decrease + // And I should see the decrease amount + }); + + test('Rating reflects consistency over multiple races', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver shows consistent performance + // Given I complete multiple races + // And I finish in similar positions each race + // When I view my rating + // Then my consistency score should be high + // And my rating should be stable + }); + + test('Rating accounts for team contribution', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver contributes to team success + // Given I am on a team + // And I score points for my team + // When I view my rating + // Then my team contribution score should reflect this + // And my overall rating should include team impact + }); + + test('Rating updates immediately after results are processed', async ({ page }) => { + // TODO: Implement test + // Scenario: Race results are processed + // Given I just completed a race + // And results are being processed + // When results are available + // Then my rating should update immediately + // And I should see the update in real-time + }); + + test('Rating calculation is transparent and understandable', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver wants to understand rating changes + // Given I view my rating details + // When I see a rating change + // Then I should see explanation of what caused it + // And I should see breakdown of calculation + // And I should see tips for improvement + }); + + test('Rating handles DNFs appropriately', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver has DNF + // Given I complete a race + // And I have a DNF (Did Not Finish) + // When I view my rating + // Then my rating should be affected + // And my reliability score should decrease + // And I should see explanation of DNF impact + }); + + test('Rating handles DNS appropriately', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver has DNS + // Given I have a DNS (Did Not Start) + // When I view my rating + // Then my rating should be affected + // And my reliability score should decrease + // And I should see explanation of DNS impact + }); + + test('Rating handles small field sizes appropriately', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver races in small field + // Given I complete a race with small field + // When I view my rating + // Then my rating should be normalized for field size + // And I should see explanation of field size impact + }); + + test('Rating handles large field sizes appropriately', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver races in large field + // Given I complete a race with large field + // When I view my rating + // Then my rating should be normalized for field size + // And I should see explanation of field size impact + }); + + test('Rating handles clean races appropriately', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver has clean race + // Given I complete a race with zero incidents + // When I view my rating + // Then my clean driving score should increase + // And my rating should benefit from clean driving + }); + + test('Rating handles penalty scenarios appropriately', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver receives penalty + // Given I complete a race + // And I receive a penalty + // When I view my rating + // Then my rating should be affected by penalty + // And I should see explanation of penalty impact + }); +}); diff --git a/tests/e2e/rating/rating-leaderboard.spec.ts b/tests/e2e/rating/rating-leaderboard.spec.ts new file mode 100644 index 000000000..507f56bf1 --- /dev/null +++ b/tests/e2e/rating/rating-leaderboard.spec.ts @@ -0,0 +1,123 @@ +import { test, expect } from '@playwright/test'; + +test.describe('GridPilot Rating - Leaderboards', () => { + test.beforeEach(async ({ page }) => { + // TODO: Implement authentication setup + // - Login as test user + // - Ensure test data exists + }); + + test('User sees drivers ranked by GridPilot Rating', async ({ page }) => { + // TODO: Implement test + // Scenario: User views rating-based leaderboard + // Given I am on the leaderboards page + // When I view the driver rankings + // Then I should see drivers sorted by GridPilot Rating + // And I should see rating values for each driver + // And I should see ranking numbers + }); + + test('User can filter drivers by rating range', async ({ page }) => { + // TODO: Implement test + // Scenario: User filters leaderboard by rating + // Given I am on the driver leaderboards page + // When I set a rating range filter + // Then I should see only drivers within that range + // And I should see filter summary + }); + + test('User can search for drivers by rating', async ({ page }) => { + // TODO: Implement test + // Scenario: User searches for specific rating + // Given I am on the driver leaderboards page + // When I search for drivers with specific rating + // Then I should see matching drivers + // And I should see search results count + }); + + test('User can sort drivers by different rating components', async ({ page }) => { + // TODO: Implement test + // Scenario: User sorts leaderboard by rating component + // Given I am on the driver leaderboards page + // When I sort by "Results Strength" + // Then drivers should be sorted by results strength + // When I sort by "Clean Driving" + // Then drivers should be sorted by clean driving score + // And I should see the sort indicator + }); + + test('User sees team rankings based on driver ratings', async ({ page }) => { + // TODO: Implement test + // Scenario: User views team leaderboards + // Given I am on the team leaderboards page + // When I view team rankings + // Then I should see teams ranked by combined driver ratings + // And I should see team rating breakdown + // And I should see driver contributions + }); + + test('User sees rating-based leaderboards with accurate data', async ({ page }) => { + // TODO: Implement test + // Scenario: User verifies leaderboard accuracy + // Given I am viewing a rating-based leaderboard + // When I check the data + // Then ratings should match driver profiles + // And rankings should be correct + // And calculations should be accurate + }); + + test('User sees empty state when no rating data exists', async ({ page }) => { + // TODO: Implement test + // Scenario: Leaderboard with no data + // Given there are no drivers with ratings + // When I view the leaderboards + // Then I should see empty state + // And I should see message about no data + }); + + test('User sees loading state while leaderboards load', async ({ page }) => { + // TODO: Implement test + // Scenario: Leaderboards load slowly + // Given I navigate to leaderboards + // When data is loading + // Then I should see loading skeleton + // And I should see loading indicators + }); + + test('User sees error state when leaderboards fail to load', async ({ page }) => { + // TODO: Implement test + // Scenario: Leaderboards fail to load + // Given I navigate to leaderboards + // When data fails to load + // Then I should see error message + // And I should see retry button + }); + + test('User can navigate from leaderboard to driver profile', async ({ page }) => { + // TODO: Implement test + // Scenario: User clicks on driver in leaderboard + // Given I am viewing a rating-based leaderboard + // When I click on a driver entry + // Then I should navigate to that driver's profile + // And I should see their detailed rating + }); + + test('User sees pagination for large leaderboards', async ({ page }) => { + // TODO: Implement test + // Scenario: Leaderboard has many drivers + // Given there are many drivers with ratings + // When I view the leaderboards + // Then I should see pagination controls + // And I can navigate through pages + // And I should see page count + }); + + test('User sees rating percentile information', async ({ page }) => { + // TODO: Implement test + // Scenario: User wants to know relative standing + // Given I am viewing a driver in leaderboard + // When I look at their rating + // Then I should see percentile (e.g., "Top 10%") + // And I should see how many drivers are above/below + }); +}); diff --git a/tests/e2e/rating/rating-profile.spec.ts b/tests/e2e/rating/rating-profile.spec.ts new file mode 100644 index 000000000..0434d83f9 --- /dev/null +++ b/tests/e2e/rating/rating-profile.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from '@playwright/test'; + +test.describe('GridPilot Rating - Profile Display', () => { + test.beforeEach(async ({ page }) => { + // TODO: Implement authentication setup + // - Login as test driver + // - Ensure driver has rating data + }); + + test('Driver sees their GridPilot Rating on profile', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver views their rating on profile + // Given I am a registered driver "John Doe" + // And I have completed several races with varying results + // And I am on my profile page + // Then I should see my GridPilot Rating displayed + // And I should see the rating value (e.g., "1500") + // And I should see the rating label (e.g., "GridPilot Rating") + }); + + test('Driver sees rating breakdown by component', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver views detailed rating breakdown + // Given I am on my profile page + // When I view the rating details + // Then I should see breakdown by: + // - Results Strength + // - Consistency + // - Clean Driving + // - Racecraft + // - Reliability + // - Team Contribution + // And each component should have a score/value + }); + + test('Driver sees rating trend over multiple seasons', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver views rating history + // Given I have raced in multiple seasons + // When I view my rating history + // Then I should see rating trend over time + // And I should see rating changes per season + // And I should see rating peaks and valleys + }); + + test('Driver sees rating comparison with league peers', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver compares rating with peers + // Given I am in a league with other drivers + // When I view my rating + // Then I should see how my rating compares to league average + // And I should see my percentile in the league + // And I should see my rank in the league + }); + + test('Driver sees rating impact on team contribution', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver sees how rating affects team + // Given I am on a team + // When I view my rating + // Then I should see my contribution to team score + // And I should see my percentage of team total + // And I should see how my rating affects team ranking + }); + + test('Driver sees rating explanation/tooltip', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver seeks explanation of rating + // Given I am viewing my rating + // When I hover over rating components + // Then I should see explanation of what each component means + // And I should see how each component is calculated + // And I should see tips for improving each component + }); + + test('Driver sees rating update after race completion', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver sees rating update after race + // Given I just completed a race + // When I view my profile + // Then I should see my rating has updated + // And I should see the change (e.g., "+15") + // And I should see what caused the change + }); + + test('Driver sees empty state when no rating data exists', async ({ page }) => { + // TODO: Implement test + // Scenario: New driver views profile + // Given I am a new driver with no races + // When I view my profile + // Then I should see empty state for rating + // And I should see message about rating calculation + // And I should see call to action to complete races + }); + + test('Driver sees loading state while rating loads', async ({ page }) => { + // TODO: Implement test + // Scenario: Driver views profile with slow connection + // Given I am on my profile page + // When rating data is loading + // Then I should see loading skeleton + // And I should see loading indicator + // And I should see placeholder values + }); + + test('Driver sees error state when rating fails to load', async ({ page }) => { + // TODO: Implement test + // Scenario: Rating data fails to load + // Given I am on my profile page + // When rating data fails to load + // Then I should see error message + // And I should see retry button + // And I should see fallback UI + }); +}); diff --git a/tests/integration/rating/README.md b/tests/integration/rating/README.md new file mode 100644 index 000000000..00ab8323d --- /dev/null +++ b/tests/integration/rating/README.md @@ -0,0 +1,239 @@ +# Rating Integration Tests + +This directory contains integration tests for the GridPilot Rating system, following the clean integration strategy defined in [`plans/clean_integration_strategy.md`](../../plans/clean_integration_strategy.md). + +## Testing Philosophy + +These tests focus on **Use Case orchestration** - verifying that Use Cases correctly interact with their Ports (Repositories, Event Publishers, etc.) using In-Memory adapters for fast, deterministic testing. + +### Key Principles + +1. **Business Logic Only**: Tests verify business logic orchestration, NOT UI rendering +2. **In-Memory Adapters**: Use In-Memory adapters for speed and determinism +3. **Zero Implementation**: These are placeholders - no actual test logic implemented +4. **Use Case Focus**: Tests verify Use Case interactions with Ports +5. **Orchestration Patterns**: Tests follow Given/When/Then patterns for business logic + +## Test Files + +### Core Rating Functionality + +- **[`rating-calculation-use-cases.integration.test.ts`](./rating-calculation-use-cases.integration.test.ts)** + - Tests for rating calculation use cases + - Covers: `CalculateRatingUseCase`, `UpdateRatingUseCase`, `GetRatingUseCase`, etc. + - Focus: Verifies rating calculation logic with In-Memory adapters + +- **[`rating-persistence-use-cases.integration.test.ts`](./rating-persistence-use-cases.integration.test.ts)** + - Tests for rating persistence use cases + - Covers: `SaveRatingUseCase`, `GetRatingHistoryUseCase`, `GetRatingTrendUseCase`, etc. + - Focus: Verifies rating data persistence and retrieval + +- **[`rating-leaderboard-use-cases.integration.test.ts`](./rating-leaderboard-use-cases.integration.test.ts)** + - Tests for rating-based leaderboard use cases + - Covers: `GetRatingLeaderboardUseCase`, `GetRatingPercentileUseCase`, `GetRatingComparisonUseCase`, etc. + - Focus: Verifies leaderboard orchestration with In-Memory adapters + +### Advanced Rating Functionality + +- **[`rating-team-contribution-use-cases.integration.test.ts`](./rating-team-contribution-use-cases.integration.test.ts)** + - Tests for team contribution rating use cases + - Covers: `CalculateTeamContributionUseCase`, `GetTeamRatingUseCase`, `GetTeamContributionBreakdownUseCase`, etc. + - Focus: Verifies team rating logic and contribution calculations + +- **[`rating-consistency-use-cases.integration.test.ts`](./rating-consistency-use-cases.integration.test.ts)** + - Tests for consistency rating use cases + - Covers: `CalculateConsistencyUseCase`, `GetConsistencyScoreUseCase`, `GetConsistencyTrendUseCase`, etc. + - Focus: Verifies consistency calculation logic + +- **[`rating-reliability-use-cases.integration.test.ts`](./rating-reliability-use-cases.integration.test.ts)** + - Tests for reliability rating use cases + - Covers: `CalculateReliabilityUseCase`, `GetReliabilityScoreUseCase`, `GetReliabilityTrendUseCase`, etc. + - Focus: Verifies reliability calculation logic (attendance, DNFs, DNSs) + +## Test Structure + +Each test file follows the same structure: + +```typescript +describe('Use Case Orchestration', () => { + let repositories: InMemoryAdapters; + let useCase: UseCase; + let eventPublisher: InMemoryEventPublisher; + + beforeAll(() => { + // Initialize In-Memory repositories and event publisher + }); + + beforeEach(() => { + // Clear all In-Memory repositories before each test + }); + + describe('UseCase - Success Path', () => { + it('should [expected outcome]', async () => { + // TODO: Implement test + // Scenario: [description] + // Given: [setup] + // When: [action] + // Then: [expected result] + // And: [event emission] + }); + }); + + describe('UseCase - Edge Cases', () => { + it('should handle [edge case]', async () => { + // TODO: Implement test + // Scenario: [description] + // Given: [setup] + // When: [action] + // Then: [expected result] + // And: [event emission] + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle [error case]', async () => { + // TODO: Implement test + // Scenario: [description] + // Given: [setup] + // When: [action] + // Then: [expected error] + // And: [event emission] + }); + }); + + describe('UseCase - Data Orchestration', () => { + it('should correctly format [data type]', async () => { + // TODO: Implement test + // Scenario: [description] + // Given: [setup] + // When: [action] + // Then: [expected data format] + }); + }); +}); +``` + +## Implementation Guidelines + +### When Implementing Tests + +1. **Initialize In-Memory Adapters**: + ```typescript + repository = new InMemoryRatingRepository(); + eventPublisher = new InMemoryEventPublisher(); + useCase = new UseCase({ repository, eventPublisher }); + ``` + +2. **Clear Repositories Before Each Test**: + ```typescript + beforeEach(() => { + repository.clear(); + eventPublisher.clear(); + }); + ``` + +3. **Test Orchestration**: + - Verify Use Case calls the correct repository methods + - Verify Use Case publishes correct events + - Verify Use Case returns correct data structure + - Verify Use Case handles errors appropriately + +4. **Test Data Format**: + - Verify rating is calculated correctly + - Verify rating breakdown is accurate + - Verify rating updates are applied correctly + - Verify rating history is maintained + +### Example Implementation + +```typescript +it('should calculate rating after race completion', async () => { + // Given: A driver with baseline rating + const driver = Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }); + await driverRepository.create(driver); + + // Given: A completed race with results + const race = Race.create({ + id: 'r1', + leagueId: 'l1', + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await raceRepository.create(race); + + const result = Result.create({ + id: 'res1', + raceId: 'r1', + driverId: 'd1', + position: 1, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 25, + incidents: 0, + startPosition: 1 + }); + await resultRepository.create(result); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId: 'd1', + raceId: 'r1' + }); + + // Then: The rating should be calculated + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.driverId.toString()).toBe('d1'); + expect(rating.rating).toBeGreaterThan(0); + expect(rating.components).toBeDefined(); + expect(rating.components.resultsStrength).toBeGreaterThan(0); + expect(rating.components.consistency).toBeGreaterThan(0); + expect(rating.components.cleanDriving).toBeGreaterThan(0); + expect(rating.components.racecraft).toBeGreaterThan(0); + expect(rating.components.reliability).toBeGreaterThan(0); + expect(rating.components.teamContribution).toBeGreaterThan(0); + + // And: EventPublisher should emit RatingCalculatedEvent + expect(eventPublisher.events).toContainEqual( + expect.objectContaining({ type: 'RatingCalculatedEvent' }) + ); +}); +``` + +## Observations + +Based on the concept documentation, the rating system is complex with many components: + +1. **Rating Components**: Results Strength, Consistency, Clean Driving, Racecraft, Reliability, Team Contribution +2. **Calculation Logic**: Weighted scoring based on multiple factors +3. **Persistence**: Rating history and trend tracking +4. **Leaderboards**: Rating-based rankings and comparisons +5. **Team Integration**: Team contribution scoring +6. **Transparency**: Clear explanation of rating changes + +Each test file contains comprehensive test scenarios covering: +- Success paths +- Edge cases (small fields, DNFs, DNSs, penalties) +- Error handling +- Data orchestration patterns +- Calculation accuracy +- Persistence verification + +## Next Steps + +1. **Implement Test Logic**: Replace TODO comments with actual test implementations +2. **Add In-Memory Adapters**: Create In-Memory adapters for all required repositories +3. **Create Use Cases**: Implement the Use Cases referenced in the tests +4. **Create Ports**: Implement the Ports (Repositories, Event Publishers, etc.) +5. **Run Tests**: Execute tests to verify Use Case orchestration +6. **Refine Tests**: Update tests based on actual implementation details + +## Related Documentation + +- [Clean Integration Strategy](../../plans/clean_integration_strategy.md) +- [Testing Layers](../../docs/TESTING_LAYERS.md) +- [BDD E2E Tests](../e2e/bdd/rating/) +- [Rating Concept](../../docs/concept/RATING.md) diff --git a/tests/integration/rating/RatingTestContext.ts b/tests/integration/rating/RatingTestContext.ts new file mode 100644 index 000000000..c1b4365d2 --- /dev/null +++ b/tests/integration/rating/RatingTestContext.ts @@ -0,0 +1,42 @@ +import { InMemoryDriverRepository } from '../../../../core/racing/infrastructure/repositories/InMemoryDriverRepository'; +import { InMemoryRaceRepository } from '../../../../core/racing/infrastructure/repositories/InMemoryRaceRepository'; +import { InMemoryLeagueRepository } from '../../../../core/racing/infrastructure/repositories/InMemoryLeagueRepository'; +import { InMemoryResultRepository } from '../../../../core/racing/infrastructure/repositories/InMemoryResultRepository'; +import { InMemoryRatingRepository } from '../../../../core/rating/infrastructure/repositories/InMemoryRatingRepository'; +import { InMemoryEventPublisher } from '../../../../adapters/events/InMemoryEventPublisher'; + +export class RatingTestContext { + private static instance: RatingTestContext; + + public readonly driverRepository: InMemoryDriverRepository; + public readonly raceRepository: InMemoryRaceRepository; + public readonly leagueRepository: InMemoryLeagueRepository; + public readonly resultRepository: InMemoryResultRepository; + public readonly ratingRepository: InMemoryRatingRepository; + public readonly eventPublisher: InMemoryEventPublisher; + + private constructor() { + this.driverRepository = new InMemoryDriverRepository(); + this.raceRepository = new InMemoryRaceRepository(); + this.leagueRepository = new InMemoryLeagueRepository(); + this.resultRepository = new InMemoryResultRepository(); + this.ratingRepository = new InMemoryRatingRepository(); + this.eventPublisher = new InMemoryEventPublisher(); + } + + public static create(): RatingTestContext { + if (!RatingTestContext.instance) { + RatingTestContext.instance = new RatingTestContext(); + } + return RatingTestContext.instance; + } + + public async clear(): Promise { + await this.driverRepository.clear(); + await this.raceRepository.clear(); + await this.leagueRepository.clear(); + await this.resultRepository.clear(); + await this.ratingRepository.clear(); + this.eventPublisher.clear(); + } +} diff --git a/tests/integration/rating/rating-calculation-use-cases.integration.test.ts b/tests/integration/rating/rating-calculation-use-cases.integration.test.ts new file mode 100644 index 000000000..3444ac33c --- /dev/null +++ b/tests/integration/rating/rating-calculation-use-cases.integration.test.ts @@ -0,0 +1,951 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { RatingTestContext } from './RatingTestContext'; +import { CalculateRatingUseCase } from '../../../../core/rating/application/use-cases/CalculateRatingUseCase'; +import { Driver } from '../../../../core/racing/domain/entities/Driver'; +import { Race } from '../../../../core/racing/domain/entities/Race'; +import { League } from '../../../../core/racing/domain/entities/League'; +import { Result as RaceResult } from '../../../../core/racing/domain/entities/result/Result'; + +describe('CalculateRatingUseCase', () => { + let context: RatingTestContext; + let calculateRatingUseCase: CalculateRatingUseCase; + + beforeAll(() => { + context = RatingTestContext.create(); + calculateRatingUseCase = new CalculateRatingUseCase( + context.driverRepository, + context.raceRepository, + context.resultRepository, + context.ratingRepository, + context.eventPublisher + ); + }); + + beforeEach(async () => { + await context.clear(); + }); + + describe('UseCase - Success Path', () => { + it('should calculate rating after race completion with strong finish', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A completed race with strong field + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + // Given: Strong race results (win against strong field) + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 1, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 25, + incidents: 0, + startPosition: 1 + }); + await context.resultRepository.create(result); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The rating should be calculated + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.driverId.toString()).toBe(driverId); + expect(rating.rating).toBeGreaterThan(0); + expect(rating.components).toBeDefined(); + expect(rating.components.resultsStrength).toBeGreaterThan(0); + expect(rating.components.consistency).toBeGreaterThan(0); + expect(rating.components.cleanDriving).toBeGreaterThan(0); + expect(rating.components.racecraft).toBeGreaterThan(0); + expect(rating.components.reliability).toBeGreaterThan(0); + expect(rating.components.teamContribution).toBeGreaterThan(0); + + // And: EventPublisher should emit RatingCalculatedEvent + expect(context.eventPublisher.events).toContainEqual( + expect.objectContaining({ type: 'RatingCalculatedEvent' }) + ); + }); + + it('should calculate rating after race completion with poor finish', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A completed race + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + // Given: Poor race results (last place with incidents) + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 20, + lapsCompleted: 18, + totalTime: 4200, + fastestLap: 120, + points: 0, + incidents: 5, + startPosition: 15 + }); + await context.resultRepository.create(result); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The rating should be calculated + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.driverId.toString()).toBe(driverId); + expect(rating.rating).toBeGreaterThan(0); + expect(rating.components).toBeDefined(); + expect(rating.components.resultsStrength).toBeGreaterThan(0); + expect(rating.components.consistency).toBeGreaterThan(0); + expect(rating.components.cleanDriving).toBeGreaterThan(0); + expect(rating.components.racecraft).toBeGreaterThan(0); + expect(rating.components.reliability).toBeGreaterThan(0); + expect(rating.components.teamContribution).toBeGreaterThan(0); + + // And: EventPublisher should emit RatingCalculatedEvent + expect(context.eventPublisher.events).toContainEqual( + expect.objectContaining({ type: 'RatingCalculatedEvent' }) + ); + }); + + it('should calculate rating with consistency over multiple races', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple completed races with consistent results + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 5; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: CalculateRatingUseCase.execute() is called for the last race + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId: 'r5' + }); + + // Then: The rating should reflect consistency + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.driverId.toString()).toBe(driverId); + expect(rating.components.consistency).toBeGreaterThan(0); + expect(rating.components.consistency).toBeGreaterThan(rating.components.resultsStrength); + }); + }); + + describe('UseCase - Edge Cases', () => { + it('should handle DNF (Did Not Finish) appropriately', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A completed race with DNF + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + // Given: DNF result + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 0, + lapsCompleted: 10, + totalTime: 0, + fastestLap: 0, + points: 0, + incidents: 3, + startPosition: 10 + }); + await context.resultRepository.create(result); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The rating should be calculated with DNF impact + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.driverId.toString()).toBe(driverId); + expect(rating.components.reliability).toBeGreaterThan(0); + expect(rating.components.reliability).toBeLessThan(100); + }); + + it('should handle DNS (Did Not Start) appropriately', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A race with DNS + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + // Given: DNS result (no participation) + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 0, + lapsCompleted: 0, + totalTime: 0, + fastestLap: 0, + points: 0, + incidents: 0, + startPosition: 0 + }); + await context.resultRepository.create(result); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The rating should be calculated with DNS impact + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.driverId.toString()).toBe(driverId); + expect(rating.components.reliability).toBeGreaterThan(0); + expect(rating.components.reliability).toBeLessThan(100); + }); + + it('should handle small field sizes appropriately', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A completed race with small field + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + // Given: Win in small field (5 drivers) + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 1, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 25, + incidents: 0, + startPosition: 1 + }); + await context.resultRepository.create(result); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The rating should be normalized for small field + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.driverId.toString()).toBe(driverId); + expect(rating.rating).toBeGreaterThan(0); + }); + + it('should handle large field sizes appropriately', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A completed race with large field + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + // Given: Win in large field (30 drivers) + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 1, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 25, + incidents: 0, + startPosition: 1 + }); + await context.resultRepository.create(result); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The rating should be normalized for large field + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.driverId.toString()).toBe(driverId); + expect(rating.rating).toBeGreaterThan(0); + }); + + it('should handle clean races appropriately', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A completed race with zero incidents + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + // Given: Clean race (zero incidents) + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 0, + startPosition: 5 + }); + await context.resultRepository.create(result); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The rating should reflect clean driving + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.driverId.toString()).toBe(driverId); + expect(rating.components.cleanDriving).toBeGreaterThan(0); + expect(rating.components.cleanDriving).toBeGreaterThan(50); + }); + + it('should handle penalty scenarios appropriately', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A completed race with penalty + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + // Given: Race with penalty + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 2, + startPosition: 5 + }); + await context.resultRepository.create(result); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The rating should be affected by penalty + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.driverId.toString()).toBe(driverId); + expect(rating.components.cleanDriving).toBeGreaterThan(0); + expect(rating.components.cleanDriving).toBeLessThan(100); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing driver', async () => { + // Given: A non-existent driver + const driverId = 'd999'; + + // Given: A completed race + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The result should be an error + expect(ratingResult.isErr()).toBe(true); + }); + + it('should handle missing race', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A non-existent race + const raceId = 'r999'; + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The result should be an error + expect(ratingResult.isErr()).toBe(true); + }); + + it('should handle missing race results', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A completed race with no results + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The result should be an error + expect(ratingResult.isErr()).toBe(true); + }); + }); + + describe('UseCase - Data Orchestration', () => { + it('should correctly format rating data structure', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A completed race + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 1, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 25, + incidents: 0, + startPosition: 1 + }); + await context.resultRepository.create(result); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The rating data should be correctly formatted + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.driverId).toBeDefined(); + expect(rating.rating).toBeDefined(); + expect(rating.components).toBeDefined(); + expect(rating.components.resultsStrength).toBeDefined(); + expect(rating.components.consistency).toBeDefined(); + expect(rating.components.cleanDriving).toBeDefined(); + expect(rating.components.racecraft).toBeDefined(); + expect(rating.components.reliability).toBeDefined(); + expect(rating.components.teamContribution).toBeDefined(); + expect(rating.timestamp).toBeDefined(); + }); + + it('should correctly calculate results strength component', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A completed race with strong finish + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 1, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 25, + incidents: 0, + startPosition: 1 + }); + await context.resultRepository.create(result); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The results strength should be calculated + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.components.resultsStrength).toBeGreaterThan(0); + expect(rating.components.resultsStrength).toBeGreaterThan(50); + }); + + it('should correctly calculate consistency component', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple completed races with consistent results + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 5; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: CalculateRatingUseCase.execute() is called for the last race + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId: 'r5' + }); + + // Then: The consistency should be calculated + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.components.consistency).toBeGreaterThan(0); + expect(rating.components.consistency).toBeGreaterThan(50); + }); + + it('should correctly calculate clean driving component', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A completed race with zero incidents + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 0, + startPosition: 5 + }); + await context.resultRepository.create(result); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The clean driving should be calculated + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.components.cleanDriving).toBeGreaterThan(0); + expect(rating.components.cleanDriving).toBeGreaterThan(50); + }); + + it('should correctly calculate racecraft component', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A completed race with positions gained + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 3, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 15, + incidents: 1, + startPosition: 10 + }); + await context.resultRepository.create(result); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The racecraft should be calculated + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.components.racecraft).toBeGreaterThan(0); + expect(rating.components.racecraft).toBeGreaterThan(50); + }); + + it('should correctly calculate reliability component', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple completed races with good attendance + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 5; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: CalculateRatingUseCase.execute() is called for the last race + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId: 'r5' + }); + + // Then: The reliability should be calculated + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.components.reliability).toBeGreaterThan(0); + expect(rating.components.reliability).toBeGreaterThan(50); + }); + + it('should correctly calculate team contribution component', async () => { + // Given: A driver with baseline rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A completed race with points + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 1, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 25, + incidents: 0, + startPosition: 1 + }); + await context.resultRepository.create(result); + + // When: CalculateRatingUseCase.execute() is called + const ratingResult = await calculateRatingUseCase.execute({ + driverId, + raceId + }); + + // Then: The team contribution should be calculated + expect(ratingResult.isOk()).toBe(true); + const rating = ratingResult.unwrap(); + expect(rating.components.teamContribution).toBeGreaterThan(0); + expect(rating.components.teamContribution).toBeGreaterThan(50); + }); + }); +}); diff --git a/tests/integration/rating/rating-consistency-use-cases.integration.test.ts b/tests/integration/rating/rating-consistency-use-cases.integration.test.ts new file mode 100644 index 000000000..8bbfb4bc8 --- /dev/null +++ b/tests/integration/rating/rating-consistency-use-cases.integration.test.ts @@ -0,0 +1,615 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { RatingTestContext } from './RatingTestContext'; +import { CalculateConsistencyUseCase } from '../../../../core/rating/application/use-cases/CalculateConsistencyUseCase'; +import { GetConsistencyScoreUseCase } from '../../../../core/rating/application/use-cases/GetConsistencyScoreUseCase'; +import { GetConsistencyTrendUseCase } from '../../../../core/rating/application/use-cases/GetConsistencyTrendUseCase'; +import { Driver } from '../../../../core/racing/domain/entities/Driver'; +import { Race } from '../../../../core/racing/domain/entities/Race'; +import { League } from '../../../../core/racing/domain/entities/League'; +import { Result as RaceResult } from '../../../../core/racing/domain/entities/result/Result'; + +describe('Rating Consistency Use Cases', () => { + let context: RatingTestContext; + let calculateConsistencyUseCase: CalculateConsistencyUseCase; + let getConsistencyScoreUseCase: GetConsistencyScoreUseCase; + let getConsistencyTrendUseCase: GetConsistencyTrendUseCase; + + beforeAll(() => { + context = RatingTestContext.create(); + calculateConsistencyUseCase = new CalculateConsistencyUseCase( + context.driverRepository, + context.raceRepository, + context.resultRepository, + context.eventPublisher + ); + getConsistencyScoreUseCase = new GetConsistencyScoreUseCase( + context.driverRepository, + context.resultRepository + ); + getConsistencyTrendUseCase = new GetConsistencyTrendUseCase( + context.driverRepository, + context.resultRepository + ); + }); + + beforeEach(async () => { + await context.clear(); + }); + + describe('CalculateConsistencyUseCase', () => { + describe('UseCase - Success Path', () => { + it('should calculate consistency for driver with consistent results', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple completed races with consistent results + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 5; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: CalculateConsistencyUseCase.execute() is called + const result = await calculateConsistencyUseCase.execute({ + driverId, + raceId: 'r5' + }); + + // Then: The consistency should be calculated + expect(result.isOk()).toBe(true); + const consistency = result.unwrap(); + expect(consistency.driverId.toString()).toBe(driverId); + expect(consistency.consistencyScore).toBeGreaterThan(0); + expect(consistency.consistencyScore).toBeGreaterThan(50); + expect(consistency.raceCount).toBe(5); + expect(consistency.positionVariance).toBeGreaterThan(0); + expect(consistency.positionVariance).toBeLessThan(10); + }); + + it('should calculate consistency for driver with varying results', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple completed races with varying results + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const positions = [1, 10, 3, 15, 5]; + for (let i = 1; i <= 5; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: positions[i - 1], + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 25 - (positions[i - 1] * 2), + incidents: 1, + startPosition: positions[i - 1] + }); + await context.resultRepository.create(result); + } + + // When: CalculateConsistencyUseCase.execute() is called + const result = await calculateConsistencyUseCase.execute({ + driverId, + raceId: 'r5' + }); + + // Then: The consistency should be calculated + expect(result.isOk()).toBe(true); + const consistency = result.unwrap(); + expect(consistency.driverId.toString()).toBe(driverId); + expect(consistency.consistencyScore).toBeGreaterThan(0); + expect(consistency.consistencyScore).toBeLessThan(50); + expect(consistency.raceCount).toBe(5); + expect(consistency.positionVariance).toBeGreaterThan(10); + }); + + it('should calculate consistency with minimum race count', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Minimum races for consistency calculation + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 3; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: CalculateConsistencyUseCase.execute() is called + const result = await calculateConsistencyUseCase.execute({ + driverId, + raceId: 'r3' + }); + + // Then: The consistency should be calculated + expect(result.isOk()).toBe(true); + const consistency = result.unwrap(); + expect(consistency.driverId.toString()).toBe(driverId); + expect(consistency.consistencyScore).toBeGreaterThan(0); + expect(consistency.raceCount).toBe(3); + }); + }); + + describe('UseCase - Edge Cases', () => { + it('should handle driver with insufficient races', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Only one race + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + + // When: CalculateConsistencyUseCase.execute() is called + const result = await calculateConsistencyUseCase.execute({ + driverId, + raceId: 'r1' + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle driver with no races', async () => { + // Given: A driver with no races + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // When: CalculateConsistencyUseCase.execute() is called + const result = await calculateConsistencyUseCase.execute({ + driverId, + raceId: 'r1' + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing driver', async () => { + // Given: A non-existent driver + const driverId = 'd999'; + + // When: CalculateConsistencyUseCase.execute() is called + const result = await calculateConsistencyUseCase.execute({ + driverId, + raceId: 'r1' + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle missing race', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // When: CalculateConsistencyUseCase.execute() is called + const result = await calculateConsistencyUseCase.execute({ + driverId, + raceId: 'r999' + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); + + describe('GetConsistencyScoreUseCase', () => { + describe('UseCase - Success Path', () => { + it('should retrieve consistency score for driver', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple completed races + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 5; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: GetConsistencyScoreUseCase.execute() is called + const result = await getConsistencyScoreUseCase.execute({ driverId }); + + // Then: The consistency score should be retrieved + expect(result.isOk()).toBe(true); + const consistency = result.unwrap(); + expect(consistency.driverId.toString()).toBe(driverId); + expect(consistency.consistencyScore).toBeGreaterThan(0); + expect(consistency.raceCount).toBe(5); + expect(consistency.positionVariance).toBeGreaterThan(0); + }); + + it('should retrieve consistency score with race limit', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple completed races + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 10; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: GetConsistencyScoreUseCase.execute() is called with limit + const result = await getConsistencyScoreUseCase.execute({ driverId, limit: 5 }); + + // Then: The consistency score should be retrieved with limit + expect(result.isOk()).toBe(true); + const consistency = result.unwrap(); + expect(consistency.driverId.toString()).toBe(driverId); + expect(consistency.raceCount).toBe(5); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing driver', async () => { + // Given: A non-existent driver + const driverId = 'd999'; + + // When: GetConsistencyScoreUseCase.execute() is called + const result = await getConsistencyScoreUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle driver with insufficient races', async () => { + // Given: A driver with only one race + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + + // When: GetConsistencyScoreUseCase.execute() is called + const result = await getConsistencyScoreUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); + + describe('GetConsistencyTrendUseCase', () => { + describe('UseCase - Success Path', () => { + it('should retrieve consistency trend for driver', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple completed races + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 5; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: GetConsistencyTrendUseCase.execute() is called + const result = await getConsistencyTrendUseCase.execute({ driverId }); + + // Then: The consistency trend should be retrieved + expect(result.isOk()).toBe(true); + const trend = result.unwrap(); + expect(trend.driverId.toString()).toBe(driverId); + expect(trend.trend).toBeDefined(); + expect(trend.trend.length).toBeGreaterThan(0); + expect(trend.averageConsistency).toBeGreaterThan(0); + expect(trend.improvement).toBeDefined(); + }); + + it('should retrieve consistency trend over specific period', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple completed races + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 10; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: GetConsistencyTrendUseCase.execute() is called with period + const result = await getConsistencyTrendUseCase.execute({ driverId, period: 7 }); + + // Then: The consistency trend should be retrieved for the period + expect(result.isOk()).toBe(true); + const trend = result.unwrap(); + expect(trend.driverId.toString()).toBe(driverId); + expect(trend.trend.length).toBeLessThanOrEqual(7); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing driver', async () => { + // Given: A non-existent driver + const driverId = 'd999'; + + // When: GetConsistencyTrendUseCase.execute() is called + const result = await getConsistencyTrendUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle driver with insufficient races', async () => { + // Given: A driver with only one race + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + + // When: GetConsistencyTrendUseCase.execute() is called + const result = await getConsistencyTrendUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); +}); diff --git a/tests/integration/rating/rating-leaderboard-use-cases.integration.test.ts b/tests/integration/rating/rating-leaderboard-use-cases.integration.test.ts new file mode 100644 index 000000000..340032f30 --- /dev/null +++ b/tests/integration/rating/rating-leaderboard-use-cases.integration.test.ts @@ -0,0 +1,487 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { RatingTestContext } from './RatingTestContext'; +import { GetRatingLeaderboardUseCase } from '../../../../core/rating/application/use-cases/GetRatingLeaderboardUseCase'; +import { GetRatingPercentileUseCase } from '../../../../core/rating/application/use-cases/GetRatingPercentileUseCase'; +import { GetRatingComparisonUseCase } from '../../../../core/rating/application/use-cases/GetRatingComparisonUseCase'; +import { Driver } from '../../../../core/racing/domain/entities/Driver'; +import { Rating } from '../../../../core/rating/domain/entities/Rating'; + +describe('Rating Leaderboard Use Cases', () => { + let context: RatingTestContext; + let getRatingLeaderboardUseCase: GetRatingLeaderboardUseCase; + let getRatingPercentileUseCase: GetRatingPercentileUseCase; + let getRatingComparisonUseCase: GetRatingComparisonUseCase; + + beforeAll(() => { + context = RatingTestContext.create(); + getRatingLeaderboardUseCase = new GetRatingLeaderboardUseCase( + context.driverRepository, + context.ratingRepository + ); + getRatingPercentileUseCase = new GetRatingPercentileUseCase( + context.driverRepository, + context.ratingRepository + ); + getRatingComparisonUseCase = new GetRatingComparisonUseCase( + context.driverRepository, + context.ratingRepository + ); + }); + + beforeEach(async () => { + await context.clear(); + }); + + describe('GetRatingLeaderboardUseCase', () => { + describe('UseCase - Success Path', () => { + it('should retrieve driver leaderboard sorted by rating', async () => { + // Given: Multiple drivers with different ratings + const drivers = [ + Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }), + Driver.create({ id: 'd2', iracingId: '101', name: 'Jane Smith', country: 'UK' }), + Driver.create({ id: 'd3', iracingId: '102', name: 'Bob Johnson', country: 'CA' }) + ]; + for (const driver of drivers) { + await context.driverRepository.create(driver); + } + + // Given: Ratings for each driver + const ratings = [ + Rating.create({ + driverId: 'd1', + rating: 1500, + components: { resultsStrength: 80, consistency: 75, cleanDriving: 90, racecraft: 85, reliability: 95, teamContribution: 70 }, + timestamp: new Date() + }), + Rating.create({ + driverId: 'd2', + rating: 1600, + components: { resultsStrength: 85, consistency: 80, cleanDriving: 92, racecraft: 88, reliability: 96, teamContribution: 75 }, + timestamp: new Date() + }), + Rating.create({ + driverId: 'd3', + rating: 1400, + components: { resultsStrength: 75, consistency: 70, cleanDriving: 88, racecraft: 82, reliability: 93, teamContribution: 65 }, + timestamp: new Date() + }) + ]; + for (const rating of ratings) { + await context.ratingRepository.save(rating); + } + + // When: GetRatingLeaderboardUseCase.execute() is called + const result = await getRatingLeaderboardUseCase.execute({}); + + // Then: The leaderboard should be retrieved sorted by rating + expect(result.isOk()).toBe(true); + const leaderboard = result.unwrap(); + expect(leaderboard).toHaveLength(3); + expect(leaderboard[0].driverId.toString()).toBe('d2'); // Highest rating + expect(leaderboard[0].rating).toBe(1600); + expect(leaderboard[1].driverId.toString()).toBe('d1'); + expect(leaderboard[2].driverId.toString()).toBe('d3'); + }); + + it('should retrieve leaderboard with limit', async () => { + // Given: Multiple drivers with different ratings + const drivers = []; + for (let i = 1; i <= 10; i++) { + const driver = Driver.create({ id: `d${i}`, iracingId: `${100 + i}`, name: `Driver ${i}`, country: 'US' }); + drivers.push(driver); + await context.driverRepository.create(driver); + } + + // Given: Ratings for each driver + for (let i = 1; i <= 10; i++) { + const rating = Rating.create({ + driverId: `d${i}`, + rating: 1400 + (i * 20), + components: { resultsStrength: 70 + i, consistency: 65 + i, cleanDriving: 80 + i, racecraft: 75 + i, reliability: 85 + i, teamContribution: 60 + i }, + timestamp: new Date() + }); + await context.ratingRepository.save(rating); + } + + // When: GetRatingLeaderboardUseCase.execute() is called with limit + const result = await getRatingLeaderboardUseCase.execute({ limit: 5 }); + + // Then: The leaderboard should be retrieved with limit + expect(result.isOk()).toBe(true); + const leaderboard = result.unwrap(); + expect(leaderboard).toHaveLength(5); + expect(leaderboard[0].rating).toBe(1600); // d10 + expect(leaderboard[4].rating).toBe(1520); // d6 + }); + + it('should retrieve leaderboard with offset', async () => { + // Given: Multiple drivers with different ratings + const drivers = []; + for (let i = 1; i <= 5; i++) { + const driver = Driver.create({ id: `d${i}`, iracingId: `${100 + i}`, name: `Driver ${i}`, country: 'US' }); + drivers.push(driver); + await context.driverRepository.create(driver); + } + + // Given: Ratings for each driver + for (let i = 1; i <= 5; i++) { + const rating = Rating.create({ + driverId: `d${i}`, + rating: 1400 + (i * 20), + components: { resultsStrength: 70 + i, consistency: 65 + i, cleanDriving: 80 + i, racecraft: 75 + i, reliability: 85 + i, teamContribution: 60 + i }, + timestamp: new Date() + }); + await context.ratingRepository.save(rating); + } + + // When: GetRatingLeaderboardUseCase.execute() is called with offset + const result = await getRatingLeaderboardUseCase.execute({ offset: 2 }); + + // Then: The leaderboard should be retrieved with offset + expect(result.isOk()).toBe(true); + const leaderboard = result.unwrap(); + expect(leaderboard).toHaveLength(3); // 5 total - 2 offset = 3 + expect(leaderboard[0].driverId.toString()).toBe('d3'); // Third highest + }); + }); + + describe('UseCase - Edge Cases', () => { + it('should handle empty leaderboard', async () => { + // Given: No drivers or ratings + + // When: GetRatingLeaderboardUseCase.execute() is called + const result = await getRatingLeaderboardUseCase.execute({}); + + // Then: The leaderboard should be empty + expect(result.isOk()).toBe(true); + const leaderboard = result.unwrap(); + expect(leaderboard).toHaveLength(0); + }); + + it('should handle drivers without ratings', async () => { + // Given: Drivers without ratings + const driver = Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // When: GetRatingLeaderboardUseCase.execute() is called + const result = await getRatingLeaderboardUseCase.execute({}); + + // Then: The leaderboard should be empty + expect(result.isOk()).toBe(true); + const leaderboard = result.unwrap(); + expect(leaderboard).toHaveLength(0); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle invalid limit', async () => { + // Given: Drivers with ratings + const driver = Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + const rating = Rating.create({ + driverId: 'd1', + rating: 1500, + components: { resultsStrength: 80, consistency: 75, cleanDriving: 90, racecraft: 85, reliability: 95, teamContribution: 70 }, + timestamp: new Date() + }); + await context.ratingRepository.save(rating); + + // When: GetRatingLeaderboardUseCase.execute() is called with invalid limit + const result = await getRatingLeaderboardUseCase.execute({ limit: -1 }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); + + describe('GetRatingPercentileUseCase', () => { + describe('UseCase - Success Path', () => { + it('should calculate percentile for driver', async () => { + // Given: Multiple drivers with different ratings + const drivers = [ + Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }), + Driver.create({ id: 'd2', iracingId: '101', name: 'Jane Smith', country: 'UK' }), + Driver.create({ id: 'd3', iracingId: '102', name: 'Bob Johnson', country: 'CA' }), + Driver.create({ id: 'd4', iracingId: '103', name: 'Alice Brown', country: 'AU' }), + Driver.create({ id: 'd5', iracingId: '104', name: 'Charlie Wilson', country: 'DE' }) + ]; + for (const driver of drivers) { + await context.driverRepository.create(driver); + } + + // Given: Ratings for each driver + const ratings = [ + Rating.create({ driverId: 'd1', rating: 1500, components: { resultsStrength: 80, consistency: 75, cleanDriving: 90, racecraft: 85, reliability: 95, teamContribution: 70 }, timestamp: new Date() }), + Rating.create({ driverId: 'd2', rating: 1600, components: { resultsStrength: 85, consistency: 80, cleanDriving: 92, racecraft: 88, reliability: 96, teamContribution: 75 }, timestamp: new Date() }), + Rating.create({ driverId: 'd3', rating: 1400, components: { resultsStrength: 75, consistency: 70, cleanDriving: 88, racecraft: 82, reliability: 93, teamContribution: 65 }, timestamp: new Date() }), + Rating.create({ driverId: 'd4', rating: 1550, components: { resultsStrength: 82, consistency: 77, cleanDriving: 91, racecraft: 86, reliability: 94, teamContribution: 72 }, timestamp: new Date() }), + Rating.create({ driverId: 'd5', rating: 1450, components: { resultsStrength: 78, consistency: 73, cleanDriving: 89, racecraft: 83, reliability: 92, teamContribution: 68 }, timestamp: new Date() }) + ]; + for (const rating of ratings) { + await context.ratingRepository.save(rating); + } + + // When: GetRatingPercentileUseCase.execute() is called for driver d2 (highest rating) + const result = await getRatingPercentileUseCase.execute({ driverId: 'd2' }); + + // Then: The percentile should be calculated + expect(result.isOk()).toBe(true); + const percentile = result.unwrap(); + expect(percentile.driverId.toString()).toBe('d2'); + expect(percentile.percentile).toBeGreaterThan(0); + expect(percentile.percentile).toBeLessThanOrEqual(100); + expect(percentile.totalDrivers).toBe(5); + expect(percentile.driversAbove).toBe(0); + expect(percentile.driversBelow).toBe(4); + }); + + it('should calculate percentile for middle-ranked driver', async () => { + // Given: Multiple drivers with different ratings + const drivers = [ + Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }), + Driver.create({ id: 'd2', iracingId: '101', name: 'Jane Smith', country: 'UK' }), + Driver.create({ id: 'd3', iracingId: '102', name: 'Bob Johnson', country: 'CA' }) + ]; + for (const driver of drivers) { + await context.driverRepository.create(driver); + } + + // Given: Ratings for each driver + const ratings = [ + Rating.create({ driverId: 'd1', rating: 1500, components: { resultsStrength: 80, consistency: 75, cleanDriving: 90, racecraft: 85, reliability: 95, teamContribution: 70 }, timestamp: new Date() }), + Rating.create({ driverId: 'd2', rating: 1400, components: { resultsStrength: 75, consistency: 70, cleanDriving: 88, racecraft: 82, reliability: 93, teamContribution: 65 }, timestamp: new Date() }), + Rating.create({ driverId: 'd3', rating: 1600, components: { resultsStrength: 85, consistency: 80, cleanDriving: 92, racecraft: 88, reliability: 96, teamContribution: 75 }, timestamp: new Date() }) + ]; + for (const rating of ratings) { + await context.ratingRepository.save(rating); + } + + // When: GetRatingPercentileUseCase.execute() is called for driver d1 (middle rating) + const result = await getRatingPercentileUseCase.execute({ driverId: 'd1' }); + + // Then: The percentile should be calculated + expect(result.isOk()).toBe(true); + const percentile = result.unwrap(); + expect(percentile.driverId.toString()).toBe('d1'); + expect(percentile.percentile).toBeGreaterThan(0); + expect(percentile.percentile).toBeLessThan(100); + expect(percentile.totalDrivers).toBe(3); + expect(percentile.driversAbove).toBe(1); // d3 + expect(percentile.driversBelow).toBe(1); // d2 + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing driver', async () => { + // Given: A non-existent driver + const driverId = 'd999'; + + // When: GetRatingPercentileUseCase.execute() is called + const result = await getRatingPercentileUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle driver with no rating', async () => { + // Given: A driver with no rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // When: GetRatingPercentileUseCase.execute() is called + const result = await getRatingPercentileUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle empty leaderboard', async () => { + // Given: No drivers or ratings + + // When: GetRatingPercentileUseCase.execute() is called + const result = await getRatingPercentileUseCase.execute({ driverId: 'd1' }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); + + describe('GetRatingComparisonUseCase', () => { + describe('UseCase - Success Path', () => { + it('should compare driver rating with another driver', async () => { + // Given: Two drivers with different ratings + const driver1 = Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }); + const driver2 = Driver.create({ id: 'd2', iracingId: '101', name: 'Jane Smith', country: 'UK' }); + await context.driverRepository.create(driver1); + await context.driverRepository.create(driver2); + + // Given: Ratings for each driver + const rating1 = Rating.create({ + driverId: 'd1', + rating: 1500, + components: { resultsStrength: 80, consistency: 75, cleanDriving: 90, racecraft: 85, reliability: 95, teamContribution: 70 }, + timestamp: new Date() + }); + const rating2 = Rating.create({ + driverId: 'd2', + rating: 1600, + components: { resultsStrength: 85, consistency: 80, cleanDriving: 92, racecraft: 88, reliability: 96, teamContribution: 75 }, + timestamp: new Date() + }); + await context.ratingRepository.save(rating1); + await context.ratingRepository.save(rating2); + + // When: GetRatingComparisonUseCase.execute() is called + const result = await getRatingComparisonUseCase.execute({ + driverId: 'd1', + compareWithDriverId: 'd2' + }); + + // Then: The comparison should be retrieved + expect(result.isOk()).toBe(true); + const comparison = result.unwrap(); + expect(comparison.driverId.toString()).toBe('d1'); + expect(comparison.compareWithDriverId.toString()).toBe('d2'); + expect(comparison.driverRating).toBe(1500); + expect(comparison.compareWithRating).toBe(1600); + expect(comparison.difference).toBe(-100); + expect(comparison.differencePercentage).toBeLessThan(0); + }); + + it('should compare driver rating with league average', async () => { + // Given: Multiple drivers with different ratings + const drivers = [ + Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }), + Driver.create({ id: 'd2', iracingId: '101', name: 'Jane Smith', country: 'UK' }), + Driver.create({ id: 'd3', iracingId: '102', name: 'Bob Johnson', country: 'CA' }) + ]; + for (const driver of drivers) { + await context.driverRepository.create(driver); + } + + // Given: Ratings for each driver + const ratings = [ + Rating.create({ driverId: 'd1', rating: 1500, components: { resultsStrength: 80, consistency: 75, cleanDriving: 90, racecraft: 85, reliability: 95, teamContribution: 70 }, timestamp: new Date() }), + Rating.create({ driverId: 'd2', rating: 1600, components: { resultsStrength: 85, consistency: 80, cleanDriving: 92, racecraft: 88, reliability: 96, teamContribution: 75 }, timestamp: new Date() }), + Rating.create({ driverId: 'd3', rating: 1400, components: { resultsStrength: 75, consistency: 70, cleanDriving: 88, racecraft: 82, reliability: 93, teamContribution: 65 }, timestamp: new Date() }) + ]; + for (const rating of ratings) { + await context.ratingRepository.save(rating); + } + + // When: GetRatingComparisonUseCase.execute() is called with league comparison + const result = await getRatingComparisonUseCase.execute({ + driverId: 'd1', + compareWithLeague: true + }); + + // Then: The comparison should be retrieved + expect(result.isOk()).toBe(true); + const comparison = result.unwrap(); + expect(comparison.driverId.toString()).toBe('d1'); + expect(comparison.driverRating).toBe(1500); + expect(comparison.leagueAverage).toBe(1500); // (1500 + 1600 + 1400) / 3 + expect(comparison.difference).toBe(0); + expect(comparison.differencePercentage).toBe(0); + }); + + it('should compare driver rating with league median', async () => { + // Given: Multiple drivers with different ratings + const drivers = [ + Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }), + Driver.create({ id: 'd2', iracingId: '101', name: 'Jane Smith', country: 'UK' }), + Driver.create({ id: 'd3', iracingId: '102', name: 'Bob Johnson', country: 'CA' }), + Driver.create({ id: 'd4', iracingId: '103', name: 'Alice Brown', country: 'AU' }) + ]; + for (const driver of drivers) { + await context.driverRepository.create(driver); + } + + // Given: Ratings for each driver + const ratings = [ + Rating.create({ driverId: 'd1', rating: 1500, components: { resultsStrength: 80, consistency: 75, cleanDriving: 90, racecraft: 85, reliability: 95, teamContribution: 70 }, timestamp: new Date() }), + Rating.create({ driverId: 'd2', rating: 1600, components: { resultsStrength: 85, consistency: 80, cleanDriving: 92, racecraft: 88, reliability: 96, teamContribution: 75 }, timestamp: new Date() }), + Rating.create({ driverId: 'd3', rating: 1400, components: { resultsStrength: 75, consistency: 70, cleanDriving: 88, racecraft: 82, reliability: 93, teamContribution: 65 }, timestamp: new Date() }), + Rating.create({ driverId: 'd4', rating: 1550, components: { resultsStrength: 82, consistency: 77, cleanDriving: 91, racecraft: 86, reliability: 94, teamContribution: 72 }, timestamp: new Date() }) + ]; + for (const rating of ratings) { + await context.ratingRepository.save(rating); + } + + // When: GetRatingComparisonUseCase.execute() is called with league median comparison + const result = await getRatingComparisonUseCase.execute({ + driverId: 'd1', + compareWithLeague: true, + useMedian: true + }); + + // Then: The comparison should be retrieved + expect(result.isOk()).toBe(true); + const comparison = result.unwrap(); + expect(comparison.driverId.toString()).toBe('d1'); + expect(comparison.driverRating).toBe(1500); + expect(comparison.leagueMedian).toBe(1525); // Median of [1400, 1500, 1550, 1600] + expect(comparison.difference).toBe(-25); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing driver', async () => { + // Given: A non-existent driver + const driverId = 'd999'; + const compareWithDriverId = 'd1'; + + // When: GetRatingComparisonUseCase.execute() is called + const result = await getRatingComparisonUseCase.execute({ + driverId, + compareWithDriverId + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle driver with no rating', async () => { + // Given: Drivers with no ratings + const driver1 = Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }); + const driver2 = Driver.create({ id: 'd2', iracingId: '101', name: 'Jane Smith', country: 'UK' }); + await context.driverRepository.create(driver1); + await context.driverRepository.create(driver2); + + // When: GetRatingComparisonUseCase.execute() is called + const result = await getRatingComparisonUseCase.execute({ + driverId: 'd1', + compareWithDriverId: 'd2' + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle empty league for comparison', async () => { + // Given: A driver with rating + const driver = Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + const rating = Rating.create({ + driverId: 'd1', + rating: 1500, + components: { resultsStrength: 80, consistency: 75, cleanDriving: 90, racecraft: 85, reliability: 95, teamContribution: 70 }, + timestamp: new Date() + }); + await context.ratingRepository.save(rating); + + // When: GetRatingComparisonUseCase.execute() is called with league comparison + const result = await getRatingComparisonUseCase.execute({ + driverId: 'd1', + compareWithLeague: true + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); +}); diff --git a/tests/integration/rating/rating-persistence-use-cases.integration.test.ts b/tests/integration/rating/rating-persistence-use-cases.integration.test.ts new file mode 100644 index 000000000..d64ee9bcd --- /dev/null +++ b/tests/integration/rating/rating-persistence-use-cases.integration.test.ts @@ -0,0 +1,473 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { RatingTestContext } from './RatingTestContext'; +import { SaveRatingUseCase } from '../../../../core/rating/application/use-cases/SaveRatingUseCase'; +import { GetRatingUseCase } from '../../../../core/rating/application/use-cases/GetRatingUseCase'; +import { GetRatingHistoryUseCase } from '../../../../core/rating/application/use-cases/GetRatingHistoryUseCase'; +import { GetRatingTrendUseCase } from '../../../../core/rating/application/use-cases/GetRatingTrendUseCase'; +import { Driver } from '../../../../core/racing/domain/entities/Driver'; +import { Rating } from '../../../../core/rating/domain/entities/Rating'; + +describe('Rating Persistence Use Cases', () => { + let context: RatingTestContext; + let saveRatingUseCase: SaveRatingUseCase; + let getRatingUseCase: GetRatingUseCase; + let getRatingHistoryUseCase: GetRatingHistoryUseCase; + let getRatingTrendUseCase: GetRatingTrendUseCase; + + beforeAll(() => { + context = RatingTestContext.create(); + saveRatingUseCase = new SaveRatingUseCase( + context.ratingRepository, + context.eventPublisher + ); + getRatingUseCase = new GetRatingUseCase( + context.ratingRepository + ); + getRatingHistoryUseCase = new GetRatingHistoryUseCase( + context.ratingRepository + ); + getRatingTrendUseCase = new GetRatingTrendUseCase( + context.ratingRepository + ); + }); + + beforeEach(async () => { + await context.clear(); + }); + + describe('SaveRatingUseCase', () => { + describe('UseCase - Success Path', () => { + it('should save rating successfully', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A rating to save + const rating = Rating.create({ + driverId, + rating: 1500, + components: { + resultsStrength: 80, + consistency: 75, + cleanDriving: 90, + racecraft: 85, + reliability: 95, + teamContribution: 70 + }, + timestamp: new Date() + }); + + // When: SaveRatingUseCase.execute() is called + const result = await saveRatingUseCase.execute({ rating }); + + // Then: The rating should be saved + expect(result.isOk()).toBe(true); + + // And: EventPublisher should emit RatingSavedEvent + expect(context.eventPublisher.events).toContainEqual( + expect.objectContaining({ type: 'RatingSavedEvent' }) + ); + }); + + it('should update existing rating', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: An initial rating + const initialRating = Rating.create({ + driverId, + rating: 1500, + components: { + resultsStrength: 80, + consistency: 75, + cleanDriving: 90, + racecraft: 85, + reliability: 95, + teamContribution: 70 + }, + timestamp: new Date(Date.now() - 86400000) + }); + await context.ratingRepository.save(initialRating); + + // Given: An updated rating + const updatedRating = Rating.create({ + driverId, + rating: 1550, + components: { + resultsStrength: 85, + consistency: 80, + cleanDriving: 92, + racecraft: 88, + reliability: 96, + teamContribution: 75 + }, + timestamp: new Date() + }); + + // When: SaveRatingUseCase.execute() is called + const result = await saveRatingUseCase.execute({ rating: updatedRating }); + + // Then: The rating should be updated + expect(result.isOk()).toBe(true); + + // And: EventPublisher should emit RatingUpdatedEvent + expect(context.eventPublisher.events).toContainEqual( + expect.objectContaining({ type: 'RatingUpdatedEvent' }) + ); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing driver', async () => { + // Given: A rating with non-existent driver + const rating = Rating.create({ + driverId: 'd999', + rating: 1500, + components: { + resultsStrength: 80, + consistency: 75, + cleanDriving: 90, + racecraft: 85, + reliability: 95, + teamContribution: 70 + }, + timestamp: new Date() + }); + + // When: SaveRatingUseCase.execute() is called + const result = await saveRatingUseCase.execute({ rating }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); + + describe('GetRatingUseCase', () => { + describe('UseCase - Success Path', () => { + it('should retrieve rating for driver', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: A saved rating + const rating = Rating.create({ + driverId, + rating: 1500, + components: { + resultsStrength: 80, + consistency: 75, + cleanDriving: 90, + racecraft: 85, + reliability: 95, + teamContribution: 70 + }, + timestamp: new Date() + }); + await context.ratingRepository.save(rating); + + // When: GetRatingUseCase.execute() is called + const result = await getRatingUseCase.execute({ driverId }); + + // Then: The rating should be retrieved + expect(result.isOk()).toBe(true); + const retrievedRating = result.unwrap(); + expect(retrievedRating.driverId.toString()).toBe(driverId); + expect(retrievedRating.rating).toBe(1500); + expect(retrievedRating.components.resultsStrength).toBe(80); + }); + + it('should return latest rating for driver', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple ratings for the same driver + const oldRating = Rating.create({ + driverId, + rating: 1400, + components: { + resultsStrength: 70, + consistency: 65, + cleanDriving: 80, + racecraft: 75, + reliability: 85, + teamContribution: 60 + }, + timestamp: new Date(Date.now() - 86400000) + }); + await context.ratingRepository.save(oldRating); + + const newRating = Rating.create({ + driverId, + rating: 1500, + components: { + resultsStrength: 80, + consistency: 75, + cleanDriving: 90, + racecraft: 85, + reliability: 95, + teamContribution: 70 + }, + timestamp: new Date() + }); + await context.ratingRepository.save(newRating); + + // When: GetRatingUseCase.execute() is called + const result = await getRatingUseCase.execute({ driverId }); + + // Then: The latest rating should be retrieved + expect(result.isOk()).toBe(true); + const retrievedRating = result.unwrap(); + expect(retrievedRating.driverId.toString()).toBe(driverId); + expect(retrievedRating.rating).toBe(1500); + expect(retrievedRating.timestamp.getTime()).toBe(newRating.timestamp.getTime()); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing driver', async () => { + // Given: A non-existent driver + const driverId = 'd999'; + + // When: GetRatingUseCase.execute() is called + const result = await getRatingUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle driver with no rating', async () => { + // Given: A driver with no rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // When: GetRatingUseCase.execute() is called + const result = await getRatingUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); + + describe('GetRatingHistoryUseCase', () => { + describe('UseCase - Success Path', () => { + it('should retrieve rating history for driver', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple ratings for the same driver + for (let i = 1; i <= 5; i++) { + const rating = Rating.create({ + driverId, + rating: 1400 + (i * 20), + components: { + resultsStrength: 70 + (i * 2), + consistency: 65 + (i * 2), + cleanDriving: 80 + (i * 2), + racecraft: 75 + (i * 2), + reliability: 85 + (i * 2), + teamContribution: 60 + (i * 2) + }, + timestamp: new Date(Date.now() - (i * 86400000)) + }); + await context.ratingRepository.save(rating); + } + + // When: GetRatingHistoryUseCase.execute() is called + const result = await getRatingHistoryUseCase.execute({ driverId }); + + // Then: The rating history should be retrieved + expect(result.isOk()).toBe(true); + const history = result.unwrap(); + expect(history).toHaveLength(5); + expect(history[0].rating).toBe(1500); + expect(history[4].rating).toBe(1420); + }); + + it('should retrieve rating history with limit', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple ratings for the same driver + for (let i = 1; i <= 10; i++) { + const rating = Rating.create({ + driverId, + rating: 1400 + (i * 10), + components: { + resultsStrength: 70 + i, + consistency: 65 + i, + cleanDriving: 80 + i, + racecraft: 75 + i, + reliability: 85 + i, + teamContribution: 60 + i + }, + timestamp: new Date(Date.now() - (i * 86400000)) + }); + await context.ratingRepository.save(rating); + } + + // When: GetRatingHistoryUseCase.execute() is called with limit + const result = await getRatingHistoryUseCase.execute({ driverId, limit: 5 }); + + // Then: The rating history should be retrieved with limit + expect(result.isOk()).toBe(true); + const history = result.unwrap(); + expect(history).toHaveLength(5); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing driver', async () => { + // Given: A non-existent driver + const driverId = 'd999'; + + // When: GetRatingHistoryUseCase.execute() is called + const result = await getRatingHistoryUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle driver with no rating history', async () => { + // Given: A driver with no rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // When: GetRatingHistoryUseCase.execute() is called + const result = await getRatingHistoryUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); + + describe('GetRatingTrendUseCase', () => { + describe('UseCase - Success Path', () => { + it('should retrieve rating trend for driver', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple ratings for the same driver + for (let i = 1; i <= 5; i++) { + const rating = Rating.create({ + driverId, + rating: 1400 + (i * 20), + components: { + resultsStrength: 70 + (i * 2), + consistency: 65 + (i * 2), + cleanDriving: 80 + (i * 2), + racecraft: 75 + (i * 2), + reliability: 85 + (i * 2), + teamContribution: 60 + (i * 2) + }, + timestamp: new Date(Date.now() - (i * 86400000)) + }); + await context.ratingRepository.save(rating); + } + + // When: GetRatingTrendUseCase.execute() is called + const result = await getRatingTrendUseCase.execute({ driverId }); + + // Then: The rating trend should be retrieved + expect(result.isOk()).toBe(true); + const trend = result.unwrap(); + expect(trend.driverId.toString()).toBe(driverId); + expect(trend.trend).toBeDefined(); + expect(trend.trend.length).toBeGreaterThan(0); + expect(trend.change).toBeGreaterThan(0); + expect(trend.changePercentage).toBeGreaterThan(0); + }); + + it('should calculate trend over specific period', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple ratings for the same driver + for (let i = 1; i <= 10; i++) { + const rating = Rating.create({ + driverId, + rating: 1400 + (i * 10), + components: { + resultsStrength: 70 + i, + consistency: 65 + i, + cleanDriving: 80 + i, + racecraft: 75 + i, + reliability: 85 + i, + teamContribution: 60 + i + }, + timestamp: new Date(Date.now() - (i * 86400000)) + }); + await context.ratingRepository.save(rating); + } + + // When: GetRatingTrendUseCase.execute() is called with period + const result = await getRatingTrendUseCase.execute({ driverId, period: 7 }); + + // Then: The rating trend should be retrieved for the period + expect(result.isOk()).toBe(true); + const trend = result.unwrap(); + expect(trend.driverId.toString()).toBe(driverId); + expect(trend.trend.length).toBeLessThanOrEqual(7); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing driver', async () => { + // Given: A non-existent driver + const driverId = 'd999'; + + // When: GetRatingTrendUseCase.execute() is called + const result = await getRatingTrendUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle driver with insufficient rating history', async () => { + // Given: A driver with only one rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + const rating = Rating.create({ + driverId, + rating: 1500, + components: { + resultsStrength: 80, + consistency: 75, + cleanDriving: 90, + racecraft: 85, + reliability: 95, + teamContribution: 70 + }, + timestamp: new Date() + }); + await context.ratingRepository.save(rating); + + // When: GetRatingTrendUseCase.execute() is called + const result = await getRatingTrendUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); +}); diff --git a/tests/integration/rating/rating-reliability-use-cases.integration.test.ts b/tests/integration/rating/rating-reliability-use-cases.integration.test.ts new file mode 100644 index 000000000..8266ed0e7 --- /dev/null +++ b/tests/integration/rating/rating-reliability-use-cases.integration.test.ts @@ -0,0 +1,850 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { RatingTestContext } from './RatingTestContext'; +import { CalculateReliabilityUseCase } from '../../../../core/rating/application/use-cases/CalculateReliabilityUseCase'; +import { GetReliabilityScoreUseCase } from '../../../../core/rating/application/use-cases/GetReliabilityScoreUseCase'; +import { GetReliabilityTrendUseCase } from '../../../../core/rating/application/use-cases/GetReliabilityTrendUseCase'; +import { Driver } from '../../../../core/racing/domain/entities/Driver'; +import { Race } from '../../../../core/racing/domain/entities/Race'; +import { League } from '../../../../core/racing/domain/entities/League'; +import { Result as RaceResult } from '../../../../core/racing/domain/entities/result/Result'; + +describe('Rating Reliability Use Cases', () => { + let context: RatingTestContext; + let calculateReliabilityUseCase: CalculateReliabilityUseCase; + let getReliabilityScoreUseCase: GetReliabilityScoreUseCase; + let getReliabilityTrendUseCase: GetReliabilityTrendUseCase; + + beforeAll(() => { + context = RatingTestContext.create(); + calculateReliabilityUseCase = new CalculateReliabilityUseCase( + context.driverRepository, + context.raceRepository, + context.resultRepository, + context.eventPublisher + ); + getReliabilityScoreUseCase = new GetReliabilityScoreUseCase( + context.driverRepository, + context.resultRepository + ); + getReliabilityTrendUseCase = new GetReliabilityTrendUseCase( + context.driverRepository, + context.resultRepository + ); + }); + + beforeEach(async () => { + await context.clear(); + }); + + describe('CalculateReliabilityUseCase', () => { + describe('UseCase - Success Path', () => { + it('should calculate reliability for driver with perfect attendance', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple completed races with perfect attendance + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 5; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: CalculateReliabilityUseCase.execute() is called + const result = await calculateReliabilityUseCase.execute({ + driverId, + raceId: 'r5' + }); + + // Then: The reliability should be calculated + expect(result.isOk()).toBe(true); + const reliability = result.unwrap(); + expect(reliability.driverId.toString()).toBe(driverId); + expect(reliability.reliabilityScore).toBeGreaterThan(0); + expect(reliability.reliabilityScore).toBeGreaterThan(90); + expect(reliability.raceCount).toBe(5); + expect(reliability.dnfCount).toBe(0); + expect(reliability.dnsCount).toBe(0); + expect(reliability.attendanceRate).toBe(100); + }); + + it('should calculate reliability with DNFs', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple races with some DNFs + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 5; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + // First 2 races are DNFs + const isDNF = i <= 2; + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: isDNF ? 0 : 5, + lapsCompleted: isDNF ? 10 : 20, + totalTime: isDNF ? 0 : 3600, + fastestLap: isDNF ? 0 : 105, + points: isDNF ? 0 : 10, + incidents: isDNF ? 3 : 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: CalculateReliabilityUseCase.execute() is called + const result = await calculateReliabilityUseCase.execute({ + driverId, + raceId: 'r5' + }); + + // Then: The reliability should be calculated with DNF impact + expect(result.isOk()).toBe(true); + const reliability = result.unwrap(); + expect(reliability.driverId.toString()).toBe(driverId); + expect(reliability.reliabilityScore).toBeGreaterThan(0); + expect(reliability.reliabilityScore).toBeLessThan(90); + expect(reliability.raceCount).toBe(5); + expect(reliability.dnfCount).toBe(2); + expect(reliability.dnsCount).toBe(0); + expect(reliability.attendanceRate).toBe(100); + }); + + it('should calculate reliability with DNSs', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple races with some DNSs + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 5; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + // First 2 races are DNSs + const isDNS = i <= 2; + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: isDNS ? 0 : 5, + lapsCompleted: isDNS ? 0 : 20, + totalTime: isDNS ? 0 : 3600, + fastestLap: isDNS ? 0 : 105, + points: isDNS ? 0 : 10, + incidents: 0, + startPosition: isDNS ? 0 : 5 + }); + await context.resultRepository.create(result); + } + + // When: CalculateReliabilityUseCase.execute() is called + const result = await calculateReliabilityUseCase.execute({ + driverId, + raceId: 'r5' + }); + + // Then: The reliability should be calculated with DNS impact + expect(result.isOk()).toBe(true); + const reliability = result.unwrap(); + expect(reliability.driverId.toString()).toBe(driverId); + expect(reliability.reliabilityScore).toBeGreaterThan(0); + expect(reliability.reliabilityScore).toBeLessThan(90); + expect(reliability.raceCount).toBe(5); + expect(reliability.dnfCount).toBe(0); + expect(reliability.dnsCount).toBe(2); + expect(reliability.attendanceRate).toBe(60); + }); + + it('should calculate reliability with mixed DNFs and DNSs', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple races with mixed issues + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 5; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + let position, lapsCompleted, totalTime, fastestLap, points, incidents, startPosition; + + if (i === 1) { + // DNS + position = 0; + lapsCompleted = 0; + totalTime = 0; + fastestLap = 0; + points = 0; + incidents = 0; + startPosition = 0; + } else if (i === 2) { + // DNF + position = 0; + lapsCompleted = 10; + totalTime = 0; + fastestLap = 0; + points = 0; + incidents = 3; + startPosition = 5; + } else { + // Completed + position = 5; + lapsCompleted = 20; + totalTime = 3600; + fastestLap = 105; + points = 10; + incidents = 1; + startPosition = 5; + } + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position, + lapsCompleted, + totalTime, + fastestLap, + points, + incidents, + startPosition + }); + await context.resultRepository.create(result); + } + + // When: CalculateReliabilityUseCase.execute() is called + const result = await calculateReliabilityUseCase.execute({ + driverId, + raceId: 'r5' + }); + + // Then: The reliability should be calculated with mixed issues + expect(result.isOk()).toBe(true); + const reliability = result.unwrap(); + expect(reliability.driverId.toString()).toBe(driverId); + expect(reliability.reliabilityScore).toBeGreaterThan(0); + expect(reliability.reliabilityScore).toBeLessThan(80); + expect(reliability.raceCount).toBe(5); + expect(reliability.dnfCount).toBe(1); + expect(reliability.dnsCount).toBe(1); + expect(reliability.attendanceRate).toBe(60); + }); + + it('should calculate reliability with minimum race count', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Minimum races for reliability calculation + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 3; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: CalculateReliabilityUseCase.execute() is called + const result = await calculateReliabilityUseCase.execute({ + driverId, + raceId: 'r3' + }); + + // Then: The reliability should be calculated + expect(result.isOk()).toBe(true); + const reliability = result.unwrap(); + expect(reliability.driverId.toString()).toBe(driverId); + expect(reliability.reliabilityScore).toBeGreaterThan(0); + expect(reliability.raceCount).toBe(3); + }); + }); + + describe('UseCase - Edge Cases', () => { + it('should handle driver with insufficient races', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Only one race + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + + // When: CalculateReliabilityUseCase.execute() is called + const result = await calculateReliabilityUseCase.execute({ + driverId, + raceId: 'r1' + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle driver with no races', async () => { + // Given: A driver with no races + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // When: CalculateReliabilityUseCase.execute() is called + const result = await calculateReliabilityUseCase.execute({ + driverId, + raceId: 'r1' + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing driver', async () => { + // Given: A non-existent driver + const driverId = 'd999'; + + // When: CalculateReliabilityUseCase.execute() is called + const result = await calculateReliabilityUseCase.execute({ + driverId, + raceId: 'r1' + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle missing race', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // When: CalculateReliabilityUseCase.execute() is called + const result = await calculateReliabilityUseCase.execute({ + driverId, + raceId: 'r999' + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); + + describe('GetReliabilityScoreUseCase', () => { + describe('UseCase - Success Path', () => { + it('should retrieve reliability score for driver', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple completed races + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 5; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: GetReliabilityScoreUseCase.execute() is called + const result = await getReliabilityScoreUseCase.execute({ driverId }); + + // Then: The reliability score should be retrieved + expect(result.isOk()).toBe(true); + const reliability = result.unwrap(); + expect(reliability.driverId.toString()).toBe(driverId); + expect(reliability.reliabilityScore).toBeGreaterThan(0); + expect(reliability.raceCount).toBe(5); + expect(reliability.dnfCount).toBe(0); + expect(reliability.dnsCount).toBe(0); + expect(reliability.attendanceRate).toBe(100); + }); + + it('should retrieve reliability score with race limit', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple completed races + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 10; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: GetReliabilityScoreUseCase.execute() is called with limit + const result = await getReliabilityScoreUseCase.execute({ driverId, limit: 5 }); + + // Then: The reliability score should be retrieved with limit + expect(result.isOk()).toBe(true); + const reliability = result.unwrap(); + expect(reliability.driverId.toString()).toBe(driverId); + expect(reliability.raceCount).toBe(5); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing driver', async () => { + // Given: A non-existent driver + const driverId = 'd999'; + + // When: GetReliabilityScoreUseCase.execute() is called + const result = await getReliabilityScoreUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle driver with insufficient races', async () => { + // Given: A driver with only one race + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + + // When: GetReliabilityScoreUseCase.execute() is called + const result = await getReliabilityScoreUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); + + describe('GetReliabilityTrendUseCase', () => { + describe('UseCase - Success Path', () => { + it('should retrieve reliability trend for driver', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple completed races + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 5; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: GetReliabilityTrendUseCase.execute() is called + const result = await getReliabilityTrendUseCase.execute({ driverId }); + + // Then: The reliability trend should be retrieved + expect(result.isOk()).toBe(true); + const trend = result.unwrap(); + expect(trend.driverId.toString()).toBe(driverId); + expect(trend.trend).toBeDefined(); + expect(trend.trend.length).toBeGreaterThan(0); + expect(trend.averageReliability).toBeGreaterThan(0); + expect(trend.attendanceRate).toBe(100); + }); + + it('should retrieve reliability trend over specific period', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple completed races + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 10; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + } + + // When: GetReliabilityTrendUseCase.execute() is called with period + const result = await getReliabilityTrendUseCase.execute({ driverId, period: 7 }); + + // Then: The reliability trend should be retrieved for the period + expect(result.isOk()).toBe(true); + const trend = result.unwrap(); + expect(trend.driverId.toString()).toBe(driverId); + expect(trend.trend.length).toBeLessThanOrEqual(7); + }); + + it('should retrieve reliability trend with DNFs and DNSs', async () => { + // Given: A driver + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // Given: Multiple races with mixed results + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + for (let i = 1; i <= 5; i++) { + const raceId = `r${i}`; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - (i * 86400000)), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + let position, lapsCompleted, totalTime, fastestLap, points, incidents, startPosition; + + if (i === 1) { + // DNS + position = 0; + lapsCompleted = 0; + totalTime = 0; + fastestLap = 0; + points = 0; + incidents = 0; + startPosition = 0; + } else if (i === 2) { + // DNF + position = 0; + lapsCompleted = 10; + totalTime = 0; + fastestLap = 0; + points = 0; + incidents = 3; + startPosition = 5; + } else { + // Completed + position = 5; + lapsCompleted = 20; + totalTime = 3600; + fastestLap = 105; + points = 10; + incidents = 1; + startPosition = 5; + } + + const result = RaceResult.create({ + id: `res${i}`, + raceId, + driverId, + position, + lapsCompleted, + totalTime, + fastestLap, + points, + incidents, + startPosition + }); + await context.resultRepository.create(result); + } + + // When: GetReliabilityTrendUseCase.execute() is called + const result = await getReliabilityTrendUseCase.execute({ driverId }); + + // Then: The reliability trend should be retrieved + expect(result.isOk()).toBe(true); + const trend = result.unwrap(); + expect(trend.driverId.toString()).toBe(driverId); + expect(trend.trend).toBeDefined(); + expect(trend.trend.length).toBeGreaterThan(0); + expect(trend.averageReliability).toBeGreaterThan(0); + expect(trend.attendanceRate).toBe(60); + expect(trend.dnfCount).toBe(1); + expect(trend.dnsCount).toBe(1); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing driver', async () => { + // Given: A non-existent driver + const driverId = 'd999'; + + // When: GetReliabilityTrendUseCase.execute() is called + const result = await getReliabilityTrendUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle driver with insufficient races', async () => { + // Given: A driver with only one race + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + const leagueId = 'l1'; + const league = League.create({ id: leagueId, name: 'Pro League', description: 'Desc', ownerId: 'o1' }); + await context.leagueRepository.create(league); + + const raceId = 'r1'; + const race = Race.create({ + id: raceId, + leagueId, + scheduledAt: new Date(Date.now() - 86400000), + track: 'Spa', + car: 'GT3', + status: 'completed' + }); + await context.raceRepository.create(race); + + const result = RaceResult.create({ + id: 'res1', + raceId, + driverId, + position: 5, + lapsCompleted: 20, + totalTime: 3600, + fastestLap: 105, + points: 10, + incidents: 1, + startPosition: 5 + }); + await context.resultRepository.create(result); + + // When: GetReliabilityTrendUseCase.execute() is called + const result = await getReliabilityTrendUseCase.execute({ driverId }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); +}); diff --git a/tests/integration/rating/rating-team-contribution-use-cases.integration.test.ts b/tests/integration/rating/rating-team-contribution-use-cases.integration.test.ts new file mode 100644 index 000000000..742f398b0 --- /dev/null +++ b/tests/integration/rating/rating-team-contribution-use-cases.integration.test.ts @@ -0,0 +1,530 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { RatingTestContext } from './RatingTestContext'; +import { CalculateTeamContributionUseCase } from '../../../../core/rating/application/use-cases/CalculateTeamContributionUseCase'; +import { GetTeamRatingUseCase } from '../../../../core/rating/application/use-cases/GetTeamRatingUseCase'; +import { GetTeamContributionBreakdownUseCase } from '../../../../core/rating/application/use-cases/GetTeamContributionBreakdownUseCase'; +import { Driver } from '../../../../core/racing/domain/entities/Driver'; +import { Team } from '../../../../core/team/domain/entities/Team'; +import { Rating } from '../../../../core/rating/domain/entities/Rating'; + +describe('Rating Team Contribution Use Cases', () => { + let context: RatingTestContext; + let calculateTeamContributionUseCase: CalculateTeamContributionUseCase; + let getTeamRatingUseCase: GetTeamRatingUseCase; + let getTeamContributionBreakdownUseCase: GetTeamContributionBreakdownUseCase; + + beforeAll(() => { + context = RatingTestContext.create(); + calculateTeamContributionUseCase = new CalculateTeamContributionUseCase( + context.driverRepository, + context.ratingRepository, + context.eventPublisher + ); + getTeamRatingUseCase = new GetTeamRatingUseCase( + context.driverRepository, + context.ratingRepository + ); + getTeamContributionBreakdownUseCase = new GetTeamContributionBreakdownUseCase( + context.driverRepository, + context.ratingRepository + ); + }); + + beforeEach(async () => { + await context.clear(); + }); + + describe('CalculateTeamContributionUseCase', () => { + describe('UseCase - Success Path', () => { + it('should calculate team contribution for single driver', async () => { + // Given: A driver with rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + const rating = Rating.create({ + driverId, + rating: 1500, + components: { + resultsStrength: 80, + consistency: 75, + cleanDriving: 90, + racecraft: 85, + reliability: 95, + teamContribution: 70 + }, + timestamp: new Date() + }); + await context.ratingRepository.save(rating); + + // When: CalculateTeamContributionUseCase.execute() is called + const result = await calculateTeamContributionUseCase.execute({ + driverId, + teamId: 't1' + }); + + // Then: The team contribution should be calculated + expect(result.isOk()).toBe(true); + const contribution = result.unwrap(); + expect(contribution.driverId.toString()).toBe(driverId); + expect(contribution.teamId.toString()).toBe('t1'); + expect(contribution.contributionScore).toBeGreaterThan(0); + expect(contribution.contributionPercentage).toBeGreaterThan(0); + expect(contribution.contributionPercentage).toBeLessThanOrEqual(100); + }); + + it('should calculate team contribution for multiple drivers', async () => { + // Given: Multiple drivers with ratings + const drivers = [ + Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }), + Driver.create({ id: 'd2', iracingId: '101', name: 'Jane Smith', country: 'UK' }), + Driver.create({ id: 'd3', iracingId: '102', name: 'Bob Johnson', country: 'CA' }) + ]; + for (const driver of drivers) { + await context.driverRepository.create(driver); + } + + // Given: Ratings for each driver + const ratings = [ + Rating.create({ + driverId: 'd1', + rating: 1500, + components: { resultsStrength: 80, consistency: 75, cleanDriving: 90, racecraft: 85, reliability: 95, teamContribution: 70 }, + timestamp: new Date() + }), + Rating.create({ + driverId: 'd2', + rating: 1600, + components: { resultsStrength: 85, consistency: 80, cleanDriving: 92, racecraft: 88, reliability: 96, teamContribution: 75 }, + timestamp: new Date() + }), + Rating.create({ + driverId: 'd3', + rating: 1400, + components: { resultsStrength: 75, consistency: 70, cleanDriving: 88, racecraft: 82, reliability: 93, teamContribution: 65 }, + timestamp: new Date() + }) + ]; + for (const rating of ratings) { + await context.ratingRepository.save(rating); + } + + // When: CalculateTeamContributionUseCase.execute() is called for each driver + const contributions = []; + for (const driver of drivers) { + const result = await calculateTeamContributionUseCase.execute({ + driverId: driver.id.toString(), + teamId: 't1' + }); + expect(result.isOk()).toBe(true); + contributions.push(result.unwrap()); + } + + // Then: The team contributions should be calculated + expect(contributions).toHaveLength(3); + expect(contributions[0].contributionScore).toBeGreaterThan(0); + expect(contributions[1].contributionScore).toBeGreaterThan(0); + expect(contributions[2].contributionScore).toBeGreaterThan(0); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing driver', async () => { + // Given: A non-existent driver + const driverId = 'd999'; + + // When: CalculateTeamContributionUseCase.execute() is called + const result = await calculateTeamContributionUseCase.execute({ + driverId, + teamId: 't1' + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle driver with no rating', async () => { + // Given: A driver with no rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + // When: CalculateTeamContributionUseCase.execute() is called + const result = await calculateTeamContributionUseCase.execute({ + driverId, + teamId: 't1' + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); + + describe('GetTeamRatingUseCase', () => { + describe('UseCase - Success Path', () => { + it('should retrieve team rating from single driver', async () => { + // Given: A driver with rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + const rating = Rating.create({ + driverId, + rating: 1500, + components: { + resultsStrength: 80, + consistency: 75, + cleanDriving: 90, + racecraft: 85, + reliability: 95, + teamContribution: 70 + }, + timestamp: new Date() + }); + await context.ratingRepository.save(rating); + + // When: GetTeamRatingUseCase.execute() is called + const result = await getTeamRatingUseCase.execute({ + teamId: 't1', + driverIds: [driverId] + }); + + // Then: The team rating should be retrieved + expect(result.isOk()).toBe(true); + const teamRating = result.unwrap(); + expect(teamRating.teamId.toString()).toBe('t1'); + expect(teamRating.teamRating).toBe(1500); + expect(teamRating.driverRatings).toHaveLength(1); + expect(teamRating.driverRatings[0].driverId.toString()).toBe(driverId); + expect(teamRating.driverRatings[0].rating).toBe(1500); + }); + + it('should retrieve team rating from multiple drivers', async () => { + // Given: Multiple drivers with ratings + const drivers = [ + Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }), + Driver.create({ id: 'd2', iracingId: '101', name: 'Jane Smith', country: 'UK' }), + Driver.create({ id: 'd3', iracingId: '102', name: 'Bob Johnson', country: 'CA' }) + ]; + for (const driver of drivers) { + await context.driverRepository.create(driver); + } + + // Given: Ratings for each driver + const ratings = [ + Rating.create({ + driverId: 'd1', + rating: 1500, + components: { resultsStrength: 80, consistency: 75, cleanDriving: 90, racecraft: 85, reliability: 95, teamContribution: 70 }, + timestamp: new Date() + }), + Rating.create({ + driverId: 'd2', + rating: 1600, + components: { resultsStrength: 85, consistency: 80, cleanDriving: 92, racecraft: 88, reliability: 96, teamContribution: 75 }, + timestamp: new Date() + }), + Rating.create({ + driverId: 'd3', + rating: 1400, + components: { resultsStrength: 75, consistency: 70, cleanDriving: 88, racecraft: 82, reliability: 93, teamContribution: 65 }, + timestamp: new Date() + }) + ]; + for (const rating of ratings) { + await context.ratingRepository.save(rating); + } + + // When: GetTeamRatingUseCase.execute() is called + const result = await getTeamRatingUseCase.execute({ + teamId: 't1', + driverIds: ['d1', 'd2', 'd3'] + }); + + // Then: The team rating should be retrieved + expect(result.isOk()).toBe(true); + const teamRating = result.unwrap(); + expect(teamRating.teamId.toString()).toBe('t1'); + expect(teamRating.teamRating).toBeGreaterThan(0); + expect(teamRating.driverRatings).toHaveLength(3); + expect(teamRating.driverRatings[0].rating).toBe(1500); + expect(teamRating.driverRatings[1].rating).toBe(1600); + expect(teamRating.driverRatings[2].rating).toBe(1400); + }); + + it('should calculate team rating as average of driver ratings', async () => { + // Given: Multiple drivers with ratings + const drivers = [ + Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }), + Driver.create({ id: 'd2', iracingId: '101', name: 'Jane Smith', country: 'UK' }) + ]; + for (const driver of drivers) { + await context.driverRepository.create(driver); + } + + // Given: Ratings for each driver + const ratings = [ + Rating.create({ + driverId: 'd1', + rating: 1500, + components: { resultsStrength: 80, consistency: 75, cleanDriving: 90, racecraft: 85, reliability: 95, teamContribution: 70 }, + timestamp: new Date() + }), + Rating.create({ + driverId: 'd2', + rating: 1700, + components: { resultsStrength: 90, consistency: 85, cleanDriving: 95, racecraft: 90, reliability: 98, teamContribution: 80 }, + timestamp: new Date() + }) + ]; + for (const rating of ratings) { + await context.ratingRepository.save(rating); + } + + // When: GetTeamRatingUseCase.execute() is called + const result = await getTeamRatingUseCase.execute({ + teamId: 't1', + driverIds: ['d1', 'd2'] + }); + + // Then: The team rating should be the average + expect(result.isOk()).toBe(true); + const teamRating = result.unwrap(); + expect(teamRating.teamRating).toBe(1600); // (1500 + 1700) / 2 + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing drivers', async () => { + // Given: Non-existent drivers + + // When: GetTeamRatingUseCase.execute() is called + const result = await getTeamRatingUseCase.execute({ + teamId: 't1', + driverIds: ['d999', 'd998'] + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle drivers with no ratings', async () => { + // Given: Drivers with no ratings + const drivers = [ + Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }), + Driver.create({ id: 'd2', iracingId: '101', name: 'Jane Smith', country: 'UK' }) + ]; + for (const driver of drivers) { + await context.driverRepository.create(driver); + } + + // When: GetTeamRatingUseCase.execute() is called + const result = await getTeamRatingUseCase.execute({ + teamId: 't1', + driverIds: ['d1', 'd2'] + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle empty driver list', async () => { + // When: GetTeamRatingUseCase.execute() is called with empty list + const result = await getTeamRatingUseCase.execute({ + teamId: 't1', + driverIds: [] + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); + + describe('GetTeamContributionBreakdownUseCase', () => { + describe('UseCase - Success Path', () => { + it('should retrieve contribution breakdown for single driver', async () => { + // Given: A driver with rating + const driverId = 'd1'; + const driver = Driver.create({ id: driverId, iracingId: '100', name: 'John Doe', country: 'US' }); + await context.driverRepository.create(driver); + + const rating = Rating.create({ + driverId, + rating: 1500, + components: { + resultsStrength: 80, + consistency: 75, + cleanDriving: 90, + racecraft: 85, + reliability: 95, + teamContribution: 70 + }, + timestamp: new Date() + }); + await context.ratingRepository.save(rating); + + // When: GetTeamContributionBreakdownUseCase.execute() is called + const result = await getTeamContributionBreakdownUseCase.execute({ + teamId: 't1', + driverIds: [driverId] + }); + + // Then: The contribution breakdown should be retrieved + expect(result.isOk()).toBe(true); + const breakdown = result.unwrap(); + expect(breakdown.teamId.toString()).toBe('t1'); + expect(breakdown.breakdown).toHaveLength(1); + expect(breakdown.breakdown[0].driverId.toString()).toBe(driverId); + expect(breakdown.breakdown[0].rating).toBe(1500); + expect(breakdown.breakdown[0].contributionScore).toBeGreaterThan(0); + expect(breakdown.breakdown[0].contributionPercentage).toBeGreaterThan(0); + expect(breakdown.breakdown[0].contributionPercentage).toBeLessThanOrEqual(100); + }); + + it('should retrieve contribution breakdown for multiple drivers', async () => { + // Given: Multiple drivers with ratings + const drivers = [ + Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }), + Driver.create({ id: 'd2', iracingId: '101', name: 'Jane Smith', country: 'UK' }), + Driver.create({ id: 'd3', iracingId: '102', name: 'Bob Johnson', country: 'CA' }) + ]; + for (const driver of drivers) { + await context.driverRepository.create(driver); + } + + // Given: Ratings for each driver + const ratings = [ + Rating.create({ + driverId: 'd1', + rating: 1500, + components: { resultsStrength: 80, consistency: 75, cleanDriving: 90, racecraft: 85, reliability: 95, teamContribution: 70 }, + timestamp: new Date() + }), + Rating.create({ + driverId: 'd2', + rating: 1600, + components: { resultsStrength: 85, consistency: 80, cleanDriving: 92, racecraft: 88, reliability: 96, teamContribution: 75 }, + timestamp: new Date() + }), + Rating.create({ + driverId: 'd3', + rating: 1400, + components: { resultsStrength: 75, consistency: 70, cleanDriving: 88, racecraft: 82, reliability: 93, teamContribution: 65 }, + timestamp: new Date() + }) + ]; + for (const rating of ratings) { + await context.ratingRepository.save(rating); + } + + // When: GetTeamContributionBreakdownUseCase.execute() is called + const result = await getTeamContributionBreakdownUseCase.execute({ + teamId: 't1', + driverIds: ['d1', 'd2', 'd3'] + }); + + // Then: The contribution breakdown should be retrieved + expect(result.isOk()).toBe(true); + const breakdown = result.unwrap(); + expect(breakdown.teamId.toString()).toBe('t1'); + expect(breakdown.breakdown).toHaveLength(3); + expect(breakdown.breakdown[0].driverId.toString()).toBe('d1'); + expect(breakdown.breakdown[1].driverId.toString()).toBe('d2'); + expect(breakdown.breakdown[2].driverId.toString()).toBe('d3'); + expect(breakdown.breakdown[0].contributionPercentage).toBeGreaterThan(0); + expect(breakdown.breakdown[1].contributionPercentage).toBeGreaterThan(0); + expect(breakdown.breakdown[2].contributionPercentage).toBeGreaterThan(0); + }); + + it('should calculate contribution percentages correctly', async () => { + // Given: Multiple drivers with different ratings + const drivers = [ + Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }), + Driver.create({ id: 'd2', iracingId: '101', name: 'Jane Smith', country: 'UK' }) + ]; + for (const driver of drivers) { + await context.driverRepository.create(driver); + } + + // Given: Ratings for each driver + const ratings = [ + Rating.create({ + driverId: 'd1', + rating: 1500, + components: { resultsStrength: 80, consistency: 75, cleanDriving: 90, racecraft: 85, reliability: 95, teamContribution: 70 }, + timestamp: new Date() + }), + Rating.create({ + driverId: 'd2', + rating: 1700, + components: { resultsStrength: 90, consistency: 85, cleanDriving: 95, racecraft: 90, reliability: 98, teamContribution: 80 }, + timestamp: new Date() + }) + ]; + for (const rating of ratings) { + await context.ratingRepository.save(rating); + } + + // When: GetTeamContributionBreakdownUseCase.execute() is called + const result = await getTeamContributionBreakdownUseCase.execute({ + teamId: 't1', + driverIds: ['d1', 'd2'] + }); + + // Then: The contribution percentages should be calculated correctly + expect(result.isOk()).toBe(true); + const breakdown = result.unwrap(); + expect(breakdown.breakdown).toHaveLength(2); + expect(breakdown.breakdown[0].contributionPercentage + breakdown.breakdown[1].contributionPercentage).toBe(100); + }); + }); + + describe('UseCase - Error Handling', () => { + it('should handle missing drivers', async () => { + // Given: Non-existent drivers + + // When: GetTeamContributionBreakdownUseCase.execute() is called + const result = await getTeamContributionBreakdownUseCase.execute({ + teamId: 't1', + driverIds: ['d999', 'd998'] + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle drivers with no ratings', async () => { + // Given: Drivers with no ratings + const drivers = [ + Driver.create({ id: 'd1', iracingId: '100', name: 'John Doe', country: 'US' }), + Driver.create({ id: 'd2', iracingId: '101', name: 'Jane Smith', country: 'UK' }) + ]; + for (const driver of drivers) { + await context.driverRepository.create(driver); + } + + // When: GetTeamContributionBreakdownUseCase.execute() is called + const result = await getTeamContributionBreakdownUseCase.execute({ + teamId: 't1', + driverIds: ['d1', 'd2'] + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + + it('should handle empty driver list', async () => { + // When: GetTeamContributionBreakdownUseCase.execute() is called with empty list + const result = await getTeamContributionBreakdownUseCase.execute({ + teamId: 't1', + driverIds: [] + }); + + // Then: The result should be an error + expect(result.isErr()).toBe(true); + }); + }); + }); +});