refactor to adapters
This commit is contained in:
364
tests/domain/AutomationSession.spec.ts
Normal file
364
tests/domain/AutomationSession.spec.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AutomationSession } from '@gridpilot/automation/domain/entities/AutomationSession';
|
||||
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
|
||||
import { SessionState } from '@gridpilot/automation/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 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
111
tests/domain/BrowserAuthenticationState.spec.ts
Normal file
111
tests/domain/BrowserAuthenticationState.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { BrowserAuthenticationState } from '@gridpilot/automation/domain/value-objects/BrowserAuthenticationState';
|
||||
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
|
||||
|
||||
describe('BrowserAuthenticationState', () => {
|
||||
describe('isFullyAuthenticated()', () => {
|
||||
test('should return true when both cookies and page authenticated', () => {
|
||||
const state = new BrowserAuthenticationState(true, true);
|
||||
|
||||
expect(state.isFullyAuthenticated()).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false when cookies valid but page unauthenticated', () => {
|
||||
const state = new BrowserAuthenticationState(true, false);
|
||||
|
||||
expect(state.isFullyAuthenticated()).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false when cookies invalid but page authenticated', () => {
|
||||
const state = new BrowserAuthenticationState(false, true);
|
||||
|
||||
expect(state.isFullyAuthenticated()).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false when both cookies and page unauthenticated', () => {
|
||||
const state = new BrowserAuthenticationState(false, false);
|
||||
|
||||
expect(state.isFullyAuthenticated()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthenticationState()', () => {
|
||||
test('should return AUTHENTICATED when both cookies and page authenticated', () => {
|
||||
const state = new BrowserAuthenticationState(true, true);
|
||||
|
||||
expect(state.getAuthenticationState()).toBe(AuthenticationState.AUTHENTICATED);
|
||||
});
|
||||
|
||||
test('should return EXPIRED when cookies valid but page unauthenticated', () => {
|
||||
const state = new BrowserAuthenticationState(true, false);
|
||||
|
||||
expect(state.getAuthenticationState()).toBe(AuthenticationState.EXPIRED);
|
||||
});
|
||||
|
||||
test('should return UNKNOWN when cookies invalid', () => {
|
||||
const state = new BrowserAuthenticationState(false, false);
|
||||
|
||||
expect(state.getAuthenticationState()).toBe(AuthenticationState.UNKNOWN);
|
||||
});
|
||||
|
||||
test('should return UNKNOWN when cookies invalid regardless of page state', () => {
|
||||
const state = new BrowserAuthenticationState(false, true);
|
||||
|
||||
expect(state.getAuthenticationState()).toBe(AuthenticationState.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requiresReauthentication()', () => {
|
||||
test('should return false when fully authenticated', () => {
|
||||
const state = new BrowserAuthenticationState(true, true);
|
||||
|
||||
expect(state.requiresReauthentication()).toBe(false);
|
||||
});
|
||||
|
||||
test('should return true when cookies valid but page unauthenticated', () => {
|
||||
const state = new BrowserAuthenticationState(true, false);
|
||||
|
||||
expect(state.requiresReauthentication()).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true when cookies invalid', () => {
|
||||
const state = new BrowserAuthenticationState(false, false);
|
||||
|
||||
expect(state.requiresReauthentication()).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true when cookies invalid but page authenticated', () => {
|
||||
const state = new BrowserAuthenticationState(false, true);
|
||||
|
||||
expect(state.requiresReauthentication()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCookieValidity()', () => {
|
||||
test('should return true when cookies are valid', () => {
|
||||
const state = new BrowserAuthenticationState(true, true);
|
||||
|
||||
expect(state.getCookieValidity()).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false when cookies are invalid', () => {
|
||||
const state = new BrowserAuthenticationState(false, false);
|
||||
|
||||
expect(state.getCookieValidity()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPageAuthenticationStatus()', () => {
|
||||
test('should return true when page is authenticated', () => {
|
||||
const state = new BrowserAuthenticationState(true, true);
|
||||
|
||||
expect(state.getPageAuthenticationStatus()).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false when page is unauthenticated', () => {
|
||||
const state = new BrowserAuthenticationState(true, false);
|
||||
|
||||
expect(state.getPageAuthenticationStatus()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
92
tests/domain/CheckoutConfirmation.spec.ts
Normal file
92
tests/domain/CheckoutConfirmation.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CheckoutConfirmation } from '@gridpilot/automation/domain/value-objects/CheckoutConfirmation';
|
||||
|
||||
describe('CheckoutConfirmation Value Object', () => {
|
||||
describe('create', () => {
|
||||
it('should create confirmed decision', () => {
|
||||
const confirmation = CheckoutConfirmation.create('confirmed');
|
||||
expect(confirmation.value).toBe('confirmed');
|
||||
});
|
||||
|
||||
it('should create cancelled decision', () => {
|
||||
const confirmation = CheckoutConfirmation.create('cancelled');
|
||||
expect(confirmation.value).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('should create timeout decision', () => {
|
||||
const confirmation = CheckoutConfirmation.create('timeout');
|
||||
expect(confirmation.value).toBe('timeout');
|
||||
});
|
||||
|
||||
it('should throw error for invalid decision', () => {
|
||||
expect(() => CheckoutConfirmation.create('invalid' as any)).toThrow(
|
||||
'Invalid checkout confirmation decision',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConfirmed', () => {
|
||||
it('should return true for confirmed decision', () => {
|
||||
const confirmation = CheckoutConfirmation.create('confirmed');
|
||||
expect(confirmation.isConfirmed()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for cancelled decision', () => {
|
||||
const confirmation = CheckoutConfirmation.create('cancelled');
|
||||
expect(confirmation.isConfirmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for timeout decision', () => {
|
||||
const confirmation = CheckoutConfirmation.create('timeout');
|
||||
expect(confirmation.isConfirmed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCancelled', () => {
|
||||
it('should return true for cancelled decision', () => {
|
||||
const confirmation = CheckoutConfirmation.create('cancelled');
|
||||
expect(confirmation.isCancelled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for confirmed decision', () => {
|
||||
const confirmation = CheckoutConfirmation.create('confirmed');
|
||||
expect(confirmation.isCancelled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for timeout decision', () => {
|
||||
const confirmation = CheckoutConfirmation.create('timeout');
|
||||
expect(confirmation.isCancelled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTimeout', () => {
|
||||
it('should return true for timeout decision', () => {
|
||||
const confirmation = CheckoutConfirmation.create('timeout');
|
||||
expect(confirmation.isTimeout()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for confirmed decision', () => {
|
||||
const confirmation = CheckoutConfirmation.create('confirmed');
|
||||
expect(confirmation.isTimeout()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for cancelled decision', () => {
|
||||
const confirmation = CheckoutConfirmation.create('cancelled');
|
||||
expect(confirmation.isTimeout()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for equal confirmations', () => {
|
||||
const confirmation1 = CheckoutConfirmation.create('confirmed');
|
||||
const confirmation2 = CheckoutConfirmation.create('confirmed');
|
||||
expect(confirmation1.equals(confirmation2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different confirmations', () => {
|
||||
const confirmation1 = CheckoutConfirmation.create('confirmed');
|
||||
const confirmation2 = CheckoutConfirmation.create('cancelled');
|
||||
expect(confirmation1.equals(confirmation2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
184
tests/domain/CheckoutPrice.spec.ts
Normal file
184
tests/domain/CheckoutPrice.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
|
||||
|
||||
/**
|
||||
* CheckoutPrice Value Object - GREEN PHASE
|
||||
*
|
||||
* Tests for price validation, parsing, and formatting.
|
||||
*/
|
||||
|
||||
describe('CheckoutPrice Value Object', () => {
|
||||
describe('Construction', () => {
|
||||
it('should create with valid price $0.50', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
expect(() => new CheckoutPrice(0.50)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should create with valid price $10.00', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
expect(() => new CheckoutPrice(10.00)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should create with valid price $100.00', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
expect(() => new CheckoutPrice(100.00)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject negative prices', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
expect(() => new CheckoutPrice(-0.50)).toThrow(/negative/i);
|
||||
});
|
||||
|
||||
it('should reject excessive prices over $10,000', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
expect(() => new CheckoutPrice(10000.01)).toThrow(/excessive|maximum/i);
|
||||
});
|
||||
|
||||
it('should accept exactly $10,000', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
expect(() => new CheckoutPrice(10000.00)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept $0.00 (zero price)', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
expect(() => new CheckoutPrice(0.00)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromString() parsing', () => {
|
||||
it('should extract $0.50 from string', () => {
|
||||
const price = CheckoutPrice.fromString('$0.50');
|
||||
expect(price.getAmount()).toBe(0.50);
|
||||
});
|
||||
|
||||
it('should extract $10.00 from string', () => {
|
||||
const price = CheckoutPrice.fromString('$10.00');
|
||||
expect(price.getAmount()).toBe(10.00);
|
||||
});
|
||||
|
||||
it('should extract $100.00 from string', () => {
|
||||
const price = CheckoutPrice.fromString('$100.00');
|
||||
expect(price.getAmount()).toBe(100.00);
|
||||
});
|
||||
|
||||
it('should reject string without dollar sign', () => {
|
||||
expect(() => CheckoutPrice.fromString('10.00')).toThrow(/invalid.*format/i);
|
||||
});
|
||||
|
||||
it('should reject string with multiple dollar signs', () => {
|
||||
expect(() => CheckoutPrice.fromString('$$10.00')).toThrow(/invalid.*format/i);
|
||||
});
|
||||
|
||||
it('should reject non-numeric values', () => {
|
||||
expect(() => CheckoutPrice.fromString('$abc')).toThrow(/invalid.*format/i);
|
||||
});
|
||||
|
||||
it('should reject empty string', () => {
|
||||
expect(() => CheckoutPrice.fromString('')).toThrow(/invalid.*format/i);
|
||||
});
|
||||
|
||||
it('should handle prices with commas $1,000.00', () => {
|
||||
const price = CheckoutPrice.fromString('$1,000.00');
|
||||
expect(price.getAmount()).toBe(1000.00);
|
||||
});
|
||||
|
||||
it('should handle whitespace around price', () => {
|
||||
const price = CheckoutPrice.fromString(' $5.00 ');
|
||||
expect(price.getAmount()).toBe(5.00);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Display formatting', () => {
|
||||
it('should format $0.50 as "$0.50"', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
const price = new CheckoutPrice(0.50);
|
||||
expect(price.toDisplayString()).toBe('$0.50');
|
||||
});
|
||||
|
||||
it('should format $10.00 as "$10.00"', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
const price = new CheckoutPrice(10.00);
|
||||
expect(price.toDisplayString()).toBe('$10.00');
|
||||
});
|
||||
|
||||
it('should format $100.00 as "$100.00"', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
const price = new CheckoutPrice(100.00);
|
||||
expect(price.toDisplayString()).toBe('$100.00');
|
||||
});
|
||||
|
||||
it('should always show two decimal places', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
const price = new CheckoutPrice(5);
|
||||
expect(price.toDisplayString()).toBe('$5.00');
|
||||
});
|
||||
|
||||
it('should round to two decimal places', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
const price = new CheckoutPrice(5.129);
|
||||
expect(price.toDisplayString()).toBe('$5.13');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zero check', () => {
|
||||
it('should detect $0.00 correctly', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
const price = new CheckoutPrice(0.00);
|
||||
expect(price.isZero()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-zero prices', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
const price = new CheckoutPrice(0.50);
|
||||
expect(price.isZero()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle floating point precision for zero', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
const price = new CheckoutPrice(0.0000001);
|
||||
expect(price.isZero()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle very small prices $0.01', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
const price = new CheckoutPrice(0.01);
|
||||
expect(price.toDisplayString()).toBe('$0.01');
|
||||
});
|
||||
|
||||
it('should handle large prices $9,999.99', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
const price = new CheckoutPrice(9999.99);
|
||||
expect(price.toDisplayString()).toBe('$9999.99');
|
||||
});
|
||||
|
||||
it('should be immutable after creation', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
const price = new CheckoutPrice(5.00);
|
||||
const amount = price.getAmount();
|
||||
expect(amount).toBe(5.00);
|
||||
// Verify no setters exist
|
||||
const mutablePrice = price as unknown as { setAmount?: unknown };
|
||||
expect(typeof mutablePrice.setAmount).toBe('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BDD Scenarios', () => {
|
||||
it('Given price string "$0.50", When parsing, Then amount is 0.50', () => {
|
||||
const price = CheckoutPrice.fromString('$0.50');
|
||||
expect(price.getAmount()).toBe(0.50);
|
||||
});
|
||||
|
||||
it('Given amount 10.00, When formatting, Then display is "$10.00"', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
const price = new CheckoutPrice(10.00);
|
||||
expect(price.toDisplayString()).toBe('$10.00');
|
||||
});
|
||||
|
||||
it('Given negative amount, When constructing, Then error is thrown', () => {
|
||||
// @ts-expect-error Testing private constructor invariants
|
||||
expect(() => new CheckoutPrice(-5.00)).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
127
tests/domain/CheckoutState.spec.ts
Normal file
127
tests/domain/CheckoutState.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CheckoutState, CheckoutStateEnum } from '@gridpilot/automation/domain/value-objects/CheckoutState';
|
||||
|
||||
/**
|
||||
* CheckoutState Value Object - GREEN PHASE
|
||||
*
|
||||
* Tests for checkout button state detection.
|
||||
*/
|
||||
|
||||
describe('CheckoutState Value Object', () => {
|
||||
describe('READY state', () => {
|
||||
it('should create READY state from btn-success class', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-success');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
|
||||
});
|
||||
|
||||
it('should detect ready state correctly', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-success');
|
||||
expect(state.isReady()).toBe(true);
|
||||
expect(state.hasInsufficientFunds()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle additional classes with btn-success', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-lg btn-success pull-right');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
|
||||
});
|
||||
|
||||
it('should be case-insensitive for btn-success', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn BTN-SUCCESS');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('INSUFFICIENT_FUNDS state', () => {
|
||||
it('should create INSUFFICIENT_FUNDS from btn-default without btn-success', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-default');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
|
||||
});
|
||||
|
||||
it('should detect insufficient funds correctly', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-default');
|
||||
expect(state.isReady()).toBe(false);
|
||||
expect(state.hasInsufficientFunds()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle btn-primary as insufficient funds', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-primary');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
|
||||
});
|
||||
|
||||
it('should handle btn-warning as insufficient funds', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-warning');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
|
||||
});
|
||||
|
||||
it('should handle disabled button as insufficient funds', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-default disabled');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UNKNOWN state', () => {
|
||||
it('should create UNKNOWN when no btn class exists', () => {
|
||||
const state = CheckoutState.fromButtonClasses('some-other-class');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.UNKNOWN);
|
||||
});
|
||||
|
||||
it('should create UNKNOWN from empty string', () => {
|
||||
const state = CheckoutState.fromButtonClasses('');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.UNKNOWN);
|
||||
});
|
||||
|
||||
it('should detect unknown state correctly', () => {
|
||||
const state = CheckoutState.fromButtonClasses('');
|
||||
expect(state.isReady()).toBe(false);
|
||||
expect(state.hasInsufficientFunds()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle whitespace in class names', () => {
|
||||
const state = CheckoutState.fromButtonClasses(' btn btn-success ');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
|
||||
});
|
||||
|
||||
it('should handle multiple spaces between classes', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-success');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
|
||||
});
|
||||
|
||||
it('should be immutable after creation', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-success');
|
||||
const originalState = state.getValue();
|
||||
expect(originalState).toBe(CheckoutStateEnum.READY);
|
||||
// Verify no setters exist
|
||||
const mutableState = state as unknown as { setState?: unknown };
|
||||
expect(typeof mutableState.setState).toBe('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BDD Scenarios', () => {
|
||||
it('Given button with btn-success, When checking state, Then state is READY', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-success');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
|
||||
});
|
||||
|
||||
it('Given button without btn-success, When checking state, Then state is INSUFFICIENT_FUNDS', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-default');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
|
||||
});
|
||||
|
||||
it('Given no button classes, When checking state, Then state is UNKNOWN', () => {
|
||||
const state = CheckoutState.fromButtonClasses('');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.UNKNOWN);
|
||||
});
|
||||
|
||||
it('Given READY state, When checking isReady, Then returns true', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-success');
|
||||
expect(state.isReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('Given INSUFFICIENT_FUNDS state, When checking hasInsufficientFunds, Then returns true', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-default');
|
||||
expect(state.hasInsufficientFunds()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
288
tests/domain/CookieConfiguration.spec.ts
Normal file
288
tests/domain/CookieConfiguration.spec.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { CookieConfiguration } from '@gridpilot/automation/domain/value-objects/CookieConfiguration';
|
||||
|
||||
describe('CookieConfiguration', () => {
|
||||
const validTargetUrl = 'https://members-ng.iracing.com/jjwtauth/success';
|
||||
|
||||
describe('domain validation', () => {
|
||||
test('should accept exact domain match', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: 'members-ng.iracing.com',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
expect(() => new CookieConfiguration(config, validTargetUrl)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should accept wildcard domain for subdomain match', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
expect(() => new CookieConfiguration(config, validTargetUrl)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should accept wildcard domain for base domain match', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
const baseUrl = 'https://iracing.com/';
|
||||
expect(() => new CookieConfiguration(config, baseUrl)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should match wildcard domain with multiple subdomain levels', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
const deepUrl = 'https://api.members-ng.iracing.com/endpoint';
|
||||
expect(() => new CookieConfiguration(config, deepUrl)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should throw error when domain does not match target', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: 'example.com',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
expect(() => new CookieConfiguration(config, validTargetUrl))
|
||||
.toThrow(/domain mismatch/i);
|
||||
});
|
||||
|
||||
test('should throw error when wildcard domain does not match target', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: '.example.com',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
expect(() => new CookieConfiguration(config, validTargetUrl))
|
||||
.toThrow(/domain mismatch/i);
|
||||
});
|
||||
|
||||
test('should throw error when subdomain does not match wildcard', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: '.racing.com',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
expect(() => new CookieConfiguration(config, validTargetUrl))
|
||||
.toThrow(/domain mismatch/i);
|
||||
});
|
||||
|
||||
test('should accept cookies from related subdomains with same base domain', () => {
|
||||
const cookie = {
|
||||
name: 'XSESSIONID',
|
||||
value: 'session_value',
|
||||
domain: 'members.iracing.com',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
// Should work: members.iracing.com → members-ng.iracing.com
|
||||
// Both share base domain "iracing.com"
|
||||
expect(() =>
|
||||
new CookieConfiguration(cookie, 'https://members-ng.iracing.com/web/racing')
|
||||
).not.toThrow();
|
||||
|
||||
const config = new CookieConfiguration(cookie, 'https://members-ng.iracing.com/web/racing');
|
||||
expect(config.getValidatedCookie().name).toBe('XSESSIONID');
|
||||
});
|
||||
|
||||
test('should reject cookies from different base domains', () => {
|
||||
const cookie = {
|
||||
name: 'SESSION',
|
||||
value: 'session_value',
|
||||
domain: 'example.com',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
// Should fail: example.com ≠ iracing.com
|
||||
expect(() =>
|
||||
new CookieConfiguration(cookie, 'https://members.iracing.com/web/racing')
|
||||
).toThrow(/domain mismatch/i);
|
||||
});
|
||||
|
||||
test('should accept cookies from exact subdomain match', () => {
|
||||
const cookie = {
|
||||
name: 'SESSION',
|
||||
value: 'session_value',
|
||||
domain: 'members-ng.iracing.com',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
// Exact match should always work
|
||||
expect(() =>
|
||||
new CookieConfiguration(cookie, 'https://members-ng.iracing.com/web/racing')
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test('should accept cookies between different subdomains of same base domain', () => {
|
||||
const cookie = {
|
||||
name: 'AUTH_TOKEN',
|
||||
value: 'token_value',
|
||||
domain: 'api.iracing.com',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
// Should work: api.iracing.com → members-ng.iracing.com
|
||||
expect(() =>
|
||||
new CookieConfiguration(cookie, 'https://members-ng.iracing.com/api')
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test('should reject subdomain cookies when base domain has insufficient parts', () => {
|
||||
const cookie = {
|
||||
name: 'TEST',
|
||||
value: 'test_value',
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
// Single-part domain should not match different single-part domain
|
||||
expect(() =>
|
||||
new CookieConfiguration(cookie, 'https://example/path')
|
||||
).toThrow(/domain mismatch/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('path validation', () => {
|
||||
test('should accept root path for any target path', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: 'members-ng.iracing.com',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
expect(() => new CookieConfiguration(config, validTargetUrl)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should accept path that is prefix of target path', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: 'members-ng.iracing.com',
|
||||
path: '/jjwtauth',
|
||||
};
|
||||
|
||||
expect(() => new CookieConfiguration(config, validTargetUrl)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should throw error when path is not prefix of target path', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: 'members-ng.iracing.com',
|
||||
path: '/other/path',
|
||||
};
|
||||
|
||||
expect(() => new CookieConfiguration(config, validTargetUrl))
|
||||
.toThrow(/path.*not valid/i);
|
||||
});
|
||||
|
||||
test('should throw error when path is longer than target path', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: 'members-ng.iracing.com',
|
||||
path: '/jjwtauth/success/extra',
|
||||
};
|
||||
|
||||
expect(() => new CookieConfiguration(config, validTargetUrl))
|
||||
.toThrow(/path.*not valid/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidatedCookie()', () => {
|
||||
test('should return cookie with validated domain and path', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: 'members-ng.iracing.com',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
const cookieConfig = new CookieConfiguration(config, validTargetUrl);
|
||||
const cookie = cookieConfig.getValidatedCookie();
|
||||
|
||||
expect(cookie.name).toBe('test_cookie');
|
||||
expect(cookie.value).toBe('test_value');
|
||||
expect(cookie.domain).toBe('members-ng.iracing.com');
|
||||
expect(cookie.path).toBe('/');
|
||||
});
|
||||
|
||||
test('should preserve all cookie properties', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: 'members-ng.iracing.com',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax' as const,
|
||||
};
|
||||
|
||||
const cookieConfig = new CookieConfiguration(config, validTargetUrl);
|
||||
const cookie = cookieConfig.getValidatedCookie();
|
||||
|
||||
expect(cookie.secure).toBe(true);
|
||||
expect(cookie.httpOnly).toBe(true);
|
||||
expect(cookie.sameSite).toBe('Lax');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('should handle empty domain', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: '',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
expect(() => new CookieConfiguration(config, validTargetUrl))
|
||||
.toThrow(/domain mismatch/i);
|
||||
});
|
||||
|
||||
test('should handle empty path', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: 'members-ng.iracing.com',
|
||||
path: '',
|
||||
};
|
||||
|
||||
expect(() => new CookieConfiguration(config, validTargetUrl))
|
||||
.toThrow(/path.*not valid/i);
|
||||
});
|
||||
|
||||
test('should handle malformed target URL', () => {
|
||||
const config = {
|
||||
name: 'test_cookie',
|
||||
value: 'test_value',
|
||||
domain: 'members-ng.iracing.com',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
expect(() => new CookieConfiguration(config, 'not-a-valid-url'))
|
||||
.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
88
tests/domain/DropScoreApplier.spec.ts
Normal file
88
tests/domain/DropScoreApplier.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { DropScoreApplier } from '@gridpilot/racing/domain/services/DropScoreApplier';
|
||||
import type { EventPointsEntry } from '@gridpilot/racing/domain/services/DropScoreApplier';
|
||||
import type { DropScorePolicy } from '@gridpilot/racing/domain/types/DropScorePolicy';
|
||||
|
||||
describe('DropScoreApplier', () => {
|
||||
it('with strategy none counts all events and drops none', () => {
|
||||
const applier = new DropScoreApplier();
|
||||
|
||||
const policy: DropScorePolicy = {
|
||||
strategy: 'none',
|
||||
};
|
||||
|
||||
const events: EventPointsEntry[] = [
|
||||
{ eventId: 'event-1', points: 25 },
|
||||
{ eventId: 'event-2', points: 18 },
|
||||
{ eventId: 'event-3', points: 15 },
|
||||
];
|
||||
|
||||
const result = applier.apply(policy, events);
|
||||
|
||||
expect(result.counted).toHaveLength(3);
|
||||
expect(result.dropped).toHaveLength(0);
|
||||
expect(result.totalPoints).toBe(25 + 18 + 15);
|
||||
});
|
||||
|
||||
it('with bestNResults keeps the highest scoring events and drops the rest', () => {
|
||||
const applier = new DropScoreApplier();
|
||||
|
||||
const policy: DropScorePolicy = {
|
||||
strategy: 'bestNResults',
|
||||
count: 6,
|
||||
};
|
||||
|
||||
const events: EventPointsEntry[] = [
|
||||
{ eventId: 'event-1', points: 25 },
|
||||
{ eventId: 'event-2', points: 18 },
|
||||
{ eventId: 'event-3', points: 15 },
|
||||
{ eventId: 'event-4', points: 12 },
|
||||
{ eventId: 'event-5', points: 10 },
|
||||
{ eventId: 'event-6', points: 8 },
|
||||
{ eventId: 'event-7', points: 6 },
|
||||
{ eventId: 'event-8', points: 4 },
|
||||
];
|
||||
|
||||
const result = applier.apply(policy, events);
|
||||
|
||||
expect(result.counted).toHaveLength(6);
|
||||
expect(result.dropped).toHaveLength(2);
|
||||
|
||||
const countedIds = result.counted.map((e) => e.eventId);
|
||||
expect(countedIds).toEqual([
|
||||
'event-1',
|
||||
'event-2',
|
||||
'event-3',
|
||||
'event-4',
|
||||
'event-5',
|
||||
'event-6',
|
||||
]);
|
||||
|
||||
const droppedIds = result.dropped.map((e) => e.eventId);
|
||||
expect(droppedIds).toEqual(['event-7', 'event-8']);
|
||||
|
||||
expect(result.totalPoints).toBe(25 + 18 + 15 + 12 + 10 + 8);
|
||||
});
|
||||
|
||||
it('bestNResults with count greater than available events counts all of them', () => {
|
||||
const applier = new DropScoreApplier();
|
||||
|
||||
const policy: DropScorePolicy = {
|
||||
strategy: 'bestNResults',
|
||||
count: 10,
|
||||
};
|
||||
|
||||
const events: EventPointsEntry[] = [
|
||||
{ eventId: 'event-1', points: 25 },
|
||||
{ eventId: 'event-2', points: 18 },
|
||||
{ eventId: 'event-3', points: 15 },
|
||||
];
|
||||
|
||||
const result = applier.apply(policy, events);
|
||||
|
||||
expect(result.counted).toHaveLength(3);
|
||||
expect(result.dropped).toHaveLength(0);
|
||||
expect(result.totalPoints).toBe(25 + 18 + 15);
|
||||
});
|
||||
});
|
||||
167
tests/domain/PageStateValidator.spec.ts
Normal file
167
tests/domain/PageStateValidator.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PageStateValidator } from '@gridpilot/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 = (selector: string) => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
107
tests/domain/RaceCreationResult.spec.ts
Normal file
107
tests/domain/RaceCreationResult.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RaceCreationResult } from '@gridpilot/automation/domain/value-objects/RaceCreationResult';
|
||||
|
||||
describe('RaceCreationResult Value Object', () => {
|
||||
describe('create', () => {
|
||||
it('should create race creation result with all fields', () => {
|
||||
const result = RaceCreationResult.create({
|
||||
sessionId: 'test-session-123',
|
||||
price: '$10.00',
|
||||
timestamp: new Date('2025-11-25T12:00:00Z'),
|
||||
});
|
||||
|
||||
expect(result.sessionId).toBe('test-session-123');
|
||||
expect(result.price).toBe('$10.00');
|
||||
expect(result.timestamp).toEqual(new Date('2025-11-25T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('should throw error for empty session ID', () => {
|
||||
expect(() =>
|
||||
RaceCreationResult.create({
|
||||
sessionId: '',
|
||||
price: '$10.00',
|
||||
timestamp: new Date(),
|
||||
})
|
||||
).toThrow('Session ID cannot be empty');
|
||||
});
|
||||
|
||||
it('should throw error for empty price', () => {
|
||||
expect(() =>
|
||||
RaceCreationResult.create({
|
||||
sessionId: 'test-session-123',
|
||||
price: '',
|
||||
timestamp: new Date(),
|
||||
})
|
||||
).toThrow('Price cannot be empty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for equal results', () => {
|
||||
const timestamp = new Date('2025-11-25T12:00:00Z');
|
||||
const result1 = RaceCreationResult.create({
|
||||
sessionId: 'test-session-123',
|
||||
price: '$10.00',
|
||||
timestamp,
|
||||
});
|
||||
const result2 = RaceCreationResult.create({
|
||||
sessionId: 'test-session-123',
|
||||
price: '$10.00',
|
||||
timestamp,
|
||||
});
|
||||
|
||||
expect(result1.equals(result2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different session IDs', () => {
|
||||
const timestamp = new Date('2025-11-25T12:00:00Z');
|
||||
const result1 = RaceCreationResult.create({
|
||||
sessionId: 'test-session-123',
|
||||
price: '$10.00',
|
||||
timestamp,
|
||||
});
|
||||
const result2 = RaceCreationResult.create({
|
||||
sessionId: 'test-session-456',
|
||||
price: '$10.00',
|
||||
timestamp,
|
||||
});
|
||||
|
||||
expect(result1.equals(result2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for different prices', () => {
|
||||
const timestamp = new Date('2025-11-25T12:00:00Z');
|
||||
const result1 = RaceCreationResult.create({
|
||||
sessionId: 'test-session-123',
|
||||
price: '$10.00',
|
||||
timestamp,
|
||||
});
|
||||
const result2 = RaceCreationResult.create({
|
||||
sessionId: 'test-session-123',
|
||||
price: '$20.00',
|
||||
timestamp,
|
||||
});
|
||||
|
||||
expect(result1.equals(result2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toJSON', () => {
|
||||
it('should serialize to JSON correctly', () => {
|
||||
const timestamp = new Date('2025-11-25T12:00:00Z');
|
||||
const result = RaceCreationResult.create({
|
||||
sessionId: 'test-session-123',
|
||||
price: '$10.00',
|
||||
timestamp,
|
||||
});
|
||||
|
||||
const json = result.toJSON();
|
||||
|
||||
expect(json).toEqual({
|
||||
sessionId: 'test-session-123',
|
||||
price: '$10.00',
|
||||
timestamp: timestamp.toISOString(),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
278
tests/domain/ScheduleCalculator.spec.ts
Normal file
278
tests/domain/ScheduleCalculator.spec.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateRaceDates, getNextWeekday, type ScheduleConfig } from '../../../../core/racing/domain/services/ScheduleCalculator';
|
||||
import type { Weekday } from '../../../../core/racing/domain/types/Weekday';
|
||||
|
||||
describe('ScheduleCalculator', () => {
|
||||
describe('calculateRaceDates', () => {
|
||||
describe('with empty or invalid input', () => {
|
||||
it('should return empty array when weekdays is empty', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: [],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates).toEqual([]);
|
||||
expect(result.seasonDurationWeeks).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty array when rounds is 0', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 0,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when rounds is negative', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: -5,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekly scheduling', () => {
|
||||
it('should schedule 8 races on Saturdays starting from a Saturday', () => {
|
||||
// Given - January 6, 2024 is a Saturday
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-06'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
// All dates should be Saturdays
|
||||
result.raceDates.forEach(date => {
|
||||
expect(date.getDay()).toBe(6); // Saturday
|
||||
});
|
||||
// First race should be Jan 6
|
||||
expect(result.raceDates[0]!.toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
// Last race should be 7 weeks later (Feb 24)
|
||||
expect(result.raceDates[7]!.toISOString().split('T')[0]).toBe('2024-02-24');
|
||||
});
|
||||
|
||||
it('should schedule races on multiple weekdays', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Wed', 'Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-01'), // Monday
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
// Should alternate between Wednesday and Saturday
|
||||
result.raceDates.forEach(date => {
|
||||
const day = date.getDay();
|
||||
expect([3, 6]).toContain(day); // Wed=3, Sat=6
|
||||
});
|
||||
});
|
||||
|
||||
it('should schedule 8 races on Sundays', () => {
|
||||
// Given - January 7, 2024 is a Sunday
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sun'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
result.raceDates.forEach(date => {
|
||||
expect(date.getDay()).toBe(0); // Sunday
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('bi-weekly scheduling', () => {
|
||||
it('should schedule races every 2 weeks on Saturdays', () => {
|
||||
// Given - January 6, 2024 is a Saturday
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'everyNWeeks',
|
||||
rounds: 4,
|
||||
startDate: new Date('2024-01-06'),
|
||||
intervalWeeks: 2,
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(4);
|
||||
// First race Jan 6
|
||||
expect(result.raceDates[0]!.toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
// Second race 2 weeks later (Jan 20)
|
||||
expect(result.raceDates[1]!.toISOString().split('T')[0]).toBe('2024-01-20');
|
||||
// Third race 2 weeks later (Feb 3)
|
||||
expect(result.raceDates[2]!.toISOString().split('T')[0]).toBe('2024-02-03');
|
||||
// Fourth race 2 weeks later (Feb 17)
|
||||
expect(result.raceDates[3]!.toISOString().split('T')[0]).toBe('2024-02-17');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with start and end dates', () => {
|
||||
it('should evenly distribute races across the date range', () => {
|
||||
// Given - 3 month season
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-06'),
|
||||
endDate: new Date('2024-03-30'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
// First race should be at or near start
|
||||
expect(result.raceDates[0]!.toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
// Races should be spread across the range, not consecutive weeks
|
||||
});
|
||||
|
||||
it('should use all available days if fewer than rounds requested', () => {
|
||||
// Given - short period with only 3 Saturdays
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 10,
|
||||
startDate: new Date('2024-01-06'),
|
||||
endDate: new Date('2024-01-21'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
// Only 3 Saturdays in this range: Jan 6, 13, 20
|
||||
expect(result.raceDates.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('season duration calculation', () => {
|
||||
it('should calculate correct season duration in weeks', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-06'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
// 8 races, 1 week apart = 7 weeks duration
|
||||
expect(result.seasonDurationWeeks).toBe(7);
|
||||
});
|
||||
|
||||
it('should return 0 duration for single race', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 1,
|
||||
startDate: new Date('2024-01-06'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(1);
|
||||
expect(result.seasonDurationWeeks).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextWeekday', () => {
|
||||
it('should return next Saturday from a Monday', () => {
|
||||
// Given - January 1, 2024 is a Monday
|
||||
const fromDate = new Date('2024-01-01');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Sat');
|
||||
|
||||
// Then
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
expect(result.getDay()).toBe(6);
|
||||
});
|
||||
|
||||
it('should return next occurrence when already on that weekday', () => {
|
||||
// Given - January 6, 2024 is a Saturday
|
||||
const fromDate = new Date('2024-01-06');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Sat');
|
||||
|
||||
// Then
|
||||
// Should return NEXT Saturday (7 days later), not same day
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-13');
|
||||
});
|
||||
|
||||
it('should return next Sunday from a Friday', () => {
|
||||
// Given - January 5, 2024 is a Friday
|
||||
const fromDate = new Date('2024-01-05');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Sun');
|
||||
|
||||
// Then
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-07');
|
||||
expect(result.getDay()).toBe(0);
|
||||
});
|
||||
|
||||
it('should return next Wednesday from a Thursday', () => {
|
||||
// Given - January 4, 2024 is a Thursday
|
||||
const fromDate = new Date('2024-01-04');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Wed');
|
||||
|
||||
// Then
|
||||
// Next Wednesday is 6 days later
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-10');
|
||||
expect(result.getDay()).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
103
tests/domain/SessionLifetime.spec.ts
Normal file
103
tests/domain/SessionLifetime.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionLifetime } from '@gridpilot/automation/domain/value-objects/SessionLifetime';
|
||||
|
||||
describe('SessionLifetime Value Object', () => {
|
||||
describe('Construction', () => {
|
||||
it('should create with valid expiry date', () => {
|
||||
const futureDate = new Date(Date.now() + 3600000);
|
||||
expect(() => new SessionLifetime(futureDate)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should create with null expiry (no expiration)', () => {
|
||||
expect(() => new SessionLifetime(null)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid dates', () => {
|
||||
const invalidDate = new Date('invalid');
|
||||
expect(() => new SessionLifetime(invalidDate)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject dates in the past', () => {
|
||||
const pastDate = new Date(Date.now() - 3600000);
|
||||
expect(() => new SessionLifetime(pastDate)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpired()', () => {
|
||||
it('should return true for expired date', () => {
|
||||
const pastDate = new Date(Date.now() - 1000);
|
||||
const lifetime = new SessionLifetime(pastDate);
|
||||
expect(lifetime.isExpired()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for valid future date', () => {
|
||||
const futureDate = new Date(Date.now() + 3600000);
|
||||
const lifetime = new SessionLifetime(futureDate);
|
||||
expect(lifetime.isExpired()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null expiry (never expires)', () => {
|
||||
const lifetime = new SessionLifetime(null);
|
||||
expect(lifetime.isExpired()).toBe(false);
|
||||
});
|
||||
|
||||
it('should consider buffer time (5 minutes)', () => {
|
||||
const nearExpiryDate = new Date(Date.now() + 240000);
|
||||
const lifetime = new SessionLifetime(nearExpiryDate);
|
||||
expect(lifetime.isExpired()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not consider expired when beyond buffer', () => {
|
||||
const safeDate = new Date(Date.now() + 360000);
|
||||
const lifetime = new SessionLifetime(safeDate);
|
||||
expect(lifetime.isExpired()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpiringSoon()', () => {
|
||||
it('should return true for date within buffer window', () => {
|
||||
const soonDate = new Date(Date.now() + 240000);
|
||||
const lifetime = new SessionLifetime(soonDate);
|
||||
expect(lifetime.isExpiringSoon()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for date far in future', () => {
|
||||
const farDate = new Date(Date.now() + 3600000);
|
||||
const lifetime = new SessionLifetime(farDate);
|
||||
expect(lifetime.isExpiringSoon()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null expiry', () => {
|
||||
const lifetime = new SessionLifetime(null);
|
||||
expect(lifetime.isExpiringSoon()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true exactly at buffer boundary (5 minutes)', () => {
|
||||
const boundaryDate = new Date(Date.now() + 300000);
|
||||
const lifetime = new SessionLifetime(boundaryDate);
|
||||
expect(lifetime.isExpiringSoon()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle timezone correctly', () => {
|
||||
const utcDate = new Date('2025-12-31T23:59:59Z');
|
||||
const lifetime = new SessionLifetime(utcDate);
|
||||
expect(lifetime.getExpiry()).toEqual(utcDate);
|
||||
});
|
||||
|
||||
it('should handle millisecond precision', () => {
|
||||
const preciseDate = new Date(Date.now() + 299999);
|
||||
const lifetime = new SessionLifetime(preciseDate);
|
||||
expect(lifetime.isExpiringSoon()).toBe(true);
|
||||
});
|
||||
|
||||
it('should provide remaining time', () => {
|
||||
const futureDate = new Date(Date.now() + 3600000);
|
||||
const lifetime = new SessionLifetime(futureDate);
|
||||
const remaining = lifetime.getRemainingTime();
|
||||
expect(remaining).toBeGreaterThan(3000000);
|
||||
expect(remaining).toBeLessThanOrEqual(3600000);
|
||||
});
|
||||
});
|
||||
});
|
||||
254
tests/domain/SessionState.spec.ts
Normal file
254
tests/domain/SessionState.spec.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionState } from '@gridpilot/automation/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 create AWAITING_CHECKOUT_CONFIRMATION state', () => {
|
||||
const state = SessionState.create('AWAITING_CHECKOUT_CONFIRMATION');
|
||||
expect(state.value).toBe('AWAITING_CHECKOUT_CONFIRMATION');
|
||||
});
|
||||
|
||||
it('should create CANCELLED state', () => {
|
||||
const state = SessionState.create('CANCELLED');
|
||||
expect(state.value).toBe('CANCELLED');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('should return false for AWAITING_CHECKOUT_CONFIRMATION state', () => {
|
||||
const state = SessionState.create('AWAITING_CHECKOUT_CONFIRMATION');
|
||||
expect(state.isTerminal()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for CANCELLED state', () => {
|
||||
const state = SessionState.create('CANCELLED');
|
||||
expect(state.isTerminal()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state transitions with new states', () => {
|
||||
it('should allow transition from IN_PROGRESS to AWAITING_CHECKOUT_CONFIRMATION', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.canTransitionTo(SessionState.create('AWAITING_CHECKOUT_CONFIRMATION'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow transition from AWAITING_CHECKOUT_CONFIRMATION to COMPLETED', () => {
|
||||
const state = SessionState.create('AWAITING_CHECKOUT_CONFIRMATION');
|
||||
expect(state.canTransitionTo(SessionState.create('COMPLETED'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow transition from AWAITING_CHECKOUT_CONFIRMATION to CANCELLED', () => {
|
||||
const state = SessionState.create('AWAITING_CHECKOUT_CONFIRMATION');
|
||||
expect(state.canTransitionTo(SessionState.create('CANCELLED'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow transition from CANCELLED to any other state', () => {
|
||||
const state = SessionState.create('CANCELLED');
|
||||
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(false);
|
||||
expect(state.canTransitionTo(SessionState.create('COMPLETED'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAwaitingCheckoutConfirmation', () => {
|
||||
it('should return true for AWAITING_CHECKOUT_CONFIRMATION state', () => {
|
||||
const state = SessionState.create('AWAITING_CHECKOUT_CONFIRMATION');
|
||||
expect(state.isAwaitingCheckoutConfirmation()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for IN_PROGRESS state', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.isAwaitingCheckoutConfirmation()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCancelled', () => {
|
||||
it('should return true for CANCELLED state', () => {
|
||||
const state = SessionState.create('CANCELLED');
|
||||
expect(state.isCancelled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for COMPLETED state', () => {
|
||||
const state = SessionState.create('COMPLETED');
|
||||
expect(state.isCancelled()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
tests/domain/StepId.spec.ts
Normal file
104
tests/domain/StepId.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StepId } from '@gridpilot/automation/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 17', () => {
|
||||
const stepId = StepId.create(17);
|
||||
expect(stepId.value).toBe(17);
|
||||
});
|
||||
|
||||
it('should throw error for step 0 (below minimum)', () => {
|
||||
expect(() => StepId.create(0)).toThrow('StepId must be between 1 and 17');
|
||||
});
|
||||
|
||||
it('should throw error for step 18 (above maximum)', () => {
|
||||
expect(() => StepId.create(18)).toThrow('StepId must be between 1 and 17');
|
||||
});
|
||||
|
||||
it('should throw error for negative step', () => {
|
||||
expect(() => StepId.create(-1)).toThrow('StepId must be between 1 and 17');
|
||||
});
|
||||
|
||||
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 17', () => {
|
||||
const stepId = StepId.create(17);
|
||||
expect(stepId.isFinalStep()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for step 16', () => {
|
||||
const stepId = StepId.create(16);
|
||||
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 16', () => {
|
||||
const stepId = StepId.create(16);
|
||||
const nextStep = stepId.next();
|
||||
expect(nextStep.value).toBe(17);
|
||||
});
|
||||
|
||||
it('should throw error when calling next on step 17', () => {
|
||||
const stepId = StepId.create(17);
|
||||
expect(() => stepId.next()).toThrow('Cannot advance beyond final step');
|
||||
});
|
||||
});
|
||||
});
|
||||
231
tests/domain/StepTransitionValidator.spec.ts
Normal file
231
tests/domain/StepTransitionValidator.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StepTransitionValidator } from '@gridpilot/automation/domain/services/StepTransitionValidator';
|
||||
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
|
||||
import { SessionState } from '@gridpilot/automation/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 17 (final step)', () => {
|
||||
const nextStep = StepId.create(17);
|
||||
|
||||
const shouldStop = StepTransitionValidator.shouldStopAtStep18(nextStep);
|
||||
|
||||
expect(shouldStop).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for steps before 17', () => {
|
||||
const nextStep = StepId.create(16);
|
||||
|
||||
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 17 (final)', () => {
|
||||
const step = StepId.create(17);
|
||||
|
||||
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 <= 17; 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user