mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 06:40:57 +00:00
261 lines
8.8 KiB
TypeScript
261 lines
8.8 KiB
TypeScript
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<typeof autoFillPlacements> = [];
|
||
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);
|
||
});
|
||
});
|
||
});
|