feat(companion): implement hosted session automation POC with TDD approach
This commit is contained in:
143
src/packages/domain/entities/AutomationSession.ts
Normal file
143
src/packages/domain/entities/AutomationSession.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { StepId } from '../value-objects/StepId';
|
||||
import { SessionState } from '../value-objects/SessionState';
|
||||
import { HostedSessionConfig } from './HostedSessionConfig';
|
||||
|
||||
export class AutomationSession {
|
||||
private readonly _id: string;
|
||||
private _currentStep: StepId;
|
||||
private _state: SessionState;
|
||||
private readonly _config: HostedSessionConfig;
|
||||
private _startedAt?: Date;
|
||||
private _completedAt?: Date;
|
||||
private _errorMessage?: string;
|
||||
|
||||
private constructor(
|
||||
id: string,
|
||||
currentStep: StepId,
|
||||
state: SessionState,
|
||||
config: HostedSessionConfig
|
||||
) {
|
||||
this._id = id;
|
||||
this._currentStep = currentStep;
|
||||
this._state = state;
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
static create(config: HostedSessionConfig): AutomationSession {
|
||||
if (!config.sessionName || config.sessionName.trim() === '') {
|
||||
throw new Error('Session name cannot be empty');
|
||||
}
|
||||
if (!config.trackId || config.trackId.trim() === '') {
|
||||
throw new Error('Track ID is required');
|
||||
}
|
||||
if (!config.carIds || config.carIds.length === 0) {
|
||||
throw new Error('At least one car must be selected');
|
||||
}
|
||||
|
||||
return new AutomationSession(
|
||||
randomUUID(),
|
||||
StepId.create(1),
|
||||
SessionState.create('PENDING'),
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get currentStep(): StepId {
|
||||
return this._currentStep;
|
||||
}
|
||||
|
||||
get state(): SessionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
get config(): HostedSessionConfig {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
get startedAt(): Date | undefined {
|
||||
return this._startedAt;
|
||||
}
|
||||
|
||||
get completedAt(): Date | undefined {
|
||||
return this._completedAt;
|
||||
}
|
||||
|
||||
get errorMessage(): string | undefined {
|
||||
return this._errorMessage;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (!this._state.isPending()) {
|
||||
throw new Error('Cannot start session that is not pending');
|
||||
}
|
||||
this._state = SessionState.create('IN_PROGRESS');
|
||||
this._startedAt = new Date();
|
||||
}
|
||||
|
||||
transitionToStep(targetStep: StepId): void {
|
||||
if (!this._state.isInProgress()) {
|
||||
throw new Error('Cannot transition steps when session is not in progress');
|
||||
}
|
||||
|
||||
if (this._currentStep.equals(targetStep)) {
|
||||
throw new Error('Already at this step');
|
||||
}
|
||||
|
||||
if (targetStep.value < this._currentStep.value) {
|
||||
throw new Error('Cannot move backward - steps must progress forward only');
|
||||
}
|
||||
|
||||
if (targetStep.value !== this._currentStep.value + 1) {
|
||||
throw new Error('Cannot skip steps - must transition sequentially');
|
||||
}
|
||||
|
||||
this._currentStep = targetStep;
|
||||
|
||||
if (this._currentStep.isFinalStep()) {
|
||||
this._state = SessionState.create('STOPPED_AT_STEP_18');
|
||||
this._completedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (!this._state.isInProgress()) {
|
||||
throw new Error('Cannot pause session that is not in progress');
|
||||
}
|
||||
this._state = SessionState.create('PAUSED');
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (this._state.value !== 'PAUSED') {
|
||||
throw new Error('Cannot resume session that is not paused');
|
||||
}
|
||||
this._state = SessionState.create('IN_PROGRESS');
|
||||
}
|
||||
|
||||
fail(errorMessage: string): void {
|
||||
if (this._state.isTerminal()) {
|
||||
throw new Error('Cannot fail terminal session');
|
||||
}
|
||||
this._state = SessionState.create('FAILED');
|
||||
this._errorMessage = errorMessage;
|
||||
this._completedAt = new Date();
|
||||
}
|
||||
|
||||
isAtModalStep(): boolean {
|
||||
return this._currentStep.isModalStep();
|
||||
}
|
||||
|
||||
getElapsedTime(): number {
|
||||
if (!this._startedAt) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const endTime = this._completedAt || new Date();
|
||||
const elapsed = endTime.getTime() - this._startedAt.getTime();
|
||||
return elapsed > 0 ? elapsed : 1;
|
||||
}
|
||||
}
|
||||
5
src/packages/domain/entities/HostedSessionConfig.ts
Normal file
5
src/packages/domain/entities/HostedSessionConfig.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface HostedSessionConfig {
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
}
|
||||
9
src/packages/domain/entities/StepExecution.ts
Normal file
9
src/packages/domain/entities/StepExecution.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { StepId } from '../value-objects/StepId';
|
||||
|
||||
export interface StepExecution {
|
||||
stepId: StepId;
|
||||
startedAt: Date;
|
||||
completedAt?: Date;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
81
src/packages/domain/services/StepTransitionValidator.ts
Normal file
81
src/packages/domain/services/StepTransitionValidator.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { StepId } from '../value-objects/StepId';
|
||||
import { SessionState } from '../value-objects/SessionState';
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
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: 'Configure Team Driving',
|
||||
18: 'Track Conditions (STOP - Manual Submit Required)',
|
||||
};
|
||||
|
||||
export class StepTransitionValidator {
|
||||
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(
|
||||
currentStep: StepId,
|
||||
nextStep: StepId
|
||||
): 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}`;
|
||||
}
|
||||
}
|
||||
81
src/packages/domain/value-objects/SessionState.ts
Normal file
81
src/packages/domain/value-objects/SessionState.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export type SessionStateValue =
|
||||
| 'PENDING'
|
||||
| 'IN_PROGRESS'
|
||||
| 'PAUSED'
|
||||
| 'COMPLETED'
|
||||
| 'FAILED'
|
||||
| 'STOPPED_AT_STEP_18';
|
||||
|
||||
const VALID_STATES: SessionStateValue[] = [
|
||||
'PENDING',
|
||||
'IN_PROGRESS',
|
||||
'PAUSED',
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
'STOPPED_AT_STEP_18',
|
||||
];
|
||||
|
||||
const VALID_TRANSITIONS: Record<SessionStateValue, SessionStateValue[]> = {
|
||||
PENDING: ['IN_PROGRESS', 'FAILED'],
|
||||
IN_PROGRESS: ['PAUSED', 'COMPLETED', 'FAILED', 'STOPPED_AT_STEP_18'],
|
||||
PAUSED: ['IN_PROGRESS', 'FAILED'],
|
||||
COMPLETED: [],
|
||||
FAILED: [],
|
||||
STOPPED_AT_STEP_18: [],
|
||||
};
|
||||
|
||||
export class SessionState {
|
||||
private readonly _value: SessionStateValue;
|
||||
|
||||
private constructor(value: SessionStateValue) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
static create(value: SessionStateValue): SessionState {
|
||||
if (!VALID_STATES.includes(value)) {
|
||||
throw new Error('Invalid session state');
|
||||
}
|
||||
return new SessionState(value);
|
||||
}
|
||||
|
||||
get value(): SessionStateValue {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
equals(other: SessionState): boolean {
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
isPending(): boolean {
|
||||
return this._value === 'PENDING';
|
||||
}
|
||||
|
||||
isInProgress(): boolean {
|
||||
return this._value === 'IN_PROGRESS';
|
||||
}
|
||||
|
||||
isCompleted(): boolean {
|
||||
return this._value === 'COMPLETED';
|
||||
}
|
||||
|
||||
isFailed(): boolean {
|
||||
return this._value === 'FAILED';
|
||||
}
|
||||
|
||||
isStoppedAtStep18(): boolean {
|
||||
return this._value === 'STOPPED_AT_STEP_18';
|
||||
}
|
||||
|
||||
canTransitionTo(targetState: SessionState): boolean {
|
||||
const allowedTransitions = VALID_TRANSITIONS[this._value];
|
||||
return allowedTransitions.includes(targetState._value);
|
||||
}
|
||||
|
||||
isTerminal(): boolean {
|
||||
return (
|
||||
this._value === 'COMPLETED' ||
|
||||
this._value === 'FAILED' ||
|
||||
this._value === 'STOPPED_AT_STEP_18'
|
||||
);
|
||||
}
|
||||
}
|
||||
40
src/packages/domain/value-objects/StepId.ts
Normal file
40
src/packages/domain/value-objects/StepId.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export class StepId {
|
||||
private readonly _value: number;
|
||||
|
||||
private constructor(value: number) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
static create(value: number): StepId {
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new Error('StepId must be an integer');
|
||||
}
|
||||
if (value < 1 || value > 18) {
|
||||
throw new Error('StepId must be between 1 and 18');
|
||||
}
|
||||
return new StepId(value);
|
||||
}
|
||||
|
||||
get value(): number {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
equals(other: StepId): boolean {
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
isModalStep(): boolean {
|
||||
return this._value === 6 || this._value === 9 || this._value === 12;
|
||||
}
|
||||
|
||||
isFinalStep(): boolean {
|
||||
return this._value === 18;
|
||||
}
|
||||
|
||||
next(): StepId {
|
||||
if (this.isFinalStep()) {
|
||||
throw new Error('Cannot advance beyond final step');
|
||||
}
|
||||
return StepId.create(this._value + 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user