Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts
2026-07-17 09:16:35 +00:00

259 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
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,
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);
});
});
});