move automation out of core

This commit is contained in:
2025-12-16 14:31:43 +01:00
parent 29dc11deb9
commit 29410708c8
145 changed files with 378 additions and 1532 deletions

View File

@@ -0,0 +1,167 @@
import { describe, it, expect } from 'vitest';
import { PageStateValidator } from 'apps/companion/main/automation/domain/services/PageStateValidator';
describe('PageStateValidator', () => {
const validator = new PageStateValidator();
describe('validateState', () => {
it('should return valid when all required selectors are present', () => {
// Arrange
const actualState = (selector: string) => {
return ['#add-car-button', '#cars-list'].includes(selector);
};
// Act
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#add-car-button', '#cars-list']
});
// Assert
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(true);
expect(value.expectedStep).toBe('cars');
expect(value.message).toContain('Page state valid');
});
it('should return invalid when required selectors are missing', () => {
// Arrange
const actualState = (selector: string) => {
return selector === '#add-car-button'; // Only one of two selectors present
};
// Act
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#add-car-button', '#cars-list']
});
// Assert
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(false);
expect(value.expectedStep).toBe('cars');
expect(value.missingSelectors).toEqual(['#cars-list']);
expect(value.message).toContain('missing required elements');
});
it('should return invalid when forbidden selectors are present', () => {
// Arrange
const actualState = (selector: string) => {
return ['#add-car-button', '#set-track'].includes(selector);
};
// Act
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#add-car-button'],
forbiddenSelectors: ['#set-track'] // Should NOT be on track page yet
});
// Assert
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(false);
expect(value.expectedStep).toBe('cars');
expect(value.unexpectedSelectors).toEqual(['#set-track']);
expect(value.message).toContain('unexpected elements');
});
it('should handle empty forbidden selectors array', () => {
// Arrange
const actualState = (selector: string) => {
return selector === '#add-car-button';
};
// Act
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#add-car-button'],
forbiddenSelectors: []
});
// Assert
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(true);
});
it('should handle undefined forbidden selectors', () => {
// Arrange
const actualState = (selector: string) => {
return selector === '#add-car-button';
};
// Act
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#add-car-button']
// forbiddenSelectors is undefined
});
// Assert
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(true);
});
it('should return error result when actualState function throws', () => {
// Arrange
const actualState = () => {
throw new Error('Selector evaluation failed');
};
// Act
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#add-car-button']
});
// Assert
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.message).toContain('Selector evaluation failed');
});
it('should provide clear error messages for missing selectors', () => {
// Arrange
const actualState = () => false; // Nothing present
// Act
const result = validator.validateState(actualState, {
expectedStep: 'track',
requiredSelectors: ['#set-track', '#track-search']
});
// Assert
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(false);
expect(value.message).toBe('Page state mismatch: Expected to be on "track" page but missing required elements');
expect(value.missingSelectors).toEqual(['#set-track', '#track-search']);
});
it('should validate complex state with both required and forbidden selectors', () => {
// Arrange - Simulate being on Cars page but Track page elements leaked through
const actualState = (selector: string) => {
const presentSelectors = ['#add-car-button', '#cars-list', '#set-track'];
return presentSelectors.includes(selector);
};
// Act
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#add-car-button', '#cars-list'],
forbiddenSelectors: ['#set-track', '#track-search']
});
// Assert
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(false); // Invalid due to forbidden selector
expect(value.unexpectedSelectors).toEqual(['#set-track']);
expect(value.message).toContain('unexpected elements');
});
});
});

View File

@@ -0,0 +1,242 @@
import type { IDomainValidationService } from '@core/shared/domain';
import { Result } from '@gridpilot/shared/result/Result';
/**
* Configuration for page state validation.
* Defines expected and forbidden elements on the current page.
*/
export interface PageStateValidation {
/** Expected wizard step name (e.g., 'cars', 'track') */
expectedStep: string;
/** Selectors that MUST be present on the page */
requiredSelectors: string[];
/** Selectors that MUST NOT be present on the page */
forbiddenSelectors?: string[];
}
/**
* Result of page state validation.
*/
export interface PageStateValidationResult {
isValid: boolean;
message: string;
expectedStep: string;
missingSelectors?: string[];
unexpectedSelectors?: string[];
}
export interface PageStateValidationInput {
actualState: (_selector: string) => boolean;
validation: PageStateValidation;
realMode?: boolean;
}
/**
* Domain service for validating page state during wizard navigation.
*
* Purpose: Prevent navigation bugs by ensuring each step executes on the correct page.
*
* Clean Architecture: This is pure domain logic with no infrastructure dependencies.
* It validates state based on selector presence/absence without knowing HOW to check them.
*/
export class PageStateValidator
implements
IDomainValidationService<PageStateValidationInput, PageStateValidationResult, Error>
{
validate(input: PageStateValidationInput): Result<PageStateValidationResult, Error> {
const { actualState, validation, realMode } = input;
if (typeof realMode === 'boolean') {
return this.validateStateEnhanced(actualState, validation, realMode);
}
return this.validateState(actualState, validation);
}
/**
* Validate that the page state matches expected conditions.
*
* @param actualState Function that checks if selectors exist on the page
* @param validation Expected page state configuration
* @returns Result with validation outcome
*/
validateState(
actualState: (_selector: string) => boolean,
validation: PageStateValidation
): Result<PageStateValidationResult, Error> {
try {
const { expectedStep, requiredSelectors, forbiddenSelectors = [] } = validation;
// Check required selectors are present
const missingSelectors = requiredSelectors.filter(_selector => !actualState(_selector));
if (missingSelectors.length > 0) {
const result: PageStateValidationResult = {
isValid: false,
message: `Page state mismatch: Expected to be on "${expectedStep}" page but missing required elements`,
expectedStep,
missingSelectors
};
return Result.ok(result);
}
// Check forbidden selectors are absent
const unexpectedSelectors = forbiddenSelectors.filter(_selector => actualState(_selector));
if (unexpectedSelectors.length > 0) {
const result: PageStateValidationResult = {
isValid: false,
message: `Page state mismatch: Found unexpected elements on "${expectedStep}" page`,
expectedStep,
unexpectedSelectors
};
return Result.ok(result);
}
// All checks passed
const result: PageStateValidationResult = {
isValid: true,
message: `Page state valid for "${expectedStep}"`,
expectedStep
};
return Result.ok(result);
} catch (error) {
return Result.err(
error instanceof Error
? error
: new Error(`Page state validation failed: ${String(error)}`)
);
}
}
/**
* Enhanced validation that tries multiple selector strategies for real iRacing HTML.
* This handles the mismatch between test expectations (data-indicator attributes)
* and real HTML structure (Chakra UI components).
*
* @param actualState Function that checks if selectors exist on the page
* @param validation Expected page state configuration
* @param realMode Whether we're in real mode (using real HTML dumps) or mock mode
* @returns Result with validation outcome
*/
validateStateEnhanced(
actualState: (_selector: string) => boolean,
validation: PageStateValidation,
realMode: boolean = false
): Result<PageStateValidationResult, Error> {
try {
const { expectedStep, requiredSelectors, forbiddenSelectors = [] } = validation;
// In real mode, try to match the actual HTML structure with fallbacks
let selectorsToCheck = [...requiredSelectors];
if (realMode) {
// Add fallback selectors for real iRacing HTML (Chakra UI structure)
const fallbackMap: Record<string, string[]> = {
cars: [
'#set-cars',
'[id*="cars"]',
'.wizard-step[id*="cars"]',
'.cars-panel',
// Real iRacing fallbacks - use step container IDs
'[data-testid*="set-cars"]',
'.chakra-stack:has([data-testid*="cars"])',
],
track: [
'#set-track',
'[id*="track"]',
'.wizard-step[id*="track"]',
'.track-panel',
// Real iRacing fallbacks
'[data-testid*="set-track"]',
'.chakra-stack:has([data-testid*="track"])',
],
'add-car': [
'a.btn:has-text("Add a Car")',
'.btn:has-text("Add a Car")',
'[data-testid*="add-car"]',
// Real iRacing button selectors
'a.btn.btn-primary.btn-block.btn-sm:has-text("Add a Car")',
],
};
// For each required selector, add fallbacks
const enhancedSelectors: string[] = [];
for (const selector of requiredSelectors) {
enhancedSelectors.push(selector);
// Add step-specific fallbacks
const lowerStep = expectedStep.toLowerCase();
if (fallbackMap[lowerStep]) {
enhancedSelectors.push(...fallbackMap[lowerStep]);
}
// Generic Chakra UI fallbacks for wizard steps
if (selector.includes('data-indicator')) {
enhancedSelectors.push(
`[id*="${expectedStep}"]`,
`[data-testid*="${expectedStep}"]`,
`.wizard-step:has([data-testid*="${expectedStep}"])`,
);
}
}
selectorsToCheck = enhancedSelectors;
}
// Check required selectors are present (with fallbacks for real mode)
const missingSelectors = requiredSelectors.filter(selector => {
if (realMode) {
const relatedSelectors = selectorsToCheck.filter(s =>
s.includes(expectedStep) ||
s.includes(
selector
.replace(/[\[\]"']/g, '')
.replace('data-indicator=', ''),
),
);
if (relatedSelectors.length === 0) {
return !actualState(selector);
}
return !relatedSelectors.some(s => actualState(s));
}
return !actualState(selector);
});
if (missingSelectors.length > 0) {
const result: PageStateValidationResult = {
isValid: false,
message: `Page state mismatch: Expected to be on "${expectedStep}" page but missing required elements`,
expectedStep,
missingSelectors
};
return Result.ok(result);
}
// Check forbidden selectors are absent
const unexpectedSelectors = forbiddenSelectors.filter(_selector => actualState(_selector));
if (unexpectedSelectors.length > 0) {
const result: PageStateValidationResult = {
isValid: false,
message: `Page state mismatch: Found unexpected elements on "${expectedStep}" page`,
expectedStep,
unexpectedSelectors
};
return Result.ok(result);
}
// All checks passed
const result: PageStateValidationResult = {
isValid: true,
message: `Page state valid for "${expectedStep}"`,
expectedStep
};
return Result.ok(result);
} catch (error) {
return Result.err(
error instanceof Error
? error
: new Error(`Page state validation failed: ${String(error)}`)
);
}
}
}

View File

@@ -0,0 +1,105 @@
import { StepId } from '../value-objects/StepId';
import type { IDomainValidationService } from '@core/shared/domain';
import { Result } from '@gridpilot/shared/result/Result';
import { SessionState } from '../value-objects/SessionState';
export interface ValidationResult {
isValid: boolean;
error?: string;
}
export interface StepTransitionValidationInput {
currentStep: StepId;
nextStep: StepId;
state: SessionState;
}
export interface StepTransitionValidationResult extends ValidationResult {}
const STEP_DESCRIPTIONS: Record<number, string> = {
1: 'Navigate to Hosted Racing page',
2: 'Click Create a Race',
3: 'Fill Race Information',
4: 'Configure Server Details',
5: 'Set Admins',
6: 'Add Admin (Modal)',
7: 'Set Time Limits',
8: 'Set Cars',
9: 'Add a Car (Modal)',
10: 'Set Car Classes',
11: 'Set Track',
12: 'Add a Track (Modal)',
13: 'Configure Track Options',
14: 'Set Time of Day',
15: 'Configure Weather',
16: 'Set Race Options',
17: 'Track Conditions (STOP - Manual Submit Required)',
};
export class StepTransitionValidator
implements
IDomainValidationService<StepTransitionValidationInput, StepTransitionValidationResult, Error>
{
validate(input: StepTransitionValidationInput): Result<StepTransitionValidationResult, Error> {
try {
const { currentStep, nextStep, state } = input;
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
return Result.ok(result);
} catch (error) {
return Result.err(
error instanceof Error
? error
: new Error(`Step transition validation failed: ${String(error)}`),
);
}
}
static canTransition(
currentStep: StepId,
nextStep: StepId,
state: SessionState
): ValidationResult {
if (!state.isInProgress()) {
return {
isValid: false,
error: 'Session must be in progress to transition steps',
};
}
if (currentStep.equals(nextStep)) {
return {
isValid: false,
error: 'Already at this step',
};
}
if (nextStep.value < currentStep.value) {
return {
isValid: false,
error: 'Cannot move backward - steps must progress forward only',
};
}
if (nextStep.value !== currentStep.value + 1) {
return {
isValid: false,
error: 'Cannot skip steps - must progress sequentially',
};
}
return { isValid: true };
}
static validateModalStepTransition(
): ValidationResult {
return { isValid: true };
}
static shouldStopAtStep18(nextStep: StepId): boolean {
return nextStep.isFinalStep();
}
static getStepDescription(step: StepId): string {
return STEP_DESCRIPTIONS[step.value] || `Step ${step.value}`;
}
}