Files
edr-platform/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts
2026-08-24 14:01:19 +00:00

205 lines
8.0 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 {
CARGO_CATEGORIES,
CARGO_CATEGORY_EXPR,
CARGO_CATEGORY_LABEL_EXPR,
CONTAINER_CLASSES,
CONTAINER_CLASS_EXPR,
HANDLING_STANDARD_HOURS_EXPR,
TARGET_DIMENSION_KEYS,
cycleRateExpr,
handlingHours,
implementRateExpr,
loadingHours,
otherActivityHours,
plannedRowsSql,
} from './operations-classification';
import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity';
/**
* Every key a classification CASE can emit, read straight off the expression.
* The categories are the join key between a report and its planned target, so a
* key the reports emit but the target dimension list does not offer is a plan
* nobody can ever enter.
*/
function emittedKeys(expr: string): string[] {
return [...expr.matchAll(/THEN '([A-Z_]+)'/g)]
.map(([, key]) => key)
.concat([...expr.matchAll(/ELSE '([A-Z_]+)'/g)].map(([, key]) => key));
}
describe('operations classification', () => {
/**
* The loading window falls back to the bookings that boarded at the stop, off
* the DEPARTING schedule — a turnaround loads for the leg it leaves on, not
* the one it arrived on. Losing either half of that silently turns a
* populated report back into an empty one.
*/
it('falls back to the booking-derived loading window, off the departing leg', () => {
for (const expr of [loadingHours('s'), handlingHours('s')]) {
expect(expr).toContain('COALESCE(s.loading_started_at');
expect(expr).toContain('s.departed_schedule_id');
expect(expr).toContain('b.origin_yard_id = (s.yard_id)');
}
});
/** Unloading is never derived — auto-unload would report it as ~0 hours. */
it('never derives the unloading half', () => {
expect(handlingHours('s')).toContain('s.unloading_started_at');
expect(handlingHours('s')).not.toContain('b.destination_yard_id');
});
/**
* Every other standard coalesces to the spec's figure. This one must not:
* the spec names no handling standard, and a fallback would publish a rate
* against a number nobody agreed to.
*/
it('leaves the handling standard null when nobody has set one', () => {
expect(HANDLING_STANDARD_HOURS_EXPR).toContain('std.handling_standard_hours_container');
expect(HANDLING_STANDARD_HOURS_EXPR).not.toContain('COALESCE(std.handling');
});
/** An unmeasured stop reports unknown activity, not a full stay of it. */
it('keeps other activity null when there is no handling window', () => {
expect(otherActivityHours('stay', 'handling')).toContain('IS NULL THEN NULL');
});
it('offers every cargo category the expression can emit as a filter option', () => {
const offered = new Set(CARGO_CATEGORIES.map((o) => o.value));
const missing = [...new Set(emittedKeys(CARGO_CATEGORY_EXPR))].filter((k) => !offered.has(k));
expect(missing).toEqual([]);
});
it('offers every container class the expression can emit', () => {
const offered = new Set(CONTAINER_CLASSES.map((o) => o.value));
const missing = [...new Set(emittedKeys(CONTAINER_CLASS_EXPR))].filter((k) => !offered.has(k));
expect(missing).toEqual([]);
});
it('labels every category, leaving none showing a raw key', () => {
for (const option of CARGO_CATEGORIES) {
expect(CARGO_CATEGORY_LABEL_EXPR).toContain(`'${option.label}'`);
}
});
/**
* A planner types a dimension key into the targets screen; the reports match
* it against what their CASE emits. If the two lists ever drift, a target is
* silently ignored — the report shows no plan and nobody is told why.
*/
it('accepts every emitted key as a target dimension key', () => {
const emitted = [
...new Set([
...emittedKeys(CARGO_CATEGORY_EXPR),
...emittedKeys(CONTAINER_CLASS_EXPR),
]),
];
const unplannable = emitted.filter((k) => !TARGET_DIMENSION_KEYS.includes(k));
expect(unplannable).toEqual([]);
});
it('keeps the target metric and dimension vocabularies non-empty and distinct', () => {
expect(new Set(TARGET_METRICS).size).toBe(TARGET_METRICS.length);
expect(new Set(TARGET_DIMENSIONS).size).toBe(TARGET_DIMENSIONS.length);
});
/**
* The spec's worked example: a full trainset holds 50 wagons, 30 of them
* carry multimodal cargo, so that cargo operated 0.6 trainsets. The SQL does
* this division; this checks the arithmetic the SQL encodes.
*/
it('matches the spec worked example for trainsets', () => {
expect(Number((30 / 50).toFixed(2))).toBe(0.6);
});
/** Ten 40ft boxes and thirty 20ft boxes is fifty TEU, not forty. */
it('matches the spec worked example for TEU', () => {
expect(10 * 2 + 30 * 1).toBe(50);
});
it('divides by NULLIF so a missing plan yields no rate rather than infinity', () => {
expect(implementRateExpr('operated', 'planned')).toContain('NULLIF(planned, 0)');
});
/**
* [(SC AD) / SC + 1] × 100 — finishing exactly on standard scores 100, and
* beating it scores above 100. Guards the sign, which is easy to invert.
*/
it('encodes the turnaround rate so on-standard is 100 and faster is more', () => {
const rate = (sc: number, ad: number) => ((sc - ad) / sc + 1) * 100;
expect(rate(65, 65)).toBe(100);
expect(rate(65, 52)).toBeGreaterThan(100);
expect(rate(65, 78)).toBeLessThan(100);
expect(cycleRateExpr('ad', 'sc')).toContain('NULLIF(sc, 0)');
});
/**
* The plan side is FULL OUTER JOINed to the operated side, so a plan row for
* a category the user filtered out comes back as a row of zeros — the bug
* where `?categories=FERTILIZER` still returned all ten planned categories.
*/
describe('plannedRowsSql cargo filter', () => {
const sqlFor = (dimension: string, params: Record<string, unknown>): string =>
plannedRowsSql('VOLUME_TONS', dimension, params, 'SELECT 1');
it('restricts targets to the selected categories', () => {
expect(sqlFor('cargo_category', { categories: ['FERTILIZER'] })).toContain(
"AND ot.dimension_key IN ('FERTILIZER')",
);
});
it('restricts a station target on its cargo type, not its key', () => {
const sql = sqlFor('station', { categories: ['SAND'] });
expect(sql).toContain("AND ot.cargo_category IN ('SAND')");
expect(sql).not.toContain('ot.dimension_key IN');
});
it('filters a container class report on its own vocabulary', () => {
expect(sqlFor('container_class', { classes: ['CONTAINER_EXPORT'] })).toContain(
"AND ot.dimension_key IN ('CONTAINER_EXPORT')",
);
});
it('leaves every target when nothing is selected', () => {
expect(sqlFor('cargo_category', {})).not.toContain('ot.dimension_key IN');
});
it('matches nothing on a value no category expression can emit', () => {
expect(sqlFor('cargo_category', { categories: ["x'; DROP TABLE"] })).toContain('AND FALSE');
});
it('narrows a station plan to the chosen country rather than suppressing it', () => {
const sql = sqlFor('station', { country: 'Djibouti' });
expect(sql).toContain("y.country = 'Djibouti'");
expect(sql).not.toContain('AND FALSE');
});
it('ignores a country that is not one of the two sides', () => {
expect(sqlFor('station', { country: "' OR true --" })).not.toContain('y.country');
});
it('leaves the country alone on a plan not keyed by station', () => {
expect(sqlFor('cargo_category', { country: 'Djibouti' })).not.toContain('y.country');
});
/**
* No target carries a route, a train or a direction, so beside a
* route-filtered actual the plan would be the whole corridor's target.
*/
it.each(['origin', 'destination', 'trainNumber', 'direction'])(
'reports no plan at all when %s narrows below the target grain',
(key) => {
expect(sqlFor('cargo_category', { [key]: 'X' })).toContain('AND FALSE');
},
);
it('keeps the plan when only period, date and category are set', () => {
const sql = sqlFor('cargo_category', {
period: 'month',
dateFrom: '2026-01-01',
categories: ['SAND'],
});
expect(sql).not.toContain('AND FALSE');
});
});
});