move automation out of core
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AutomationSession } from 'apps/companion/main/automation/domain/entities/AutomationSession';
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
|
||||
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 17 (safety checkpoint)', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
// Advance through all steps to 17
|
||||
for (let i = 2; i <= 17; i++) {
|
||||
session.transitionToStep(StepId.create(i));
|
||||
}
|
||||
|
||||
expect(session.currentStep.value).toBe(17);
|
||||
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 17
|
||||
for (let i = 2; i <= 17; 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 17
|
||||
for (let i = 2; i <= 17; i++) {
|
||||
session.transitionToStep(StepId.create(i));
|
||||
}
|
||||
|
||||
const elapsed = session.getElapsedTime();
|
||||
expect(elapsed).toBeGreaterThan(0);
|
||||
expect(session.state.isStoppedAtStep18()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { IEntity } from '@core/shared/domain';
|
||||
import { StepId } from '../value-objects/StepId';
|
||||
|
||||
import type { HostedSessionConfig } from '../types/HostedSessionConfig';
|
||||
import { AutomationDomainError } from '../errors/AutomationDomainError';
|
||||
import { SessionState } from '../value-objects/SessionState';
|
||||
|
||||
export class AutomationSession implements IEntity<string> {
|
||||
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 AutomationDomainError('Session name cannot be empty');
|
||||
}
|
||||
if (!config.trackId || config.trackId.trim() === '') {
|
||||
throw new AutomationDomainError('Track ID is required');
|
||||
}
|
||||
if (!config.carIds || config.carIds.length === 0) {
|
||||
throw new AutomationDomainError('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 AutomationDomainError('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 AutomationDomainError('Cannot transition steps when session is not in progress');
|
||||
}
|
||||
|
||||
if (this._currentStep.equals(targetStep)) {
|
||||
throw new AutomationDomainError('Already at this step');
|
||||
}
|
||||
|
||||
if (targetStep.value < this._currentStep.value) {
|
||||
throw new AutomationDomainError('Cannot move backward - steps must progress forward only');
|
||||
}
|
||||
|
||||
if (targetStep.value !== this._currentStep.value + 1) {
|
||||
throw new AutomationDomainError('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 AutomationDomainError('Cannot pause session that is not in progress');
|
||||
}
|
||||
this._state = SessionState.create('PAUSED');
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (this._state.value !== 'PAUSED') {
|
||||
throw new AutomationDomainError('Cannot resume session that is not paused');
|
||||
}
|
||||
this._state = SessionState.create('IN_PROGRESS');
|
||||
}
|
||||
|
||||
fail(errorMessage: string): void {
|
||||
if (this._state.isTerminal()) {
|
||||
throw new AutomationDomainError('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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { StepId } from '../value-objects/StepId';
|
||||
|
||||
/**
|
||||
* Domain Type: StepExecution
|
||||
*
|
||||
* Represents execution metadata for a single automation step.
|
||||
* This is a pure data shape (DTO-like), not an entity or value object.
|
||||
*/
|
||||
export interface StepExecution {
|
||||
stepId: StepId;
|
||||
startedAt: Date;
|
||||
completedAt?: Date;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user