import { describe, it, expect } from 'vitest'; import { autoFillPlacements, unitKey, validateLocalPlacements } from './containerPlacement.util'; import type { ContainerUnitRow } from '@/types/trainScheduling'; function makeUnits(containerType: string, sizeFt: number, quantity: number): ContainerUnitRow[] { const units: ContainerUnitRow[] = []; const containersPerWagon = sizeFt >= 40 ? 1 : 2; const wagonsPerUnit = sizeFt >= 40 ? 1 : 0.5; for (let i = 0; i < quantity; i++) { units.push({ bookingId: 'booking-1', bookingReference: 'BKG-001', bookingContainerId: 'bc-1', unitIndex: i, containerTypeId: 'ct-1', containerTypeCode: containerType, label: `${containerType} ${i + 1}/${quantity}`, grossWeightTons: 25, sizeFt, wagonsPerUnit, containersPerWagon, teuSlots: sizeFt >= 40 ? 2 : 1, }); } return units; } describe('containerPlacement.util', () => { describe('unitKey', () => { it('creates unique keys for units', () => { expect(unitKey('bc-1', 0)).toBe('bc-1:0'); expect(unitKey('bc-1', 1)).toBe('bc-1:1'); expect(unitKey('bc-2', 0)).toBe('bc-2:0'); }); }); describe('autoFillPlacements', () => { it('returns empty array when no units or slots', () => { expect(autoFillPlacements([], [1, 2, 3])).toEqual([]); expect(autoFillPlacements(makeUnits('20GP', 20, 1), [])).toEqual([]); }); it('places 2×20ft containers in 1 wagon slot', () => { const units = makeUnits('20GP', 20, 2); const slots = [1, 2, 3]; const placements = autoFillPlacements(units, slots); expect(placements).toHaveLength(2); // Both 20ft containers should be in slot 1 expect(placements[0]?.sequenceNo).toBe(1); expect(placements[1]?.sequenceNo).toBe(1); }); it('places 6×20ft containers in 3 wagon slots (2 per wagon)', () => { const units = makeUnits('20GP', 20, 6); const slots = [1, 2, 3, 4, 5]; const placements = autoFillPlacements(units, slots); expect(placements).toHaveLength(6); // Units 0,1 -> Slot 1 expect(placements[0]?.sequenceNo).toBe(1); expect(placements[1]?.sequenceNo).toBe(1); // Units 2,3 -> Slot 2 expect(placements[2]?.sequenceNo).toBe(2); expect(placements[3]?.sequenceNo).toBe(2); // Units 4,5 -> Slot 3 expect(placements[4]?.sequenceNo).toBe(3); expect(placements[5]?.sequenceNo).toBe(3); }); it('places 1×40ft container in 1 wagon slot', () => { const units = makeUnits('40GP', 40, 1); const slots = [1, 2, 3]; const placements = autoFillPlacements(units, slots); expect(placements).toHaveLength(1); expect(placements[0]?.sequenceNo).toBe(1); }); it('places 3×40ft containers in 3 wagon slots (1 per wagon)', () => { const units = makeUnits('40GP', 40, 3); const slots = [1, 2, 3, 4, 5]; const placements = autoFillPlacements(units, slots); expect(placements).toHaveLength(3); // Each 40ft container gets its own slot expect(placements[0]?.sequenceNo).toBe(1); expect(placements[1]?.sequenceNo).toBe(2); expect(placements[2]?.sequenceNo).toBe(3); }); it('handles mixed 20ft and 40ft containers correctly', () => { const units20 = makeUnits('20GP', 20, 2); const units40 = makeUnits('40GP', 40, 1); const units = [...units20, ...units40]; const slots = [1, 2, 3, 4, 5]; const placements = autoFillPlacements(units, slots); expect(placements).toHaveLength(3); // First two 20ft containers share slot 1 expect(placements[0]?.sequenceNo).toBe(1); expect(placements[1]?.sequenceNo).toBe(1); // 40ft container gets slot 2 expect(placements[2]?.sequenceNo).toBe(2); }); it('never shares a wagon between a 40ft and a 20ft when the 40ft comes first', () => { // Regression: a 40ft (2 TEU) must occupy its own wagon and never pair with a 20ft. const units40 = makeUnits('40GP', 40, 1); const units20 = makeUnits('20GP', 20, 2); const units = [...units40, ...units20]; const slots = [1, 2, 3, 4]; const placements = autoFillPlacements(units, slots); expect(placements).toHaveLength(3); // 40ft alone on slot 1 expect(placements[0]?.sequenceNo).toBe(1); // both 20ft together on slot 2 — NOT on slot 1 with the 40ft expect(placements[1]?.sequenceNo).toBe(2); expect(placements[2]?.sequenceNo).toBe(2); }); it('falls back to last slot when running out of slots', () => { const units = makeUnits('20GP', 20, 6); const slots = [1, 2]; // Only 2 slots available const placements = autoFillPlacements(units, slots); expect(placements).toHaveLength(6); // First 4 units fit in slots 1 and 2 expect(placements[0]?.sequenceNo).toBe(1); expect(placements[1]?.sequenceNo).toBe(1); expect(placements[2]?.sequenceNo).toBe(2); expect(placements[3]?.sequenceNo).toBe(2); // Remaining units fall back to last available slot (slot 2) expect(placements[4]?.sequenceNo).toBe(2); expect(placements[5]?.sequenceNo).toBe(2); }); it('defaults to 2 containers per wagon when sizeFt is not provided', () => { const units: ContainerUnitRow[] = [ { bookingId: 'booking-1', bookingReference: 'BKG-001', bookingContainerId: 'bc-1', unitIndex: 0, containerTypeId: 'ct-1', containerTypeCode: '20GP', label: 'Container 1', grossWeightTons: 25, // sizeFt not provided, should default to 2 per wagon }, { bookingId: 'booking-1', bookingReference: 'BKG-001', bookingContainerId: 'bc-1', unitIndex: 1, containerTypeId: 'ct-1', containerTypeCode: '20GP', label: 'Container 2', grossWeightTons: 25, }, ]; const slots = [1, 2, 3]; const placements = autoFillPlacements(units, slots); expect(placements[0]?.sequenceNo).toBe(1); expect(placements[1]?.sequenceNo).toBe(1); }); it('uses 1 container per wagon for 40ft when sizeFt is 40', () => { const units: ContainerUnitRow[] = [ { bookingId: 'booking-1', bookingReference: 'BKG-001', bookingContainerId: 'bc-1', unitIndex: 0, containerTypeId: 'ct-1', containerTypeCode: '40GP', label: 'Container 1', grossWeightTons: 25, sizeFt: 40, }, { bookingId: 'booking-1', bookingReference: 'BKG-001', bookingContainerId: 'bc-1', unitIndex: 1, containerTypeId: 'ct-1', containerTypeCode: '40GP', label: 'Container 2', grossWeightTons: 25, sizeFt: 40, }, ]; const slots = [1, 2, 3]; const placements = autoFillPlacements(units, slots); // Each 40ft container should get its own slot expect(placements[0]?.sequenceNo).toBe(1); expect(placements[1]?.sequenceNo).toBe(2); }); }); describe('validateLocalPlacements', () => { it('returns empty array for valid placements', () => { const units = makeUnits('20GP', 20, 1); const placements = [ { bookingContainerId: 'bc-1', unitIndex: 0, sequenceNo: 1, containerNumber: 'CNTR123', }, ]; expect(validateLocalPlacements(units, placements)).toEqual([]); }); it('returns error for missing slot', () => { const units = makeUnits('20GP', 20, 1); const placements: ReturnType = []; const issues = validateLocalPlacements(units, placements); expect(issues.some((i) => i.includes('Slot missing'))).toBe(true); }); it('returns error for missing container number', () => { const units = makeUnits('20GP', 20, 1); const placements = [ { bookingContainerId: 'bc-1', unitIndex: 0, sequenceNo: 1, // No containerNumber or containerId }, ]; const issues = validateLocalPlacements(units, placements); expect(issues.some((i) => i.includes('Enter a container number'))).toBe(true); }); it('returns error for duplicate container numbers', () => { const units = makeUnits('20GP', 20, 2); const placements = [ { bookingContainerId: 'bc-1', unitIndex: 0, sequenceNo: 1, containerNumber: 'CNTR123', }, { bookingContainerId: 'bc-1', unitIndex: 1, sequenceNo: 1, containerNumber: 'CNTR123', // Duplicate! }, ]; const issues = validateLocalPlacements(units, placements); expect(issues.some((i) => i.includes('Duplicate container number'))).toBe(true); }); }); });