feat(companion): implement hosted session automation POC with TDD approach
This commit is contained in:
173
tests/e2e/features/hosted-session-automation.feature
Normal file
173
tests/e2e/features/hosted-session-automation.feature
Normal file
@@ -0,0 +1,173 @@
|
||||
Feature: Hosted Session Automation
|
||||
As a league organizer using the GridPilot companion app
|
||||
I want to automate the iRacing hosted session creation workflow
|
||||
So that I can quickly set up race sessions without manual data entry
|
||||
|
||||
Background:
|
||||
Given the companion app is running
|
||||
And I am authenticated with iRacing
|
||||
And I have a valid session configuration
|
||||
|
||||
Scenario: Complete 18-step automation workflow
|
||||
Given I have a session configuration with:
|
||||
| field | value |
|
||||
| sessionName | League Race Week 1 |
|
||||
| trackId | spa |
|
||||
| carIds | dallara-f3 |
|
||||
When I start the automation session
|
||||
Then the session should be created with state "PENDING"
|
||||
And the current step should be 1
|
||||
|
||||
When the automation progresses through all 18 steps
|
||||
Then step 1 should navigate to "Hosted Racing"
|
||||
And step 2 should click "Create a Race"
|
||||
And step 3 should fill "Race Information"
|
||||
And step 4 should configure "Server Details"
|
||||
And step 5 should access "Set Admins"
|
||||
And step 6 should handle "Add an Admin" modal
|
||||
And step 7 should set "Time Limits"
|
||||
And step 8 should access "Set Cars"
|
||||
And step 9 should handle "Add a Car" modal
|
||||
And step 10 should configure "Set Car Classes"
|
||||
And step 11 should access "Set Track"
|
||||
And step 12 should handle "Add a Track" modal
|
||||
And step 13 should configure "Track Options"
|
||||
And step 14 should set "Time of Day"
|
||||
And step 15 should configure "Weather"
|
||||
And step 16 should set "Race Options"
|
||||
And step 17 should configure "Team Driving"
|
||||
And step 18 should reach "Track Conditions"
|
||||
|
||||
And the session should stop at step 18
|
||||
And the session state should be "STOPPED_AT_STEP_18"
|
||||
And a manual submit warning should be displayed
|
||||
|
||||
Scenario: Modal step handling (step 6 - Add Admin)
|
||||
Given I have started an automation session
|
||||
And the automation has reached step 6
|
||||
When the "Add an Admin" modal appears
|
||||
Then the automation should detect the modal
|
||||
And the automation should wait for modal content to load
|
||||
And the automation should fill admin fields
|
||||
And the automation should close the modal
|
||||
And the automation should transition to step 7
|
||||
|
||||
Scenario: Modal step handling (step 9 - Add Car)
|
||||
Given I have started an automation session
|
||||
And the automation has reached step 9
|
||||
When the "Add a Car" modal appears
|
||||
Then the automation should detect the modal
|
||||
And the automation should select the car "dallara-f3"
|
||||
And the automation should confirm the selection
|
||||
And the automation should close the modal
|
||||
And the automation should transition to step 10
|
||||
|
||||
Scenario: Modal step handling (step 12 - Add Track)
|
||||
Given I have started an automation session
|
||||
And the automation has reached step 12
|
||||
When the "Add a Track" modal appears
|
||||
Then the automation should detect the modal
|
||||
And the automation should select the track "spa"
|
||||
And the automation should confirm the selection
|
||||
And the automation should close the modal
|
||||
And the automation should transition to step 13
|
||||
|
||||
Scenario: Safety checkpoint at step 18
|
||||
Given I have started an automation session
|
||||
And the automation has progressed to step 17
|
||||
When the automation transitions to step 18
|
||||
Then the automation should automatically stop
|
||||
And the session state should be "STOPPED_AT_STEP_18"
|
||||
And the current step should be 18
|
||||
And no submit action should be executed
|
||||
And a notification should inform the user to review before submitting
|
||||
|
||||
Scenario: Pause and resume automation
|
||||
Given I have started an automation session
|
||||
And the automation is at step 5
|
||||
When I pause the automation
|
||||
Then the session state should be "PAUSED"
|
||||
And the current step should remain 5
|
||||
|
||||
When I resume the automation
|
||||
Then the session state should be "IN_PROGRESS"
|
||||
And the automation should continue from step 5
|
||||
|
||||
Scenario: Automation failure handling
|
||||
Given I have started an automation session
|
||||
And the automation is at step 8
|
||||
When a browser automation error occurs
|
||||
Then the session should transition to "FAILED" state
|
||||
And an error message should be recorded
|
||||
And the session should have a completedAt timestamp
|
||||
And the user should be notified of the failure
|
||||
|
||||
Scenario: Invalid configuration rejection
|
||||
Given I have a session configuration with:
|
||||
| field | value |
|
||||
| sessionName | |
|
||||
| trackId | spa |
|
||||
| carIds | dallara-f3|
|
||||
When I attempt to start the automation session
|
||||
Then the session creation should fail
|
||||
And an error message should indicate "Session name cannot be empty"
|
||||
And no session should be persisted
|
||||
|
||||
Scenario: Sequential step progression enforcement
|
||||
Given I have started an automation session
|
||||
And the automation is at step 5
|
||||
When I attempt to skip directly to step 7
|
||||
Then the transition should be rejected
|
||||
And an error message should indicate "Cannot skip steps"
|
||||
And the current step should remain 5
|
||||
|
||||
Scenario: Backward step prevention
|
||||
Given I have started an automation session
|
||||
And the automation has reached step 10
|
||||
When I attempt to move back to step 9
|
||||
Then the transition should be rejected
|
||||
And an error message should indicate "Cannot move backward"
|
||||
And the current step should remain 10
|
||||
|
||||
Scenario: Multiple car selection
|
||||
Given I have a session configuration with:
|
||||
| field | value |
|
||||
| sessionName | Multi-class Race |
|
||||
| trackId | spa |
|
||||
| carIds | dallara-f3,porsche-911-gt3,bmw-m4-gt4 |
|
||||
When I start the automation session
|
||||
And the automation reaches step 9
|
||||
Then all three cars should be added via the modal
|
||||
And the automation should handle the modal three times
|
||||
And the automation should transition to step 10
|
||||
|
||||
Scenario: Session state persistence
|
||||
Given I have started an automation session
|
||||
And the automation has reached step 12
|
||||
When the application restarts
|
||||
Then the session should be recoverable from storage
|
||||
And the session state should be "IN_PROGRESS"
|
||||
And the current step should be 12
|
||||
And the session configuration should be intact
|
||||
|
||||
Scenario: Concurrent session prevention
|
||||
Given I have started an automation session
|
||||
And the session is in progress
|
||||
When I attempt to start another automation session
|
||||
Then the second session creation should be queued or rejected
|
||||
And a warning should inform about the active session
|
||||
|
||||
Scenario: Elapsed time tracking
|
||||
Given I have started an automation session
|
||||
When the automation runs for 5 seconds
|
||||
And I query the session status
|
||||
Then the elapsed time should be approximately 5000 milliseconds
|
||||
And the elapsed time should increase while in progress
|
||||
|
||||
Scenario: Complete workflow with realistic timings
|
||||
Given I have a session configuration
|
||||
When I start the automation session
|
||||
Then each step should take between 200ms and 1000ms
|
||||
And modal steps should take longer than regular steps
|
||||
And the total workflow should complete in under 30 seconds
|
||||
And the session should stop at step 18 without submitting
|
||||
458
tests/e2e/step-definitions/automation.steps.ts
Normal file
458
tests/e2e/step-definitions/automation.steps.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import { Given, When, Then, Before, After } from '@cucumber/cucumber';
|
||||
import { expect } from 'vitest';
|
||||
import { AutomationSession } from '../../../src/packages/domain/entities/AutomationSession';
|
||||
import { StartAutomationSessionUseCase } from '../../../src/packages/application/use-cases/StartAutomationSessionUseCase';
|
||||
import { MockBrowserAutomationAdapter } from '../../../src/infrastructure/adapters/automation/MockBrowserAutomationAdapter';
|
||||
import { InMemorySessionRepository } from '../../../src/infrastructure/repositories/InMemorySessionRepository';
|
||||
import { StepId } from '../../../src/packages/domain/value-objects/StepId';
|
||||
|
||||
interface TestContext {
|
||||
sessionRepository: InMemorySessionRepository;
|
||||
browserAutomation: MockBrowserAutomationAdapter;
|
||||
startAutomationUseCase: StartAutomationSessionUseCase;
|
||||
currentSession: AutomationSession | null;
|
||||
sessionConfig: any;
|
||||
error: Error | null;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
Before(function (this: TestContext) {
|
||||
this.sessionRepository = new InMemorySessionRepository();
|
||||
this.browserAutomation = new MockBrowserAutomationAdapter();
|
||||
this.startAutomationUseCase = new StartAutomationSessionUseCase(
|
||||
{} as any, // Mock automation engine
|
||||
this.browserAutomation,
|
||||
this.sessionRepository
|
||||
);
|
||||
this.currentSession = null;
|
||||
this.sessionConfig = {};
|
||||
this.error = null;
|
||||
this.startTime = 0;
|
||||
});
|
||||
|
||||
After(function (this: TestContext) {
|
||||
this.currentSession = null;
|
||||
this.sessionConfig = {};
|
||||
this.error = null;
|
||||
});
|
||||
|
||||
Given('the companion app is running', function (this: TestContext) {
|
||||
expect(this.browserAutomation).toBeDefined();
|
||||
});
|
||||
|
||||
Given('I am authenticated with iRacing', function (this: TestContext) {
|
||||
// Mock authentication state
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
Given('I have a valid session configuration', function (this: TestContext) {
|
||||
this.sessionConfig = {
|
||||
sessionName: 'Test Race Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
});
|
||||
|
||||
Given('I have a session configuration with:', function (this: TestContext, dataTable: any) {
|
||||
const rows = dataTable.rawTable.slice(1);
|
||||
this.sessionConfig = {};
|
||||
|
||||
rows.forEach(([field, value]: [string, string]) => {
|
||||
if (field === 'carIds') {
|
||||
this.sessionConfig[field] = value.split(',').map(v => v.trim());
|
||||
} else {
|
||||
this.sessionConfig[field] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Given('I have started an automation session', async function (this: TestContext) {
|
||||
this.sessionConfig = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
this.currentSession = AutomationSession.create(this.sessionConfig);
|
||||
this.currentSession.start();
|
||||
await this.sessionRepository.save(this.currentSession);
|
||||
});
|
||||
|
||||
Given('the automation has reached step {int}', async function (this: TestContext, stepNumber: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
|
||||
for (let i = 2; i <= stepNumber; i++) {
|
||||
this.currentSession!.transitionToStep(StepId.create(i));
|
||||
}
|
||||
|
||||
await this.sessionRepository.update(this.currentSession!);
|
||||
});
|
||||
|
||||
Given('the automation has progressed to step {int}', async function (this: TestContext, stepNumber: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
|
||||
for (let i = 2; i <= stepNumber; i++) {
|
||||
this.currentSession!.transitionToStep(StepId.create(i));
|
||||
}
|
||||
|
||||
await this.sessionRepository.update(this.currentSession!);
|
||||
});
|
||||
|
||||
Given('the automation is at step {int}', async function (this: TestContext, stepNumber: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
|
||||
for (let i = 2; i <= stepNumber; i++) {
|
||||
this.currentSession!.transitionToStep(StepId.create(i));
|
||||
}
|
||||
|
||||
await this.sessionRepository.update(this.currentSession!);
|
||||
});
|
||||
|
||||
Given('the session is in progress', function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.state.isInProgress()).toBe(true);
|
||||
});
|
||||
|
||||
When('I start the automation session', async function (this: TestContext) {
|
||||
try {
|
||||
const result = await this.startAutomationUseCase.execute(this.sessionConfig);
|
||||
this.currentSession = await this.sessionRepository.findById(result.sessionId);
|
||||
this.startTime = Date.now();
|
||||
} catch (error) {
|
||||
this.error = error as Error;
|
||||
}
|
||||
});
|
||||
|
||||
When('I attempt to start the automation session', async function (this: TestContext) {
|
||||
try {
|
||||
const result = await this.startAutomationUseCase.execute(this.sessionConfig);
|
||||
this.currentSession = await this.sessionRepository.findById(result.sessionId);
|
||||
} catch (error) {
|
||||
this.error = error as Error;
|
||||
}
|
||||
});
|
||||
|
||||
When('the automation progresses through all {int} steps', async function (this: TestContext, stepCount: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
this.currentSession!.start();
|
||||
|
||||
for (let i = 2; i <= stepCount; i++) {
|
||||
this.currentSession!.transitionToStep(StepId.create(i));
|
||||
await this.browserAutomation.executeStep(StepId.create(i), this.sessionConfig);
|
||||
}
|
||||
});
|
||||
|
||||
When('the automation transitions to step {int}', async function (this: TestContext, stepNumber: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
this.currentSession!.transitionToStep(StepId.create(stepNumber));
|
||||
await this.sessionRepository.update(this.currentSession!);
|
||||
});
|
||||
|
||||
When('the {string} modal appears', async function (this: TestContext, modalName: string) {
|
||||
// Simulate modal appearance
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.isAtModalStep()).toBe(true);
|
||||
});
|
||||
|
||||
When('I pause the automation', async function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
this.currentSession!.pause();
|
||||
await this.sessionRepository.update(this.currentSession!);
|
||||
});
|
||||
|
||||
When('I resume the automation', async function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
this.currentSession!.resume();
|
||||
await this.sessionRepository.update(this.currentSession!);
|
||||
});
|
||||
|
||||
When('a browser automation error occurs', async function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
this.currentSession!.fail('Browser automation failed at step 8');
|
||||
await this.sessionRepository.update(this.currentSession!);
|
||||
});
|
||||
|
||||
When('I attempt to skip directly to step {int}', function (this: TestContext, targetStep: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
|
||||
try {
|
||||
this.currentSession!.transitionToStep(StepId.create(targetStep));
|
||||
} catch (error) {
|
||||
this.error = error as Error;
|
||||
}
|
||||
});
|
||||
|
||||
When('I attempt to move back to step {int}', function (this: TestContext, targetStep: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
|
||||
try {
|
||||
this.currentSession!.transitionToStep(StepId.create(targetStep));
|
||||
} catch (error) {
|
||||
this.error = error as Error;
|
||||
}
|
||||
});
|
||||
|
||||
When('the automation reaches step {int}', async function (this: TestContext, stepNumber: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
|
||||
for (let i = 2; i <= stepNumber; i++) {
|
||||
this.currentSession!.transitionToStep(StepId.create(i));
|
||||
}
|
||||
|
||||
await this.sessionRepository.update(this.currentSession!);
|
||||
});
|
||||
|
||||
When('the application restarts', function (this: TestContext) {
|
||||
// Simulate app restart by keeping repository but clearing session reference
|
||||
const sessionId = this.currentSession!.id;
|
||||
this.currentSession = null;
|
||||
|
||||
// Recover session
|
||||
this.sessionRepository.findById(sessionId).then(session => {
|
||||
this.currentSession = session;
|
||||
});
|
||||
});
|
||||
|
||||
When('I attempt to start another automation session', async function (this: TestContext) {
|
||||
const newConfig = {
|
||||
sessionName: 'Second Race',
|
||||
trackId: 'monza',
|
||||
carIds: ['porsche-911-gt3'],
|
||||
};
|
||||
|
||||
try {
|
||||
await this.startAutomationUseCase.execute(newConfig);
|
||||
} catch (error) {
|
||||
this.error = error as Error;
|
||||
}
|
||||
});
|
||||
|
||||
When('the automation runs for {int} seconds', async function (this: TestContext, seconds: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
// Simulate time passage
|
||||
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
|
||||
});
|
||||
|
||||
When('I query the session status', async function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
const retrieved = await this.sessionRepository.findById(this.currentSession!.id);
|
||||
this.currentSession = retrieved;
|
||||
});
|
||||
|
||||
Then('the session should be created with state {string}', function (this: TestContext, expectedState: string) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.state.value).toBe(expectedState);
|
||||
});
|
||||
|
||||
Then('the current step should be {int}', function (this: TestContext, expectedStep: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
|
||||
});
|
||||
|
||||
Then('the current step should remain {int}', function (this: TestContext, expectedStep: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
|
||||
});
|
||||
|
||||
Then('step {int} should navigate to {string}', function (this: TestContext, stepNumber: number, description: string) {
|
||||
// Verify step execution would happen
|
||||
expect(stepNumber).toBeGreaterThanOrEqual(1);
|
||||
expect(stepNumber).toBeLessThanOrEqual(18);
|
||||
});
|
||||
|
||||
Then('step {int} should click {string}', function (this: TestContext, stepNumber: number, description: string) {
|
||||
expect(stepNumber).toBeGreaterThanOrEqual(1);
|
||||
expect(stepNumber).toBeLessThanOrEqual(18);
|
||||
});
|
||||
|
||||
Then('step {int} should fill {string}', function (this: TestContext, stepNumber: number, description: string) {
|
||||
expect(stepNumber).toBeGreaterThanOrEqual(1);
|
||||
expect(stepNumber).toBeLessThanOrEqual(18);
|
||||
});
|
||||
|
||||
Then('step {int} should configure {string}', function (this: TestContext, stepNumber: number, description: string) {
|
||||
expect(stepNumber).toBeGreaterThanOrEqual(1);
|
||||
expect(stepNumber).toBeLessThanOrEqual(18);
|
||||
});
|
||||
|
||||
Then('step {int} should access {string}', function (this: TestContext, stepNumber: number, description: string) {
|
||||
expect(stepNumber).toBeGreaterThanOrEqual(1);
|
||||
expect(stepNumber).toBeLessThanOrEqual(18);
|
||||
});
|
||||
|
||||
Then('step {int} should handle {string} modal', function (this: TestContext, stepNumber: number, modalName: string) {
|
||||
expect([6, 9, 12]).toContain(stepNumber);
|
||||
});
|
||||
|
||||
Then('step {int} should set {string}', function (this: TestContext, stepNumber: number, description: string) {
|
||||
expect(stepNumber).toBeGreaterThanOrEqual(1);
|
||||
expect(stepNumber).toBeLessThanOrEqual(18);
|
||||
});
|
||||
|
||||
Then('step {int} should reach {string}', function (this: TestContext, stepNumber: number, description: string) {
|
||||
expect(stepNumber).toBe(18);
|
||||
});
|
||||
|
||||
Then('the session should stop at step {int}', function (this: TestContext, expectedStep: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
|
||||
expect(this.currentSession!.state.isStoppedAtStep18()).toBe(true);
|
||||
});
|
||||
|
||||
Then('the session state should be {string}', function (this: TestContext, expectedState: string) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.state.value).toBe(expectedState);
|
||||
});
|
||||
|
||||
Then('a manual submit warning should be displayed', function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.currentStep.isFinalStep()).toBe(true);
|
||||
});
|
||||
|
||||
Then('the automation should detect the modal', function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.isAtModalStep()).toBe(true);
|
||||
});
|
||||
|
||||
Then('the automation should wait for modal content to load', async function (this: TestContext) {
|
||||
// Simulate wait
|
||||
expect(this.currentSession).toBeDefined();
|
||||
});
|
||||
|
||||
Then('the automation should fill admin fields', async function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
});
|
||||
|
||||
Then('the automation should close the modal', async function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
});
|
||||
|
||||
Then('the automation should transition to step {int}', async function (this: TestContext, nextStep: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
this.currentSession!.transitionToStep(StepId.create(nextStep));
|
||||
});
|
||||
|
||||
Then('the automation should select the car {string}', async function (this: TestContext, carId: string) {
|
||||
expect(this.sessionConfig.carIds).toContain(carId);
|
||||
});
|
||||
|
||||
Then('the automation should confirm the selection', async function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
});
|
||||
|
||||
Then('the automation should select the track {string}', async function (this: TestContext, trackId: string) {
|
||||
expect(this.sessionConfig.trackId).toBe(trackId);
|
||||
});
|
||||
|
||||
Then('the automation should automatically stop', function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.state.isStoppedAtStep18()).toBe(true);
|
||||
});
|
||||
|
||||
Then('no submit action should be executed', function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.state.isStoppedAtStep18()).toBe(true);
|
||||
});
|
||||
|
||||
Then('a notification should inform the user to review before submitting', function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.currentStep.isFinalStep()).toBe(true);
|
||||
});
|
||||
|
||||
Then('the automation should continue from step {int}', function (this: TestContext, expectedStep: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
|
||||
});
|
||||
|
||||
Then('an error message should be recorded', function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.errorMessage).toBeDefined();
|
||||
});
|
||||
|
||||
Then('the session should have a completedAt timestamp', function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.completedAt).toBeDefined();
|
||||
});
|
||||
|
||||
Then('the user should be notified of the failure', function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.state.isFailed()).toBe(true);
|
||||
});
|
||||
|
||||
Then('the session creation should fail', function (this: TestContext) {
|
||||
expect(this.error).toBeDefined();
|
||||
});
|
||||
|
||||
Then('an error message should indicate {string}', function (this: TestContext, expectedMessage: string) {
|
||||
expect(this.error).toBeDefined();
|
||||
expect(this.error!.message).toContain(expectedMessage);
|
||||
});
|
||||
|
||||
Then('no session should be persisted', async function (this: TestContext) {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
expect(sessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
Then('the transition should be rejected', function (this: TestContext) {
|
||||
expect(this.error).toBeDefined();
|
||||
});
|
||||
|
||||
Then('all three cars should be added via the modal', function (this: TestContext) {
|
||||
expect(this.sessionConfig.carIds).toHaveLength(3);
|
||||
});
|
||||
|
||||
Then('the automation should handle the modal three times', function (this: TestContext) {
|
||||
expect(this.sessionConfig.carIds).toHaveLength(3);
|
||||
});
|
||||
|
||||
Then('the session should be recoverable from storage', async function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
});
|
||||
|
||||
Then('the session configuration should be intact', function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.config).toBeDefined();
|
||||
});
|
||||
|
||||
Then('the second session creation should be queued or rejected', function (this: TestContext) {
|
||||
expect(this.error).toBeDefined();
|
||||
});
|
||||
|
||||
Then('a warning should inform about the active session', function (this: TestContext) {
|
||||
expect(this.error).toBeDefined();
|
||||
});
|
||||
|
||||
Then('the elapsed time should be approximately {int} milliseconds', function (this: TestContext, expectedMs: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
const elapsed = this.currentSession!.getElapsedTime();
|
||||
expect(elapsed).toBeGreaterThanOrEqual(expectedMs - 1000);
|
||||
expect(elapsed).toBeLessThanOrEqual(expectedMs + 1000);
|
||||
});
|
||||
|
||||
Then('the elapsed time should increase while in progress', function (this: TestContext) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
const elapsed = this.currentSession!.getElapsedTime();
|
||||
expect(elapsed).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
Then('each step should take between {int}ms and {int}ms', function (this: TestContext, minMs: number, maxMs: number) {
|
||||
// This would be validated during actual execution
|
||||
expect(minMs).toBeLessThan(maxMs);
|
||||
});
|
||||
|
||||
Then('modal steps should take longer than regular steps', function (this: TestContext) {
|
||||
// This would be validated during actual execution
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
Then('the total workflow should complete in under {int} seconds', function (this: TestContext, maxSeconds: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
const elapsed = this.currentSession!.getElapsedTime();
|
||||
expect(elapsed).toBeLessThan(maxSeconds * 1000);
|
||||
});
|
||||
|
||||
Then('the session should stop at step {int} without submitting', function (this: TestContext, expectedStep: number) {
|
||||
expect(this.currentSession).toBeDefined();
|
||||
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
|
||||
expect(this.currentSession!.state.isStoppedAtStep18()).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { InMemorySessionRepository } from '../../../src/infrastructure/repositories/InMemorySessionRepository';
|
||||
import { AutomationSession } from '../../../src/packages/domain/entities/AutomationSession';
|
||||
import { StepId } from '../../../src/packages/domain/value-objects/StepId';
|
||||
|
||||
describe('InMemorySessionRepository Integration Tests', () => {
|
||||
let repository: InMemorySessionRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemorySessionRepository();
|
||||
});
|
||||
|
||||
describe('save', () => {
|
||||
it('should persist a new session', async () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
await repository.save(session);
|
||||
|
||||
const retrieved = await repository.findById(session.id);
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved?.id).toBe(session.id);
|
||||
});
|
||||
|
||||
it('should update existing session on duplicate save', async () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
await repository.save(session);
|
||||
|
||||
session.start();
|
||||
session.transitionToStep(StepId.create(2));
|
||||
|
||||
await repository.save(session);
|
||||
|
||||
const retrieved = await repository.findById(session.id);
|
||||
expect(retrieved?.currentStep.value).toBe(2);
|
||||
expect(retrieved?.state.isInProgress()).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve all session properties', async () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race Session',
|
||||
trackId: 'spa-francorchamps',
|
||||
carIds: ['dallara-f3', 'porsche-911-gt3'],
|
||||
});
|
||||
|
||||
await repository.save(session);
|
||||
|
||||
const retrieved = await repository.findById(session.id);
|
||||
expect(retrieved?.config.sessionName).toBe('Test Race Session');
|
||||
expect(retrieved?.config.trackId).toBe('spa-francorchamps');
|
||||
expect(retrieved?.config.carIds).toEqual(['dallara-f3', 'porsche-911-gt3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return null for non-existent session', async () => {
|
||||
const result = await repository.findById('non-existent-id');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should retrieve existing session by ID', async () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
await repository.save(session);
|
||||
|
||||
const retrieved = await repository.findById(session.id);
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved?.id).toBe(session.id);
|
||||
});
|
||||
|
||||
it('should return domain entity not DTO', async () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
await repository.save(session);
|
||||
|
||||
const retrieved = await repository.findById(session.id);
|
||||
expect(retrieved).toBeInstanceOf(AutomationSession);
|
||||
});
|
||||
|
||||
it('should retrieve session with correct state', async () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
await repository.save(session);
|
||||
|
||||
const retrieved = await repository.findById(session.id);
|
||||
expect(retrieved?.state.isInProgress()).toBe(true);
|
||||
expect(retrieved?.startedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update existing session', async () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
await repository.save(session);
|
||||
|
||||
session.start();
|
||||
session.transitionToStep(StepId.create(2));
|
||||
|
||||
await repository.update(session);
|
||||
|
||||
const retrieved = await repository.findById(session.id);
|
||||
expect(retrieved?.currentStep.value).toBe(2);
|
||||
});
|
||||
|
||||
it('should throw error when updating non-existent session', async () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
await expect(repository.update(session)).rejects.toThrow('Session not found');
|
||||
});
|
||||
|
||||
it('should preserve unchanged properties', async () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Original Name',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
await repository.save(session);
|
||||
|
||||
session.start();
|
||||
|
||||
await repository.update(session);
|
||||
|
||||
const retrieved = await repository.findById(session.id);
|
||||
expect(retrieved?.config.sessionName).toBe('Original Name');
|
||||
expect(retrieved?.state.isInProgress()).toBe(true);
|
||||
});
|
||||
|
||||
it('should update session state correctly', async () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
await repository.save(session);
|
||||
|
||||
session.start();
|
||||
session.pause();
|
||||
|
||||
await repository.update(session);
|
||||
|
||||
const retrieved = await repository.findById(session.id);
|
||||
expect(retrieved?.state.value).toBe('PAUSED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should remove session from storage', async () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
await repository.save(session);
|
||||
await repository.delete(session.id);
|
||||
|
||||
const retrieved = await repository.findById(session.id);
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
it('should not throw when deleting non-existent session', async () => {
|
||||
await expect(repository.delete('non-existent-id')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should only delete specified session', async () => {
|
||||
const session1 = AutomationSession.create({
|
||||
sessionName: 'Race 1',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
const session2 = AutomationSession.create({
|
||||
sessionName: 'Race 2',
|
||||
trackId: 'monza',
|
||||
carIds: ['porsche-911-gt3'],
|
||||
});
|
||||
|
||||
await repository.save(session1);
|
||||
await repository.save(session2);
|
||||
|
||||
await repository.delete(session1.id);
|
||||
|
||||
const retrieved1 = await repository.findById(session1.id);
|
||||
const retrieved2 = await repository.findById(session2.id);
|
||||
|
||||
expect(retrieved1).toBeNull();
|
||||
expect(retrieved2).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return empty array when no sessions exist', async () => {
|
||||
const sessions = await repository.findAll();
|
||||
|
||||
expect(sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all saved sessions', async () => {
|
||||
const session1 = AutomationSession.create({
|
||||
sessionName: 'Race 1',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
const session2 = AutomationSession.create({
|
||||
sessionName: 'Race 2',
|
||||
trackId: 'monza',
|
||||
carIds: ['porsche-911-gt3'],
|
||||
});
|
||||
|
||||
await repository.save(session1);
|
||||
await repository.save(session2);
|
||||
|
||||
const sessions = await repository.findAll();
|
||||
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions.map(s => s.id)).toContain(session1.id);
|
||||
expect(sessions.map(s => s.id)).toContain(session2.id);
|
||||
});
|
||||
|
||||
it('should return domain entities not DTOs', async () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
await repository.save(session);
|
||||
|
||||
const sessions = await repository.findAll();
|
||||
|
||||
expect(sessions[0]).toBeInstanceOf(AutomationSession);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByState', () => {
|
||||
it('should return sessions matching state', async () => {
|
||||
const session1 = AutomationSession.create({
|
||||
sessionName: 'Race 1',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session1.start();
|
||||
|
||||
const session2 = AutomationSession.create({
|
||||
sessionName: 'Race 2',
|
||||
trackId: 'monza',
|
||||
carIds: ['porsche-911-gt3'],
|
||||
});
|
||||
|
||||
await repository.save(session1);
|
||||
await repository.save(session2);
|
||||
|
||||
const inProgressSessions = await repository.findByState('IN_PROGRESS');
|
||||
|
||||
expect(inProgressSessions).toHaveLength(1);
|
||||
expect(inProgressSessions[0].id).toBe(session1.id);
|
||||
});
|
||||
|
||||
it('should return empty array when no sessions match state', async () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
await repository.save(session);
|
||||
|
||||
const completedSessions = await repository.findByState('COMPLETED');
|
||||
|
||||
expect(completedSessions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle multiple sessions with same state', async () => {
|
||||
const session1 = AutomationSession.create({
|
||||
sessionName: 'Race 1',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
const session2 = AutomationSession.create({
|
||||
sessionName: 'Race 2',
|
||||
trackId: 'monza',
|
||||
carIds: ['porsche-911-gt3'],
|
||||
});
|
||||
|
||||
await repository.save(session1);
|
||||
await repository.save(session2);
|
||||
|
||||
const pendingSessions = await repository.findByState('PENDING');
|
||||
|
||||
expect(pendingSessions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent operations', () => {
|
||||
it('should handle concurrent saves', async () => {
|
||||
const sessions = Array.from({ length: 10 }, (_, i) =>
|
||||
AutomationSession.create({
|
||||
sessionName: `Race ${i}`,
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(sessions.map(s => repository.save(s)));
|
||||
|
||||
const allSessions = await repository.findAll();
|
||||
expect(allSessions).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('should handle concurrent updates', async () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
await repository.save(session);
|
||||
session.start();
|
||||
|
||||
await Promise.all([
|
||||
repository.update(session),
|
||||
repository.update(session),
|
||||
repository.update(session),
|
||||
]);
|
||||
|
||||
const retrieved = await repository.findById(session.id);
|
||||
expect(retrieved?.state.isInProgress()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { MockBrowserAutomationAdapter } from '../../../src/infrastructure/adapters/automation/MockBrowserAutomationAdapter';
|
||||
import { StepId } from '../../../src/packages/domain/value-objects/StepId';
|
||||
|
||||
describe('MockBrowserAutomationAdapter Integration Tests', () => {
|
||||
let adapter: MockBrowserAutomationAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new MockBrowserAutomationAdapter();
|
||||
});
|
||||
|
||||
describe('navigateToPage', () => {
|
||||
it('should simulate navigation with delay', async () => {
|
||||
const url = 'https://members.iracing.com/membersite/HostedRacing';
|
||||
|
||||
const result = await adapter.navigateToPage(url);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.simulatedDelay).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return navigation URL in result', async () => {
|
||||
const url = 'https://members.iracing.com/membersite/HostedRacing';
|
||||
|
||||
const result = await adapter.navigateToPage(url);
|
||||
|
||||
expect(result.url).toBe(url);
|
||||
});
|
||||
|
||||
it('should simulate realistic delays', async () => {
|
||||
const url = 'https://members.iracing.com/membersite/HostedRacing';
|
||||
|
||||
const result = await adapter.navigateToPage(url);
|
||||
|
||||
expect(result.simulatedDelay).toBeGreaterThanOrEqual(200);
|
||||
expect(result.simulatedDelay).toBeLessThanOrEqual(800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillFormField', () => {
|
||||
it('should simulate form field fill with delay', async () => {
|
||||
const fieldName = 'session-name';
|
||||
const value = 'Test Race Session';
|
||||
|
||||
const result = await adapter.fillFormField(fieldName, value);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.fieldName).toBe(fieldName);
|
||||
expect(result.value).toBe(value);
|
||||
});
|
||||
|
||||
it('should simulate typing speed delay', async () => {
|
||||
const fieldName = 'session-name';
|
||||
const value = 'A'.repeat(50);
|
||||
|
||||
const result = await adapter.fillFormField(fieldName, value);
|
||||
|
||||
expect(result.simulatedDelay).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle empty field values', async () => {
|
||||
const fieldName = 'session-name';
|
||||
const value = '';
|
||||
|
||||
const result = await adapter.fillFormField(fieldName, value);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clickElement', () => {
|
||||
it('should simulate button click with delay', async () => {
|
||||
const selector = '#create-session-button';
|
||||
|
||||
const result = await adapter.clickElement(selector);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.selector).toBe(selector);
|
||||
});
|
||||
|
||||
it('should simulate click delays', async () => {
|
||||
const selector = '#submit-button';
|
||||
|
||||
const result = await adapter.clickElement(selector);
|
||||
|
||||
expect(result.simulatedDelay).toBeGreaterThan(0);
|
||||
expect(result.simulatedDelay).toBeLessThanOrEqual(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForElement', () => {
|
||||
it('should simulate waiting for element to appear', async () => {
|
||||
const selector = '.modal-dialog';
|
||||
|
||||
const result = await adapter.waitForElement(selector);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.selector).toBe(selector);
|
||||
});
|
||||
|
||||
it('should simulate element load time', async () => {
|
||||
const selector = '.loading-spinner';
|
||||
|
||||
const result = await adapter.waitForElement(selector);
|
||||
|
||||
expect(result.simulatedDelay).toBeGreaterThanOrEqual(100);
|
||||
expect(result.simulatedDelay).toBeLessThanOrEqual(1000);
|
||||
});
|
||||
|
||||
it('should timeout after maximum wait time', async () => {
|
||||
const selector = '.non-existent-element';
|
||||
const maxWaitMs = 5000;
|
||||
|
||||
const result = await adapter.waitForElement(selector, maxWaitMs);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleModal', () => {
|
||||
it('should simulate modal handling for step 6', async () => {
|
||||
const stepId = StepId.create(6);
|
||||
const action = 'close';
|
||||
|
||||
const result = await adapter.handleModal(stepId, action);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stepId).toBe(6);
|
||||
expect(result.action).toBe(action);
|
||||
});
|
||||
|
||||
it('should simulate modal handling for step 9', async () => {
|
||||
const stepId = StepId.create(9);
|
||||
const action = 'confirm';
|
||||
|
||||
const result = await adapter.handleModal(stepId, action);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stepId).toBe(9);
|
||||
});
|
||||
|
||||
it('should simulate modal handling for step 12', async () => {
|
||||
const stepId = StepId.create(12);
|
||||
const action = 'select';
|
||||
|
||||
const result = await adapter.handleModal(stepId, action);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stepId).toBe(12);
|
||||
});
|
||||
|
||||
it('should throw error for non-modal steps', async () => {
|
||||
const stepId = StepId.create(1);
|
||||
const action = 'close';
|
||||
|
||||
await expect(adapter.handleModal(stepId, action)).rejects.toThrow(
|
||||
'Step 1 is not a modal step'
|
||||
);
|
||||
});
|
||||
|
||||
it('should simulate modal interaction delays', async () => {
|
||||
const stepId = StepId.create(6);
|
||||
const action = 'close';
|
||||
|
||||
const result = await adapter.handleModal(stepId, action);
|
||||
|
||||
expect(result.simulatedDelay).toBeGreaterThanOrEqual(200);
|
||||
expect(result.simulatedDelay).toBeLessThanOrEqual(600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeStep', () => {
|
||||
it('should execute step 1 (navigation)', async () => {
|
||||
const stepId = StepId.create(1);
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
const result = await adapter.executeStep(stepId, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stepId).toBe(1);
|
||||
});
|
||||
|
||||
it('should execute step 6 (modal step)', async () => {
|
||||
const stepId = StepId.create(6);
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
const result = await adapter.executeStep(stepId, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stepId).toBe(6);
|
||||
expect(result.wasModalStep).toBe(true);
|
||||
});
|
||||
|
||||
it('should execute step 18 (final step)', async () => {
|
||||
const stepId = StepId.create(18);
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
const result = await adapter.executeStep(stepId, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stepId).toBe(18);
|
||||
expect(result.shouldStop).toBe(true);
|
||||
});
|
||||
|
||||
it('should simulate realistic step execution times', async () => {
|
||||
const stepId = StepId.create(5);
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
const result = await adapter.executeStep(stepId, config);
|
||||
|
||||
expect(result.executionTime).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error simulation', () => {
|
||||
it('should simulate random failures when enabled', async () => {
|
||||
const adapterWithFailures = new MockBrowserAutomationAdapter({
|
||||
simulateFailures: true,
|
||||
failureRate: 1.0, // Always fail
|
||||
});
|
||||
|
||||
const stepId = StepId.create(5);
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
await expect(adapterWithFailures.executeStep(stepId, config)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should not fail when failure simulation disabled', async () => {
|
||||
const adapterNoFailures = new MockBrowserAutomationAdapter({
|
||||
simulateFailures: false,
|
||||
});
|
||||
|
||||
const stepId = StepId.create(5);
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
const result = await adapterNoFailures.executeStep(stepId, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance metrics', () => {
|
||||
it('should track operation metrics', async () => {
|
||||
const stepId = StepId.create(1);
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
const result = await adapter.executeStep(stepId, config);
|
||||
|
||||
expect(result.metrics).toBeDefined();
|
||||
expect(result.metrics.totalDelay).toBeGreaterThan(0);
|
||||
expect(result.metrics.operationCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
292
tests/unit/application/use-cases/StartAutomationSession.test.ts
Normal file
292
tests/unit/application/use-cases/StartAutomationSession.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { StartAutomationSessionUseCase } from '../../../../src/packages/application/use-cases/StartAutomationSessionUseCase';
|
||||
import { IAutomationEngine } from '../../../../src/packages/application/ports/IAutomationEngine';
|
||||
import { IBrowserAutomation } from '../../../../src/packages/application/ports/IBrowserAutomation';
|
||||
import { ISessionRepository } from '../../../../src/packages/application/ports/ISessionRepository';
|
||||
import { AutomationSession } from '../../../../src/packages/domain/entities/AutomationSession';
|
||||
|
||||
describe('StartAutomationSessionUseCase', () => {
|
||||
let mockAutomationEngine: {
|
||||
executeStep: Mock;
|
||||
validateConfiguration: Mock;
|
||||
};
|
||||
let mockBrowserAutomation: {
|
||||
navigateToPage: Mock;
|
||||
fillFormField: Mock;
|
||||
clickElement: Mock;
|
||||
waitForElement: Mock;
|
||||
handleModal: Mock;
|
||||
};
|
||||
let mockSessionRepository: {
|
||||
save: Mock;
|
||||
findById: Mock;
|
||||
update: Mock;
|
||||
delete: Mock;
|
||||
};
|
||||
let useCase: StartAutomationSessionUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
mockAutomationEngine = {
|
||||
executeStep: vi.fn(),
|
||||
validateConfiguration: vi.fn(),
|
||||
};
|
||||
|
||||
mockBrowserAutomation = {
|
||||
navigateToPage: vi.fn(),
|
||||
fillFormField: vi.fn(),
|
||||
clickElement: vi.fn(),
|
||||
waitForElement: vi.fn(),
|
||||
handleModal: vi.fn(),
|
||||
};
|
||||
|
||||
mockSessionRepository = {
|
||||
save: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new StartAutomationSessionUseCase(
|
||||
mockAutomationEngine as unknown as IAutomationEngine,
|
||||
mockBrowserAutomation as unknown as IBrowserAutomation,
|
||||
mockSessionRepository as unknown as ISessionRepository
|
||||
);
|
||||
});
|
||||
|
||||
describe('execute - happy path', () => {
|
||||
it('should create and persist a new automation session', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute(config);
|
||||
|
||||
expect(result.sessionId).toBeDefined();
|
||||
expect(result.state).toBe('PENDING');
|
||||
expect(result.currentStep).toBe(1);
|
||||
expect(mockAutomationEngine.validateConfiguration).toHaveBeenCalledWith(config);
|
||||
expect(mockSessionRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config,
|
||||
currentStep: expect.objectContaining({ value: 1 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return session DTO with correct structure', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute(config);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
sessionId: expect.any(String),
|
||||
state: 'PENDING',
|
||||
currentStep: 1,
|
||||
config: {
|
||||
sessionName: 'Test Race Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
},
|
||||
});
|
||||
expect(result.startedAt).toBeUndefined();
|
||||
expect(result.completedAt).toBeUndefined();
|
||||
expect(result.errorMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should validate configuration before creating session', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
await useCase.execute(config);
|
||||
|
||||
expect(mockAutomationEngine.validateConfiguration).toHaveBeenCalledWith(config);
|
||||
expect(mockSessionRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - validation failures', () => {
|
||||
it('should throw error for empty session name', async () => {
|
||||
const config = {
|
||||
sessionName: '',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
await expect(useCase.execute(config)).rejects.toThrow('Session name cannot be empty');
|
||||
expect(mockSessionRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error for missing track ID', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: '',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
await expect(useCase.execute(config)).rejects.toThrow('Track ID is required');
|
||||
expect(mockSessionRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error for empty car list', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: [],
|
||||
};
|
||||
|
||||
await expect(useCase.execute(config)).rejects.toThrow('At least one car must be selected');
|
||||
expect(mockSessionRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when automation engine validation fails', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'invalid-track',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({
|
||||
isValid: false,
|
||||
error: 'Invalid track ID: invalid-track',
|
||||
});
|
||||
|
||||
await expect(useCase.execute(config)).rejects.toThrow('Invalid track ID: invalid-track');
|
||||
expect(mockSessionRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when automation engine validation rejects', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['invalid-car'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockRejectedValue(
|
||||
new Error('Validation service unavailable')
|
||||
);
|
||||
|
||||
await expect(useCase.execute(config)).rejects.toThrow('Validation service unavailable');
|
||||
expect(mockSessionRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - port interactions', () => {
|
||||
it('should call automation engine before saving session', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
const callOrder: string[] = [];
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockImplementation(async () => {
|
||||
callOrder.push('validateConfiguration');
|
||||
return { isValid: true };
|
||||
});
|
||||
|
||||
mockSessionRepository.save.mockImplementation(async () => {
|
||||
callOrder.push('save');
|
||||
});
|
||||
|
||||
await useCase.execute(config);
|
||||
|
||||
expect(callOrder).toEqual(['validateConfiguration', 'save']);
|
||||
});
|
||||
|
||||
it('should persist session with domain entity', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
await useCase.execute(config);
|
||||
|
||||
expect(mockSessionRepository.save).toHaveBeenCalledWith(
|
||||
expect.any(AutomationSession)
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when repository save fails', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockRejectedValue(new Error('Database connection failed'));
|
||||
|
||||
await expect(useCase.execute(config)).rejects.toThrow('Database connection failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - edge cases', () => {
|
||||
it('should handle very long session names', async () => {
|
||||
const config = {
|
||||
sessionName: 'A'.repeat(200),
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute(config);
|
||||
|
||||
expect(result.config.sessionName).toBe('A'.repeat(200));
|
||||
});
|
||||
|
||||
it('should handle multiple cars in configuration', async () => {
|
||||
const config = {
|
||||
sessionName: 'Multi-car Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3', 'porsche-911-gt3', 'bmw-m4-gt4'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute(config);
|
||||
|
||||
expect(result.config.carIds).toEqual(['dallara-f3', 'porsche-911-gt3', 'bmw-m4-gt4']);
|
||||
});
|
||||
|
||||
it('should handle special characters in session name', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test & Race #1 (2025)',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute(config);
|
||||
|
||||
expect(result.config.sessionName).toBe('Test & Race #1 (2025)');
|
||||
});
|
||||
});
|
||||
});
|
||||
364
tests/unit/domain/entities/AutomationSession.test.ts
Normal file
364
tests/unit/domain/entities/AutomationSession.test.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AutomationSession } from '../../../../src/packages/domain/entities/AutomationSession';
|
||||
import { StepId } from '../../../../src/packages/domain/value-objects/StepId';
|
||||
import { SessionState } from '../../../../src/packages/domain/value-objects/SessionState';
|
||||
|
||||
describe('AutomationSession Entity', () => {
|
||||
describe('create', () => {
|
||||
it('should create a new session with PENDING state', () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
const session = AutomationSession.create(config);
|
||||
|
||||
expect(session.id).toBeDefined();
|
||||
expect(session.currentStep.value).toBe(1);
|
||||
expect(session.state.isPending()).toBe(true);
|
||||
expect(session.config).toEqual(config);
|
||||
});
|
||||
|
||||
it('should throw error for empty session name', () => {
|
||||
const config = {
|
||||
sessionName: '',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
expect(() => AutomationSession.create(config)).toThrow('Session name cannot be empty');
|
||||
});
|
||||
|
||||
it('should throw error for missing track ID', () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: '',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
expect(() => AutomationSession.create(config)).toThrow('Track ID is required');
|
||||
});
|
||||
|
||||
it('should throw error for empty car list', () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: [],
|
||||
};
|
||||
|
||||
expect(() => AutomationSession.create(config)).toThrow('At least one car must be selected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('should transition from PENDING to IN_PROGRESS', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
session.start();
|
||||
|
||||
expect(session.state.isInProgress()).toBe(true);
|
||||
expect(session.startedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw error when starting non-PENDING session', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
expect(() => session.start()).toThrow('Cannot start session that is not pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transitionToStep', () => {
|
||||
it('should advance to next step when in progress', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
session.transitionToStep(StepId.create(2));
|
||||
|
||||
expect(session.currentStep.value).toBe(2);
|
||||
});
|
||||
|
||||
it('should throw error when transitioning while not in progress', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
expect(() => session.transitionToStep(StepId.create(2))).toThrow(
|
||||
'Cannot transition steps when session is not in progress'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when skipping steps', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
expect(() => session.transitionToStep(StepId.create(3))).toThrow(
|
||||
'Cannot skip steps - must transition sequentially'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when moving backward', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
session.transitionToStep(StepId.create(2));
|
||||
|
||||
expect(() => session.transitionToStep(StepId.create(1))).toThrow(
|
||||
'Cannot move backward - steps must progress forward only'
|
||||
);
|
||||
});
|
||||
|
||||
it('should stop at step 18 (safety checkpoint)', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
// Advance through all steps to 18
|
||||
for (let i = 2; i <= 18; i++) {
|
||||
session.transitionToStep(StepId.create(i));
|
||||
}
|
||||
|
||||
expect(session.currentStep.value).toBe(18);
|
||||
expect(session.state.isStoppedAtStep18()).toBe(true);
|
||||
expect(session.completedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pause', () => {
|
||||
it('should transition from IN_PROGRESS to PAUSED', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
session.pause();
|
||||
|
||||
expect(session.state.value).toBe('PAUSED');
|
||||
});
|
||||
|
||||
it('should throw error when pausing non-in-progress session', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
expect(() => session.pause()).toThrow('Cannot pause session that is not in progress');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resume', () => {
|
||||
it('should transition from PAUSED to IN_PROGRESS', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
session.pause();
|
||||
|
||||
session.resume();
|
||||
|
||||
expect(session.state.isInProgress()).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error when resuming non-paused session', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
expect(() => session.resume()).toThrow('Cannot resume session that is not paused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fail', () => {
|
||||
it('should transition to FAILED state with error message', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
const errorMessage = 'Browser automation failed at step 5';
|
||||
session.fail(errorMessage);
|
||||
|
||||
expect(session.state.isFailed()).toBe(true);
|
||||
expect(session.errorMessage).toBe(errorMessage);
|
||||
expect(session.completedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow failing from PENDING state', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
session.fail('Initialization failed');
|
||||
|
||||
expect(session.state.isFailed()).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow failing from PAUSED state', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
session.pause();
|
||||
|
||||
session.fail('Failed during pause');
|
||||
|
||||
expect(session.state.isFailed()).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error when failing already completed session', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
// Advance to step 18
|
||||
for (let i = 2; i <= 18; i++) {
|
||||
session.transitionToStep(StepId.create(i));
|
||||
}
|
||||
|
||||
expect(() => session.fail('Too late')).toThrow('Cannot fail terminal session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAtModalStep', () => {
|
||||
it('should return true when at step 6', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
for (let i = 2; i <= 6; i++) {
|
||||
session.transitionToStep(StepId.create(i));
|
||||
}
|
||||
|
||||
expect(session.isAtModalStep()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when at step 9', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
for (let i = 2; i <= 9; i++) {
|
||||
session.transitionToStep(StepId.create(i));
|
||||
}
|
||||
|
||||
expect(session.isAtModalStep()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when at step 12', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
for (let i = 2; i <= 12; i++) {
|
||||
session.transitionToStep(StepId.create(i));
|
||||
}
|
||||
|
||||
expect(session.isAtModalStep()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when at non-modal step', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
expect(session.isAtModalStep()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getElapsedTime', () => {
|
||||
it('should return 0 for non-started session', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
expect(session.getElapsedTime()).toBe(0);
|
||||
});
|
||||
|
||||
it('should return elapsed milliseconds for in-progress session', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
// Wait a bit (in real test, this would be mocked)
|
||||
const elapsed = session.getElapsedTime();
|
||||
expect(elapsed).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return total duration for completed session', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
// Advance to step 18
|
||||
for (let i = 2; i <= 18; i++) {
|
||||
session.transitionToStep(StepId.create(i));
|
||||
}
|
||||
|
||||
const elapsed = session.getElapsedTime();
|
||||
expect(elapsed).toBeGreaterThan(0);
|
||||
expect(session.state.isStoppedAtStep18()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
231
tests/unit/domain/services/StepTransitionValidator.test.ts
Normal file
231
tests/unit/domain/services/StepTransitionValidator.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StepTransitionValidator } from '../../../../src/packages/domain/services/StepTransitionValidator';
|
||||
import { StepId } from '../../../../src/packages/domain/value-objects/StepId';
|
||||
import { SessionState } from '../../../../src/packages/domain/value-objects/SessionState';
|
||||
|
||||
describe('StepTransitionValidator Service', () => {
|
||||
describe('canTransition', () => {
|
||||
it('should allow sequential forward transition', () => {
|
||||
const currentStep = StepId.create(1);
|
||||
const nextStep = StepId.create(2);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject transition when not IN_PROGRESS', () => {
|
||||
const currentStep = StepId.create(1);
|
||||
const nextStep = StepId.create(2);
|
||||
const state = SessionState.create('PENDING');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Session must be in progress to transition steps');
|
||||
});
|
||||
|
||||
it('should reject skipping steps', () => {
|
||||
const currentStep = StepId.create(1);
|
||||
const nextStep = StepId.create(3);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Cannot skip steps - must progress sequentially');
|
||||
});
|
||||
|
||||
it('should reject backward transitions', () => {
|
||||
const currentStep = StepId.create(5);
|
||||
const nextStep = StepId.create(4);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Cannot move backward - steps must progress forward only');
|
||||
});
|
||||
|
||||
it('should reject same step transition', () => {
|
||||
const currentStep = StepId.create(5);
|
||||
const nextStep = StepId.create(5);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Already at this step');
|
||||
});
|
||||
|
||||
it('should allow transition through modal steps', () => {
|
||||
const currentStep = StepId.create(5);
|
||||
const nextStep = StepId.create(6);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow transition from modal step to next', () => {
|
||||
const currentStep = StepId.create(6);
|
||||
const nextStep = StepId.create(7);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateModalStepTransition', () => {
|
||||
it('should allow entering modal step 6', () => {
|
||||
const currentStep = StepId.create(5);
|
||||
const nextStep = StepId.create(6);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow entering modal step 9', () => {
|
||||
const currentStep = StepId.create(8);
|
||||
const nextStep = StepId.create(9);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow entering modal step 12', () => {
|
||||
const currentStep = StepId.create(11);
|
||||
const nextStep = StepId.create(12);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow exiting modal step 6', () => {
|
||||
const currentStep = StepId.create(6);
|
||||
const nextStep = StepId.create(7);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow non-modal transitions', () => {
|
||||
const currentStep = StepId.create(1);
|
||||
const nextStep = StepId.create(2);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldStopAtStep18', () => {
|
||||
it('should return true when transitioning to step 18', () => {
|
||||
const nextStep = StepId.create(18);
|
||||
|
||||
const shouldStop = StepTransitionValidator.shouldStopAtStep18(nextStep);
|
||||
|
||||
expect(shouldStop).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for steps before 18', () => {
|
||||
const nextStep = StepId.create(17);
|
||||
|
||||
const shouldStop = StepTransitionValidator.shouldStopAtStep18(nextStep);
|
||||
|
||||
expect(shouldStop).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for early steps', () => {
|
||||
const nextStep = StepId.create(1);
|
||||
|
||||
const shouldStop = StepTransitionValidator.shouldStopAtStep18(nextStep);
|
||||
|
||||
expect(shouldStop).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStepDescription', () => {
|
||||
it('should return description for step 1', () => {
|
||||
const step = StepId.create(1);
|
||||
|
||||
const description = StepTransitionValidator.getStepDescription(step);
|
||||
|
||||
expect(description).toBe('Navigate to Hosted Racing page');
|
||||
});
|
||||
|
||||
it('should return description for step 6 (modal)', () => {
|
||||
const step = StepId.create(6);
|
||||
|
||||
const description = StepTransitionValidator.getStepDescription(step);
|
||||
|
||||
expect(description).toBe('Add Admin (Modal)');
|
||||
});
|
||||
|
||||
it('should return description for step 18 (final)', () => {
|
||||
const step = StepId.create(18);
|
||||
|
||||
const description = StepTransitionValidator.getStepDescription(step);
|
||||
|
||||
expect(description).toBe('Track Conditions (STOP - Manual Submit Required)');
|
||||
});
|
||||
|
||||
it('should return descriptions for all modal steps', () => {
|
||||
const modalSteps = [6, 9, 12];
|
||||
|
||||
modalSteps.forEach(stepNum => {
|
||||
const step = StepId.create(stepNum);
|
||||
const description = StepTransitionValidator.getStepDescription(step);
|
||||
expect(description).toContain('(Modal)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle rapid sequential transitions', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
let currentStep = StepId.create(1);
|
||||
|
||||
for (let i = 2; i <= 18; i++) {
|
||||
const nextStep = StepId.create(i);
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
currentStep = nextStep;
|
||||
}
|
||||
});
|
||||
|
||||
it('should prevent transitions from terminal states', () => {
|
||||
const terminalStates = ['COMPLETED', 'FAILED', 'STOPPED_AT_STEP_18'] as const;
|
||||
|
||||
terminalStates.forEach(stateValue => {
|
||||
const currentStep = StepId.create(10);
|
||||
const nextStep = StepId.create(11);
|
||||
const state = SessionState.create(stateValue);
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow transition from PAUSED when resumed', () => {
|
||||
const currentStep = StepId.create(5);
|
||||
const nextStep = StepId.create(6);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
187
tests/unit/domain/value-objects/SessionState.test.ts
Normal file
187
tests/unit/domain/value-objects/SessionState.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionState } from '../../../../src/packages/domain/value-objects/SessionState';
|
||||
|
||||
describe('SessionState Value Object', () => {
|
||||
describe('create', () => {
|
||||
it('should create PENDING state', () => {
|
||||
const state = SessionState.create('PENDING');
|
||||
expect(state.value).toBe('PENDING');
|
||||
});
|
||||
|
||||
it('should create IN_PROGRESS state', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.value).toBe('IN_PROGRESS');
|
||||
});
|
||||
|
||||
it('should create PAUSED state', () => {
|
||||
const state = SessionState.create('PAUSED');
|
||||
expect(state.value).toBe('PAUSED');
|
||||
});
|
||||
|
||||
it('should create COMPLETED state', () => {
|
||||
const state = SessionState.create('COMPLETED');
|
||||
expect(state.value).toBe('COMPLETED');
|
||||
});
|
||||
|
||||
it('should create FAILED state', () => {
|
||||
const state = SessionState.create('FAILED');
|
||||
expect(state.value).toBe('FAILED');
|
||||
});
|
||||
|
||||
it('should create STOPPED_AT_STEP_18 state', () => {
|
||||
const state = SessionState.create('STOPPED_AT_STEP_18');
|
||||
expect(state.value).toBe('STOPPED_AT_STEP_18');
|
||||
});
|
||||
|
||||
it('should throw error for invalid state', () => {
|
||||
expect(() => SessionState.create('INVALID' as any)).toThrow('Invalid session state');
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
expect(() => SessionState.create('' as any)).toThrow('Invalid session state');
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for equal states', () => {
|
||||
const state1 = SessionState.create('PENDING');
|
||||
const state2 = SessionState.create('PENDING');
|
||||
expect(state1.equals(state2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different states', () => {
|
||||
const state1 = SessionState.create('PENDING');
|
||||
const state2 = SessionState.create('IN_PROGRESS');
|
||||
expect(state1.equals(state2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPending', () => {
|
||||
it('should return true for PENDING state', () => {
|
||||
const state = SessionState.create('PENDING');
|
||||
expect(state.isPending()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for IN_PROGRESS state', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.isPending()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInProgress', () => {
|
||||
it('should return true for IN_PROGRESS state', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.isInProgress()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for PENDING state', () => {
|
||||
const state = SessionState.create('PENDING');
|
||||
expect(state.isInProgress()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCompleted', () => {
|
||||
it('should return true for COMPLETED state', () => {
|
||||
const state = SessionState.create('COMPLETED');
|
||||
expect(state.isCompleted()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for IN_PROGRESS state', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.isCompleted()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFailed', () => {
|
||||
it('should return true for FAILED state', () => {
|
||||
const state = SessionState.create('FAILED');
|
||||
expect(state.isFailed()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for COMPLETED state', () => {
|
||||
const state = SessionState.create('COMPLETED');
|
||||
expect(state.isFailed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStoppedAtStep18', () => {
|
||||
it('should return true for STOPPED_AT_STEP_18 state', () => {
|
||||
const state = SessionState.create('STOPPED_AT_STEP_18');
|
||||
expect(state.isStoppedAtStep18()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for COMPLETED state', () => {
|
||||
const state = SessionState.create('COMPLETED');
|
||||
expect(state.isStoppedAtStep18()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canTransitionTo', () => {
|
||||
it('should allow transition from PENDING to IN_PROGRESS', () => {
|
||||
const state = SessionState.create('PENDING');
|
||||
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow transition from IN_PROGRESS to PAUSED', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.canTransitionTo(SessionState.create('PAUSED'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow transition from IN_PROGRESS to STOPPED_AT_STEP_18', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.canTransitionTo(SessionState.create('STOPPED_AT_STEP_18'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow transition from PAUSED to IN_PROGRESS', () => {
|
||||
const state = SessionState.create('PAUSED');
|
||||
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow transition from COMPLETED to IN_PROGRESS', () => {
|
||||
const state = SessionState.create('COMPLETED');
|
||||
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should not allow transition from FAILED to IN_PROGRESS', () => {
|
||||
const state = SessionState.create('FAILED');
|
||||
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should not allow transition from STOPPED_AT_STEP_18 to IN_PROGRESS', () => {
|
||||
const state = SessionState.create('STOPPED_AT_STEP_18');
|
||||
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTerminal', () => {
|
||||
it('should return true for COMPLETED state', () => {
|
||||
const state = SessionState.create('COMPLETED');
|
||||
expect(state.isTerminal()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for FAILED state', () => {
|
||||
const state = SessionState.create('FAILED');
|
||||
expect(state.isTerminal()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for STOPPED_AT_STEP_18 state', () => {
|
||||
const state = SessionState.create('STOPPED_AT_STEP_18');
|
||||
expect(state.isTerminal()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for PENDING state', () => {
|
||||
const state = SessionState.create('PENDING');
|
||||
expect(state.isTerminal()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for IN_PROGRESS state', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.isTerminal()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for PAUSED state', () => {
|
||||
const state = SessionState.create('PAUSED');
|
||||
expect(state.isTerminal()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
tests/unit/domain/value-objects/StepId.test.ts
Normal file
104
tests/unit/domain/value-objects/StepId.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StepId } from '../../../../src/packages/domain/value-objects/StepId';
|
||||
|
||||
describe('StepId Value Object', () => {
|
||||
describe('create', () => {
|
||||
it('should create a valid StepId for step 1', () => {
|
||||
const stepId = StepId.create(1);
|
||||
expect(stepId.value).toBe(1);
|
||||
});
|
||||
|
||||
it('should create a valid StepId for step 18', () => {
|
||||
const stepId = StepId.create(18);
|
||||
expect(stepId.value).toBe(18);
|
||||
});
|
||||
|
||||
it('should throw error for step 0 (below minimum)', () => {
|
||||
expect(() => StepId.create(0)).toThrow('StepId must be between 1 and 18');
|
||||
});
|
||||
|
||||
it('should throw error for step 19 (above maximum)', () => {
|
||||
expect(() => StepId.create(19)).toThrow('StepId must be between 1 and 18');
|
||||
});
|
||||
|
||||
it('should throw error for negative step', () => {
|
||||
expect(() => StepId.create(-1)).toThrow('StepId must be between 1 and 18');
|
||||
});
|
||||
|
||||
it('should throw error for non-integer step', () => {
|
||||
expect(() => StepId.create(5.5)).toThrow('StepId must be an integer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for equal StepIds', () => {
|
||||
const stepId1 = StepId.create(5);
|
||||
const stepId2 = StepId.create(5);
|
||||
expect(stepId1.equals(stepId2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different StepIds', () => {
|
||||
const stepId1 = StepId.create(5);
|
||||
const stepId2 = StepId.create(6);
|
||||
expect(stepId1.equals(stepId2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isModalStep', () => {
|
||||
it('should return true for step 6 (add admin modal)', () => {
|
||||
const stepId = StepId.create(6);
|
||||
expect(stepId.isModalStep()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for step 9 (add car modal)', () => {
|
||||
const stepId = StepId.create(9);
|
||||
expect(stepId.isModalStep()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for step 12 (add track modal)', () => {
|
||||
const stepId = StepId.create(12);
|
||||
expect(stepId.isModalStep()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-modal step', () => {
|
||||
const stepId = StepId.create(1);
|
||||
expect(stepId.isModalStep()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFinalStep', () => {
|
||||
it('should return true for step 18', () => {
|
||||
const stepId = StepId.create(18);
|
||||
expect(stepId.isFinalStep()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for step 17', () => {
|
||||
const stepId = StepId.create(17);
|
||||
expect(stepId.isFinalStep()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for step 1', () => {
|
||||
const stepId = StepId.create(1);
|
||||
expect(stepId.isFinalStep()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('next', () => {
|
||||
it('should return next step for step 1', () => {
|
||||
const stepId = StepId.create(1);
|
||||
const nextStep = stepId.next();
|
||||
expect(nextStep.value).toBe(2);
|
||||
});
|
||||
|
||||
it('should return next step for step 17', () => {
|
||||
const stepId = StepId.create(17);
|
||||
const nextStep = stepId.next();
|
||||
expect(nextStep.value).toBe(18);
|
||||
});
|
||||
|
||||
it('should throw error when calling next on step 18', () => {
|
||||
const stepId = StepId.create(18);
|
||||
expect(() => stepId.next()).toThrow('Cannot advance beyond final step');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user