import { CrewDutyRole } from './entities/train-crew-assignment.entity'; import { TrainCrewRole } from './entities/train-crew-member.entity'; import { AssignmentFacts, CorridorContext, CorridorYard, CrewDemandInput, legAllowsNationality, overtimeHours, specializedRequirements, technicianRequirement, validateCrewComposition, } from './crew-composition.rules'; /** * A slice of the real corridor, using the production display_order values: * GMP 3, Feto 7, Meiso 10, Dire Dawa 12, Nagad 19. */ const YARD: Record = { GMP: { id: 'y-gmp', label: 'GMP', country: 'Ethiopia', displayOrder: 3 }, FETO: { id: 'y-feto', label: 'Feto', country: 'Ethiopia', displayOrder: 7 }, MEISO: { id: 'y-meiso', label: 'Meiso', country: 'Ethiopia', displayOrder: 10 }, DIRE_DAWA: { id: 'y-dd', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 12 }, NAGAD: { id: 'y-nagad', label: 'Nagad', country: 'Djibouti', displayOrder: 19 }, }; const CORRIDOR: CorridorContext = { yards: new Map(Object.values(YARD).map((y) => [y.id, y])), originOrder: YARD.GMP.displayOrder, destinationOrder: YARD.NAGAD.displayOrder, direDawaOrder: YARD.DIRE_DAWA.displayOrder, }; const NO_DEMAND: CrewDemandInput = { hasBadOrderWagon: false, badOrderWagonLabels: [], hasReeferCargo: false, reeferSources: [], hasHazmatCargo: false, hazmatSources: [], hasBreakBulkCargo: false, breakBulkSources: [], hasLivestockCargo: false, livestockSources: [], }; let seq = 0; const driver = ( nationality: 'ETHIOPIAN' | 'DJIBOUTIAN', from: CorridorYard, to: CorridorYard, dutyRole: CrewDutyRole, ): AssignmentFacts => ({ crewMemberId: `driver-${++seq}`, role: TrainCrewRole.TRAIN_DRIVER, dutyRole, fromYardId: from.id, toYardId: to.id, nationality, memberName: `Driver ${seq}`, }); const crewOfRole = (role: TrainCrewRole, count: number): AssignmentFacts[] => Array.from({ length: count }, () => ({ crewMemberId: `member-${++seq}`, role, nationality: 'ETHIOPIAN', memberName: `Member ${seq}`, })); const codes = (result: { issues: Array<{ code: string }> }) => result.issues.map((i) => i.code); describe('crew composition rules (ITLMS Rolling Stock)', () => { const validate = ( assignments: AssignmentFacts[], demand: CrewDemandInput = NO_DEMAND, ) => validateCrewComposition(assignments, demand, CORRIDOR); /** One Ethiopian Primary over the whole route — the minimum viable crew. */ const soloPrimary = () => driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY); describe('free-form crew sizing', () => { it('accepts a single driver working the whole corridor', () => { const result = validate([soloPrimary()]); expect(result.issues).toEqual([]); expect(result.complete).toBe(true); }); it.each([1, 3, 4, 6, 8])('accepts a crew of %i drivers on one leg', (count) => { const drivers = [ soloPrimary(), ...Array.from({ length: count - 1 }, () => driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT), ), ]; expect(validate(drivers).complete).toBe(true); }); it('accepts any number of federal police, including none', () => { for (const count of [0, 1, 4, 9]) { const result = validate([ soloPrimary(), ...crewOfRole(TrainCrewRole.FEDERAL_POLICE, count), ]); expect(result.complete).toBe(true); } }); it('requires at least one driver', () => { const result = validate(crewOfRole(TrainCrewRole.FEDERAL_POLICE, 4)); expect(codes(result)).toContain('DRIVER_COUNT'); }); }); describe('yard-to-yard legs', () => { it('lets staff hand over at any intermediate yard', () => { // Three legs the old fixed segments could not express: GMP–Feto, // Feto–Meiso, Meiso–Nagad. const result = validate([ driver('ETHIOPIAN', YARD.GMP, YARD.FETO, CrewDutyRole.PRIMARY), driver('ETHIOPIAN', YARD.FETO, YARD.MEISO, CrewDutyRole.PRIMARY), driver('ETHIOPIAN', YARD.MEISO, YARD.NAGAD, CrewDutyRole.PRIMARY), ]); expect(result.issues).toEqual([]); expect(result.complete).toBe(true); }); it('rejects a leg with the same yard at both ends', () => { const result = validate([ driver('ETHIOPIAN', YARD.FETO, YARD.FETO, CrewDutyRole.PRIMARY), ]); expect(codes(result)).toContain('DRIVER_LEG_EMPTY'); }); it('rejects a yard outside the schedule route', () => { const outside: CorridorYard = { id: 'y-sebeta', label: 'Sebeta', country: 'Ethiopia', displayOrder: 1, // before the GMP origin }; const corridor: CorridorContext = { ...CORRIDOR, yards: new Map([...(CORRIDOR.yards ?? []), [outside.id, outside]]), }; const result = validateCrewComposition( [driver('ETHIOPIAN', outside, YARD.NAGAD, CrewDutyRole.PRIMARY)], NO_DEMAND, corridor, ); expect(codes(result)).toContain('LEG_OUTSIDE_ROUTE'); }); it('requires a from-yard, to-yard and duty role on every driver', () => { const result = validate([ { crewMemberId: 'd1', role: TrainCrewRole.TRAIN_DRIVER, nationality: 'ETHIOPIAN', memberName: 'Unslotted Driver', }, ]); expect(codes(result)).toContain('DRIVER_SLOT_INCOMPLETE'); }); }); describe('§1.1 territorial boundary', () => { const dd = YARD.DIRE_DAWA.displayOrder; it('lets a Djibouti driver work at or beyond Dire Dawa', () => { expect(legAllowsNationality(YARD.DIRE_DAWA, YARD.NAGAD, 'DJIBOUTIAN', dd)).toBe(true); }); it('bars a Djibouti driver from any leg west of Dire Dawa', () => { expect(legAllowsNationality(YARD.GMP, YARD.DIRE_DAWA, 'DJIBOUTIAN', dd)).toBe(false); expect(legAllowsNationality(YARD.FETO, YARD.MEISO, 'DJIBOUTIAN', dd)).toBe(false); }); it('leaves Ethiopian drivers unrestricted', () => { expect(legAllowsNationality(YARD.GMP, YARD.NAGAD, 'ETHIOPIAN', dd)).toBe(true); expect(legAllowsNationality(YARD.DIRE_DAWA, YARD.NAGAD, 'ETHIOPIAN', dd)).toBe(true); }); it('flags a Djibouti driver placed on a western leg', () => { const result = validate([ driver('DJIBOUTIAN', YARD.GMP, YARD.FETO, CrewDutyRole.PRIMARY), ]); expect(codes(result)).toContain('TERRITORIAL_BOUNDARY'); }); it('accepts the documented split: Ethiopians west, Djiboutians east', () => { const result = validate([ driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY), driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.ASSISTANT), driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY), driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.ASSISTANT), ]); expect(result.issues).toEqual([]); expect(result.runType).toBe('LONG_RUN'); }); }); describe('one Primary per leg', () => { it('rejects two Primaries on the same leg', () => { const result = validate([ driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY), driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY), ]); expect(codes(result)).toContain('DUPLICATE_PRIMARY'); }); it('allows a Primary on each of two different legs', () => { const result = validate([ driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY), driver('ETHIOPIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY), ]); expect(result.complete).toBe(true); }); it('allows many Assistants alongside one Primary', () => { const result = validate([ driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY), driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT), driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT), driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.BENCH_RELIEF), ]); expect(result.complete).toBe(true); }); it('requires a Primary on every covered leg', () => { const result = validate([ driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT), ]); expect(codes(result)).toContain('PRIMARY_MISSING'); }); }); describe('§1.1 run type', () => { it('is a long run when the legs span the whole route', () => { const result = validate([ driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY), driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY), ]); expect(result.runType).toBe('LONG_RUN'); }); it('is a short run when the legs cover only part of the route', () => { const result = validate([ driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY), ]); expect(result.runType).toBe('SHORT_RUN'); }); }); describe('§1.2 technical maintenance crew', () => { it('requires no technician when no bad-order wagon is attached', () => { expect(technicianRequirement(NO_DEMAND).min).toBe(0); }); it('forces one technician when a bad-order wagon is attached', () => { const demand = { ...NO_DEMAND, hasBadOrderWagon: true, badOrderWagonLabels: ['WG-1042'], }; expect(technicianRequirement(demand).min).toBe(1); const result = validate([soloPrimary()], demand); expect(codes(result)).toContain('TECHNICIAN_REQUIRED'); // The wagon that forced it is named, so the demand is explicable. expect(result.issues.find((i) => i.code === 'TECHNICIAN_REQUIRED')?.message) .toContain('WG-1042'); }); }); describe('§1.2 specialized cargo crew', () => { it('asks for nothing when no specialized cargo is aboard', () => { expect(specializedRequirements(NO_DEMAND)).toEqual([]); }); it('requires a reefer technician only when reefer cargo is aboard', () => { const demand = { ...NO_DEMAND, hasReeferCargo: true, reeferSources: ['BK-1'] }; const rules = specializedRequirements(demand); expect(rules).toHaveLength(1); expect(rules[0].role).toBe(TrainCrewRole.REEFER_TECHNICIAN); expect(rules[0].min).toBe(1); }); it('blocks a hazmat run with no escort assigned', () => { const result = validate([soloPrimary()], { ...NO_DEMAND, hasHazmatCargo: true, hazmatSources: ['BK-2024-0891'], }); expect(codes(result)).toContain('SPECIALIZED_REQUIRED'); expect(result.complete).toBe(false); }); it('passes once the escort is assigned, at any count', () => { for (const escorts of [1, 2, 5]) { const result = validate( [soloPrimary(), ...crewOfRole(TrainCrewRole.HAZMAT_ESCORT, escorts)], { ...NO_DEMAND, hasHazmatCargo: true, hazmatSources: ['BK-2024-0891'] }, ); expect(result.complete).toBe(true); } }); }); describe('duplicate seats', () => { it('flags a member assigned twice on one run', () => { const twice = crewOfRole(TrainCrewRole.FEDERAL_POLICE, 1)[0]; const result = validate([soloPrimary(), twice, twice]); expect(codes(result)).toContain('DUPLICATE_MEMBER'); }); }); describe('§3.2 overtime hours', () => { it('reproduces the documented worked example', () => { // PDF: 500h worked against a 240h standard => 260h variance, // split 156h at the 1.5x tier and 104h at the 1.75x tier. expect(overtimeHours(500)).toEqual({ variance: 260, tier1Hours: 156, tier2Hours: 104, }); }); it('reports no overtime below the monthly standard', () => { expect(overtimeHours(200)).toEqual({ variance: 0, tier1Hours: 0, tier2Hours: 0, }); }); it('splits the variance 60/40 as a flat convention', () => { const { tier1Hours, tier2Hours, variance } = overtimeHours(340); expect(variance).toBe(100); expect(tier1Hours).toBe(60); expect(tier2Hours).toBe(40); }); }); });